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)の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の公式で一覧が公開されています。
実行は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カバレッジのレポートを見ることができます。
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ツール上で実行するように設定すればよいでしょう。
それについては長くなってきたので後半の記事で説明します。
jqのyaml版コマンド yq は2種類ある
以前flutterのバージョン切り替えをCI環境で動的にやる記事の中で、yamlの指定したキーを取り出すのに yq
コマンドを使っていたのですが、このyqの利用に関してハマったことをメモとして残しておきます。
yqとは
yqとはjqのyaml版でyamlに対してクエリを発行して部分的な文字列を出力するコマンドです。
jqはこちら。
"yq" でググると2つのyqがヒットする
Googleで yq
を検索すると、なんと全く異なる2種類がヒットします。
https://www.google.com/search?q=yq
Python製のyq
1つ目はpythonベースの yq コマンドです。
yq takes YAML input, converts it to JSON, and pipes it to jq:
とあるので、yamlをjsonに変換して引数のコマンドを処理しているように見えるので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が入ってなくても動きます。
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経由で入れるか、brewでpython-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. 公式のプラットフォームで公開されていること
- 2. 評価が高く、利用実績が豊富であること
- 3. ドキュメントが充実していること
- 4. ライセンスの自由度が高いこと
- 5. 更新が活発であること
- 6. プラグインの内部のライブラリの依存リスクが明確になっていること
- 7. いざとなったら自分たちでメンテナンスできること
1. 公式のプラットフォームで公開されていること
野良プラグインは怪しさ満点です。公式のマーケットプレイスで公開されているもの、さらに言えば公式が認めているもの(公式バッチがついている、メンテナーが企業になっている等)を使いましょう。
また実際にどう動いているのかソースが追えるようにGitHubでソース公開されているものを利用しましょう。
2. 評価が高く、利用実績が豊富であること
他の人も利用していて、かつ評価が高ければ、信頼度もあります。
3. ドキュメントが充実していること
2の裏返しかもしれませんが、ドキュメントがあると使いやすいですよね。
4. ライセンスの自由度が高いこと
MITやApache2.0など、再利用に関して自由度が高いものが法的リスクが低いです。
5. 更新が活発であること
プラグインが動作するプラットフォームや依存するソフトウェアに追従できていないと、あっという間に負債になってしまいます。
GitHubを見に行ってcommit数が順調に伸びていたり、Issueに対してきちんと対応されているかを見ましょう。
6. プラグインの内部のライブラリの依存リスクが明確になっていること
利用しているライブラリやプラグインが依存するものが大きいと、結局そこがボトルネックになってしまいます。
プラグインの中で依存するライブラリの中に、枯れたライブラリがないか目を通しておきましょう。
7. いざとなったら自分たちでメンテナンスできること
最終的に利用を続けるとなったときに、自分たちでパッチを充てたり修正していける内容なのかを見ましょう。
仮にそうだったとして、そのプラグインを使い続けるのか、余裕のあるときに自分で独自実装してしまうのかは再度立ち返って考えてみるといいと思います。
結局はソフトウェアのバージョンアップライフサイクルを考えたときに、それに依存するものを切り分けて、メンテナンス対象としてどう扱っていくかをきちんと考えよう ということなのだと思います。最近は言語やFWのバージョンアップライフサイクルは早く、1年も経てばあっという間に古くなってしまいます。
第三者の成果物を利用することは実装の手間を省くことができる一方で、時間が経ってから自分たちが取り扱えなくなったり依存関係によって更新できない負債と化すリスクもあるので十分注意しながら選定したいものです。