作業メモを移行しているため、前提条件が抜けており、一部わかりづらい記載があります。
過去の現場で、Flutter アプリの flavor 対応するまでに行ったことをまとめる。せっかくいい経験ができたので、忘れないにようにメモとして残す。
基本的には、下の記事で書いてあることをベースにまとめている。この記事では、Flutter アプリを自動リリースするまでの話まで拡張している。
こちらの記事については、flutter 3.10.6 で動作させた場合の内容となっています。下の記事では、3.16 移行で動作させており、dart-define-from-file で定義した環境変数をデコードするなどが追加で発生する可能性があります。
【Flutter 3.19対応】Dart-define-from-fileを使って開発環境と本番環境を分ける
https://zenn.dev
現在 2023 年 10 月現在、開発と本番での設定を Xcode の xcconfig
や Android の AndroidManifest.xml
で内部で切り替えることができない。そのため、普段の開発のベースブランチには開発用の Certificate、Provisioning File や外部サービスまでの API キーが埋め込みされている。それらをオプションで渡す環境によって動的に変更できないため、審査ビルドの提出時にはそれらを手動で本番用のものに切り替える作業が発生する。
その作業が発生することから、以下の問題が発生しており、解決するのが今回のモチベーションとなる。
Flutter 3.7 以降で使える --dart-define-from-file
というオプションがあるので、今回はそれを多く利用する。これは今までの --dart-define
オプションの延長になる。これを使用すると --dart-define
に key=value で渡していた値たちを 1 つの JSON ファイルにまとめて定義したものをビルド時に伝えられる。
↓ ipa と appbundle 生成コマンドの --dart-define-from-file
オプションの説明は下になる。
➜ apps git:(develop) ✗ fvm flutter build ipa --help
Build an iOS archive bundle and IPA for distribution (macOS host only).
--dart-define-from-file=<use-define-config.json> The path of a json format file where flutter define a global constant pool. Json entry will be available as constants from the String.fromEnvironment, bool.fromEnvironment, int.fromEnvironment, and double.fromEnvironment constructors; the key and field are json values.
そのため、--dart-define-from-file
オプションに渡す環境毎の値を列挙した JSON を用意する。作るファイルは以下の 3 つになる。
それぞれに定義する key=value はこちら。
キー名 | 値 |
---|---|
ENVIORNMENT | 環境 (DEV / STAGING / PROD) |
PROVISIONING_PROFILE_SPECIFIER_ENVIRONMENT | iOS の provisioning file の名前に含まれている環境 (dev / staging / prod) |
APP_TITLE | 端末のホーム画面でアプリアイコンの下に表示されるタイトル |
APPLICATION_ID_SUFFIX | dev と staging には .dev をつける。prod は空文字にする。 |
DYNAMIC_LINK_DOMAIN | Firebase dynamic link 使用しているアプリについては、そのドメインを入れる。 |
SCHEME | スキームを有効にしたい場合は、そのアプリのスキームを入れる |
GOOGLE_MAP_API_KEY | GOOGLE Map を使用しているアプリについては、その API キーを入れる。 |
例がこちら。
{
"ENVIRONMENT": "DEV",
"LOWERCASE_ENVIRONMENT": "dev",
"APP_TITLE": "app title (DEV)",
"APP_ID": "apps.jp.dev",
"APPLICATION_ID_SUFFIX": ".dev",
"DYNAMIC_LINK_DOMAIN": "apps.page.link",
"SCHEME": "apps_scheme",
"GOOGLE_MAP_API_KEY": "google map api key"
}
基本的に形式は以下の形になる。ベースとなる ID についてはビジネス側に決めてもらう運用なので、アプリ開発スタート時に決めてもらうように依頼する。
このとき 2023 年 10 月まで従来のアプリたちの Bundle ID と Package Name を使わずに、変更する必要がある。本当はこの変更しようにしたかったが、難しかった理由があるので、記述する。
AndroidManifest.xml
の <manifest>
タグの package
属性を動的に変更できないのが一番の原因にある。後述に書いた Android の flavor 対応しているときに、環境毎の設定を持った JSON を manifest.xml
に伝えるが、<package>
属性に渡した変数が展開されなかった。
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="${ここを @string/application_id などで動的に変えることができない。変数が展開されなかった}"
>
...
</manifest>
これにより build.gradle
に定義している applicationId
と <manifest>
タグの package 属性の値が違うことによりビルドができない。<manifest>
タグの package 属性を決めうちがならないといけないことがわかったので、代案を考えることになった。
そこで、 Android のドキュメント を読んだところ、applicationIdSuffix
というオプションが build.gradle
にある。applicationId
を build.gradle
に書くと package
属性の値を applicationId
として利用できると考えた。本番ではマーケティングチームからいただいた package name をそのまま使い、開発では .dev
の接尾後をつける。これにより、最終的に本番と開発で違う package name を使用できる。
注意: 以前の SDK ツールとの互換性維持のため、build.gradle ファイルで applicationId プロパティを定義していない場合は、ビルドツールでは AndroidManifest.xml ファイルのパッケージ名がアプリケーション ID として使用されます。その場合、パッケージ名をリファクタリングすると、アプリケーション ID も変更されます。
defaultConfig {
applicationIdSuffix APPLICATION_ID_SUFFIX
...
}
このような Android の事情から、開発の package 名の変更しないといけなく、それにより iOS の bundle ID と揃えるのがわかりやすいかと考えた。それにより、Package Name と Bundle ID を変更する。iOS については、変更する上でそのクライアント企業の Apple Developer アカウントで証明書や Provisioning file を作り直さないといけない。
開発本体 | 本番本体 |
---|---|
${アプリの省略語}.dev | ${ビジネス側が決めた ID} |
開発 image notification | 本番 image notification |
---|---|
${アプリの省略語}.dev.imageNotification | ${ビジネス側が決めた ID}.imageNotification |
開発本体 | 本番本体 |
---|---|
${ビジネス側が決めた ID}.dev | ${ビジネス側が決めた ID} |
開発 image notification | 本番 image notification |
---|---|
${ビジネス側が決めた ID}.dev.imageNotification | ${ビジネス側が決めた ID}.imageNotification |
まず前提として、dart-define-from-file
オプションで JSON を渡して IPA を生成すると、key=value が入った Generated.xcconfig
が生成される。以下は上の JSON を使ってビルドしたときに生成された中身となる。
ENVIRONMENT=STAGING
PROVISIONING_PROFILE_SPECIFIER_ENVIRONMENT=dev
APP_TITLE=apps title(DEV)
APPLICATION_ID_SUFFIX=
DYNAMIC_LINK_DOMAIN=apps.page.link
SCHEME=apps_scheme
GOOGLE_MAP_API_KEY=google map api key
これを project.pbxproj
に伝える作業が必要となる。
build setting タグをクリックし、そこで $(KEY_NAME) という形で、適宜名前を定義していく。例えば、OS のホーム画面で表示されるアプリ名は bundle display name
の値で決まるので、Info.plist
で $(APP_TITLE)
を参照するように設定を変更する。
Dynamic link か Health SDK をアプリに導入するときは別途対応が必要とする。以下 3 つの entitlements ファイルを用意する。
下の例は、ローカルでの開発のときに参照される entitlements なので、開発用の dynamic link のドメインを入れる。PROD には本番のドメインを入れる。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:devitaco.page.link</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
</dict>
</plist>
Firebase を使っているため、設定ファイルをプロジェクトにいれないといけないが、それも環境によって動的に変更しないといけない。
エントリーポイントへコピーするタスクを書く前にまずはコピーする設定ファイルを用意する。
Copy Bundle Resources の上に配置することを注意する。また Output Files に $(SCROOT)/GoogleService-info.plist
を入れることを忘れずに。
出力された Generated.xcconfig
をラップした imageNotificaition 用の xcconfig を新たに作成する。内容については、元々ある xcconfig
ファイルと Generated.xcconfig
を合わせたものになる。
#include "Flutter/Generated.xcconfig"
#include? "Pods/Target Support Files/Pods-ImageNotification/Pods-ImageNotification.debug.xcconfig"
#include "Flutter/Generated.xcconfig"
#include? "Pods/Target Support Files/Pods-ImageNotification/Pods-ImageNotification.release.xcconfig"
この 2 つを xcconfig 設定で参照する。image nofitication のほうの info.plist や build setting で xcconfig に書いてある key=value を参照できるように設定する。
GitHub Actions で Testflight へ審査ビルドをアップロード行う workflow を構築する。 GitHub Actions の CI に埋めないといけないものや参照するファイルの作成するやり方を記載する。
flutter build ipa
サブコマンドの --export-options-plist
オプションに plist
を渡さないと ipa
ファイルを生成するところでエラーが出てしまう。なので、 Firebase App Distribution にアプリをデプロイするまでの流れ を参考に、plist を作成する。
※ GitHub Actions での指定サンプル、ExportOptions/
配下の plist にローカルで発行した plist の情報を記載して更新する。
$ flutter build ipa --export-options-plist=ios/ExportOptions/stg.plist --release --dart-define-from-file=flavor/STAGING.json
先にローカルで審査用のビルドを作る。これは審査用なので、本番用の証明書や provisioning file が使用されていることを事前に確認すること。あとは上の :esa: を頼りに、xcarchive を ipa として export し、ExportOptions.plist
ファイルを入手する。
$ fvm flutter build ipa --release --dart-define-from-file=flavor/PROD.json
後述の Testflight へ審査ビルドをアップロードする CLI で ISSUER_ID と KEY_ID を渡さないといけない。メールアドレスとパスワードを使用できるが、セキュリティ的に甘いのと開発用のアカウントを別途用意する手間を省きたい。そのため、ISSUER_ID とキーID の両方が必要になる。その 2 つを取得するため、 App Store Connect のキータブをクリックし、キーを生成する。
生成については、権限が必要なので、足りない場合はビジネス側に依頼する。 ここで注意なのは保存について気をつけること。キーは一度ダウンロードしたら二度とできないので、しっかり google drive に保管する。
AuthKey を GitHub Actions で動かすマシンに伝えないといけないので、以下のキー名でそれぞれ値を secrets に入れる。
ipa の validate と upload については、xcode-cli
で提供される CLI コマンドを使用する。完成した CLI をここにも貼っておく。本番用の証明書と Provisioning file を base64 化した値を下のキーに対して、GitHub Actions の secrets に入れていく。
name: Deploy Prod iOS
on:
workflow_dispatch:
jobs:
build:
runs-on: macos-12
timeout-minutes: 60
steps:
- name: Select Xcode version
run: sudo xcode-select -s '/Applications/Xcode_14.2.app/Contents/Developer'
- uses: actions/checkout@v3
with:
fetch-depth: 1
- name: make ssh directory
run: |
mkdir -p $HOME/.ssh/
- name: checkout module-core
env:
TOKEN: ${{ secrets.SUBMODULE_ACCESSKEY }}
run: |
echo -e "$TOKEN" > $HOME/.ssh/id_module_core_rsa
chmod 600 $HOME/.ssh/id_module_core_rsa
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/id_module_core_rsa"
git submodule update --init --force --recursive lib_core
- name: checkout module-health
env:
MODULE_HEALTH_ACCESSKEY: ${{ secrets.MODULE_HEALTH_ACCESSKEY }}
run: |
echo -e "$MODULE_HEALTH_ACCESSKEY" > $HOME/.ssh/id_module_health_rsa
chmod 600 $HOME/.ssh/id_module_health_rsa
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/id_module_health_rsa"
git submodule update --init --recursive module-health
- name: checkout module-payment
env:
MODULE_PAYMENT_ACCESSKEY: ${{ secrets.MODULE_PAYMENT_ACCESSKEY }}
run: |
echo -e "$MODULE_PAYMENT_ACCESSKEY" > $HOME/.ssh/id_module_payment_rsa
sudo chmod 600 $HOME/.ssh/id_module_payment_rsa
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/id_module_payment_rsa"
git submodule update --init --recursive module-payment
- name: Create dot env file
run: |
touch .env
echo "${{ secrets.SECRETS_ENV }}" >> .env
- name: Import Provisioning Profile
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo '${{ secrets.PROVISIONING_PROFILE_PROD }}' | base64 -d > ~/Library/MobileDevice/Provisioning\ Profiles/apps\ dev.mobileprovision
echo '${{ secrets.NOTIFICATION_SERVICE_PROVISIONING_PROFILE_PROD }}' | base64 -d > ~/Library/MobileDevice/Provisioning\ Profiles/dev\ ImageNotification.mobileprovision
- name: Import Code-Signing Certificates
uses: Apple-Actions/import-codesign-certs@v1
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12_PROD }}
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD_PROD }}
- uses: kuhnroyal/flutter-fvm-config-action@v1
id: fvm-config-action
- uses: subosito/[email protected]
with:
channel: ${{ steps.fvm-config-action.outputs.FLUTTER_CHANNEL }}
flutter-version: ${{ steps.fvm-config-action.outputs.FLUTTER_VERSION }}
- name: flutter dependencies install
run: flutter pub get
- name: build ipa
run: flutter build ipa --export-options-plist=ios/ExportOptions/prod.plist --release --dart-define-from-file=flavor/PROD.json
- name: Upload to artifacts
uses: actions/[email protected]
with:
name: ios
path: /Users/runner/work/apps/demo/build/ios/ipa/module_app.ipa
if-no-files-found: error
upload:
needs: [build]
runs-on: macos-12
timeout-minutes: 20
steps:
- name: Select Xcode version
run: sudo xcode-select -s '/Applications/Xcode_14.2.app/Contents/Developer'
- uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Download artifact
uses: actions/[email protected]
with:
name: ios
path: ios
- name: Create private keys
run: |
mkdir -p ~/.private_keys && touch ~/.private_keys/AuthKey_{{ secrets.APP_STORE_CONNECT_KEY_ID }}.p8
echo "${{ secrets.APP_STORE_CONNECT_PRIVATE_KEYS }}" >> ~/.private_keys/AuthKey_{{ APP_STORE_CONNECT_KEY_ID }}.p8
- name: Validate ipa
run: |
xcrun altool --validate-app -f ios/module_app.ipa -t ios --apiKey ${{ secrets.APP_STORE_CONNECT_KEY_ID }} --apiIssuer ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
- name: Upload to TestFlight
run: |
xcrun altool --upload-app -f ios/module_app.ipa -t ios --apiKey ${{ secrets.APP_STORE_CONNECT_KEY_ID }} --apiIssuer ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
- name: Delete artifact
uses: geekyeggo/delete-artifact@v1
with:
name: ios
dart-define-from-file
で定義した値を AndroidManifest.xml
に伝えるには先に build.gradle に伝える必要がある。
android {
defaultConfig {
applicationIdSuffix APPLICATION_ID_SUFFIX
minSdkVersion 26
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
resValue "string", "app_title", APP_TITLE
resValue "string", "dynamic_link_domain", DYNAMIC_LINK_DOMAIN
resValue "string", "scheme", SCHEME
resValue "string", "google_map_api_key", GOOGLE_MAP_API_KEY
}
}
上の DYNAMIC_LINK_DOMAIN
を例に取ると、AndroidManifest.xml に伝えるのは下の形となる。これを適宜必要な箇所で置き換えて、値が動的にする。
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:host="@string/dynamic_link_domain"
android:scheme="https"
/>
</intent-filter>
アプリたちは Firebase を使っているため、Firebase との接続で使う設定ファイルをプロジェクトにいれないといけない。それも環境によって動的に変更しないといけない。
エントリーポイントへコピーするタスクを書く前にまずはコピーする設定ファイルを用意する。
Firebase の設定ファイルが環境毎で変わるようにする。エントリーポイントのパスは android/app/google-services.json
なので、そこにコピーする。
task selectGoogleServicesJson(type: Copy) {
from "src/$ENVIRONMENT/google-services.json"
into './'
}
tasks.whenTaskAdded { task ->
task.dependsOn copySources
if (task.name == 'processDebugGoogleServices' || task.name == 'processReleaseGoogleServices') {
task.dependsOn selectGoogleServicesJson
}
}
GitHub Actions で Google Play Console の内部テストへのアップロードを行う workflow を構築する。前提として、GitHub ActionsによるGoogle Play Consoleへのアプリ自動アップロードの取り組み の記事を参考して、workflow を構築する。
GitHub ActionsによるGoogle Play Consoleへのアプリ自動アップロードの取り組み - ZOZO TECH BLOG
https://techblog.zozo.com
各アプリのプロジェクトで Google Play Android Developer API を有効にしてください。 有効にしたら、下のような画面が表示されます。
ここはビジネスサイドにお願いして作成してもらう。ここでの作業は上の記事の通りに作成すればいいので、割愛する。
サービスアカウントで発行した JSON を下のキーで保存する。
以下の GitHub Actions を追加する。
deploy:
needs: [build]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 1
- name: create-json
id: create-json
uses: jsdaniell/[email protected]
with:
name: "service_account.json"
json: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
- name: Download artifact
uses: actions/[email protected]
with:
name: android
path: android
- name: upload artifact to Google Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: service_account.json
packageName: ${{ package name }}
releaseFiles: android/app-release.aab
track: internal
- name: Delete artifact
uses: geekyeggo/delete-artifact@v1
with:
name: android
上記の Android の package name の変更があり、最終的に iOS の ApplicationId も変更することになった。しかし、事前の技術検証フェーズで、dart-define-from-file
に記載した development team id が事前解決されず、ipa 生成でエラーになってしまった。(エラーログを取っておけばよかった。)
同じような issue が見つかればよかったのだが、それが見つからなかった。dart-define-from-file
の仕様かバグなのかは要調査する。もしかしたら、flutter の話だけではなく、iOS 開発において、development team をそもそも動的に変更できない可能性もある。
まだアプリ自体が公開されていないときには draft
を渡し、その後では completed
を渡す必要がある。(参照: 未公開のリリース)
- name: upload artifact to Google Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: service_account.json
packageName: ${{ package name }}
releaseFiles: android/app-release.aab
track: internal
# まだアプリ自体が一度も公開されていない状態だと draft に指定しないといけないため、初期リリースのバージョン 1.0.0 のときは draft を渡す
status: ${{ needs.build.outputs.VERSION_NAME == '1.0.0' && 'draft' || 'completed' }}