昨日の夕方、公開中の Android アプリを並べて targetSdkVersion を数え直していました。Google Play の API レベル 36 必須化が明日に迫っているためです。個人開発で複数本を抱えていると、どれが片づいていてどれが残っているのか、記憶はあてになりません。
一通り確認したあと、念のため npx expo-doctor を通しました。全項目が緑で返ってきました。
そこで安心しかけて、手が止まりました。この緑は、何を保証してくれた緑なのでしょうか。
恥ずかしながら、私は expo-doctor が何を見ているのかを正確には把握していませんでした。配布物を開けば読める話です。読んだ結果、期限に関わる部分については、想像とかなり違っていました。
expo-doctor 1.20.4 に登録されている 22 項目
インストールせずに中身だけ確認したかったので、npm レジストリから配布物を直接取り出しました。プロジェクトを汚さずに済みます。
META=$(curl -s https://registry.npmjs.org/expo-doctor)
echo "$META" | node -e "
let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>{
const j=JSON.parse(s); const v=j['dist-tags'].latest;
console.log('latest =', v);
console.log('tarball =', j.versions[v].dist.tarball);
});"
# latest = 1.20.4
# tarball = https://registry.npmjs.org/expo-doctor/-/expo-doctor-1.20.4.tgz
curl -sL https://registry.npmjs.org/expo-doctor/-/expo-doctor-1.20.4.tgz -o expo-doctor.tgz
tar xzf expo-doctor.tgzpackage/build/index.js は 984 KB の単一バンドルで、各チェックはクラスとして並んでいます。クラス名・説明文・適用される SDK バージョン範囲は、次の 1 コマンドで取り出せます。
node -e "
const s = require('fs').readFileSync('package/build/index.js', 'utf8');
const re = /class\s+(\w+Check\w*)\s*\{description=\"([^\"]+)\";sdkVersionRange=\"([^\"]*)\"/g;
let m;
while ((m = re.exec(s))) console.log(m[1].padEnd(46), m[3].padEnd(20), m[2]);
"出力された 22 項目を、適用範囲とあわせて整理したものが次の表です。範囲が * の項目は SDK バージョンに関係なく走ります。
| チェック名 | 適用 SDK 範囲 | 見ている内容 |
|---|---|---|
| StoreCompatibilityCheck | * | ストア提出のバージョン要件 |
| ExpoConfigSchemaCheck | * | app.json / app.config.js のスキーマ |
| ExpoConfigCommonIssueCheck | * | Expo 設定にありがちな記述ミス |
| AppConfigFieldsNotSyncedToNativeProjectsCheck | * | 非 CNG 構成で native 側へ反映されない設定項目 |
| PackageJsonCheck | * | package.json の一般的な問題 |
| PackageManagerVersionCheck | * | npm / yarn のバージョン |
| LockfileCheck | * | ロックファイルの有無 |
| EnvLocalFilesCheck | * | ローカル環境ファイルのコミット混入 |
| NativeToolingVersionCheck | * | native ツールチェーンのバージョン |
| PeerDependencyChecks | * | 必須 peer 依存の不足 |
| ProjectSetupCheck | * | プロジェクト構成のよくある不備 |
| IllegalPackageCheck | >=44.0.0 | native モジュールが持つ非互換なサポートパッケージ |
| SupportPackageVersionCheck | >=45.0.0 <54.0.0 | サポートパッケージのバージョン整合 |
| GlobalPackageInstalledLocallyCheck | >=46.0.0 | 旧グローバル CLI のローカル混入 |
| InstalledDependencyVersionCheck | >=46.0.0 | SDK が要求するバージョンとの一致 |
| MetroConfigCheck | >=51.0.0 | Metro 設定の問題 |
| ReactNativeDirectoryCheck | >=51.0.0 | React Native Directory のメタデータ照合 |
| AutolinkingDependencyDuplicatesCheck | >=54.0.0 | 依存の重複インストール |
| DependencyVersionOverrideCheck | >=55.0.0 | 上書きされた依存バージョン |
| HermesV1VersionCheck | >=55.0.0 <58.0.0 | Hermes V1 の退行を含む SDK バージョン |
| VectorIconsCheck | >=56.0.0 | @expo/vector-icons と競合するアイコンパッケージ |
| ExpoRouterReactNavigationCheck | >=56.0.0 <57.0.0 | expo-router と @react-navigation の同居 |
眺めていて気づくのは、大半が「依存関係とプロジェクト構成の健康診断」であることです。パッケージのバージョン、設定ファイルの書式、重複、ロックファイル。手元の作業環境が壊れていないかを見る道具として、非常によくできています。
そして、ストア側の要件に触れている項目は、この 22 のうち 1 つだけです。
ストアを見ている項目は 1 つで、基準は 2024 年のまま
StoreCompatibilityCheck の定義部分を取り出すと、判定に使う定数がそのまま書かれています。
// package/build/index.js から抜粋(expo-doctor 1.20.4)
u.PLAY_STORE_MINIMUM_REQS = {
effectiveDate: "August 31 2024",
AndroidSdkVersion: 34,
ExpoSdkVersion: 50
};effectiveDate が 2024 年 8 月 31 日、基準となる API レベルが 34 です。これは 2 年前の Google Play の要件であり、明日 2026 年 8 月 31 日から適用される API レベル 36 の要件ではありません。
判定のロジックも素直で、targetSdkVersion が 34 未満のときだけ警告を出します。34、35、36 はいずれも「問題なし」として扱われます。つまり、targetSdkVersion 35 のまま放置しているアプリに対して、expo-doctor は今日も緑を返します。
android ディレクトリを持たない CNG 構成の場合は、expo-build-properties プラグインの android.targetSdkVersion を見て、それもなければ Expo SDK が 50 以上かどうかで判定します。SDK 57 を使っていれば、この分岐でも当然のように通過します。
名前は StoreCompatibilityCheck と複数形ですが、参照している定数は PLAY_STORE_MINIMUM_REQS の 1 つだけです。バンドル内に App Store 側の要件を表す定数は見当たりませんでした。iOS の最低 OS バージョンや Xcode の要件は、この 22 項目の外にあります。App Store と Google Play の両方へ出している場合、片方しか見ていない道具だという前提で読む必要があります。
これは expo-doctor の欠陥というより、道具の役割の問題だと私は受け止めています。expo-doctor はプロジェクトの整合性を見る道具であって、ストアの審査要件を追いかける道具として設計されてはいません。ただ、名前と出力の見た目から「提出前の最終確認」として使ってしまう人は、私を含めて少なくないはずです。
build.gradle に書いた値が読まれない条件
もう一段掘ってみると、android ディレクトリがある構成では、さらに手前で判定が止まる場合がありました。値の読み取りに使われている正規表現は次のものです。
const E = /^\s*targetSdkVersion\s*=\s*(?:Integer\.parseInt\(findProperty\('android\.targetSdkVersion'\)\s*\?:\s*'(\d+)'\)|'(\d+)')/m;
const w = i.match(E)?.[1]; // 参照しているのは 1 番目のキャプチャグループ括弧が 2 つあります。1 番目は Integer.parseInt(findProperty(...) ?: '35') 形式の中身、2 番目は targetSdkVersion = '35' のような素のクォート形式です。ところが値を取り出す側は 1 番目しか参照していません。素のクォート形式で書かれていると、正規表現自体は一致するのに、読み取られる値は undefined になります。
同じ定数と正規表現をそのまま持ってきて、書き方を変えながら通してみました。
// check-store-gate.mjs
const PLAY_STORE_MINIMUM_REQS = { AndroidSdkVersion: 34 };
const TARGET_SDK_RE =
/^\s*targetSdkVersion\s*=\s*(?:Integer\.parseInt\(findProperty\('android\.targetSdkVersion'\)\s*\?:\s*'(\d+)'\)|'(\d+)')/m;
function judge(gradleText) {
const raw = gradleText.match(TARGET_SDK_RE)?.[1];
const value = raw ? parseInt(raw, 10) : undefined;
const flagged = !!(value && value < PLAY_STORE_MINIMUM_REQS.AndroidSdkVersion);
return { matched: TARGET_SDK_RE.test(gradleText), value, verdict: flagged ? '警告あり' : '警告なし' };
}
const samples = [
["targetSdkVersion = '33'", "ext {\n targetSdkVersion = '33'\n}"],
["targetSdkVersion = '35'", "ext {\n targetSdkVersion = '35'\n}"],
['targetSdkVersion = 35', 'ext {\n targetSdkVersion = 35\n}'],
["parseInt(... ?: '33')", "ext {\n targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '33')\n}"],
["parseInt(... ?: '35')", "ext {\n targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '35')\n}"],
['(ext ブロックなし)', 'apply plugin: "expo-root-project"'],
];
for (const [label, text] of samples) {
const r = judge(text);
console.log(label.padEnd(26), 'regex一致=' + String(r.matched).padEnd(6),
'読み取り値=' + String(r.value).padEnd(10), r.verdict);
}手元の Node.js 22.23.2 で実行した結果です。
| build.gradle の書き方 | 正規表現の一致 | 読み取られた値 | 判定 |
|---|---|---|---|
targetSdkVersion = '33' | 一致 | undefined | 警告なし |
targetSdkVersion = '35' | 一致 | undefined | 警告なし |
targetSdkVersion = 35 | 不一致 | undefined | 警告なし |
Integer.parseInt(... ?: '33') | 一致 | 33 | 警告あり |
Integer.parseInt(... ?: '35') | 一致 | 35 | 警告なし |
| ext ブロックなし | 不一致 | undefined | 警告なし |
targetSdkVersion = '33' と書いてあっても警告は出ません。値を読み取れているのは Integer.parseInt(findProperty(...)) 形式だけでした。
さらに、SDK 57 の bare テンプレート(expo-template-bare-minimum@57.0.20)の android/build.gradle を実際に取り出して確認したところ、ext ブロックに targetSdkVersion の記述そのものがありませんでした。android/app/build.gradle の 94 行目が targetSdkVersion rootProject.ext.targetSdkVersion を参照し、その値は expo-root-project プラグイン側で決まる構成になっています。この形では、そもそも正規表現が当たる文字列が存在しません。
つまり現行 SDK の標準構成では、StoreCompatibilityCheck は android ディレクトリを見つけても、値を読めないまま通過します。緑が返ってくるのは当然でした。
黙らせられる項目と、黙らせない方がよい項目
バンドルを読んでいて、もう一つ実務で効きそうな部分が見つかりました。いくつかのチェックは環境変数と package.json の設定で挙動を変えられます。
| 指定方法 | 効果 | 私の使い分け |
|---|---|---|
EXPO_DOCTOR_SKIP_DEPENDENCY_VERSION_CHECK | SDK 要求バージョンとの照合チェックを登録しない | 常用しない。意図的に固定している依存がある短期間のみ |
EXPO_DOCTOR_ENABLE_DIRECTORY_CHECK | React Native Directory 照合の有効・無効を強制。設定より優先 | CI で外部 API に依存させたくないときに無効化 |
EXPO_DOCTOR_WARN_ON_NETWORK_ERRORS | 失敗が全て通信エラー起因なら終了コードを落とさない | CI では入れておく。ネットワークの揺れでビルドを止めない |
expo.doctor.reactNativeDirectoryCheck.exclude | 照合から除外するパッケージ。/正規表現/ 形式も可 | 社内・自作モジュールの登録に使う |
expo.doctor.reactNativeDirectoryCheck.listUnknownPackages | false でメタデータ未登録パッケージの警告を抑制 | ノイズが多いときだけ |
expo.doctor.appConfigFieldsNotSyncedCheck.enabled | 非 CNG 構成向けチェックの有効・無効 | 触らない |
EXPO_DOCTOR_WARN_ON_NETWORK_ERRORS の挙動は、実装を読むと分かりやすい形でした。失敗したチェックのうち通信エラー起因のものを数え、それが失敗全体と同数のときだけ、終了コードを落とさずに戻ります。通信エラー以外の失敗が 1 件でも混ざっていれば、通常どおり失敗として扱われます。CI に入れておいて実害が出にくいのは、この設計のおかげです。
一方で EXPO_DOCTOR_SKIP_DEPENDENCY_VERSION_CHECK は、私は常用しないようにしています。これを付けるとチェック自体が登録されず、代わりに「無効化されています」というログが 1 行出るだけになります。無効にした事実は残りますが、次にそのリポジトリを触る自分がログを読むとは限りません。
明日以降、何で確認するか
今回分かったことを、自分向けの結論として 3 行にまとめておきます。
expo-doctor の緑は、依存関係とプロジェクト構成が整っている証明です。ストアの現行要件を満たしている証明ではありません。API レベル 36 の判定には、別の手段が要ります。
実効値の確認は、ビルド成果物か Play Console 側で行うのが確実です。手元の設定ファイルの見た目と、実際にビルドへ渡っている値がずれる箇所については、Rork のプロジェクトで targetSdkVersion 36 を通すまでに直した3か所に整理しています。依存ライブラリ側が引きずってくる要素については、targetSdk を 36 にした後、依存ライブラリのどこを見るかが近い話題です。
そして、今日の作業としてお勧めしたいことが 1 つあります。手元のアプリの android/app/build.gradle を開き、targetSdkVersion が直値なのか rootProject.ext 経由なのかを確認してください。後者であれば、expo-doctor の判定はその値に届いていません。数分で終わります。自分が使っている Expo のパッチ版を把握しておく手順は、30日で9回出た Expo のパッチのうち、自分のアプリに入っているのはどれかを確かめるにまとめました。
道具を疑ったというより、道具の担当範囲を勘違いしていたのは私のほうでした。読んでよかったと思っています。お読みいただきありがとうございました。