同じ週に、Rork の画面と自分のターミナルを行き来していました。片方では生成した画面がその場で実機に出て、もう片方では手元のリポジトリに向かってエージェントが差分を書いている。並べてみると、どちらも同じくらい速く動きます。それで「どちらが優れているか」を比べようとして、半日ほど無駄にしました。
比較の材料が出てこないのです。生成された画面のコードを読んでも、エージェントが書いた差分を読んでも、質としてはどちらも許容範囲でした。判断が付かないまま、その日はどちらにも決めずに終わりました。
決め手が見つかったのは、比べるのをやめて自分のリポジトリの履歴を数えたときです。過去に自分が時間を溶かしていた場所は、生成 AI が得意な層とは違うところにありました。
比べる軸を間違えていました
「Rork と、ターミナルで動くコーディングエージェント。どちらでアプリを作るか」という問い方をすると、比較対象は自然とコードの品質になります。どちらが読みやすい JSX を書くか、命名が素直か、状態管理の置き場所が適切か。
ただ、その軸で差が出たとしても、その差は実務ではすぐ埋まります。読みにくければ直せばよく、直す作業はどちらの環境でも同じくらいの手間です。
実務で埋まらない差は、別のところにありました。アプリをビルドして実機に載せる環境を、自分で持つか、相手に預けるか です。
Rork の側にビルド環境を預けると、Mac も Xcode も Android Studio も要りません。Companion 経由なら有料の Apple Developer アカウントを持たないまま実機で確認できます。代わりに、ios/ や android/ の中の1行に手を伸ばしたい日が来たとき、そこに手が届きません。
手元にビルド環境を持つと、その1行に届きます。代わりに、SDK の更新、証明書の期限、Gradle と AGP の組み合わせ、そのすべてを自分で維持し続けることになります。
どちらが優れているという話ではなく、自分のプロジェクトがネイティブ層にどれだけ触るか で答えが変わる。そこまで整理して、次に「では自分はどれだけ触っているのか」を測ることにしました。
保守の作業がどの層に着地しているかを数える
印象では答えが出ないので、git の履歴から数えました。変更されたファイルを層で分類するだけの短いスクリプトです。
#!/usr/bin/env bash
# 直近 N 件のコミットで変更されたファイルを層別に数える。
# 使い方: ./native-landing.sh [コミット数(既定 200)]
set -euo pipefail
LIMIT = " ${1 :- 200} "
js = 0 ; native = 0 ; asset = 0 ; plugin = 0 ; other = 0
while IFS = read -r f ; do
case " $f " in
ios/ *| android/ *|* .pbxproj |* Info.plist |* .entitlements |* build.gradle *|* .podspec | Podfile *| gradle.properties )
native = $(( native+1 )) ;;
plugins/ *| app.json | app.config. *| eas.json | expo-module.config.json )
plugin = $(( plugin+1 )) ;;
assets/ *|* .png |* .jpg |* .webp |* .mp3 |* .m4a |* .caf )
asset = $(( asset+1 )) ;;
* .ts |* .tsx |* .js |* .jsx )
js = $(( js+1 )) ;;
*) other = $(( other+1 )) ;;
esac
done < <( git log -n " $LIMIT " --name-only --pretty=format: | grep -v '^$' | sort -u )
total = $(( js+native+asset+plugin+other ))
[ " $total " -eq 0 ] && { echo "対象ファイルがありません" ; exit 0 ; }
pct () { awk -v a=" $1 " -v b=" $total " 'BEGIN{printf "%.1f", (a*100)/b}' ; }
printf '直近 %s コミットで触れたファイル: %s 件\n\n' " $LIMIT " " $total "
printf ' JS/TS 層 %4s 件 (%s%%)\n' " $js " "$( pct " $js ")"
printf ' ネイティブ設定層 %4s 件 (%s%%)\n' " $native " "$( pct " $native ")"
printf ' 設定・プラグイン層 %4s 件 (%s%%)\n' " $plugin " "$( pct " $plugin ")"
printf ' アセット層 %4s 件 (%s%%)\n' " $asset " "$( pct " $asset ")"
printf ' その他 %4s 件 (%s%%)\n' " $other " "$( pct " $other ")"
printf '\nネイティブ側の着地率: %s%%\n' "$( pct $(( native+plugin )))"
出力はこうなります。手元に作った検証用の Expo プロジェクトで走らせたものです。
直近 50 コミットで触れたファイル: 6 件
JS/TS 層 1 件 (16.7%)
ネイティブ設定層 2 件 (33.3%)
設定・プラグイン層 2 件 (33.3%)
アセット層 1 件 (16.7%)
その他 0 件 (0.0%)
ネイティブ側の着地率: 66.7%
git log --name-only は同じファイルを何度でも出すので、sort -u で一意にしています。「何回触ったか」ではなく「何種類のファイルに触ったか」を見たいためです。回数で数えると、毎日触る画面ファイルが票を独占して、年に数回しか触らないのに毎回半日溶ける build.gradle が埋もれてしまいます。
この数え方を、個人開発で長く運用しているアプリに向けたとき、結果は予想と逆でした。私は自分のことを「JS/TS を書いている人」だと思っていたのですが、履歴に残っていたのは設定層とネイティブ層の作業のほうが厚いのです。新しい画面解像度への対応、ビルドツールのバージョン組み合わせ、署名まわり、App Store と Google Play の申告事項。どれも画面のコードではありません。
そして生成 AI がいちばん速いのは、まさにその画面のコードの層でした。自分が時間を溶かしている層と、生成が効く層がずれている 。これが半日迷った理由でした。
着地率の読み方はこう整理しています。
ネイティブ側の着地率 読み取り 推奨
20% 未満 設定はほぼ既定のままで足りている ビルド環境は預けたままでよい
20〜50% 設定に手は入るが、プラグインで表現できる範囲 生成側を主・手元を従で回す
50% 超 ネイティブ層の作業が保守の中心になっている 手元にビルド環境を持つ判断が要る
この表は絶対的な基準ではありません。私は「50% を超えたら考え直す」を目安に置いていますが、アプリの性格によって適正値は動きます。広告やアプリ内課金を積んでいるアプリは構造的に設定層が厚くなりますし、逆に情報を表示するだけのアプリなら 10% 台に収まります。
設定がどこまで生成の中で完結するか
着地率は過去の話です。これから作るものについては、app.json を読めば見当が付きます。設定の鍵を「生成プロンプトの中で閉じるもの」と「ネイティブ側の合意が要るもの」に仕分けるスクリプトを書きました。
#!/usr/bin/env node
// app.json / app.config.json を読み、
// 「生成プロンプトの中で完結する設定」と「ネイティブ側の合意が要る設定」を仕分ける。
// 使い方: node config-reach.mjs [app.json のパス]
import { readFileSync, existsSync } from "node:fs" ;
const path = process.argv[ 2 ] ?? "app.json" ;
if ( ! existsSync (path)) {
console. error ( `設定ファイルが見つかりません: ${ path }` );
process. exit ( 1 );
}
const expo = JSON . parse ( readFileSync (path, "utf8" )).expo ?? {};
// ネイティブ側の合意(署名・ケイパビリティ・審査での説明)が要る鍵
const NATIVE_BOUND = {
"ios.entitlements" : "ケイパビリティの有効化とプロビジョニングの作り直しが要ります" ,
"ios.infoPlist.UIBackgroundModes" : "バックグラウンド実行の申告です。審査で用途を説明します" ,
"ios.infoPlist.LSApplicationQueriesSchemes" : "問い合わせ先スキームの事前宣言です。上限50件です" ,
"ios.associatedDomains" : "サーバー側の apple-app-site-association と対で動きます" ,
"android.permissions" : "実行時許可の説明と Play の宣言フォームに波及します" ,
"android.blockedPermissions" : "依存ライブラリが自動で足す許可の打ち消しです" ,
};
const get = ( obj , dotted ) =>
dotted. split ( "." ). reduce (( o , k ) => (o == null ? undefined : o[k]), obj);
const bound = [];
for ( const [ key , why ] of Object. entries ( NATIVE_BOUND )) {
const v = get (expo, key);
if (v == null ) continue ;
const count = Array. isArray (v) ? v. length : Object. keys (v). length ;
bound. push ({ key, count, why });
}
// 目的文字列(NS...UsageDescription)は審査差し戻しの常連なので別枠で数える
const info = expo.ios?.infoPlist ?? {};
const purposeStrings = Object. entries (info)
. filter (([ k ]) => k. startsWith ( "NS" ) && k. endsWith ( "UsageDescription" ))
. map (([ k , v ]) => ({ key: k, length: String (v). length }));
// config plugin: ローカルパス指定はネイティブ生成に手を入れている印
const plugins = (expo.plugins ?? []). map (( p ) => (Array. isArray (p) ? p[ 0 ] : p));
const localPlugins = plugins. filter (( p ) => typeof p === "string" && p. startsWith ( "." ));
console. log ( `# ${ expo . name ?? path } の設定到達範囲 \n ` );
console. log ( "## ネイティブ側の合意が要る設定" );
if (bound. length === 0 ) console. log ( " なし(生成の中で完結します)" );
for ( const b of bound) console. log ( ` - ${ b . key }(${ b . count }件)… ${ b . why }` );
console. log ( " \n ## 目的文字列" );
if (purposeStrings. length === 0 ) console. log ( " なし" );
for ( const p of purposeStrings) {
const flag = p. length < 20 ? " ← 短すぎます。用途と結果を1文で書き直してください" : "" ;
console. log ( ` - ${ p . key }(${ p . length }文字)${ flag }` );
}
console. log ( " \n ## ローカル config plugin" );
if (localPlugins. length === 0 ) console. log ( " なし" );
for ( const p of localPlugins) console. log ( ` - ${ p } … 再生成のたびに残っているか確認が要ります` );
const score = bound. length + localPlugins. length ;
console. log (
` \n 判定: ネイティブ側に出ている論点 ${ score } 件。` +
(score === 0
? " 生成側だけで回せる範囲です。"
: score <= 2
? " 生成側を主、ネイティブ側を従で回せます。"
: " ネイティブ側を主に据えたほうが手戻りが少なくなります。" )
);
バックグラウンド音声と HealthKit と写真ライブラリを使う構成に向けると、こう出ます。
# wallpaper-demo の設定到達範囲
## ネイティブ側の合意が要る設定
- ios.entitlements(1件)… ケイパビリティの有効化とプロビジョニングの作り直しが要ります
- ios.infoPlist.UIBackgroundModes(1件)… バックグラウンド実行の申告です。審査で用途を説明します
- ios.infoPlist.LSApplicationQueriesSchemes(2件)… 問い合わせ先スキームの事前宣言です。上限50件です
- android.permissions(1件)… 実行時許可の説明と Play の宣言フォームに波及します
## 目的文字列
- NSPhotoLibraryAddUsageDescription(13文字) ← 短すぎます。用途と結果を1文で書き直してください
## ローカル config plugin
- ./plugins/withAudioBackgroundMode … 再生成のたびに残っているか確認が要ります
判定: ネイティブ側に出ている論点 5 件。 ネイティブ側を主に据えたほうが手戻りが少なくなります。
目的文字列の長さを見ているのは、短い目的文字列が審査の差し戻し理由になりやすいためです。「写真ライブラリに保存します」は 13 文字で、機能名の言い換えにしかなっていません。何のために取得して、ユーザーにとって何が起きるのかが入っていないと、自動解析の段階で不十分と判定されます。20 文字という閾値は緩めに置いた警告線で、実際には「なぜ」と「何が起きるか」が両方入っているかを人の目で読んで決めています。
このスクリプトの価値は、判定の1行そのものよりも、列挙されたものを見た瞬間に「これは生成の外だ」と分かること にあります。プロンプトで「HealthKit を使う機能を追加して」と書いたとき、生成側が返せるのはコードだけです。ケイパビリティの有効化とプロビジョニングの作り直しは、その外側に残ります。
境界を「戻せる形」で固定する
ネイティブ層に手を入れる判断をしたら、次に来るのが再生成との衝突です。Expo SDK 57 の expo prebuild は、既定で ios/ と android/ を破棄して作り直すようになりました。手で入れた1行は、そこで黙って消えます。
消えたことに気付かないまま提出まで進むのが最悪の形なので、差分をパッチとして固定し、当たるかどうかを検査するようにしています。
#!/usr/bin/env bash
# ネイティブ側の手作業差分をパッチとして固定し、再生成後に当て直せるか検証する。
# 使い方: ./pin-native.sh save … 現在のネイティブ差分を native.patch に保存
# ./pin-native.sh check … 保存済みパッチが今のツリーに当たるか確認(適用はしない)
set -euo pipefail
PATCH = "native.patch"
BASE = "${ BASE_REF :- $( git rev-list --max-parents = 0 HEAD | tail -1 )}"
PATHS = ( ios android )
case " ${1 :- } " in
save )
git diff " $BASE " -- "${ PATHS [ @ ]}" > " $PATCH "
lines = $( grep -c '^[+-][^+-]' " $PATCH " || true )
echo "保存しました: $PATCH (変更行 ${ lines :- 0 })"
echo "→ この行数がゼロに近いほど、再生成に強い構成です"
;;
check )
[ -s " $PATCH " ] || { echo "パッチが空です。生成側だけで完結しています" ; exit 0 ; }
if git apply --check --reverse " $PATCH " 2> /dev/null ; then
echo "適用済みです(差分は現在のツリーに反映されています)"
elif git apply --check " $PATCH " 2> /dev/null ; then
echo "未適用ですが、そのまま当てられます"
else
echo "衝突します。生成側がネイティブ設定を書き換えました"
echo "→ 該当箇所を config plugin へ移してください"
exit 1
fi
;;
*) echo "usage: $0 {save|check}" ; exit 2 ;;
esac
三つの状態が正しく分かれることを、検証用リポジトリで確認しています。差分を保存した直後は「適用済みです」、ネイティブディレクトリを初期状態へ戻すと「未適用ですが、そのまま当てられます」、生成側が同じ行を書き換えた状態では「衝突します」で終了コード 1 を返します。
--check を付けているのは、当てないこと が目的だからです。ここで自動適用してしまうと、生成側の変更を上書きしたのか、自分の変更が生きているのかが分からなくなります。当たるかどうかだけを見て、判断は人が下す。この一手間が、あとで「なぜこの設定が消えたのか」を追う時間を丸ごと節約します。
BASE_REF を外から渡せるようにしてあるのは、リポジトリの最初のコミットが必ずしも「生成直後の状態」ではないためです。生成物を取り込んだコミットのハッシュを渡すのが本来の使い方になります。
保存されたパッチの変更行数は、そのままリスク量として読めます。ゼロなら再生成し放題です。数行なら config plugin へ移す作業が現実的な範囲に収まります。数百行あるなら、その時点で「ビルド環境は手元にある」と認めたほうが早いはずです。ios/ と android/ を .gitignore から外して常時管理下に置く、という判断です。
計測でつまずいた3つの落とし穴
上の3本を自分の環境に持ち込むとき、私が実際に踏んだ、あるいは踏みかけた注意点です。どれも数字を静かに嘘にするタイプの罠です。
1. ios/ と android/ が .gitignore に入ったまま数えている
Expo の既定の構成では、ネイティブディレクトリは生成物として扱われ、.gitignore に入っています。この状態で native-landing.sh を走らせると、ネイティブ層の件数がゼロで出ます。着地率 0% という結果を見て「生成側で完結している」と読むのが、この記事でいちばん起きやすい誤読です。
対処は単純で、計測の前に追跡状況を確認します。
git check-ignore -v ios android 2> /dev/null || echo "追跡されています"
無視されている場合は、app.json と plugins/ の変更量、つまり設定・プラグイン層のほうを読んでください。ネイティブの実体が git に無いだけで、作業が無かったわけではありません。
2. パッチを当ててから check を走らせている
pin-native.sh check は、当たるかどうかを見る道具です。手で git apply した直後に走らせると当然「適用済みです」と返り、衝突を検出する機会そのものが失われます。順序は「再生成 → check → 判断 → 適用」です。逆順にすると、この検査は何も守ってくれません。
私はこれを一度やりかけて、--reverse の分岐が先に評価される理由を読み直すまで気付きませんでした。
3. 同じ設定を app.json と config plugin の両方に書いている
移行の途中でありがちな状態です。両方に書いても片方が勝つだけで、エラーにはなりません。どちらが勝ったかはビルド後の Info.plist を見るまで分からず、ローカルでは通ったのに配布ビルドで挙動が変わる、という形で表面化します。
移し終えたら app.json 側を消す。この後始末を、移行作業と同じコミットに含めるのを推奨します。別コミットに回すと、そのまま残ります。
実際にどう住み分けているか
以上を踏まえて、私自身は道具ではなく機能の単位で分けています。
作業の性質 どちらに置くか 理由
画面と導線の試作 Rork 側 実機で触れるまでが速く、捨てる前提の試作に向いています
デザインの詰め・文言調整 Rork 側 プレビューとの往復が短いほど質が上がる作業です
ストア申告に紐づく設定 手元 目的文字列や許可の宣言は、App Store の審査での説明とセットで管理したいためです
ビルドツールのバージョン整合 手元 失敗の再現と切り分けに、ログの全文が要ります
既存アプリの保守 手元 過去の判断の履歴が手元にあり、そこから切り離せません
新しく作るものは Rork 側から入り、上の検査でネイティブ側の論点が増えてきた時点で手元へ引き取る。この順序にしてから、迷う時間がほぼ無くなりました。
逆方向、つまり手元にあるものを Rork 側へ移す判断は、これまで一度もしていません。既存アプリは過去の判断の集積で、その履歴ごと移せる形になっていないためです。移せるとしたら、それは移すのではなく作り直すことになります。
想定と違ったこと
最初は「自由度が高いほうが上位互換だろう」と考えていました。手元にビルド環境を持てば何でもできるのだから、預ける理由は入門者向けの便宜くらいだろう、と。
数えてみて分かったのは、手元に持つことのコストは、使う日ではなく使わない日にもかかり続ける ということです。SDK は勝手に更新され、証明書は勝手に期限を迎え、ビルドツールの組み合わせは勝手に壊れます。年に数回しか ios/ を開かないプロジェクトでも、その維持費は毎月発生しています。
着地率が低いプロジェクトほど、その維持費が無駄になっている。逆に着地率が高いプロジェクトでは、その維持費が本来の仕事に見合っている。これは「自由度」という言葉では出てこない見え方でした。
もう一つ、生成 AI の速さの効き所についてです。速いのは画面のコードを書く場面ですが、そこは元々そこまで時間がかかっていなかった層でした。時間を溶かしているのは、ビルドが通らない原因の切り分けや、審査で差し戻された理由の読み解きのほうです。この層は、どちらの道具を選んでも短縮されません。道具の選択で変わるのは、そこに自分が到達できるかどうかだけ です。
まず最初の一手
自分のプロジェクトで native-landing.sh を走らせてみてください。数分で終わりますし、出てくる数字はおそらく予想とずれます。
そのずれが、道具を選び直すかどうかの一番確かな材料になります。私の場合はそこから逆算して、新規は生成側、保守は手元、という今の形に落ち着きました。
なお、Companion で実機確認をどこまで進められて、どこから有料の Apple Developer アカウントが要るのかは、Rork Companion の無料実機テストと Apple Developer 契約のタイミング に整理しています。ネイティブ設定を config plugin へ移す具体的な手順は、Expo の config plugin でネイティブ設定を管理する が参考になるはずです。
実装の判断は人によって違って当然ですので、この分け方をそのまま採用する必要はありません。ただ、数える手順そのものは誰の環境でも同じように使えます。お読みいただきありがとうございました。