← Blog一覧へ

iOSにも検証用アプリを用意して本番と同時にインストールできるようにした

#tech#iOS#Flutter#GitHub Actions

先日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.plistCFBundleDisplayName
Androidのホーム画面 業務アプリ AndroidManifest.xmlandroid:label

正式名称に合わせてiOSも日本語にし、検証アプリは末尾に「(検証)」を付ける方針にしました。既にインストールしている利用者のホーム画面ではアプリ名が変わって見えるので、周知とセットです。

Android側も対応が要りました。android:labelAndroidManifest.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です。署名のときにアプリへ埋め込まれます。

Universal Linksが開く仕組み。アプリ側は .entitlements に applinks:<ドメイン> を書き、サーバー側は https://<ドメイン>/apple-app-site-association (AASA) に「このアプリIDを許可する」と書く。iOSがこの2つを照合し、アプリ側の宣言とサーバー側の許可が揃って初めてUniversal Linksが成立する。Bundle IDを分けると検証アプリは別のアプリIDになるので、AASAの許可リストに検証アプリIDがなければ開かない。

サーバー側のこのファイルは、頭文字を取って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つ並びました。本番を消さずにテストしてもらう、という当初の目的はこれで達成です。

まとめ

実機には本番と検証が並んで入りました。