github actions でやっているcommit checkの処理を早く終わらせたかった
3行で
- 平行に処理してみたりキャッシュが効くようにしたが早くはならなかった
- やってみたかったのでやった
- 後悔はしていない
その時のPR: github.com
チェックを並列に実行するが効果なし(むしろ増)
これまでのコミットチェックではAndroidのLintとktlint、UnitTestをstep
で順番に行っていた。高速化と言ったら並列化だろ、ってことで、PRのタイトルにもした通りまずはこれらのstep
を分割してjob
に昇格し、平行に実行させてみた。ついでに、共通で行うコンパイルの処理を前段に、レポートの処理を後段で行うようにしたらちょっとしたビルドパイプラインになった。
github actionsでは、jobs
の下に列挙したjobはすべて平行に実行される。指定したjob
の終了を待ってから開始したいjob
はその設定でjobs.needs: <<前段のjob-id>>
を書いておく。前段のjob-id
は[ ]
で囲んで複数指定できる。今回のパイプラインはjob
だけ書くと次のようになる。
- jobs - compile - lint - needs: compile - ktlint - needs: compile - unittest - needs: compile - report - needs: [lint, ktlint, unittest]
あとはそれぞれのjob
でGradleのキャッシュをリストアする処理などが行われるように設定していく。キャッシュのリストアのオーバーヘッドはあるだろうけどまあちょっとぐらいは早くなるでしょという期待を胸にワークフローを走らせてみたところ、これまで15分程度だった実行時間が20分ぐらいかかるようになってしまった。どうやらキャッシュのリストアが長すぎるというのと、ビルドキャッシュが効いていないというのが原因のようだったのでこれの対策を行うことにした。
キャッシュをちゃんとやる
キャッシュは2GB程度あり、リストアには2分程度かかっていた。これを前段でも中段でもやっているのでどうにかして節約したい。キャッシュにはactions/cache
を使っていて、当初はマニュアルのGradleの例に書いてる設定をほぼそのまま流用していた。しかし、この設定だとキーが変わったりキャッシュを意識的に消す処理をしない限りこれまで使っていたバージョンのwrapperやらcachesのなんやかやが全てキャッシュに残ったままになってしまい *1 、雪だるま式にサイズが大きくなってしまうのではないかと考えた。そこで、キャッシュサイズを小さくしつつ、かつサイズが必要以上に大きくなりすぎないようにするための方法を考えることにした。
現時点でのGradle 6.8.3において、私の環境では次のディレクトリにキャッシュが保存される様子。
- ~/.gradle
- caches
- wrapper
- <<プロジェクトルート>>/.gradle
このうち、wrapperディレクトリの下にはgradle wrapperのバイナリがバージョンごとに分かれて入っている。これがバージョンごとに200~300MB程度あり、実際に使うのは1つだけなのでそれ以外は不要である。まずはここを削るために次のようにした。
- name: cache gradle wrapper uses: actions/cache@v2 with: path: ~/.gradle/wrapper key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}
使用するgradle wrapperのバージョンはgradle-wrapper.propertiesに書いてあるものを使うのでこのファイルのハッシュをキーに使うことにして、このキーでキャッシュが見つからない時は代わりのキャッシュをリストアしないようrestore-keys
を指定しないようにした。
あとはライブラリのキャッシュやビルドキャッシュを保存する部分だが、ここはあっさりと、
- name: cache gradle uses: actions/cache@v2 with: path: | ~/.gradle/caches ./.gradle key: ${{ runner.os }}-gradle-cache-${{ github.sha }} restore-keys: | ${{ runner.os }}-gradle-cache- - name: cache build uses: actions/cache@v2 with: path: ./**/build key: ${{ runner.os }}-assemble-${{ github.sha }} restore-keys: | ${{ runner.os }}-assemble-
という感じにしてみた。キーにはgithub.sha
を付けてcommit checkごとに新しくなるようにした。ただし、~/.gradle/caches
や./.gradle
の下にはバージョン番号のディレクトリがあり、Gradleのバージョンが変わるごとに増えていくと考えられるがこれを消す処理は入れていない。消そうとせずにここのキーにもgradle-wrapper.properties
のハッシュをつけてもいいかもしれない。
この改善によって、2GB近くあったキャッシュが500MB程度になり、20分に膨れ上がった実行時間が17分にまで抑えられた。キャッシュのない初回の実行なので次回以降はもう少し短くなるはず。あとはmasterブランチにマージするワークフローでも同じようなこと*2をやってやれば次のPRの時にもこのキャッシュを引き継げる。
ふりかえり
テストの後片付けでLiveData.removeObserver()すると怒られることがある
はじめに
そんなことをやる必要はありません。あくまでもネタとしてお読みください。
今回の:
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3 org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.3 androidx.lifecycle:lifecycle-livedata-ktx:2.2.0
顛末
ViewModel
のテストで、LiveData
やFlow
の監視を開始したり終了したり流れてきた値をチェックする処理を簡単にできるようにしたいと考えて、そのようなクラスを作り現行の処理と置き換えたところ、次のようにコルーチンの処理が全部終わってないといって怒られた。
kotlinx.coroutines.test.UncompletedCoroutinesError: Unfinished coroutines during teardown. Ensure all coroutines are completed or cancelled by your test. at kotlinx.coroutines.test.TestCoroutineDispatcher.cleanupTestCoroutines(TestCoroutineDispatcher.kt:178) ...
試しにコルーチンを起動させている個所をコメントアウトするなどしてみたが状況は変わらず、特に変わったことしてないはずなのにおかしいなーなどと思いながらコードを見直したところ、良かれと思って後片付けの箇所に足した処理があったのを思い出した。
override fun finished(description: Description?) { // TestWatcher の関数 super.finished(description) observers.forEach { (l, o -> l.removeObserver(o) } // これ scope.cancel() }
該当の処理をコメントアウトしたところ、怒られが解消した。
解説
androidx.lifecycle.liveData()
やandroidx.lifecycle.asLiveData()
で作ったLiveData
*1 は、activeになった時に内部のコルーチンスコープを使ってFlow.collect()
を開始する。その後、inactiveな状態になると一定時間(デフォルトでは5秒)待ったあとでFlow.collect()
のJob
をキャンセルするようになっている。この一定時間待つという処理にdelay()
が使われている。ということは当然コルーチンが起動しているので、これが完了するのを待たなければならない。テスト冒頭でDispatchers.setMain()
をしつつ、kotlinx.coroutines.test.runBlockingTest()
やそれに相当する関数を使うなどして、例えば、
runBlockingTest { observers.forEach { (l, o -> l.removeObserver(o) } }
としてやることでdelay()
を含んだJob
を消化できる。
ちなみに
androidx.lifecycle.asFlow()
の中ではJob
のキャンセル時にGlobalScope
なコルーチンが起動するのでこれも終わるまで待てないとだめ(一敗)。
*1:asLiveData()は内部でliveData()を呼んでいる
fluxとかMVIみたいな構造のアプリを作ってみたかった その5
前回からの進捗としては、NavigationDelegate
をActivity
, Fragment
に持たせることにしたり機能追加をやっていた。その中でViewModel
以下のクラスをリファクタリングしたところしっくりくるような形になったのでまとめてみる。
クラスの構成について
Androidのデータバインディングのスタイルをなるべく守りたいので、ViewModel
をDataBinding
に渡してデータのプロパティやイベントリスナの関数とバインドしたいという大前提がある。また、Repository
のインタフェースはFlow
を返すかsuspend
関数にして値を返すことにしているので、Flow
からLiveData
に変換する必要がある *1 。そのためにはCoroutineContext
が必要なので、ViewModel.viewModelScope.coroutineContext
を使うことにする。そのような前提と、これまでの実装を踏まえつつクラスごとの役割分担を見直した結果、次のような構成になった。
作ってる物の解像度が上がって作りやすくなった気がするけどこうやって見るとなんかごちゃごちゃしてんな pic.twitter.com/FjNs9kdmhO
— まつだあきひと (@akihito104) 2021年3月2日
この構成の基本的なアイディアは、ViewModel
をイベントリスナの部分と状態遷移の部分とに分割して、それぞれの処理を別のクラスに委譲するということ。ViewModel
の主な役割は状態のFlow
をLiveData
に変換するのみになる。また、ViewModel
はviewModelScope
を持っていたり、LiveData
を持たせる都合上、Androidの世界とビジネスモデルとの境界に立って両者の橋渡しを行う役割も暗に持つことになった。イベントリスナや状態を表すためのデータはViewModel
側が別途定義する。
イベントリスナはActions
クラスに委譲する。当初、このクラスにはMVIのIntent
のような役割を持たせたいと考えて、イベントバスに流れてくるイベントを捕まえて個別のFlow
を作ることだけをやっていたのだが、いまいち役割が軽く見えていて不要かもしれないと思い始めていた。しかし、今回の見直しで、Actions
にイベントリスナを実装させることでUIイベントの生成から個別のFlow
に流すまでを担当することになり、よりMVIのIntent
に近づいたように思う。また、こうすることでイベントを流す具体的な処理や具体的なイベントクラスを隠蔽することができるようになった。
状態遷移はViewModelSource
クラスに委譲する。このクラスはもともとViewStates
という名前で、View
の状態遷移を担当していたのでやること自体はあまり変わらない。ただ、このクラスにActions
を注入する都合から、このクラスにもイベントリスナを実装させActions
に委譲する形にしておけば、ViewModel
はこのクラスだけに依存すればよいことになる。もしそうなったとき、ViewStates
とViewModel
との違いは状態をFlow
で流すかLiveData
で持つかだけになるので、実質的にViewStates
はほぼViewModel
と同じものになるんだなと考えて名前をViewModel
の方に寄せる(ViewModelSource
)ことにした。
状態遷移の処理もこれを機に見直した。当初はView
のプロパティをそれぞれ個別のFlow
やLiveData
にわけて更新していたが、複数のFlow
やLiveData
が関係しあうとコードが複雑になってよくなかったので、ViewModel
から提供される状態データのインタフェースを実装したデータクラスを使って一元的に管理することにした。このあたりのことはまた別の記事に書こうと思うが、ざっくりいうとRxやFlow
にあるscan()
のような仕組みを使って、イベントが流れてくるごとに新しい状態のデータを生成して流すというような感じの実装になっている。
流れてきたイベントや状態遷移の結果、画面遷移したりSnackbar
で表示するようなちょっとしたメッセージフィードバックをしたいときはそれ用のFlow
を用意してActivity
やFramgent
向けにイベントを流す。
ここまでやってきて
当初はfluxやMVIのようなものを目指して作ってきたが、自分の理解力が足りなかったり実装力が無かったりしてバグが多く、ちょっと足したり削ったり移したりするとすぐ壊れてしまって難しかった。しかし、今の形に至り、ある程度わかりやすくまとまったのではないかと感じている。今、これは何かと尋ねられたら、大きくなってしまったViewModel
を分割するための一つの方法、と答えると思う。今の実装をもっと洗練させていくとfluxやMVIといったものに近づいていくのかもしれないが、もうあまり意識していない。ただ、ある程度の達成感は得られたものの、この試みがここで終わるのかと聞かれるとそれも違う気がしていて、同じタイトルでこのまま続けていくかもしれない。
*1:いずれFlowのままDataBindingに渡せるようになるだろうから必要なくなるかも