Flutter アプリを flavor 対応から自動リリースするまでの道のり

作成日: 2023-10-01 /

作業メモを移行しているため、前提条件が抜けており、一部わかりづらい記載があります。

概要

過去の現場で、Flutter アプリの flavor 対応するまでに行ったことをまとめる。せっかくいい経験ができたので、忘れないにようにメモとして残す。

基本的には、下の記事で書いてあることをベースにまとめている。この記事では、Flutter アプリを自動リリースするまでの話まで拡張している。

こちらの記事については、flutter 3.10.6 で動作させた場合の内容となっています。下の記事では、3.16 移行で動作させており、dart-define-from-file で定義した環境変数をデコードするなどが追加で発生する可能性があります。

【Flutter 3.19対応】Dart-define-from-fileを使って開発環境と本番環境を分ける

【Flutter 3.19対応】Dart-define-from-fileを使って開発環境と本番環境を分ける

https://zenn.dev

【Flutter 3.19対応】Dart-define-from-fileを使って開発環境と本番環境を分ける

モチベーション

現在 2023 年 10 月現在、開発と本番での設定を Xcode の xcconfig や Android の AndroidManifest.xml で内部で切り替えることができない。そのため、普段の開発のベースブランチには開発用の Certificate、Provisioning File や外部サービスまでの API キーが埋め込みされている。それらをオプションで渡す環境によって動的に変更できないため、審査ビルドの提出時にはそれらを手動で本番用のものに切り替える作業が発生する。

その作業が発生することから、以下の問題が発生しており、解決するのが今回のモチベーションとなる。

  • 上の本番用の設定に切り替える作業は手動になるため、どうしても作業漏れが発生する。毎回チェックするコストのもコストが高い。
  • 両 OS のアプリのリリース作業を並行で行えない。先に片方の OS の審査ビルドを提出して、もう片方の作業。両 OS の審査ビルド提出までの時間がかかっている。
  • 属人性が高い。人によって作業効率が高い、気をつけるべきポイントなどが変わってしまう。
  • タグ作成と一緒のタイミングでリリースを行えておらず、バージョンが実態とずれてしまう。

対応内容

両 OS の対応

1. 環境毎に動的に変わる値を列挙した JSON を用意

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 つになる。

  1. flavor/DEV.json
  2. flavor/STAGING.json
  3. flavor/PROD.json

それぞれに定義する key=value はこちら。

キー名
ENVIORNMENT環境 (DEV / STAGING / PROD)
PROVISIONING_PROFILE_SPECIFIER_ENVIRONMENTiOS の provisioning file の名前に含まれている環境 (dev / staging / prod)
APP_TITLE端末のホーム画面でアプリアイコンの下に表示されるタイトル
APPLICATION_ID_SUFFIXdev と staging には .dev をつける。prod は空文字にする。
DYNAMIC_LINK_DOMAINFirebase dynamic link 使用しているアプリについては、そのドメインを入れる。
SCHEMEスキームを有効にしたい場合は、そのアプリのスキームを入れる
GOOGLE_MAP_API_KEYGOOGLE Map を使用しているアプリについては、その API キーを入れる。

例がこちら。

apps/flavor/DEV.json
{
  "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"
}

2. iOS (Bundle ID) / Android(Package name) の変更

基本的に形式は以下の形になる。ベースとなる ID についてはビジネス側に決めてもらう運用なので、アプリ開発スタート時に決めてもらうように依頼する。

このとき 2023 年 10 月まで従来のアプリたちの Bundle ID と Package Name を使わずに、変更する必要がある。本当はこの変更しようにしたかったが、難しかった理由があるので、記述する。

AndroidManifest.xml<manifest> タグの package 属性を動的に変更できないのが一番の原因にある。後述に書いた Android の flavor 対応しているときに、環境毎の設定を持った JSON を manifest.xml に伝えるが、<package> 属性に渡した変数が展開されなかった。

AndroidManifest.xml
<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にある。applicationIdbuild.gradle に書くと package 属性の値を applicationId として利用できると考えた。本番ではマーケティングチームからいただいた package name をそのまま使い、開発では .dev の接尾後をつける。これにより、最終的に本番と開発で違う package name を使用できる。

注意: 以前の SDK ツールとの互換性維持のため、build.gradle ファイルで applicationId プロパティを定義していない場合は、ビルドツールでは AndroidManifest.xml ファイルのパッケージ名がアプリケーション ID として使用されます。その場合、パッケージ名をリファクタリングすると、アプリケーション ID も変更されます。

build.gradle
  defaultConfig {
    applicationIdSuffix APPLICATION_ID_SUFFIX
    ...
  }

このような Android の事情から、開発の package 名の変更しないといけなく、それにより iOS の bundle ID と揃えるのがわかりやすいかと考えた。それにより、Package Name と Bundle ID を変更する。iOS については、変更する上でそのクライアント企業の Apple Developer アカウントで証明書や Provisioning file を作り直さないといけない。

flavor 対応前

開発本体本番本体
${アプリの省略語}.dev${ビジネス側が決めた ID}
開発 image notification本番 image notification
${アプリの省略語}.dev.imageNotification${ビジネス側が決めた ID}.imageNotification

flavor 対応後

開発本体本番本体
${ビジネス側が決めた ID}.dev${ビジネス側が決めた ID}
開発 image notification本番 image notification
${ビジネス側が決めた ID}.dev.imageNotification${ビジネス側が決めた ID}.imageNotification

iOS での対応

1. dart-define-from-file 経由で xcconfig に設定された値を pbxproj に伝える

まず前提として、dart-define-from-file オプションで JSON を渡して IPA を生成すると、key=value が入った Generated.xcconfig が生成される。以下は上の JSON を使ってビルドしたときに生成された中身となる。

Generated.xcconfig
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 に伝える作業が必要となる。

1. xcconfig に定義されている key=value をinfo.plist へ入力する

build setting タグをクリックし、そこで $(KEY_NAME) という形で、適宜名前を定義していく。例えば、OS のホーム画面で表示されるアプリ名は bundle display name の値で決まるので、Info.plist$(APP_TITLE) を参照するように設定を変更する。

Dynamic link か Health SDK をアプリに導入するときは別途対応が必要とする。以下 3 つの entitlements ファイルを用意する。

  • Runner/RunnerDEV.entitlements
  • Runner/RunnerSTAGING.entitlements
  • Runner/RunnerPROD.entitlements

下の例は、ローカルでの開発のときに参照される entitlements なので、開発用の dynamic link のドメインを入れる。PROD には本番のドメインを入れる。

Runner/RunnerDEV.entitlements.xml
<?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>

2. Firebase のファイルをビルド時にエントリーポイントへコピーするタスクを追加

Firebase を使っているため、設定ファイルをプロジェクトにいれないといけないが、それも環境によって動的に変更しないといけない。

1. 開発と本番用の設定ファイルを環境毎のディレクトリに配置する

エントリーポイントへコピーするタスクを書く前にまずはコピーする設定ファイルを用意する。

  • ios/DEV/GoogleService-Info.plist
  • ios/STAGING/GoogleService-Info.plist
  • ios/PROD/GoogleService-Info.plist

2. コピーするタスクを追加

Copy Bundle Resources の上に配置することを注意する。また Output Files に $(SCROOT)/GoogleService-info.plist を入れることを忘れずに。

3. imageNotification の xcconfig の追加

出力された Generated.xcconfig をラップした imageNotificaition 用の xcconfig を新たに作成する。内容については、元々ある xcconfig ファイルと Generated.xcconfig を合わせたものになる。

ios/ImageNotification/Debug.xcconfig
#include "Flutter/Generated.xcconfig"
#include? "Pods/Target Support Files/Pods-ImageNotification/Pods-ImageNotification.debug.xcconfig"
ios/ImageNotification/Release.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 を参照できるように設定する。

4. App Store Connect の Testflight へのアップロード

GitHub Actions で Testflight へ審査ビルドをアップロード行う workflow を構築する。 GitHub Actions の CI に埋めないといけないものや参照するファイルの作成するやり方を記載する。

1. ExportOptions.plist をローカルで一度発行する

flutter build ipa サブコマンドの --export-options-plist オプションに plist を渡さないと ipa ファイルを生成するところでエラーが出てしまう。なので、 Firebase App Distribution にアプリをデプロイするまでの流れ を参考に、plist を作成する。

※ GitHub Actions での指定サンプル、ExportOptions/ 配下の plist にローカルで発行した plist の情報を記載して更新する。

actions.yaml
$ 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

2. App Store Connect で AuthKey の発行

後述の Testflight へ審査ビルドをアップロードする CLI で ISSUER_ID と KEY_ID を渡さないといけない。メールアドレスとパスワードを使用できるが、セキュリティ的に甘いのと開発用のアカウントを別途用意する手間を省きたい。そのため、ISSUER_ID とキーID の両方が必要になる。その 2 つを取得するため、 App Store Connect のキータブをクリックし、キーを生成する。

生成については、権限が必要なので、足りない場合はビジネス側に依頼する。 ここで注意なのは保存について気をつけること。キーは一度ダウンロードしたら二度とできないので、しっかり google drive に保管する。

3. 発行された ISSUE_ID と KEY_ID を GitHub Actions の Secret に入れる

AuthKey を GitHub Actions で動かすマシンに伝えないといけないので、以下のキー名でそれぞれ値を secrets に入れる。

4. workflow で ipa の validate と upload を行う

ipa の validate と upload については、xcode-cli で提供される CLI コマンドを使用する。完成した CLI をここにも貼っておく。本番用の証明書と Provisioning file を base64 化した値を下のキーに対して、GitHub Actions の secrets に入れていく。

deploy_prod_ios.yml
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

Android での対応

1. dart-define-from-file で定義された値を AndroidManifest.xml ファイルに設定する

1. json で定義されている値を build.gradle に定義する

dart-define-from-file で定義した値を AndroidManifest.xml に伝えるには先に build.gradle に伝える必要がある。

android/app/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
  }
}

2. build.gradleで定義された値を AndroidManifest.xml に伝える

上の DYNAMIC_LINK_DOMAIN を例に取ると、AndroidManifest.xml に伝えるのは下の形となる。これを適宜必要な箇所で置き換えて、値が動的にする。

android/app/src/main/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>

2. Firebase のファイルをビルド時にエントリーポイントへコピーするタスクを追加

アプリたちは Firebase を使っているため、Firebase との接続で使う設定ファイルをプロジェクトにいれないといけない。それも環境によって動的に変更しないといけない。

1. 開発と本番用の設定ファイルを環境毎のディレクトリに配置する

エントリーポイントへコピーするタスクを書く前にまずはコピーする設定ファイルを用意する。

  • ios/DEV/GoogleService-Info.plist
  • ios/STAGING/GoogleService-Info.plist
  • ios/PROD/GoogleService-Info.plist

2. build.gradle にコピーするタスクを書く

Firebase の設定ファイルが環境毎で変わるようにする。エントリーポイントのパスは android/app/google-services.json なので、そこにコピーする。

android/app/build.gradle
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
    }
}

3. Google Play Console の内部テストへのアップロード

GitHub Actions で Google Play Console の内部テストへのアップロードを行う workflow を構築する。前提として、GitHub ActionsによるGoogle Play Consoleへのアプリ自動アップロードの取り組み の記事を参考して、workflow を構築する。

GitHub ActionsによるGoogle Play Consoleへのアプリ自動アップロードの取り組み - ZOZO TECH BLOG

GitHub ActionsによるGoogle Play Consoleへのアプリ自動アップロードの取り組み - ZOZO TECH BLOG

https://techblog.zozo.com

GitHub ActionsによるGoogle Play Consoleへのアプリ自動アップロードの取り組み - ZOZO TECH BLOG

1. アプリの GCP のプロジェクトで Google Play Android Developer API を有効にする

各アプリのプロジェクトで Google Play Android Developer API を有効にしてください。 有効にしたら、下のような画面が表示されます。

2. サービスアカウントの発行

ここはビジネスサイドにお願いして作成してもらう。ここでの作業は上の記事の通りに作成すればいいので、割愛する。

3. 発行したサービスアカウントを GitHub Actions の secrets に設定する

サービスアカウントで発行した JSON を下のキーで保存する。

  • GOOGLE_PLAY_SERVICE_ACCOUNT_JSON

4. workflow で App Bundle をアップロード

以下の GitHub Actions を追加する。

deploy_android_prod.yml
  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

対応するまでに詰まったこと

development team id を動的に変更することができなかった

上記の Android の package name の変更があり、最終的に iOS の ApplicationId も変更することになった。しかし、事前の技術検証フェーズで、dart-define-from-file に記載した development team id が事前解決されず、ipa 生成でエラーになってしまった。(エラーログを取っておけばよかった。)

同じような issue が見つかればよかったのだが、それが見つからなかった。dart-define-from-file の仕様かバグなのかは要調査する。もしかしたら、flutter の話だけではなく、iOS 開発において、development team をそもそも動的に変更できない可能性もある。

Android の appbundle アップロード時の status に渡す値を適切に決める

まだアプリ自体が公開されていないときには draft を渡し、その後では completed を渡す必要がある。(参照: 未公開のリリース)

deploy_android_prod.yml
      - 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' }}