fresh digitable

めんどくさかったなってことを振り返ったり振り返らなかったりするための記録

ここまでの話をまとめてzennに投稿した

fluxとかMVIみたいな構造のアプリを作ってみたかったシリーズの主にその5と6をまとめたりまとめなかったりしてZennに投稿した。

zenn.dev

FeaturedにのったりPick upということで公式のTwitterアカウントで流れたりして、自分としては初めてのことなのでよかった。

以下、雑感。

  • いいね数:平日の深夜に投稿して数時間で2、朝になってもう2、全部で4。決して多くはないけど反応がもらえるのがよかった。
  • いいねされつくした後にGAを入れた:いわゆるPVは40ぐらい、平均エンゲージメント時間は11秒らしい。ちゃんと計れてるのか分からないけどあんまり読まれてない様子で厳しい。
  • AndroidとKotlinのtrendingにはもうしばらく残り続けそうなのでまだ何か起きるかな?
  • タイトルとか内容とかをもう少し見直した方がいいだろうか。やりたいことが伝わっているのか分からない。こんなんMVIじゃないやろ的なツッコミがあるかなとも思っていたけどそこまで優しくなかった。
  • 今年はもう少しZennに書いていきたいと思っている。いろんな人の役に立てる記事を1本でもいいから書けるようになりたい。

fluxとかMVIみたいな構造のアプリを作ってみたかった その6

前の記事: akihito104.hatenablog.com

Viewの状態の更新処理はViewModelSourceで行っている。このクラスでの過去の実装を振り返り、反省点を踏まえてリファクタリングした時のことについて書く。その5の内容に強く依存しているので先にそちらを読むことをお勧めする。

これまでのこと

UIのイベントはRxのPublishSubjectをイベントバスにして流している。また、Repositoryの関数はLiveDataを返すようにしていたが、インタフェースをsuspend関数にしたりFlowを返すように統一していくことにした。この移行中はRxのObservableFlowLiveDataと3種類のイベントソースが入り乱れており相互運用に悩んだりした*1

イベントソースの多様さもさることながら、プロパティの更新処理にも課題があった。当初、プロパティの更新処理は、関連するイベントソースからのイベントを個々に受けて更新するというスタイルだった。個々のイベントソースやプロパティの中には依存関係があり、次のような感じで数珠つなぎにしたり、分岐させたり合流させる必要があった。

val user: LiveData<User> = repository.getUserSource(userId)

val isIconVisible: LiveData<Boolean> = user.map { it.iconUrl != null }

val isFollowerOfCurrentUser: LiveData<Boolean> = combine(  // combine() は2つのLiveDataを監視するプロジェクトオリジナルの関数
  appSettings.currentUserIdSource, 
  user
).switchMap { currentUserId, u ->
  relationshipRepository.getRelationship(currentUserId, u.id)
}.map {
  it.isSourceUserFollowedByTargetUser
}

ここからさらにプロパティが増えていくと依存関係も複雑になって壊れやすくなるし、書く順番にも気を配らなくてはならない。また、見た目では何となく上から順に処理が実行されそうだが、実際にはActivityFragmentから監視しているやつが間に割り込んだりしてくる。

反省点

  • イベントソースの型が複数種類ある: これは全部Flowにそろえた。ViewModelSourceFlowしか取り扱わない。ObservableActionsの中でFlowに変換しておく。
  • プロパティごとに変更処理を書くと複雑になる: LiveDataとかFlowをどんどん連結していくより、data classの中にまとめて書いた方が関連性が分かりやすい。
data class State(val user: User) {
  val isIconVisible: Boolean get() = user.iconUrl != null
  ...
}

イベントソースをFlowに統一して簡単に合流させられるようにし、別々のプロパティに分かれていたViewの状態を一つのクラスにまとめることにした。これで、あるイベントが流れてきた時、現在の状態を見て新しい状態オブジェクトを作れるようにしたい。

改善のアイディア

FlowのオペレータではrunningReducescanあたりが適当と思われる。Flow.scan()は初期値を与えられるので今回はこちらを使った。完成のイメージは

val state: Flow<State> = merge(
  actions.event1,
  actions.event2,
  repository.getSource(...), 
  ...
).scan(State()) { s, e ->
  when(e) {
    is E1 -> ...
    is E2 -> ...
    ...
  }
}

みたいな感じ。だた、Actionsでせっかく分けたのにmergeして再びwhen文で場合分けするのはかなり無駄なので工夫する必要がある。

どのイベントソースのFlowも自分がきっかけとなって状態を更新したいという思いは一つのはず。では、状態を更新する方法(関数)に変換して流したらどうか。中でsuspend関数を呼びたいので、

Flow<Event> -(何かして)→ Flow<suspend (State) -> State>

のようにしてscan()に渡す方向で考えてみる。また、イベントが流れてきた時に現在の状態と組み合わせて次の状態のオブジェクトを作りたいので、(何かして)の部分は

suspend (State, Event) -> State

みたいなものが必要*2。これらの条件を満たすものは次のような感じ。

val scanFun: suspend (State, Event) -> State = { s, e -> s.copy(...) /* 例として */ }

val update: Flow<suspend (State) -> State> = eventSource.mapLatest { event -> 
  val updateFun: suspend (State) -> State = { s -> scanFun(s, event) }
  updateFun
}

val state: Flow<State> = merge(update)
  .scan(State()) { s, u -> u(s) }

あとはscanFunの部分だけを書けばいいように便利関数とかを作る。

flatMap的な仕組み

Flow<Event>Flow<suspend (State) -> Flow<State>>Flow<suspend (State) -> State>

のようなケースにも対応したかったので、Flow.scan()に機能を足したようなものを作ることにした。Repositoryのデータソースではなく状態のFlowを受けて新しいFlowを作るということにしたい。現在のStateを流すために今回はStateFlowを使うことにしたがもっといい実装があるような気もする。

使っていて今のところ困ったことは起きていないが、もっと込み入ったことをやらせようとすると何か起きてしまうかもしれない。

まとめ

あまり洗練できていないけど、ユーザーがこれをしたときこうする、とか、このデータソースを監視して値が変わったらこれする、みたいな処理をまとめられて以前より*3はわかりやすくなったんじゃないかと思う。

すでに世に出ているAndroidのMVIのフレームワークと見比べると、やっていることが違いすぎてとてもじゃないけどMVIをやっていますとは言えない。記述量も圧倒的に多いのでもうちょっと考えたい。とくにActionsはもっと定型化できるように思う。

*1:イベントソースがObservableやLiveDataだとsuspend関数をどうやって呼ぶのがいいのかなど

*2:関数型のプログラミングを勉強してたらその用語とかを使ってもっといい感じに説明できるんだろうか

*3:当社比ってことです

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の時にもこのキャッシュを引き継げる。

ふりかえり

  • 平行化した結果トータルで見ると実行時間が増えている(ターンアラウンドタイムも微増)ので今の規模なら素直にstepで直列に実行した方がよかったかもしれない。
  • 平行に実行したいタスクを今後増やしていけるようになったということにして個人的には納得した。
  • タスクではなくモジュールで分けてみてもよかったかもしれない。
  • github actionsはstepの設定を共通化できたらいいのにと思う。どんどんコピペしなきゃいけないんだけどyamlだからインデントの深さをうっかり間違えると怒られるので。

*1:自分のPCの~/.gradleの下を見てそう思った

*2:テストだけのシンプルなものでよい