つばくろぐ @takamii228

知は力なり

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

はじめに

アプリケーションを継続的に安全に機能追加していくプラクティスとして継続的インテグレーション(CI:Continuous Integration)があります。

コードの追加、すなわちPushやPull Request / Merge Request契機でCIを実行し、その変更が既存機能を破壊していないことを確認してからマージするような仕組みにして、対象のブランチを常にクリーンに保つことでアプリケーションが常に安定して動くことを保証します。

今回はFlutterを使ったAndroidアプリ・iOSアプリケーションにおけるCIパイプラインを構築した例を紹介します。

FlutterのCIパイプラインで実行すること

一般的な CIパイプラインでやることは以下のようなものがあります。

Flutterを使ったAndroid / iOSアプリ開発では、ネイティブコードの部分を考慮して以下の4つのレイヤのCIパイプラインを実行すればよいでしょう。

  1. Flutterレイヤ(Dart
  2. Androidレイヤ (Kotlin/Java)
  3. iOSレイヤ (Swift/Objective-C)
  4. 結合・E2Eテストレイヤ(Android / iOS

f:id:takamii228:20200426134046p:plain

それぞれ順番に見ていきます。

1. Flutterレイヤ(Dart)のCIパイプライン

FlutterレイヤではDart部分のCIパイプラインを見ていきます。Flutterではflutterコマンドの中で静的解析やユニットテストのコマンドが用意されているのでそれを実行すればよいでしょう。

静的解析

静的解析については、DartのLintがflutterコマンド経由で利用することができます。プロジェクトのrootにanalysis_options.yamlを定義すると、そのルールに従ったLintを実行してくれます。

analysis_options.yaml についてはflutterのレポジトリにもサンプルが用意されているのでまずはこちらを参考にするとよいでしょう。

flutter/analysis_options.yaml at master · flutter/flutter · GitHub

LintのルールはDartの公式で一覧が公開されています。

Linter for Dart

実行はflutterコマンドを実行するだけです。

$ flutter analyze
Analyzing flutter_app...                                                
No issues found! (ran in 10.9s)

ユニットテスト

Dart層のユニットテストdart単体テストWidgetテストの2つがあり、いずれもflutterコマンドで実行ができます。

$ flutter test
00:09 +3: All tests passed!   

ユニットテストWidgetについては公式のガイドが参考になります。

ユニットテストカバレッジレポートも出すことができます。testコマンドに--coverageオプションをつけて実行すると、 coverage/lcov.info ファイルが出力されます。これをlcovコマンドのgenhtmlでhtmlを出力することができます。

$ flutter test --coverage 
00:10 +3: All tests passed!                                                                                            
Collecting coverage information...                                  19ms

$ genhtml -o coverage coverage/lcov.info
Reading data file coverage/lcov.info
Resolved relative source file path "lib/counter.dart" with CWD to "/Users/takami228/git/flutter_app/lib/counter.dart".
Found 2 entries.
Found common filename prefix "/Users/takami228/git/flutter_app"
Writing .css and .png files.
Generating output.
Processing file lib/main.dart
Processing file lib/counter.dart
Writing directory view page.
Overall coverage rate:
  lines......: 96.2% (25 of 26 lines)
  functions..: no data found

htmlファイルを開くと、以下のようなC0カバレッジのレポートを見ることができます。

f:id:takamii228:20200426000650p:plain

2. Androidレイヤ (Kotlin/Java)のCIパイプライン

Flutterを使ったAndriodレイヤのパイプラインは基本的には通常のAndroidアプリと同様のことを実行すればよいでしょう。

メインのソースはFlutterで記述し、ネイティブレイヤのモジュールをKotlin/Javaで記述する、というケースが多いと思うのでそのモジュールに対して静的解析・単体テストを実行すれば十分だと思います。

Flutterプロジェクトのrootからandroidフォルダに入れば通常のAndroidプロジェクトと同様に扱えるので、graldeのタスクで静的解析や単体テストを実行するように定義すればよいです。

# 静的解析を実行
$ ./graldew :app:lint

# ユニットテストを実行
$ ./graldew :app:test

3. iOSレイヤ (Swift/Objective-C)のCIパイプライン

iOSレイヤもAndroidと同様に、Swift/Objectiv-Cで記述したモジュールに対しての静的解析とユニットテストを実行すればよいでしょう。

ここでは静的解析には swiftlintユニットテストにはXCTestを実行するコマンドを例にあげます。XCTestを実行可能なビルドスキームを定義しておきましょう。

# 静的解析を実行
$ swiftlint lint --strict

# ユニットテストを実行
$ xcodebuild test -scheme Runner-Test

4. 結合・E2Eテストレイヤ(Android / iOS)のCIパイプライン

最後は結合テスト・E2Eテストです。FlutterにはFlutter driverというE2Eテストのフレームワークが用意されています。実際にアプリを動かして、UI操作をコードで制御し、画面遷移や描画内容が正しいことを確認することができます。AppiumはまだFlutterには対応中のようなので、UIテストにはFlutter driverを利用するのがよいでしょう。

An introduction to integration testing - Flutter

flutter driveコマンドでテスト対象のターゲットを指定することでE2Eテストが実行できます。

$ flutter drive --target=test_driver/app.dart
Using device Android SDK built for x86.
Starting application: test_driver/app.dart
Installing build/app/outputs/apk/app.apk...                         3.0s
Running Gradle task 'assembleDebug'...                                  
Running Gradle task 'assembleDebug'... Done                        27.2s
✓ Built build/app/outputs/apk/debug/app-debug.apk.
Installing build/app/outputs/apk/app.apk...                         2.7s

....

[info ] FlutterDriver: Connecting to Flutter application at http://127.0.0.1:63515/lShiCMdeCec=/
[trace] FlutterDriver: Isolate found with number: 3216252190998139
[trace] FlutterDriver: Isolate is paused at start.
[trace] FlutterDriver: Attempting to resume isolate
[trace] FlutterDriver: Waiting for service extension
[info ] FlutterDriver: Connected to Flutter application.
00:02 +0: Counter App starts at 0

00:03 +1: Counter App increments the counter

00:03 +2: Counter App (tearDownAll)

00:03 +2: All tests passed!

Stopping application instance.

  flutter driverでは動作環境が複数ある場合は一番上位のもの上でしか実行されないため、実行対象を指定したい場合は-dオプションでdeviceIdを指定します。

$ flutter devices 
2 connected devices:

Android SDK built for x86 • emulator-5554                        • android-x86 • Android 10 (API 29) (emulator)
iPhone 11 Pro Max         • DB3DD7DC-1574-41D3-B739-B53E281F003B • ios         •
com.apple.CoreSimulator.SimRuntime.iOS-13-4 (simulator)

$ flutter drive --target=test_driver/app.dart -d DB3DD7DC-1574-41D3-B739-B53E281F003B
Starting application: test_driver/app.dart
Running Xcode build...                                                  
                                                   
 ├─Assembling Flutter resources...                          24.5s
 └─Compiling, linking and signing...                        19.8s
Xcode build done.                                           63.0s
flutter: Observatory listening on http://127.0.0.1:64370/ZIVy-qOa5uM=/
00:00 +0: Counter App (setUpAll)

[info ] FlutterDriver: Connecting to Flutter application at http://127.0.0.1:64370/ZIVy-qOa5uM=/
[trace] FlutterDriver: Isolate found with number: 1868789005249195
[trace] FlutterDriver: Isolate is paused at start.
[trace] FlutterDriver: Attempting to resume isolate
[trace] FlutterDriver: Waiting for service extension
[info ] FlutterDriver: Connected to Flutter application.
00:02 +0: Counter App starts at 0

00:02 +1: Counter App increments the counter

00:02 +2: Counter App (tearDownAll)

00:02 +2: All tests passed!

Stopping application instance.

まとめ:FlutterのCIパイプラインを構築する

以上の4つのレイヤで実行するコマンド類をシェルスクリプトにまとめて、順番に実行するようにすればFlutterのCIパイプラインは完成です。 実際にCIパイプラインを組む場合はビルド実行に冪等性をもたせる必要があるため、依存関係解決やcleanの実行も忘れずに行うようにしましょう。

flutter-ci.sh

#!/bin/sh

set -x
set -e

# cleanを実行する
flutter clean

# 依存関係を解決する
flutter pub get

# 静的解析を実行する
flutter analyze

# ユニットテストを実行する
flutter test --coverage

android-ci.sh

#!/bin/sh

set -x
set -e

# フォルダを移動する
cd android

# cleanを実行
./graldew :app:clean

# 静的解析を実行
./graldew :app:lint

# ユニットテストを実行
./graldew :app:test

# フォルダを移動する
cd ..

ios-ci.sh

#!/bin/sh

set -x
set -e

# フォルダを移動する
cd ios

# cleanを実行
xcodebuild clean -scheme Runner-Test
rm -rf ~/Library/Developer/Xcode/DerivedData/

# 依存関係を解決
pod install

# 静的解析を実行
swiftlint lint --strict

# ユニットテストを実行
xcodebuild test -scheme Runner-Test

# フォルダを移動する
cd ..

android-e2e-test.sh

#!/bin/sh

set -x
set -e

# AndroidのdeviceIdを取得
ANDROID_DEVICE_ID=`flutter devices | grep "android" | cut -f2 -d "" | tr -d " "`

# AndroidのE2Eテストを実行する
flutter driver --target=test_driver/app.dart -d ${ANDROID_DEVICE_ID}

ios-e2e-test.sh

#!/bin/sh

set -x
set -e

# iOSのdeviceIdを取得
IOS_DEVICE_ID=`flutter devices | grep "ios" | cut -f2 -d "" | tr -d " "`

# iOSのE2Eテストを実行する
flutter driver --target=test_driver/app.dart -d ${IOS_DEVICE_ID}

あとはこれらのシェルスクリプトを各種CIツール上で実行するように設定すればよいでしょう。

それについては長くなってきたので後半の記事で説明します。

takamii.hatenablog.com

jqのyaml版コマンド yq は2種類ある

以前flutterのバージョン切り替えをCI環境で動的にやる記事の中で、yamlの指定したキーを取り出すのに yqコマンドを使っていたのですが、このyqの利用に関してハマったことをメモとして残しておきます。

takamii.hatenablog.com

yqとは

yqとはjqのyaml版でyamlに対してクエリを発行して部分的な文字列を出力するコマンドです。

jqはこちら。

stedolan.github.io

"yq" でググると2つのyqがヒットする

Googleyq を検索すると、なんと全く異なる2種類がヒットします。

https://www.google.com/search?q=yq

Python製のyq

1つ目はpythonベースの yq コマンドです。

github.com

kislyuk.github.io

pypi.org

yq takes YAML input, converts it to JSON, and pipes it to jq:

とあるので、yamljsonに変換して引数のコマンドを処理しているように見えるのでjqでできることは基本できるようです。jqがないと動きません。

$ cat pubspec.yaml
environment:
  sdk: ">=2.1.0 <3.0.0"
  flutter: 1.12.13+hotfix.8
...

$ cat pubspec.yaml | yq -r .environment.flutter
1.12.13+hotfix.8

Go製のyq

もうひとつはGoでかかれたyqです。こちらはjqが入ってなくても動きます。

github.com

mikefarah.gitbook.io

Pythonのypと比べて、diffやマージ等ができるようです。参照のコマンドも微妙に違う。

$ cat pubspec.yaml 
environment:
  sdk: ">=2.1.0 <3.0.0"
  flutter: 1.12.13+hotfix.8
...

$ yq r pubspec.yaml environment.flutter
1.12.13+hotfix.8

brew install yqで入るのはどっち?

brew install yq で入るyqは、Go製のyqです。

$ brew install yq
==> Downloading https://homebrew.bintray.com/bottles/yq-3.2.1.catalina.bottle.tar.gz
==> Downloading from https://akamai.bintray.com/8c/8cbb0eda1f9d8c20342c41979e2cca5440e6215e85c36a3f2
######################################################################## 100.0%
==> Pouring yq-3.2.1.catalina.bottle.tar.gz
🍺  /usr/local/Cellar/yq/3.2.1: 5 files, 5.9MB

Python製のyqを入れるにはpip経由で入れるか、brewpython-yqと指定するようです。

$ brew install python-yq
==> Downloading https://homebrew.bintray.com/bottles/python-yq-2.10.0_1.catalina.bottle.tar.gz
Already downloaded: /Users/takami228/Library/Caches/Homebrew/downloads/4488cbac19c6771a781cfdfe3532dc49d77b45b388fc700a12154ac416867ade--python-yq-2.10.0_1.catalina.bottle.tar.gz
==> Pouring python-yq-2.10.0_1.catalina.bottle.tar.gz
🍺  /usr/local/Cellar/python-yq/2.10.0_1: 99 files, 617.1KB

どっちがいいの?

どちらもそれなりのスターがついていて、利用記事も出てきます。

ドキュメントを読む限りGoのyqのほうがコマンドがリッチそうに見えますが、指定したキーを取り出すだけの用途であればどちらでもよさそうです。

CIやスクリプトに組み込んでチームとして利用していく場合は、どちらのyqコマンドを使うのかをきちんと明記して、統一しておくのが良いでしょう。

※私のチームでは一時期両方のコマンドが混在していて混乱しました。

ライブラリやプラグインを使うときに気にしていること

アプリケーション開発において実装したい機能を実現するために、世の中に公開されているライブラリやプラグインを使ってフルスクラッチでの実装の手間を省くことがあります。その一方で、プラグインを理由にプラットフォームのアップデートを断念したり塩漬けにしているシーンをよく見かけました。

これまでJenkins、WordPress、Flutterとプラグインを選定する場面が多くあったなーと思っていて、知らず知らずのうちに自分の中での選び方の基準みたいなものができつつあったのでここで言語化しておこうと思います。

なお機能的な要件を満たすことは大前提となるのでここでは省きます。

1. 公式のプラットフォームで公開されていること

野良プラグインは怪しさ満点です。公式のマーケットプレイスで公開されているもの、さらに言えば公式が認めているもの(公式バッチがついている、メンテナーが企業になっている等)を使いましょう。

また実際にどう動いているのかソースが追えるようにGitHubでソース公開されているものを利用しましょう。

2. 評価が高く、利用実績が豊富であること

他の人も利用していて、かつ評価が高ければ、信頼度もあります。

3. ドキュメントが充実していること

2の裏返しかもしれませんが、ドキュメントがあると使いやすいですよね。

4. ライセンスの自由度が高いこと

MITやApache2.0など、再利用に関して自由度が高いものが法的リスクが低いです。

5. 更新が活発であること

プラグインが動作するプラットフォームや依存するソフトウェアに追従できていないと、あっという間に負債になってしまいます。

GitHubを見に行ってcommit数が順調に伸びていたり、Issueに対してきちんと対応されているかを見ましょう。

6. プラグインの内部のライブラリの依存リスクが明確になっていること

利用しているライブラリやプラグインが依存するものが大きいと、結局そこがボトルネックになってしまいます。

プラグインの中で依存するライブラリの中に、枯れたライブラリがないか目を通しておきましょう。

7. いざとなったら自分たちでメンテナンスできること

最終的に利用を続けるとなったときに、自分たちでパッチを充てたり修正していける内容なのかを見ましょう。

仮にそうだったとして、そのプラグインを使い続けるのか、余裕のあるときに自分で独自実装してしまうのかは再度立ち返って考えてみるといいと思います。


結局はソフトウェアのバージョンアップライフサイクルを考えたときに、それに依存するものを切り分けて、メンテナンス対象としてどう扱っていくかをきちんと考えよう ということなのだと思います。最近は言語やFWのバージョンアップライフサイクルは早く、1年も経てばあっという間に古くなってしまいます。

三者の成果物を利用することは実装の手間を省くことができる一方で、時間が経ってから自分たちが取り扱えなくなったり依存関係によって更新できない負債と化すリスクもあるので十分注意しながら選定したいものです。