iOSにも検証用アプリを用意して本番と同時にインストールできるようにした
先日iOSアプリのTestFlight配信をGitHub Actionsに載せたのですが、次に出てきたのは「検証用のビルドを配ると本番アプリが消える」という問題でした。iOSは同じBundle IDのアプリを端末に1つしか入れられないためです。Bundle IDを分けて別アプリとして扱わせることで解決しました。
前提として、対象はFlutterで書かれた1つのアプリです。Androidにはproduct flavorが3つあり、internal だけ applicationId が別になっています。
productFlavors {
dev { } // 本番と同じID
internal { applicationId "com.example.mobileapp.dev" } // 別アプリ扱い
prod { }
}
iOS側はこうです。
PRODUCT_BUNDLE_IDENTIFIER = com.example.mobileapp ← 1つだけ
CFBundleDisplayName = Mobile App ← 固定
Xcode scheme = Runner のみ
flutter build ipa --flavor internal を叩いても、対応するschemeが無いので失敗します。なお internal の接続先は開発用サーバーです。
本番を消さずにテストしてもらう
Bundle IDを分ける動機は、実際に業務で本番アプリを使っている利用者にテストを依頼する場面があることです。「検証版を入れるので本番を一度消してください」とは言えません。
Androidは applicationId が分かれていたのでこの問題が起きず、iOSだけが未対応のまま残っていました。iOSはBundle IDが違えば完全に別のアプリとして扱うので、両方を同じ端末に入れられます。
iOSのflavorはConfigurationとSchemeで作る
方式は3つ考えられました。
| 方式 | 内容 | 結論 |
|---|---|---|
| Configuration + Scheme を追加 | Flutter公式の方法 | 採用。Androidと同じ --flavor で扱える |
CIで project.pbxproj を置換 |
ビルド時にsedで書き換える | ローカルで再現できない |
| ブランチやリポジトリを分ける | コードごと分ける | 管理不能になる |
初期構築の手間はConfiguration追加が一番大きいのですが、一度作ればローカルでもCIでも同じコマンドで動きます。CIでだけ成立する仕組みは、手元で再現できない不具合を必ず生むので選びませんでした。
追加後の構造はこうなります。
Configuration : Debug / Release / Profile ← 既存(本番用)
Debug-internal / Release-internal / Profile-internal ← 追加
Scheme : Runner ← 既存
internal ← 追加
flutter build ipa --flavor internal を実行すると、Flutterはscheme internal とconfiguration Release-internal を指定して xcodebuild を呼びます。命名は規約で決まっているので、この名前でなければ拾われません。
Configurationごとに変える値は3つです。
| 設定 | 本番(既存) | internal(追加) |
|---|---|---|
PRODUCT_BUNDLE_IDENTIFIER |
com.example.mobileapp |
com.example.mobileapp.dev |
APP_DISPLAY_NAME(新設) |
業務アプリ | 業務アプリ(検証) |
PROVISIONING_PROFILE_SPECIFIER |
既存のApp Store用Profile | 検証用に新規作成したProfile |
Info.plist 側は変数参照に変えておきます。
<key>CFBundleDisplayName</key>
-<string>Mobile App</string>
+<string>$(APP_DISPLAY_NAME)</string>
project.pbxproj の編集は手で書くと壊れるので、xcodeproj gemでスクリプトを書きました。CocoaPodsが依存しているので、Flutterのプロジェクトなら大抵すでに入っています。追加したのはプロジェクトとRunnerとRunnerTestsの各ターゲットに3構成ずつ、計9個です。既存の値をコピーしてから差分だけ上書きする冪等なスクリプトにしたので、再実行しても壊れません。
ホーム画面で区別できないと意味がない
Bundle IDを分けても、アイコンと名前が同じでは端末上で見分けが付きません。表示名を3か所見たら、iOSだけ英語のままでした。
| 場所 | 表示 | 設定箇所 |
|---|---|---|
| App Store | 業務アプリ | App Store Connectの「App名」 |
| iOSのホーム画面 | Mobile App | Info.plist の CFBundleDisplayName |
| Androidのホーム画面 | 業務アプリ | AndroidManifest.xml の android:label |
正式名称に合わせてiOSも日本語にし、検証アプリは末尾に「(検証)」を付ける方針にしました。既にインストールしている利用者のホーム画面ではアプリ名が変わって見えるので、周知とセットです。
Android側も対応が要りました。android:label が AndroidManifest.xml に直書きされていて、flavorごとの上書きが存在しなかったためです。applicationId は分かれているのに表示名は同じ、つまりホーム画面に「業務アプリ」が2つ並ぶ状態でした。
-<application android:label="業務アプリ" ...>
+<application android:label="@string/app_name" ...>
あとは src/main/res/values/ と src/internal/res/values/ にそれぞれ app_name を置けば、flavorごとに切り替わります。
アイコンは追加制作が不要でした。理由としては既にflavorとアセットの対応表で internal は開発用を使う設定になっていたからです。TOP画面の背景とスプラッシュも同様です。
entitlementsは変数展開できない
今回一番はまったのがここです。
このアプリはUniversal Linksを使っています。https:// のURLをタップしたときにブラウザではなくアプリを開く仕組みで、成立にはアプリとサーバーの両方の設定が要ります。アプリ側の宣言を書くのが .entitlements で、アプリがどの機能を使うかをAppleに申告するplistです。署名のときにアプリへ埋め込まれます。

サーバー側のこのファイルは、頭文字を取ってAASAと呼ばれます。どちらか片方だけでは動きません。そしてBundle IDを分けたということは、検証アプリのアプリIDはサーバー側の許可リストに載っていないということです。
最初は開発用サーバーのAASAに検証アプリのIDを足して終わりのつもりでした。ところが検証アプリの接続先設定を見ると、Universal Links用のドメインだけ本番を指したままだったのです。
開発サーバーのAASA : .dev のアプリIDを追加済み
検証アプリのDOMAIN : 本番ドメイン ← 噛み合っていない
entitlements : applinks:本番ドメイン ← 噛み合っていない
検証アプリは本番ドメインのAASAを見に行き、そこに .dev のアプリIDが無いので動かない。本番と検証で経路を完全に分ける方針に決めました。
| 本番アプリ | 検証アプリ | |
|---|---|---|
| API接続先 | example.com |
dev.example.com |
| Universal Linksのドメイン | example.com |
dev.example.com |
| entitlements | Runner.entitlements |
RunnerInternal.entitlements |
ここで、entitlementsを1ファイルのまま両方のドメインを書けば済むのでは、と考えました。これが罠です。理由は2つあります。
まず、entitlementsは Info.plist と違って $(...) の変数展開が効きません。表示名と同じ手は使えない。
そしてもっとまずいのは、1ファイルに両ドメインを書くと本番アプリまで開発ドメインのUniversal Linksを拾ってしまうことです。開発サーバーのAASAには本番のアプリIDも含まれているので、条件が揃ってしまう。本番アプリが開発URLで起動する状態になります。アプリ内でドメインを比較して弾いているので実害は小さいものの、意図しない経路が残るのは気持ち悪い。
結局 RunnerInternal.entitlements を新規作成し、internalの3つのConfigurationに CODE_SIGN_ENTITLEMENTS を割り当てました。
Release → Runner/Runner.entitlements (本番ドメイン)
Release-internal → Runner/RunnerInternal.entitlements (開発ドメイン)
サーバー側は details に検証アプリのエントリを足すだけです。appID は <チームID>.<Bundle ID> の形式になります。
"details": [
{
"appID": "XXXXXXXXXX.com.example.mobileapp",
"paths": ["*"]
+ },
+ {
+ "appID": "XXXXXXXXXX.com.example.mobileapp.dev",
+ "paths": ["*"]
}
]
反映確認で1つ注意が要ります。iOS 14以降、端末はAASAをサーバーから直接ではなくAppleのCDN経由で取得します。しかも取りに行く頻度が低い。
On iOS 14 and later, Apple’s CDN retrieves and caches the AASA file. When your app is installed, devices download the file from the CDN immediately. Devices check for updates approximately once per week after app installation. To download a newer version of the AASA file, reinstall the app. There is no direct CDN invalidation option. (iOS 14以降、AppleのCDNがAASAファイルを取得してキャッシュする。アプリがインストールされると、端末はCDNからファイルを即座にダウンロードする。インストール後、端末は週に1回程度の頻度で更新を確認する。新しいAASAファイルをダウンロードさせるにはアプリを再インストールする。CDNを直接無効化する手段はない) — Apple Developer「TN3155: Debugging universal links — Understand Apple’s CDN」
サーバーを更新してもすぐには反映されないので、直接取得とCDN経由の両方を見ないと状況が分かりません。テスターに配り直すときは、アプリを入れ直してもらうのが確実です。
curl -sS -i https://example.com/apple-app-site-association
curl -sS "https://app-site-association.cdn-apple.com/a/v1/example.com"
なお、検証アプリのリンクは開発ドメインなので、動作確認には開発ドメインのURLを書いたNFCタグやQRコードが要ります。本番のタグをかざしても検証アプリは反応しません。設計どおりの動作ですが、テスターに渡す前に説明が要る部分です。
配信先も別アプリになる
Apple側で増えるのはApp IDとProvisioning Profileだけです。証明書はチーム単位なので本番と共用できます。
App Store Connectでは検証アプリを新規アプリとして作ることになり、TestFlightも別管理になります。ただしApp Storeで一般公開する必要はないので、スクリーンショットや説明文といった申請素材は不要です。
今回の動作確認は、App Store Connectのユーザーとして招待した内部テスターで行いました。内部テスターは審査なしで配信できます。ただし現場の方に配る段になると、相手にApp Store Connectのアカウントを持ってもらうわけにはいかないので外部テスターです。検証アプリは新規アプリ扱いなので、そこで初回のBeta App Reviewが入ります。急ぎで配る予定があるなら、この待ち時間を見込んでおく必要があります。
端末のUDIDを登録するAd Hoc配信もありますが、100台上限で端末が増えるたびにProfileの作り直しが発生します。TestFlightがある以上、選ぶ理由はありません。
ビルドのトリガーは workflow_dispatch の手動実行にしました。検証ビルドは「今から試したい」というタイミングで打つことが多く、タグを残す意味が薄いためです。タグでのビルドは本番だけです。
ビルドが通ったことと、中身が正しいことは別
前回入れたIPAの検証ステップは、SDKとバージョンとBundle IDを表示するだけでした。今回、Bundle IDについては期待値と一致しなければジョブを落とすように変えています。flavorの指定はビルド全体に効くわりに、間違っていても最後まで通ってしまうからです。
手元での確認では、出来上がったIPAを展開して、AOTバイナリ——Dartのコードを事前コンパイルした実行本体——に焼き込まれた文字列を見ました。
.env.internal : 含まれる ← 開発サーバーに接続
.env.prod : 含まれない
assets/initial-dev.png : 含まれる ← 「検証」入りのTOP画面
assets/initial.png : 含まれない
Dartの定数はコンパイル時に解決されるので、どの環境ファイルとアセットがバイナリに入ったかを見れば、flavorが本当に効いているかが分かります。接続先とTOP画面とアイコンと表示名とBundle IDが、すべて検証用に切り替わっていました。
CIの結果もそのまま貼っておきます。
| 項目 | 値 |
|---|---|
| Bundle ID | com.example.mobileapp.dev |
| 表示名 | 業務アプリ(検証) |
| Version / Build | 3.5.1 (3) |
| SDK | iphoneos26.2 |
| 署名 | 検証用Profile |
配ってみたところ、実機のホーム画面に本番と「(検証)」が2つ並びました。本番を消さずにテストしてもらう、という当初の目的はこれで達成です。
まとめ
- iOSは同じBundle IDのアプリを端末に1つしか入れられない。本番を消さずにテストしてもらうには、Bundle IDごと分けて別アプリにする
- iOSのflavorはXcodeのConfigurationとScheme追加で作る。
--flavor internalは schemeinternalと configurationRelease-internalを規約で探しに行く project.pbxprojの編集はxcodeprojgemで冪等なスクリプトにする。手書きもsed置換も壊れる- Bundle IDを分けただけでは端末上で見分けが付かない。表示名とアイコンをflavorごとに切り替えるところまでが1セット
- entitlementsは変数展開が効かないのでファイルごと分ける。1ファイルに両ドメインを書くと本番アプリまで開発ドメインのリンクを拾う
- AASAはAppleのCDN経由で配られ、端末が更新を見に行くのは週1回程度。確実に反映させるならアプリの再インストールが早い
- 検証アプリはApp Store Connect上も別アプリ。内部テスターなら審査なしで配れるが、外部テスターへの初回配信ではBeta App Reviewが入る
実機には本番と検証が並んで入りました。