fresh digitable

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

Jetpack Compose + Paging3でstickyHeaderをやるにはどうしたらいいんだ

前置き

  • paging3: 3.3.6
  • room: 2.8.0

  • YouTubeとTwitchで登録しているチャンネルの配信情報をひとつにまとめて見れるアプリを作っている:

  • 配信中のリストと配信予定のリストとをタブで分けており、タブには項目名とそれぞれの配信アイテム数を表示している
    • 配信中のリストは開始時刻の新しい順にソートして表示している
    • 配信予定のリストは配信開始予定の日付ごとにグルーピングして開始時刻順にソートし、グループの日付をstickyHeaderで表示している

Pagingについて

最初はYouTubeだけの対応で、配信の取得処理が紆余曲折して結局ページングを必要としない形に落ち着いたというのもありPagingを使っていなかった。その後Twitchも対応することにしたり、作っている間に登録チャンネルも累計200近く増えたりしたけど表示するアイテムがせいぜいでも全部で200件ぐらいで、読み込みが遅いとか、動きがもっさりしているとかいうことも感じられなかったので必要性も感じていなかった。そもそもPagingSourceRemoteMediatorとを合わせて使うべきと思い込んでいたというのもあるが、用途に対してあまりフィットしていないと感じていたのと、性能面においても必要性を感じていなかったのとがありPagingの採用に対しては消極的だった。

その後、DBからデータを取り出した後に共通クラスに変換したり並べ替えたり共通のルールを実装したりなどやっていると、いろんなクラスを定義してはラップしてを繰り返してるのが無駄だな~とかアイテムの表示に関してサービスごとのルールもあればアプリとしてのルールもあるんだけどそのための実装がいろんなところに散らばる感じになるのが面倒くさいな~とかいろんな不満を覚えるようになった。ただ、それですぐPagingにするか~となったわけでもなく、Paging使ったらビジネスロジックを全部SQLに書くんでしょ~テスト面倒くさくならない~?とか、構造がPagingに引っ張られたりするのが自分的にあんまり好きじゃなかったのでしばらくは問題から目をそらしながら生きていた。

しかしながら、DBから取得した後の処理がごちゃついているのも解決できるし、DBを通る処理のテストはモックしない主義なのでテスト容易性に関してはいまさら気にすることでもないだろうとなり、SQL一発でUIモデルをとってくるのが実装的にも性能的にもよいのか?まあサンドボックス的プロジェクトだしやってみてから考えてみてもいいか…などと思いPagingへの移行を目指すことにした。

Pagingとのたたかい

  • PagingSourceをDBから取得するにあたっては、SQLを書かなければならない。DBのテーブルはサービスごとに都合のいい形で定義してあるので、サービスごとにそれぞれSELECTしたりJOINするSQLを書き、それらをUNIONでひとまとめにした後WHEREしてSORTする感じになる。
  • YouTubeの配信予定は登録チャンネルのプレイリストを並列にリクエスト(5~10秒で300件前後)し、そのレスポンスをそれぞれDBに書き込んでいた。Roomで作るFlowなデータはクエリに含まれるテーブルを監視して、テーブルが変更される都度クエリを実行してデータを流す。つまり短時間に大量のデータがPagingSourceから流れてくるのでUIの更新が非常に激しい状態になっていた。更新の少ないチャンネルのプレイリストを取得する頻度を下げるなどしてリクエストの回数を減らしたり、レスポンスをある程度まとめてからDBに書き込むようにした。
  • それでも一時的にデータが激しく流れてしまうことがある。そんな時はFlow.debounce()で流量を抑える。アイテムの追加削除アニメーションを入れてる場合はアニメーションの長さより短い間隔でデータを流しても仕方ないので適度に間引くのがよさそう。
@OptIn(FlowPreview::class)
val pagingItem = pagingData.flow
    .debounce(300.milliseconds)
    .cachedIn(viewModelScope)
  • 時々LazyColumnItemIdが重複しているという旨のメッセージが出てクラッシュすることがある。UIの更新中にデータの長さが変わってしまったり、追加と削除の順序が悪い時に起きるイメージがあり、debounce()で事故率は下がりそうではある。ぐぐるとkeyを指定しないことにしてやり過ごす記事がよく出てくるが、よく見るとそもそも昔のAPIに関する記事だったりする。少なくともPaging3.3.6ではLazyPagingItems.itemKey()を使って、アイテムがプレースホルダの場合はそれ用のID*1を返してもらうようにする。
LazyColumn(...) {
    items(
        count = lazyPagingItems.itemCount,
        key = lazyPagingItems.itemKey { item -> item.id },
    ) { ... }
}
  • 配信中リストはこれでいいが、配信予定リストは日付のヘッダーをはさみたい。これまではViewModelでグルーピングしてKeyValueに変換していたが、PagingDataを使ってるときはどうすればいいのだ?ぐぐるLazyPagingItems.peak()を使ってComposable関数側でKeyValueにしてるのを見つける。真似してみるが読み込み時にクラッシュしたり画面遷移して戻ってくるとリストの表示位置を覚えてなかったりしてうまくいかない。

どうするのがいいんや

Pagingにはリストの区切りなどを挿入するためのinsertSeparators()というリストの中に別のアイテムを挟み込める関数があり、いかにもな感じだったのでこれを使ってみようとした。

lazyPagingItems.insertSeparators { before, after ->
    if (before == null && after != null) {
        HeaderUiModel(after)
    } else if (isHeaderNeeded(before, after)) {
        HeaderUiModel(checkNotNull(after))
    } else {
        null
    }
}

が、当然のことながらリスト長がヘッダーの数だけ長くなってしまう。アイテムの総数をタブに表示するためにLazyPagingItems.itemCountを使っているのでこれは困ったことになった。また、stickyHeaderをやりたい場合はLazyListScopeの中ではこんな感じで使うことになる。ちゃんと動くまで試行錯誤していないのであくまでもイメージ程度のものとして考えてください*2

for (i in 0 until lazyPagingItems.itemCount) {
    val item = lazyPagingItems.peak(i)
    if (item is HeaderUiModel) {
        stickyHeader(...) {
            HeaderView(item)
        }
    } else if (item is ListItemUiModel) {
        val listItem = lazyPagingItems[i]
        item(...) { // 1個だけ置くやつ
            ListItemView(listItem)
        }
    }
}

さらに、これは何が原因かわかっていないのだが、リストアイテムをタップして画面遷移し、またリストに戻ってくるとヘッダーが無くなってしまったりすることが起きたりもしていて、今回の目的にはあんまり向いていないのかもしれない。

LazyPagingItemsに変換する前にヘッダーを入れるのは今回は断念して、LazyPagingItemssnapshotを使ってみることにした。やっていることはpeak()を使うのと本質的に変わらない。

val snapshot = lazyPagingItems.itemSnapshotList
val grouped = snapshot.items.mapIndexed { i, item -> item.key to i + snapshot.placeholdersBefore }
    .groupBy { (k, _) -> k }
    .mapValues { (_, v) -> v.map { it.second } }
items(
    count = snapshot.placeholdersBefore,
    key = lazyPagingItems.itemKey { it.itemKey },
    contentType = lazyPagingItems.itemContentType { "placeholder" },
    itemContent = {},
)
grouped.forEach { (header, itemIndex) ->
    stickyHeader(
        key = header.text,
        contentType = "header",
    ) {
        HeaderView(
            label = header.text,
            modifier = Modifier.animateItem(),
        )
    }
    items(
        items = itemIndex,
        key = { index -> lazyPagingItems.itemKey { it.itemKey }(index) },
        contentType = { "content" },
    ) {
        val item = lazyPagingItems[it] as? ListItemUiModel ?: return@items
        content(item)
    }
}

ポイントは次の二つ。

  • LazyPagingItems.get(Int)でページ境界付近のデータを取得すると、次のページが新しく読み込まれる。items()itemContentでは必要な部分のデータだけ取得できるようにしたいので、LazyListPaging.get(Int)itemContentの中で呼びたい。リストアイテムをグルーピングする時に一旦インデクスに置き換えることで、いい感じのタイミングで次のページを読み込めるようになっている。
  • ItemSnapshotList.placeholdersBeforeの数をitems()に渡すことで別画面から戻ってきた時に元の位置のアイテムを表示できる。

まとめと所感

  • LazyPagingItems.get()items()itemContentの中で使う
  • LazyListScopeにアイテムの総数を正しく伝えることで、画面遷移から戻ってきても元のリストの位置から復帰できる
  • insertSeparatorsstickyHeader用ではない可能性がある
  • アイテム総数の件を妥協して、ヘッダーが消える現象が回避できればinsertSeparatorsを使いたい
  • 本当にこれでいいのか、だれか教えてくれ

*1:Pagingで定義されているクラスのオブジェクト

*2:これを書いているときにふと思いついたのだが items(...) { if (...) this@hoge.stickyHeader { ... } else ... } みたいにできるんだろうか

WorkManagerを使ったInst. test

前置き

なんかこういうやつのテストを書きたいとする。HogeWorkerCoroutineWorkerを実装しており、doWork()の中でHogeUseCase.invoke(HogeUseCase.Input)を呼び、返ってきたkotlin.ResultをもとにListenableWorker.Resultを返す。終わったかどうか、うまくいったかどうかが分かりにくいのでWorkerの状態がわかるいい感じの戻り値も返すようにしたい。

// HogeWorkderのcompanion object
fun enqueue(context: Context, input: HogeUseCase.Input) {
    val workReq = OneTimeWorkRequestBuilder<HogeWorker>()
        .setInputData(input.toWorkerData())
        .build()
    val workManager = WorkManager.getInstance(context)
    workManager.enqueue(workReq)
}

説明を簡単にするためにこうしているけど、実際には引数をDataに変換する処理やHogeWorkder.doWork()の中身をリファクタリングしたくて、それらの処理の一番外側にあたるこの関数のテストを書きたいという感じです*1。そういう必要がなければTestListenableWorkerBuilderHogeWorkerを作ってdoWork()を呼ぶみたいなテストを書けばよいでしょう。

じゃあどうする

公式の開発ドキュメントを見るとListenableFutureを返してくるやつのテストが出てくる。Guavaを使っていればこれでいいんだけど、なんと自分のプロジェクトでは使ったことがなかったので一旦保留*2

WorkManager ver.2.9.0Flow<WorkInfo?>を使って監視できるようになったらしいので、これを使うのがよさそう。WorkManager.getWorkInfoByIdFlow(UUID)が返すFlowWorkManagerが管理するデータベースを(Roomを使って)監視した結果なので、少なくともOneTimeWorkであればWorkInfo.stateが終了時の状態になるまで待っていればテストもできそう。ということで、テスト対象のクラスはつぎのようにした。

fun enqueue(context: Context, input: HogeUseCase.Input): Flow<WorkInfo?> {
    val workReq = OneTimeWorkRequestBuilder<HogeWorker>()
        .setInputData(input.toWorkerData())
        .build()
    return WorkManager.getInstance(context).run {
        enqueue(workReq)
        getWorkInfoByIdFlow(workReq.id)
    }
}

したがってテスト側はこんな感じ。

val actual = HogeWorker.enqueue(context, data)
     .firstOrNull { it?.state?.isFinished == true }

ver. 2.10.0Configuration.BuilderCoroutineContextを渡せるようになった。WorkManagerTestInitHelper.initializeTestWorkManager()ExecutorsMode.PRESERVE_EXECUTORSオプションと合わせてテスト実行時のCoroutine Dispatcherやスレッドを適切に設定するとよい。

*1:リファクタリングした結果UseCaseとかInput.toWorkerData()とかができた

*2:この関数を使おうとするとそんなクラスは知らないといわれるんだけど、ドキュメントにはこのライブラリを追加しましょうみたいな案内もない。そういうもの?よくわからない。

Roomで独自のマイグレーションを実装してテストまで書いた

背景

あるテーブルのカラムを、新しく作成するテーブルに移したくなったので自分でマイグレーションを実装して、ついでにテストも書いた。

github.com

Roomは簡単なマイグレーションなら、@AutoMigrationAutoMigrationSpecクラス(につけるアノテーション)で十分事足りるし、どうせ自分しか使っていないのでdestructive migrationしてしまってもいいんだけど、せっかくの機会なのでやってみることにした。

具体的にやりたいことは次の通り。ざっくりいうと正規化みたいなことです。

  • 配信カテゴリを保存するカテゴリテーブルを新しく追加する。カテゴリID、カテゴリ名、画像URLなどのカラムを持つ。
  • 配信予定テーブルが持っているカテゴリIDとカテゴリ名をカテゴリテーブルにコピーする。
  • 配信予定テーブルが持っているカテゴリIDカラムに外部キー制約を追加し、カテゴリ名カラムを削除する。

カラムのデータをコピーしようとしなけばAutoMigration@DeleteColumn.Entitiesの組み合わせでOKです。

マイグレーションの実装

  • Migrationクラスを実装してRoomDatabaseのBuilderにこれを渡す。詳しいことは公式のページを参照のこと: Migrate your Room database  |  App data and files  |  Android Developers
  • 今のAndroid(API Level: 34)のSQLiteには外部キー制約だけを追加したり、あるカラムだけを削除するクエリは無い。したがって、新しくテーブルを作成して元テーブルのデータをコピーし、元テーブルを削除するというようなことが必要である。
    • AutoMigrationDeleteColumnをやったことがあれば、自動生成されたコードの中に実際のクエリがあるので気になった方は見てみてほしい。自分もこれを見ながら流れを把握した。
    • 一旦AutoMigrationで自動生成させてこのコードをコピーし、追加の処理だけ自分で書いたっていい。
  • SQLを書く段になるとGeminiのコードアシストがかなり便利で、カラムの列挙くらいのことはすぐにやってくれる。
  • 自動生成のコードではテーブルの外部キー制約をチェックする処理が呼ばれているが、これはライブラリ内限定スコープの関数になっておりこちらからは使えない。本来は必要ないものかもしれないが、少なくともテスト中は同等のクエリを自分で書いてチェックするとよい。

テスト

  • なにせ初めてのことで不安なので、思った通りに動くかどうかをAndroidTestで確認することにした。
  • スキーマJSONファイルを出力していれば、これをAndroidTestの際に見えるようにしておく。その辺の設定は公式( Migrate your Room database  |  App data and files  |  Android Developers )を参照のこと。
  • 基本的にはSupportSQLiteDatabaseAPIを使ってデータを書き込むことになる。execSQL()よりはinsert()+ContentValuesの方がINSERTできたかどうかを戻り値でチェックできるのでおすすめです。
  • Roomは外部キー制約のチェックを行っているが、これはSQLiteのデフォルトの設定ではないので、テストするにあたってはセットアップ時にsetForeignKeyConstraintsEnabled(true)を呼ぶのがよい。
  • バージョンアップ前のデータが思ったように移行できているか、外部キー制約は正しく設定できているかなどをチェックするテストがあれば十分でしょうか。
  • 今回の変更のように、配信予定テーブルにINSERTする際に配信カテゴリテーブルにも新しいレコードを追加しなければならなくなったりするので、Daoまわりのテストも必要に応じて行ってください。

雑感

  • スキーマJSONファイルが初めて役に立ったのでよかった
  • 今回はMigrationTestHelperを継承してTestRuleを実装したが、継承せずに独自のTestRuleでTestHelperを隠ぺいしてもよかったかも
  • マイグレーションせずに済むならそれに越したことはないので最初によく考えよう