Expo SDK 57 が公開され、React Native が 0.86 へ上がりました。破壊的変更がない想定と告知されているので、追従そのものは軽く済みそうです。
それでも、個人開発で運用している Rork 生成アプリのひとつに expo prebuild --clean を打つ前、ひとつ確かめておきたいことがありました。
ios/ に自分で入れた手入れが、いくつあるのか。
Rork が吐くのはコード所有型のプロジェクトです。生成された ios/ を Xcode で開き、リンカフラグを1つ足し、AppDelegate に SDK の初期化を1行差し込む。そういう小さな手入れは、入れた瞬間には覚えていても、三ヶ月経つと忘れます。そして --clean はそれを問答無用で消します。
そこで、消えるものだけを数える仕組みを作りました。作ってみると、事前の見立てとはかなり違う数字が出てきました。
「差分が多いから危ない」は成り立たない
検証用に、prebuild が2回吐いた ios/ ツリーを模したフィクスチャを組みました。ソース320本相当、project.pbxproj は331行。片方(baseline)は素の生成物、もう片方(current)には現実によくある手入れを3つだけ入れてあります。
| 手入れの場所 | 内容 |
MyApp/AppDelegate.mm | [FIRApp configure]; を1行挿入 |
MyApp/Info.plist | NSCameraUsageDescription を追加 |
MyApp.xcodeproj/project.pbxproj | OTHER_LDFLAGS = "-ObjC" を追加 |
実質的な変更は3行。これに素の diff をかけると、こうなりました。
$ diff -rq ./baseline ./current | wc -l
6
$ diff -r ./baseline ./current | grep -c '^[<>]'
653
$ wc -l < ./baseline/MyApp.xcodeproj/project.pbxproj
331
$ diff ./baseline/.../project.pbxproj ./current/.../project.pbxproj | grep -c '^[<>]'
641
331行のファイルに、641行の差分。両側のほぼ全行が「変わった」と報告されています。
原因は pbxproj の objectID です。Xcode のプロジェクトファイルは各オブジェクトを24桁の16進数で識別しており、この値は再生成のたびに総入れ替えになります。内容が1文字も変わっていなくても、全行が別物として並びます。
つまり差分の量は、危険度を何ひとつ表していません。653行の海の中に、本当に守るべき3行が沈んでいる。走査対象に絞った649行で見ても、意味を持つのは 0.46% です。この比率を先に知らないまま --clean を打つと、消えたことにすら気づけません。
除外するもの、正規化するもの
数える前に、対象を絞ります。判断は2段階です。
そもそも見ない(差分を見る価値がないディレクトリ)
Pods/ — pod install の産物です。守るべき情報は Podfile 側にあります
build/ DerivedData/ .gradle/ app/build/ — ビルド中間物
xcuserdata/ — Xcode の UI 状態。開いた人ごとに毎回変わります
見るが、揺れを潰す(意味を持たない表現の正規化)
| 正規化ルール | 対象 | 置換後 |
| pbxproj objectID | 24桁の16進数 | <OBJID> |
| CocoaPods チェックサム | 40桁の16進数 | <SHA1> |
| 生成時刻 | ISO 8601 形式の日時 | <TIME> |
| 絶対パス | /Users/… /home/… | <PATH> |
絶対パスを入れているのは、マシンを移ったときやチームで共有したときに効くためです。CI のコンテナと手元の Mac ではホームディレクトリが違い、それだけで xcconfig が全面的に「変わった」ことになります。
実質的な手入れだけを数えるスクリプト
以上をそのまま実装したものが次です。実行すると、走査したファイルを「完全一致」「ノイズのみの差分」「実質的な手入れ」に分けて報告し、実質的な手入れが1件でもあれば終了コード1を返します。アップグレード前のゲートとして CI に置ける形にしてあります。
#!/usr/bin/env node
/**
* native-drift.mjs — prebuild の再生成で「本当に失われる手入れ」だけを数える
*
* 使い方:
* node native-drift.mjs <baseline-dir> <current-dir> [--json report.json]
*
* baseline : 手を入れる前の生成物(クリーンな prebuild の出力)
* current : 今リポジトリにある ios/ or android/
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
const EXCLUDE_DIRS = [
'Pods', // pod install の産物。Podfile を見れば足りる
'build', 'DerivedData',
'.gradle', 'app/build',
'xcuserdata', // Xcode の UI 状態。人ごとに毎回変わる
];
const EXCLUDE_FILES = ['.DS_Store', 'UserInterfaceState.xcuserstate'];
// 「再生成のたびに必ず変わるが、意味は変わらない」表現を潰す
const NORMALIZERS = [
{ name: 'pbxproj-object-id', re: /\b[0-9A-F]{24}\b/g, to: '<OBJID>' },
{ name: 'pod-checksum', re: /\b[0-9a-f]{40}\b/g, to: '<SHA1>' },
{ name: 'timestamp', re: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, to: '<TIME>' },
{ name: 'abs-path', re: /\/(Users|home)\/[^\s"']+/g, to: '<PATH>' },
];
function normalize(text) {
let out = text;
for (const n of NORMALIZERS) out = out.replace(n.re, n.to);
// 行の集合として比べるので、ここで並べ替える必要はありません(実測でも差が出ませんでした)
return out.split('\n').map((l) => l.replace(/\s+$/, '')).filter((l) => l !== '').join('\n');
}
function walk(root, base = root, acc = []) {
let entries;
try { entries = readdirSync(root, { withFileTypes: true }); } catch { return acc; }
for (const e of entries) {
const full = join(root, e.name);
if (e.isDirectory()) {
if (EXCLUDE_DIRS.some((d) => d === e.name || full.includes(sep + d + sep))) continue;
walk(full, base, acc);
} else if (e.isFile()) {
if (EXCLUDE_FILES.includes(e.name)) continue;
acc.push(relative(base, full));
}
}
return acc;
}
function readSafe(p) {
try {
const st = statSync(p);
if (st.size > 2 * 1024 * 1024) return null; // 巨大ファイルは対象外
const buf = readFileSync(p);
if (buf.includes(0)) return null; // バイナリ判定
return buf.toString('utf8');
} catch { return null; }
}
function substantiveHunks(aText, bText) {
// 正規化で行番号は崩れるので、行の集合として比較する
const A = new Set(normalize(aText).split('\n'));
const B = new Set(normalize(bText).split('\n'));
return {
added: [...B].filter((l) => !A.has(l)),
removed: [...A].filter((l) => !B.has(l)),
};
}
function main() {
const [baseDir, curDir] = process.argv.slice(2);
if (!baseDir || !curDir) {
console.error('usage: node native-drift.mjs <baseline-dir> <current-dir> [--json out.json]');
process.exit(2);
}
const jsonIdx = process.argv.indexOf('--json');
const jsonOut = jsonIdx > -1 ? process.argv[jsonIdx + 1] : null;
const baseFiles = new Set(walk(baseDir));
const curFiles = new Set(walk(curDir));
const all = [...new Set([...baseFiles, ...curFiles])].sort();
const report = {
scanned: all.length, identical: 0, noiseOnly: 0,
substantive: [], onlyInCurrent: [], onlyInBaseline: [], skippedBinary: 0,
};
for (const rel of all) {
if (!baseFiles.has(rel)) { report.onlyInCurrent.push(rel); continue; }
if (!curFiles.has(rel)) { report.onlyInBaseline.push(rel); continue; }
const a = readSafe(join(baseDir, rel));
const b = readSafe(join(curDir, rel));
if (a === null || b === null) { report.skippedBinary++; continue; }
if (a === b) { report.identical++; continue; }
const { added, removed } = substantiveHunks(a, b);
if (added.length === 0 && removed.length === 0) { report.noiseOnly++; continue; }
report.substantive.push({ file: rel, added, removed });
}
console.log('── native drift report ──────────────────────');
console.log(`走査ファイル数 : ${report.scanned}`);
console.log(`完全一致 : ${report.identical}`);
console.log(`ノイズのみの差分 : ${report.noiseOnly} ← prebuild が毎回変えるだけ`);
console.log(`実質的な手入れ : ${report.substantive.length} ← 再生成で失われる`);
console.log(`current にのみ存在 : ${report.onlyInCurrent.length}`);
console.log(`baseline にのみ存在 : ${report.onlyInBaseline.length}`);
console.log('');
for (const s of report.substantive) {
console.log(`▼ ${s.file}`);
for (const l of s.removed) console.log(` - ${l.trim()}`);
for (const l of s.added) console.log(` + ${l.trim()}`);
console.log('');
}
for (const f of report.onlyInCurrent) console.log(`▼ ${f} (生成物に無い=手で足したファイル)`);
if (jsonOut) writeFileSync(jsonOut, JSON.stringify(report, null, 2));
const lost = report.substantive.length + report.onlyInCurrent.length;
if (lost > 0) {
console.log(`\n❌ 再生成で失われる変更が ${lost} 件あります。config plugin へ外部化してから prebuild --clean を実行してください。`);
process.exit(1);
}
console.log('\n✅ 再生成で失われる手入れはありません。');
}
main();
先ほどのフィクスチャに通した出力がこちらです。
$ node native-drift.mjs ./baseline ./current --json report.json
── native drift report ──────────────────────
走査ファイル数 : 5
完全一致 : 1
ノイズのみの差分 : 1 ← prebuild が毎回変えるだけ
実質的な手入れ : 3 ← 再生成で失われる
current にのみ存在 : 0
baseline にのみ存在 : 0
▼ MyApp.xcodeproj/project.pbxproj
+ OTHER_LDFLAGS = "-ObjC";
▼ MyApp/AppDelegate.mm
+ [FIRApp configure];
▼ MyApp/Info.plist
+ <key>NSCameraUsageDescription</key><string>プロフィール写真の撮影に使用します</string>
❌ 再生成で失われる変更が 3 件あります。config plugin へ外部化してから prebuild --clean を実行してください。
$ echo $?
1
653行が3行になりました。走査対象も7ファイルから5ファイルへ絞られています。実行時間は Node.js v22 で 0.03〜0.04 秒でした。CI の待ち時間として気にする水準ではありません。
CI に置く場合は、アップグレード用ブランチだけで走らせることを推奨します。通常の開発ブランチでは ios/ に触らない日が続くため、常時実行しても得られる情報がほとんどありません。
--json を付けると同じ内容が機械可読な形で落ちます。アップグレード対応の PR に添付しておくと、レビューする側が「何を config plugin へ移したか」を照合できます。
どの正規化ルールが効いているのかを測る
正規化ルールを4つ並べましたが、全部が同じだけ効いているわけではありません。1つずつ外して、検出される差分行数がどう動くかを測りました。
| 条件 | 検出された差分行数(走査対象の5ファイル) | うち本物 |
| 正規化なし(素の行比較) | 649 | 3 |
| objectID の正規化だけ外す | 643 | 3 |
| チェックサムの正規化だけ外す | 9 | 3 |
| 全ルール適用 | 3 | 3 |
効きの大半は objectID の正規化ひとつが担っています。これを外すと 643 行、誤検出は 214 倍に膨らみ、素の比較とほとんど変わらなくなります。逆に言えば、pbxproj の 24 桁 16 進数さえ潰せば、他は微調整の域です。
チェックサムの正規化は 3 行を 9 行に増やすだけですが、この 6 行は Podfile.lock の SPEC CHECKSUMS と PODFILE CHECKSUM です。毎回変わる上に、変わったこと自体には情報がありません。放っておくと「毎回必ず1件は実質差分が出るツール」になり、ゲートとして誰も見なくなります。誤検出をゼロに保てるかどうかは、精度の問題というより、運用が続くかどうかの問題でした。
なお私自身、書き始めた時点では正規化後の行を sort() してから比べていました。pbxproj はブロックの順序が入れ替わるので必要だろう、という見立てです。測ってみると sort() の有無で結果は 3 行のまま動きませんでした。集合として比較している以上、順序は最初から関係なかったわけです。掲載したコードからは外してあります。
数えた手入れを config plugin へ移す
数え上げは目的ではありません。目的は、--clean を打っても手入れが自動で戻ってくる状態にすることです。
先ほど検出された3件を、そのまま Expo の config plugin へ移し替えます。
/**
* with-native-edits.js — ios/ への手入れを config plugin として外部化する
*
* app.json:
* { "expo": { "plugins": [["./with-native-edits", { "cameraUsage": "…" }]] } }
*/
const { withInfoPlist, withAppDelegate, withXcodeProject } = require('@expo/config-plugins');
// --- 1. Info.plist: 権限文言 ---
const withCameraUsage = (config, { cameraUsage }) =>
withInfoPlist(config, (cfg) => {
// 既に同じ値なら触らない(毎回 dirty にしないため)
if (cfg.modResults.NSCameraUsageDescription !== cameraUsage) {
cfg.modResults.NSCameraUsageDescription = cameraUsage;
}
return cfg;
});
// --- 2. AppDelegate: SDK の初期化を1行差し込む ---
const ANCHOR = 'self.moduleName = @"main";';
const INJECT = '[FIRApp configure];';
const withFirebaseInit = (config) =>
withAppDelegate(config, (cfg) => {
const src = cfg.modResults.contents;
if (src.includes(INJECT)) return cfg; // 冪等性: 二重挿入を防ぐ
if (!src.includes(ANCHOR)) {
// アンカーが消えたら黙って素通りせず、必ず落とす。
// ここを警告で済ませると SDK 更新後に初期化だけが静かに消える。
throw new Error(
`[with-native-edits] AppDelegate のアンカー "${ANCHOR}" が見つかりません。` +
` Expo SDK の更新でテンプレートが変わった可能性があります。ANCHOR を更新してください。`
);
}
cfg.modResults.contents = src.replace(ANCHOR, `${INJECT}\n ${ANCHOR}`);
return cfg;
});
// --- 3. pbxproj: ビルド設定 ---
const withObjCLinkFlag = (config) =>
withXcodeProject(config, (cfg) => {
const project = cfg.modResults;
const configurations = project.pbxXCBuildConfigurationSection();
for (const key of Object.keys(configurations)) {
const entry = configurations[key];
if (typeof entry !== 'object' || !entry.buildSettings) continue;
if (!('PRODUCT_NAME' in entry.buildSettings)) continue; // アプリターゲットだけに限定
const cur = entry.buildSettings.OTHER_LDFLAGS;
const flags = Array.isArray(cur) ? cur : cur ? [cur] : ['"$(inherited)"'];
if (!flags.includes('"-ObjC"')) flags.push('"-ObjC"');
entry.buildSettings.OTHER_LDFLAGS = flags;
}
return cfg;
});
module.exports = (config, props = {}) => {
const opts = { cameraUsage: 'プロフィール写真の撮影に使用します', ...props };
config = withCameraUsage(config, opts);
config = withFirebaseInit(config);
config = withObjCLinkFlag(config);
return config;
};
withXcodeProject の中で PRODUCT_NAME の有無を見ているのは、アプリターゲットの設定だけに絞るためです。pbxXCBuildConfigurationSection() は Pods 側のターゲット設定も返してくるので、素直に全部へ書くと、pod install のたびに上書きされる場所へフラグを撒くことになります。
手元でモックを通した結果です。PRODUCT_NAME を持たないエントリには何も書かれていません。
AAA1 → OTHER_LDFLAGS = ["\"$(inherited)\"","\"-ObjC\""]
AAA2 → OTHER_LDFLAGS = ["\"$(inherited)\"","\"-ObjC\""]
BBB1 → OTHER_LDFLAGS = undefined
2回適用後 AAA2 = ["\"$(inherited)\"","\"-ObjC\""]
アンカーが消えたら、警告ではなく止める
withAppDelegate のような文字列置換型の plugin で最も危ないのは、アンカーが見つからなかったときの振る舞いです。ここが本番運用でいちばん深い落とし穴になります。
素直に書くと、こうしたくなります。
if (!src.includes(ANCHOR)) {
console.warn('anchor not found, skipping');
return cfg;
}
これは避けました。理由は、この分岐が踏まれるのは決まって SDK をまたいだ直後だからです。テンプレートが変わってアンカーが消えると、警告はビルドログの数千行に紛れ、prebuild は成功し、ビルドも通り、アプリも起動します。初期化されていない SDK だけが黙って死んでいる。気づくのは、クラッシュレポートが上がってこないことに数日後に違和感を覚えたときです。
停止させれば、その場で分かります。prebuild が落ちるのはアップグレード作業の真っ最中で、直す文脈がまだ頭にあるうちです。エラーメッセージにアンカー文字列そのものを載せておけば、対処は該当行を探して定数を書き換えるだけで済みます。
同じ注意点は Android 側の withMainApplication にもそのまま当てはまります。テンプレートが変わる頻度は iOS より低いものの、変わったときに静かに素通りする危険は変わりません。
実際に3つの入力を通して確かめました。
[infoPlist] NSCameraUsageDescription = "プロフィール写真の撮影に使用します"
[appDelegate] 2回適用後の [FIRApp configure] 出現回数 = 1 ✅ 冪等
[appDelegate] ✅ アンカー消失で停止: [with-native-edits] AppDelegate のアンカー "self.moduleName = @"main";" が見つ…
冪等性の確認を入れているのは、prebuild が既存の ios/ を残したまま走る場合があるためです。--clean を付けない実行を繰り返すと、置換型の plugin は同じ行を積み上げていきます。挿入前に includes(INJECT) を見るだけで防げます。
ここまでを、上げる前の3ステップに畳む
plugin を書いたら、最初のスクリプトへ戻って答え合わせをします。
- 現状の
ios/ を退避する — mv ios ios.bak
- plugin を
app.json に登録し、npx expo prebuild --platform ios --clean を実行する
node native-drift.mjs ./ios.bak ./ios を走らせ、終了コード0を確認する
3 で 0 が返れば、退避した ios.bak は捨てて構いません。手入れは全て plugin 側へ移り、--clean を何度打っても同じものが再現されます。
実際にこの一巡を通したときの出力です。
── native drift report ──────────────────────
走査ファイル数 : 5
完全一致 : 3
ノイズのみの差分 : 2 ← prebuild が毎回変えるだけ
実質的な手入れ : 0 ← 再生成で失われる
✅ 再生成で失われる手入れはありません。
$ echo $?
0
3 で 0 以外が返るなら、まだ plugin に移せていない手入れが残っています。報告された行をそのまま次の plugin の材料にします。
守るのはディレクトリではなく、手入れの意図
この一連を通して見立てが変わったことが、ひとつあります。
ios/ と android/ を Git にコミットして守る、という方針です。生成物とはいえ手を入れたのだから、消えないように履歴に残しておく。着手前の私はそう考えていました。
けれど、コミットするとノイズが常時 diff に乗ります。pbxproj の 641 行は、コミットした瞬間から毎回のレビュー対象になります。人はそれを読みません。読まない diff の中で、本当に守るべき3行はいちばん埋もれやすい場所に置かれることになります。
数えてみて分かったのは、守るべきものがディレクトリではなかったということでした。守るべきは「なぜその1行を足したのか」という意図であり、それを書き留める場所として ios/ は最も不向きな部類です。plugin へ移せば、意図はコードとコメントとして残り、再生成のたびに自動で適用され、消えたときには停止して知らせてくれます。
SDK 57 は破壊的変更のない、軽い追従になる見込みです。App Store への提出を控えた時期に重い更新をぶつけずに済む、という意味でも、棚卸しには向いた回だと考えています。壊れないと分かっている更新でリハーサルを済ませておけば、次に重い更新が来たときに落ち着いて臨めます。
まずは mv ios ios.bak と prebuild --clean、そして数えるところまで。それだけでも、自分のアプリに何本の手入れが入っていたのかが分かります。私の場合、覚えていた数と実際の数は一致しませんでした。
参考にした一次情報として、config plugin の API は Expo Config Plugins のリファレンス、prebuild の挙動は Expo Prebuild のドキュメント にまとまっています。お読みいただきありがとうございました。