Infinito Nirone 7

白羽の矢を刺すスタイル

Wercker での Android アプリの CI を速くするポイント

CI の速さは開発のプロセスを高速化する上で重要です。Android アプリのビルドはどうしても時間のかかる部分が多く数分から十数分の時間を要してしまいますが、工夫次第では数十秒から数分の短縮が可能です。 プロジェクトの規模や構成にもよるので一概にすべて効果があるとは言いにくい部分もありますが、この記事で取り上げる幾つかのポイントを抑えておくと、CI の高速化に役立つと思います。今回は Wercker を使用した場合の高速化の方法を書きます。

1.ビルドキャッシュ

Wercker の場合、WERCKER_CACHE_DIRという環境変数にキャッシュのためのディレクトリ情報が入っています。ここにビルドキャッシュを放り込むことで、複数ステップで gradlew コマンドを叩いたときにあとで実行するコマンドが速くなります。また、dependencies の artifact をキャッシュすることで、次回以降のビルドで依存解決が高速になります。

ビルドキャッシュをWERCKER_CACHE_DIRに放り込む場合は、gradlew コマンドのオプションに--project-cache-dir=$WERCKER_CACHE_DIRを指定します。

dependencies の artifact をWERCKER_CACHE_DIRに放り込む場合は、ビルド後のステップで~/.m2~/.gradleWERCKER_CACHE_DIRに cp するか、ビルド前に~/.m2~/.gradleWERCKER_CACHE_DIR配下のディレクトリを指すようシンボリックリンクを作るかのいずれかの方法があります。

依存するライブラリが多くなればなるほど効果が出ます。最近は JCenter が不安定なのか dependencies がダウンロードできなくてビルドがコケるという事象が日に何度か起こっていて、その失敗を解消することにも役立ちます。

2.マルチモジュール化

巨大なモジュールをモリモリビルドするのではなく、小さなモジュールを並列でビルドするようにします。最近の Gradle プラグインはマルチモジュールのビルドについて改善が入っているので、並列ビルドで起こりがちな問題もある程度はうまくやってくれます。キャッシュが効くとなお速いので、ローカルマシンでのビルド時間のほうが改善するかもしれません。

3.メモリ割り当て

Androidアプリのビルドはとにかくメモリが重要です。何はなくともメモリだけは広く確保する必要があります。MacBook Pro など多くのラップトップマシンでは 16GB が上限となりますが、Wercker では(2017年6月現在のところ)コンテナごとにメモリの上限は設定していないようですので、思い切って 32GB 割り当てるなどという富豪的な使い方ができます。特に build.gradle で指定するdexOptionsdexInProcessを有効にしたとき、javaMaxHeapSizeが大きくないと時間がかかってしまいます。CIという環境変数trueのときは32gなど大きな数字を割り当て、そうでないローカルマシン等では8gなどになるような柔軟性があるとよいです。

dexOptions {
  dexInProcess true
  javaMaxHeapSize "true".equals(System.getenv("CI")) ? "32g" : "8g"
}

大抵の CI as a Service ではCI環境変数が用意されているはずです。サービスによっては環境変数の値が真偽値の場合とCIサービス名の場合とがあるので注意が必要です。 また、dexOptionsjavaMaxHeapSizeを指定しても、gradle.propertiesorg.gradle.jvmArgsに何も指定しないと「もっと大きなヒープを使わないと意味が無いぞ」という警告が出ます。併せて、次のようにJVMが使える領域を大きくします。

org.gradle.jvmargs=-Xmx33280M

ただし、この記述をそのままリポジトリに放り込むと、そんなにたくさんメモリのないマシンで困ることになります。できれば CI でだけ大きな領域を確保したいので、リポジトリに入れる gradle.propertis にはローカルマシンで確保可能な数字にしておき、CI ではビルドステップの前に次のようなコマンドを実行するようにして大きな領域を確保するようにします。

- script:
  name: set up environment
  code: |
    echo -e "org.gradle.jvmargs=-Xmx33280M\nandroid.enableBuildCache=true\norg.gradle.parallel=true\norg.gradle.caching=true\norg.gradle.configureondemand=true\n" > gradle.properties

4.テストの並列実行

アプリの機能が増えればその分テストも増え、テストを実行する時間も長くなります。testOptionsでテストを並列実行するための設定項目があるのでこれを使います。ただし、並列実行すると壊れるテストも中にはあるかもしれませんので注意してください。

testOptions {
  unitTests.all {
    maxParallelForks = 2
    forkEvery = 150
  }
}

maxParallelForksが何並列で動かすかを決めるパラメータで、forkEveryJVM を再起動するタイミングをいつにするかを決めるパラメータです。この設定の場合、2並列で150個のテストケースを実行するごとに JVM を再起動します。

併せて、テスト実行時のメモリについても設定しましょう。次の例では Java 8 のランタイムでテストを動かすことを想定しています。

testOptions {
  unitTests.all {
    maxHeapSize = '8192m'
    jvmArgs '-XX:MaxMetaspaceSize=8192M', '-noverify', '-Xmx8192M'
  }
}

5.ビルドステップの整理

大抵の CI as a Service では、ビルドそのものや個々のビルドステップごとにタイムアウトが設定されていて、一定時間を過ぎてもコマンドが終わらないと失敗とみなされます。このため、時間のかかるステップを小分けにしてタイムアウトを回避するような対策をとることがあります。一方で、ステップを小分けにすると、毎回 gradlew を叩いてプロセスを起こすので、少し無駄な時間がどうしてもできてしまいます。先述のビルドキャッシュである程度は改善できますが、これまでの高速化ポイントで速くなったステップは、タイムアウト以内に納まるのであればマージしましょう。自分の場合、assemble と test でそれぞれ別のステップを作っていましたが、それぞれタイムアウト以内に十分納まる時間で終わるようになったのでマージしました。