浮世絵壁紙アプリの iOS 版と Android 版を、私は10年近く別々のコードベースで保守してきました。同じ機能を2回ずつ実装する暮らしにすっかり慣れてしまっていたので、Android Studio の移行エージェント — React Native・Web フレームワーク・iOS のコードを解析してネイティブ Kotlin アプリへ自動移行するというプレビュー機能 — の発表を見たときは、期待より先に疑いが湧きました。
「数週間かかる移行が数時間になる」という触れ込みを、自分のコードで確かめずに信じるわけにはいきません。そこで実際のアプリから一画面だけを切り出して移行エージェントに渡し、生成された Kotlin を1行ずつ読みました。結論を先に書くと、構造の移植は想像以上、ライフサイクルの解釈は想像どおり危険、広告と課金には一切立ち入らない、という三層の結果でした。
「数週間が数時間に」の中身 — 移行エージェントは何をするのか
移行エージェントの動きは、大きく3段階に分かれています。
解析 : 既存コードの画面構成・データフロー・依存ライブラリをマッピングし、移行レポートを作る
計画 : 画面・モジュール単位の移行プランを提示し、対応関係(UIKit のどの部品を Compose のどの部品に移すか)を示す
生成 : Kotlin + Jetpack Compose のプロジェクトとして出力する
対象は React Native・Web フレームワーク・iOS の3系統で、今回は対応関係の推論が最も難しいはずの iOS → Kotlin を選びました。同時に「Android Bench」という LLM の Android 開発性能を測るベンチマークも公開されており、生成品質を外から測られる前提でこの機能を出してきた、という Google 側の自信も読み取れます。
ただしプレビュー段階の機能ですから、ここに書く挙動は今後変わる可能性があります。本稿は2026年6月時点の、個人開発の現場から見た一つの定点観測として読んでいただければと思います。
検証対象 — 浮世絵壁紙アプリの「カテゴリ一覧」を一画面だけ移す
最初からプロジェクト全体を食わせることはしませんでした。エージェントへの依頼はレビューできる単位で切る のが私の基本方針で、移行エージェントも例外ではありません。生成物の癖が分からないうちに数百ファイルを受け取っても、検収のしようがないからです。
題材に選んだのはカテゴリ一覧画面です。理由は、個人開発アプリの典型がこの一画面に詰まっているからです。
UICollectionView によるグリッド表示と非同期の画像読み込み
課金状態(広告非表示を購入済みか)によって広告セルの有無を切り替える分岐
画面に戻ってくるたびに購入状態を再評価するライフサイクル処理
実測値も残しておきます。対象は関連クラスを含めて Swift 約2,100行。解析レポートの生成からビルドが通る状態まで約40分(うちエージェントの処理時間が約25分)、生成されたファイルは38本でした。「数時間」という触れ込みは、一画面単位で見るかぎり誇張ではありません。問題は所要時間ではなく、生成されたコードの中身です。
生成された Kotlin の率直な評価 — 構造は良い、ライフサイクルは危ない
まず移行前の iOS 実装の核心部を示します。この画面で一番大事なのは、viewWillAppear で購入状態を再評価している部分です。
// CategoryGridViewController.swift — 移行前の iOS 実装(抜粋)
final class CategoryGridViewController : UIViewController {
private var categories: [WallpaperCategory] = []
private let repository = CategoryRepository ()
override func viewDidLoad () {
super . viewDidLoad ()
configureCollectionView ()
}
override func viewWillAppear ( _ animated: Bool ) {
super . viewWillAppear (animated)
// 設定画面で「広告非表示」を購入して戻ってきた直後にも
// 広告セルを確実に消すため、表示のたびに購入状態を見る
Task {
let isAdFree = await PurchaseState.shared. isAdFree ()
self .categories = try await repository. fetchCategories ()
self . applySnapshot ( showAds : ! isAdFree)
}
}
}
移行エージェントが生成した Compose 実装がこちらです。一見、まったく問題なさそうに見えます。
// CategoryGridScreen.kt — 移行エージェントが生成した実装(手直し前)
@Composable
fun CategoryGridScreen (viewModel: CategoryGridViewModel = viewModel ()) {
val uiState by viewModel.uiState. collectAsState ()
LaunchedEffect (Unit) {
// viewWillAppear 相当として生成されたが、
// これは「初回コンポジション時に1回」しか実行されない
viewModel. loadCategories ()
}
LazyVerticalGrid (columns = GridCells. Fixed ( 2 )) {
items (uiState.categories) { category ->
CategoryCard (category)
}
}
}
評価できる点は多くありました。UICollectionView + DiffableDataSource の構成を LazyVerticalGrid に対応づける判断は自然で、ViewModel の分離も教科書どおりです。Swift 側のオプショナルの扱いから Kotlin の nullability を推論する精度も、手書きと見分けがつかない水準でした。
一方で、上のコードには本番で事故になる差が埋まっています。viewWillAppear は「画面が表示されるたび」に呼ばれますが、生成された LaunchedEffect(Unit) は「初回コンポジション時に1回」しか走りません。つまり、設定画面で広告非表示を購入して戻ってきても広告セルが残る、という不具合がここで生まれます。コンパイルは通り、初回表示は正常に動き、特定の操作手順でだけ壊れる — レビューで一番見落としやすい種類の差です。
手直し後の実装はこうなります。
// CategoryGridScreen.kt — 手直し後:viewWillAppear の「意図」を移す
@Composable
fun CategoryGridScreen (viewModel: CategoryGridViewModel = viewModel ()) {
val uiState by viewModel.uiState. collectAsState ()
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect (lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
// 画面復帰のたびに購入状態とカテゴリを再評価する
viewModel. refreshPurchaseStateAndCategories ()
}
}
lifecycleOwner.lifecycle. addObserver (observer)
onDispose { lifecycleOwner.lifecycle. removeObserver (observer) }
}
LazyVerticalGrid (columns = GridCells. Fixed ( 2 )) {
items (uiState.categories, key = { it.id }) { category ->
CategoryCard (category)
}
}
}
この修正で「購入直後に戻ると広告セルが残る」再現手順は消えました。構文の対応表ではなく挙動の意図を移せるか — 自動移行の信頼性は、結局この一点に集約されるのだと思います。
手直しが必要だった12箇所の内訳
生成された38ファイルに対して、受け入れまでに手を入れたのは12箇所でした。内訳を残しておきます。
ライフサイクル解釈: 3箇所 — 上記の viewWillAppear 問題と、その亜種(viewDidDisappear での再生停止が onDispose に移されておらず、バックグラウンドでスライドショーが回り続ける)
依存ライブラリのバージョン: 2箇所 — 画像読み込みに最新の Coil が自動追加されたものの、既存モジュールの Kotlin バージョンと衝突してビルドが通らない。エージェントは「新規プロジェクト」を前提に最新版を選ぶ癖があります
UI 文字列の直書き: 5箇所 — カテゴリ名の接頭辞やエラーメッセージが strings.xml に出ておらず、コードに直接埋まっていた。多言語対応しているアプリでは致命傷になります
広告・課金のスタブ: 2箇所 — 後述しますが、これは欠陥というより仕様です
行数で見ると、生成された約3,800行のうち手を入れたのは200行弱、率にして5%ほどです。残る95%はそのまま使える水準でした。ただし手直しの大半が「コンパイルは通るが挙動が違う」型に偏っているため、修正行数の少なさを安心材料にはできません。
移行エージェントが立ち入らない領域 — 広告・課金・OS 固有概念
事前に一番気になっていたのは、収益に直結する配線をどう扱うかでした。結果から言うと、AdMob の初期化・同意フロー・Play Billing の購入処理は、すべて TODO コメント付きのスタブとして生成されます。StoreKit 2 の Transaction.currentEntitlements に1対1で対応する API は Play Billing には存在しないので、ここは翻訳ではなく設計判断そのものです。壁紙アプリ4本の課金コードを StoreKit 2 へ移し替えたとき も痛感しましたが、課金まわりは「動いているように見える」状態が一番怖い領域です。
同じ理由で、WallpaperManager のような Android にしか存在しない概念も生成対象になりません。壁紙の設定機能は iOS では「画像を保存してもらい手動で設定してもらう」しかない一方、Android では直接設定まで実装できます。この非対称は移行ではなく新規設計の領域です。
正直に言えば、ここは安心材料でした。収益の配線を推論で「移行」されて、気づかないまま審査に出してしまうより、明示的に穴を空けておいてくれる方が事故は少ないからです。自動化の価値は全部やってくれることではなく、任せられる範囲が明確なことだと、個人開発を長く続けるほど感じます。
受け入れ前の検査を Antigravity で定型化する
12箇所の手直しのうち、目視のコードレビューだけで見つけられた自信があるのは半分程度です。残りは「観点を固定して機械的に総当たりする」ことで見つけました。私はこの総当たりを Antigravity のレビュー用エージェントに任せています。
生成コードのレビューで効いたのは、観点を欲張らないことでした。AGENTS.md に書いているプレイブックの該当部分をそのまま載せます。
## generated-kotlin-review — 自動移行コードの受け入れ検査
生成された .kt ファイルを対象に、次の4点だけを順に検査する。
1. ライフサイクル: LaunchedEffect(Unit) が「画面復帰時にも走る」前提の
処理を抱えていないか。元の iOS 実装の viewWillAppear / viewDidDisappear
と突き合わせて、実行タイミングの意図が保たれているかを判定する
2. 依存: build.gradle.kts に追加されたライブラリのバージョンが、
既存モジュールの Kotlin / AGP バージョンと矛盾しないか
3. 文字列: UI に表示される文字列が直書きされていないか(strings.xml へ)
4. 既存資産: 既存の AdManager / BillingManager を使わず
類似機能を新規実装していないか
検査結果はファイルごとに「問題なし / 要修正(理由1行)」の2値で
report.md に出力する。修正の実施は別タスクとし、この検査では行わない。
ポイントは、検査と修正を同じタスクにしないことです。検査エージェントに修正までさせると、報告が「直しておきました」に変わってしまい、人間が差分を判断する余地が消えます。この役割分離は依存ライブラリ更新の運用設計 で固めた方式の流用で、移行コードの検収にもそのまま通用しました。
どんなプロジェクトなら任せられるか — 判断の3軸
一画面分の検証から一般化するのは慎重であるべきですが、判断軸としては次の3つに整理できると考えています。
プラットフォーム固有 API の密度 : 課金・広告・通知・ウィジェット・壁紙設定のような OS 固有機能が厚いアプリほど、自動移行の守備範囲は狭くなります。画面とデータフローが主体のアプリなら適性は高めです
既存 Android 版の有無 : すでに Android 版がある場合、生成コードへの乗り換えは、クラッシュ対策や端末対応で積み上げた既存資産を捨てる判断を意味します。一方、iOS にしかないアプリの Android 版を新規に起こすなら、叩き台としての価値は高くなります
保守の残存期間 : あと1〜2年で畳む予定のアプリに移行コストを払う意味はありません。移行は投資なので、回収期間とセットで考える必要があります
私のケースでは、10年運用してきた既存 Android 版を生成コードで置き換える判断はしませんでした。広告のウォーターフォール調整や機種別の不具合対応の蓄積は、コードの行数には表れない資産だからです。逆に、iOS にしか出していない小規模アプリの Android 進出なら、最初の1週間分の土台作りが半日に圧縮される感覚がありました。
結論 — 二重保守の解消にはまだ早く、叩き台としては想像以上
検証前の私は「どうせ動かないコードが出てくる」側に賭けていました。実際には、構造の移植精度は予想を大きく上回り、その一方でライフサイクルの意図と収益まわりの配線という、アプリの信頼性と売上を支える二点が人間の仕事として残りました。この残り方は、むしろ健全だと感じています。
もし試すなら、最初の対象は1画面に絞ることをおすすめします。生成コードの癖 — どこを賢く移し、どこで意図を取り違えるか — を自分の目で確認してから全体の移行計画を立てても、遅くはありません。同じように二重保守と付き合っている方の判断材料になれば幸いです。