つばくろぐ @takamii228

知は力なり

Flutterを使ったAndroid・iOSアプリ開発のCDパイプラインを構築する #flutter

はじめに

前回はCIパイプラインについて前半・後半に分けて説明を行いました。

takamii.hatenablog.com

takamii.hatenablog.com

今回はCDパイプラインについて説明します。

CDとは継続的デリバリー(Continuouse Delivery)の略で、アプリケーションを継続的に短いサイクルでリリース可能な状態にするプラクティスのことを指します。

Android / iOSアプリにおけるCDパイプラインとは、ソースコードからアプリを端末にインストールしたりストア申請可能な状態であるapkファイル・ipaファイルをいつでも作成できる状態にする、ということになります。

今回の検証に利用したFlutter環境は以下の通りです。

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[] Flutter (Channel unknown, v1.12.13+hotfix.9, on Mac OS X 10.15.4 19E266, locale en-JP)
[] Android toolchain - develop for Android devices (Android SDK version 28.0.3)
[] Xcode - develop for iOS and macOS (Xcode 11.4.1)
[] Android Studio (version 3.6)

また今回はapk/ipaファイル作成のみについて触れるのですが、実際の開発現場では検証環境や内部テスト向けのアプリと実際の配信するアプリを環境差分で分けて管理する必要があると思います。

Flutterの場合はflavorを独自に定義することで、それらについても対応することができるのですが今回はそこの説明はスコープ外とします。

軽く触れると、Androidは通常のAndroidと同様のやり方でできます。iOSについては命名規則に従ってSchemeを定義すればよいです。 こちらが参考になります。

medium.com

Flutterを使ったAndroidアプリでapkファイルを作成する

Flutterで構築したアプリケーションからapkファイルを作成する方法は基本的には公式のガイドに従えばよいです。

flutter.dev

Android固有の設定

FlutterのAndroid側のプロジェクトにはandroid pluginを内包しているため、基本的には通常のAndroidと同じビルド設定で問題ありません。 

公式のガイド通りに以下をbuild.gradleに設定していけばよいでしょう。

  • apkの署名設定
  • R8によるアプリ圧縮設定
  • R8(proguard)による難読化設定
  • バージョン番号とバージョンコードの設定

Flutterにはデフォルトでdebugprofilereleaseの3種類のbuildTypeが用意されていますが、apkを作るときにはreleaseのところに設定を記述しましょう。

ビルドスクリプトを構築する

公式ガイド通りにflutter build apk --releaseコマンドを実行すればよいです。

引数でtarget-platformやビルド番号・バージョン番号が指定できるので、32bitと64bitを分けてビルドする場合は指定するようにしましょう。

FlutterはAppBundleにも対応しているようなのでそれを利用しても良いかもしれません。

今回はバージョン番号を引数に受け取って32bit/64bit別々にビルドするシェルスクリプトを作ってみました。

ストア申請のときに32bitと64bitを分けて申請する場合は32bitのバージョン番号より64bitのバージョン番号を大きくしておく必要があるので、シェルの中で+1するようにしました。

developer.android.com

build-android-apk.sh

#!/bin/sh
set -x
set -e

# TARGET_APP_VERSION
TARGET_APP_VERSION=${1}

# TARGET_VERSION_CODE
TARGET_VERSION_CODE_ARM=${2}
TARGET_VERSION_CODE_ARM64=$(expr ${2} + 1)

# Artifacts Path
OUTPUT_APK_PATH="./artifacts/android"

# cleanを実行
flutter clean

# 依存関係を解決
flutter pub get

# arm用のapkを作成
flutter build apk --release --target-platform=android-arm \
  --build-name=${TARGET_APP_VERSION} --build-number=${TARGET_VERSION_CODE_ARM}

# 成果物をコピー
cp build/app/outputs/apk/release/app-release.apk \
  ${OUTPUT_APK_PATH}/app-release-arm-${TARGET_APP_VERSION}.${TARGET_VERSION_CODE_ARM}.apk

# cleanを実行
flutter clean

# arm64用のapkを作成
flutter build apk --release --target-platform=android-arm64 \
  --build-name=${TARGET_APP_VERSION} --build-number=${TARGET_VERSION_CODE_ARM64}

# 成果物をコピー
cp build/app/outputs/apk/release/app-release.apk \
  ${OUTPUT_APK_PATH}/app-release-arm64-${TARGET_APP_VERSION}.${TARGET_VERSION_CODE_ARM}.apk

実行ログは以下のようになります。

$ ./build-android-apk.sh 1.0.0 100
+ set -e
+ TARGET_APP_VERSION=1.0.0
+ TARGET_VERSION_CODE_ARM=100
++ expr 100 + 1
+ TARGET_VERSION_CODE_ARM64=101
+ OUTPUT_APK_PATH=./artifacts/android
+ flutter pub get
Running "flutter pub get" in appcenter_sample...                    0.3s
+ flutter clean
Cleaning Xcode workspace...                                         3.0s
Deleting build...                                                  105ms
Deleting .dart_tool...                                               1ms
+ flutter build apk --release --target-platform=android-arm --build-name=1.0.0 --build-number=100
Removed unused resources: Binary resource data reduced from 44KB to 35KB: Removed 20%
Running Gradle task 'assembleRelease'...                                
Running Gradle task 'assembleRelease'... Done                      39.4s
✓ Built build/app/outputs/apk/release/app-release.apk (5.1MB).
+ cp build/app/outputs/apk/release/app-release.apk ./artifacts/android/app-release-arm-1.0.0.100.apk
+ flutter clean
Cleaning Xcode workspace...                                         2.0s
Deleting build...                                                   82ms
Deleting .dart_tool...                                               2ms
+ flutter build apk --release --target-platform=android-arm64 --build-name=1.0.0 --build-number=101
Removed unused resources: Binary resource data reduced from 44KB to 35KB: Removed 20%
Running Gradle task 'assembleRelease'...                                
Running Gradle task 'assembleRelease'... Done                      35.3s
✓ Built build/app/outputs/apk/release/app-release.apk (5.4MB).
+ cp build/app/outputs/apk/release/app-release.apk ./artifacts/android/app-release-arm64-1.0.0.101.apk

Flutterを使ったiOSアプリでipaファイルを作成する

Android同様、iOSでもFlutterの公式ドキュメント通りに実行すればよいです。

flutter.dev

iOS固有の設定

通常のiOSアプリ開発と同様、ipaファイル作成に必要な証明書とprovisioning profileを準備してビルドを実行するマシンにインストールして おきます。

Android同様、FlutterにはデフォルトでDebugProfileReleaseの3種類のSchemeが定義されていて、ipa作成時にはReleaseのスキームを利用します。Androidと異なって頭文字が大文字な点に注意が必要です。

追加でビルド時に実行したいものがあればReleaseのスキームの設定の中に追記しておくとよいでしょう。

ビルドスクリプトを構築する

公式ガイドを読むと以下を順番に実行せよと書いてあります。

  1. flutter build ios --releaseを実行する
  2. XCode上でバージョン番号とビルド番号を設定し、Generic iOS DevicesでArchiveビルドを実行する
  3. Archiveファイルをビルドしてipaファイルを作成する

要はflutterコマンドを実行した後にxcodebuildコマンドを実行すればよいのです。

Archiveファイルからipaファイルを作成するときにExportOptions.plistファイルが必要なので、環境に応じて準備しておきましょう。

iOSもバージョン番号は外から注入できるようにしてスクリプトを組みましょう。

build-ios-ipa.sh

#!/bin/sh
set -x
set -e

# TARGET_APP_VERSION
TARGET_APP_VERSION=${1}

# TARGET_BUILD_NO
TARGET_BUILD_NO=${2}

# Artifacts Path
OUTPUT_APK_PATH="./artifacts/ios"

# cleanを実行
flutter clean

# 依存関係を解決
flutter pub get

# flutter buildを実行
flutter build ios --release \
  --build-name=${TARGET_APP_VERSION} --build-number=${TARGET_BUILD_NO}

cd ios

# archiveを作成
EXPORT_OPTION_PLIST="ExportOptions.plist"

xcodebuild -workspace Runner.xcworkspace -scheme Runner -sdk iphoneos \
  -configuration Release archive -archivePath ../build/Runner.xcarchive

# ipa ファイルを作成
xcodebuild -allowProvisioningUpdates -exportArchive \
  -archivePath ../build/Runner.xcarchive \
  -exportOptionsPlist ${EXPORT_OPTION_PLIST} -exportPath ../build/ios-release

cd ..

# 成果物をコピー
cp ./build/ios-release/Runner.ipa ${OUTPUT_APK_PATH}/app-release-${TARGET_APP_VERSION}.${TARGET_BUILD_NO}.ipa

実行ログは途中は省略しますが以下のようになります。

$ ./build-ios-ipa.sh 1.0.0 100
+ set -e
+ TARGET_APP_VERSION=1.0.0
+ TARGET_BUILD_NO=100
+ OUTPUT_APK_PATH=./artifacts/ios
+ flutter clean
Cleaning Xcode workspace...                                         2.0s
Deleting build...                                                   34ms
Deleting .dart_tool...                                               0ms
+ flutter build ios --release --build-name=1.0.0 --build-number=100
Building com.takamiii.appcentersample for device (ios-release)...
Automatically signing iOS for device deployment using specified development team in Xcode project: xxxxxxxx
Running Xcode build...                                                  
                                                   
 ├─Building Dart code...                                    13.9s
 ├─Generating dSYM file...                                   0.1s
 ├─Stripping debug symbols...                                0.0s
 ├─Assembling Flutter resources...                           0.7s
 └─Compiling, linking and signing...                         9.0s
Xcode build done.                                           26.1s
Built /Users/takami228/Documents/flutter-dev/flutter_sample/build/ios/iphoneos/Runner.app.
+ cd ios
+ EXPORT_OPTION_PLIST=ExportOptions.plist
+ xcodebuild -workspace Runner.xcworkspace -scheme Runner -sdk iphoneos -configuration Release archive -archivePath ../build/Runner.xcarchive
...
** ARCHIVE SUCCEEDED **

+ xcodebuild -allowProvisioningUpdates -exportArchive -archivePath ../build/Runner.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath ../build/ios-release
2020-04-26 17:29:59.502 xcodebuild[34691:937911] [MT] IDEDistribution: -[IDEDistributionLogging _createLoggingBundleAtPath:]: Created bundle at path '/var/folders/6n/llxh8m296896rymtgjfbkmxw0000gn/T/Runner_2020-04-26_17-29-59.501.xcdistributionlogs'.
Exported Runner to: /Users/takami228/Documents/flutter-dev/flutter_sample/build/ios-release
** EXPORT SUCCEEDED **

+ cd ..
+ cp ./build/ios-release/Runner.ipa ./artifacts/ios/app-release-1.0.0.100.ipa

クラッシュレポートやFirebase Crashlyticsでクラッシュを解析するためにビルド時のdSYMファイルが必要になるケースがあるので、忘れないようにここでアップロードしたり退避させるのがよいでしょう。

CD環境と統合する

あとはbuild-android-apk.sh および build-ios-ipa.sh を署名情報や証明書がインストールされたマシンやCI環境でtagプッシュ契機で実行するようにすればCDパイプラインの完成です。

下の図はGitLab CIでの実行の流れを表した図になります。

f:id:takamii228:20200426175707p:plain

GitLab CIでは以下のように.gitlab-ci.ymlを定義すればよいでしょう。

...
release:
  stagie: build
  tags:
    - release-ci-runner
  only:
    - tags
  script:
    - ./build-android-apk.sh ${TARGET_APP_VERSION} ${TARGET_VERSION_CODE} 
    - ./build-ios-ipa.sh ${TARGET_APP_VERSION} ${TARGET_BUILD_NO} 

まとめ

CI編と合わせてapk、ipaの作成を自動化するCDパイプラインを構築する例を説明しました。

CDパイプラインを自動化しておくことでリリース作業のハードルが下がり、リリース作業の属人化を防ぎ、結果としてリリース頻度を増やすことができます。

リリース頻度を増やすことはサービスの競争力を強化する大きなポイントとなるので、ぜひ参考にしてみてください。

またCIパイプラインと同様に、CDパイプラインも一度作って終わりではなく、アプリケーションを提供し続ける限り同様にメンテナンスが必要です。

Flutterはまだまだ変化が激しい技術領域なので、これから破壊的な変更が発生する可能性は0ではないので、前のめりで追従していく気持ちで頑張ってついていきましょう。

2021/7/2 追記

ipaを作成する flutter build ipaコマンドがflutter 2.0から利用できます。xcodebuildコマンドが省略できるのでぜひ利用してみてください。

takamii.hatenablog.com