Paging3 はページネーションされたデータを逐次的に読み込むためのライブラリです。例えばページネーションされた API への問い合わせの結果をリスト表示し、リスト末端に近づくと自動的に再フェッチするようなケースに使用されます。
この記事では著者が最も苦しめられた refresh key の実装について説明します。refresh key は特にデータの一部の更新を伴うケースに用いられ、適切に実装しないとスクロール位置がリセットされる・ジャンプするといった UX 上の問題を招きます。公式ドキュメントの説明も限られており、いくつかの背景知識が要求されることからこの記事を執筆しました。
この記事では Paging3 基本的使い方は説明しません。また、この記事では Jetpack Compose を使用する場合に限って説明を行います。
この記事の説明が間違っている・例が動かない場合は教えてもらえると助かります。
Jetpack Compose の思想と Paging3
Jetpack Compose は React と同様に宣言的 UI フレームワークです。Compose では純粋関数に対して @Composable アノテーションを付加することによりコンポーネントやカプセル化されたロジックを作成します。これらのコンポーネントを組み合わせて画面を作成します。
コンポーネントは @Stable な値をデータとして取ることができます。@Stable であるとは、内部可変性が適切に通知される (または内部可変性を持たない) もので、@Composable な関数が受け取った引数の差分を適切に検出することができます。コンポーネントが他のコンポーネントを呼び出す関係について、コンポーネントは木構造をなします。この意味でデータは親から子に、イベントはコールバックを伝って子から親に伝えられます。この特徴は単一方向データフローと呼ばれます。
Paging3 はこの単一方向データフロー上で扱われるように設計されています。すなわち Paging3 から提供されるデータは @Stable です。そのためデータの追加だけでなくデータの削除やパッチを行った際にもデータ全体が更新されます。逆に言えば、Paging3 で提供されるデータソースを定義する際にはこの条件を満たすように実装しなければなりません。
refresh key の役割
ページングのライフサイクル
前述の通りページングデータは @Stable なオブジェクトで、Pager により Flow として提供されます。PagingSource は Pager により作成・呼び出されるオブジェクトで、ページの読み込みと再生成時にページングキーを生成する役割を持ちます。単に新しいページを追加する際には PagingSource.load が呼び出されますが、既存のデータが破棄される場合には PagingSource は自身を無効化する必要があり、その際 Pager は PagingSource の新しいインスタンスを生成します。新しいインスタンスを生成すると Pager は PagingSource.getRefreshKey を呼び出し、PagingSource.load に渡すことで最初のページを読み込みます。
(mermaid の都合でうまいことインスタンスの破棄再生成が書けなかったためダイアグラムとしては不正確です)
ここでいう DataSource は外部の API は RemoteMediator による DB キャッシュです。RemoteMediator を使用する場合 DB を監視して変更があった場合に自身を invalidate() します。
UI レイヤーとのやり取り
Paging3 で収集されたデータは一般的に view model から Flow<PagingData<T>> として提供されます。今回は UI レイヤーに Compose を想定しており、一般的には collectAsLazyPagingItems によって収集されます。その値は LazyColumn・LazyRow・LazyGrid のいずれかによってリスト・グリッド形式で表示されることがほとんどでしょう。
Compose に限らず可変長個のリストを表示する UI では実際に見えている部分だけを描画することが一般的であり、Paging3 もその挙動を期待した実装となっています。Paging3 はページングデータへのインデックスアクセスを提供しており、アクセスされた際にそのインデックスを内部的に保持します。この値を基準に Pager は追加のページを読みます。またこの値は PagingSource の再生成時に getRefreshKey の引数である PagingState の anchorPosition として使用されます。
PagingSource が再生成される際、 UI には新しい PagingData が提供されます。LazyListState などのスクロール位置を保持する状態はアイテムの添字ではなくキーを基準としてスクロール位置 (およびオフセット) を保持します。
getRefreshKey の実装
実装の要件
PagingSource.getRefreshKey() は PagingSource の再生成後に呼ばれ、再生成後初めての load() 呼び出しの際のキーとして使用されます。この時引数には PagingState が設定され、anchorPosition に最後にアクセスされたアイテムのキーが提供されます。これをもとに読み込むページの key を決定する必要があります。
前述のとおり、再生成後の読み込みでリストの要素が変わった場合も、UI はキーが一致するアイテムを手がかりにスクロール位置を保持しようとします。 そのため PagingSource 再生成後に読み込まれるページは UI レイヤーが保持しているスクロール位置の基準になるキーのアイテムを含まなければなりません。このときには以下の要素が影響します。
-
PagingDataから最後に読み込まれたアイテム - UI のスクロール位置の基準アイテム (一番最初に見えている要素など)
- 画面サイズ
- 追加されるデータの向き (キーの前の値が読み込まれるか、後ろの値が読み込まれるか)
ここで、最後に読み込まれたアイテムとスクロール位置として保持されているアイテムは一般に一致しません。例えば LazyColumn を下向きにスクロールしているとき、最も最近読み込まれたアイテムは画面最下部のアイテムですが、スクロール位置の基準は画面最上部のアイテムとなります。この場合画面サイズに依存する分だけの「ずれ」が発生します。anchorPosition からこの「ずれ」の分離れたアイテムを推測し、そのアイテムが含まれるように refresh key を作らなければなりません。
実装では画面サイズを用いることはせず、PagingConfig.initialPageSize を使用します。この値は画面を十分覆うことができるサイズに設定するよう推奨されているためです。以下に具体的な実装を見ていきます。
データの更新がない場合
PagingSource が破棄・再生成されない状況ではもはや refresh key を考える必要がありません。単に null を返せばよく、このメソッドが呼ばれることもありません。
override fun getRefreshKey(state: PagingState<Int, MyData>): Int? {
return null
}
offset ページネーション
公式ドキュメントの例に記載されているものです。
ページ番号とページサイズを指定してページを取得するページネーションを想定します。ページングキーはページ番号 (Int) です。
override fun getRefreshKey(state: PagingState<Int, MyData>): Int? {
val anchorPosition = state.anchorPosition ?: return null
// 最後にアクセスされた要素を含むページ
val page = state.closestPageToPosition(anchorPosition) ?: return null
return page.prevKey?.plus(1) ?: page.nextKey?.minus(1)
}
prevKey + 1 は page のページ番号を表します。同様に nextKey が null でないとき、nextKey - 1 もこのページのページ番号を表します。すなわちどちらの場合も最後にアクセスされた要素を含むページのキーを表しています。anchorPosition が UI のスクロール基準とするアイテムより前であればスクロール位置が保存されますが、そうでない場合は 1 画面以内のスモールジャンプが発生します。
prevKey と nextKey がどちらも null の場合は最初かつ最後のページであるため、null を返して最初から読み込み直しても問題ありません。
cursor ページネーション
カーソルとページサイズを指定してページを取得するページネーションを想定します。ページングキーはカーソル (K) です。API などが次のページ・前のページのキーを返却するようなケースを想定しています。
override fun getRefreshKey(state: PagingState<K, MyData>): K? {
val anchorPosition = state.anchorPosition ?: return null
// 最後にアクセスされた要素
val item = state.closestItemToPosition(anchorPosition)
return item.key
}
この場合は最後にアクセスされた要素のキーそのものを返します。anchorPosition が UI のスクロール基準とするアイテムより前であればスクロール位置が保存されますが、そうでない場合は 1 画面以内のスモールジャンプが発生します。
ページ境界のあるカーソルページネーション
カーソルページネーションではあるものの、境界のアイテムを指定しないといけないケースを想定します。
override fun getRefreshKey(state: PagingState<K, MyData>): K? {
val anchorPosition = state.anchorPosition ?: return null
// 最後にアクセスされた要素を含むページ
val page = state.closestPageToPosition(anchorPosition)
return page.data.firstOrNull()?.key
}
anchorPosition に対応するアイテムを含むページの先頭要素のキーを返します。このキーはページの境界であるという条件を満たします。initialPageSize が pageSize の 2 倍以上のサイズがあることを仮定すると、anchorPosition が UI のスクロール基準とするアイテムより前であればスクロール位置が保存されます。そうでなくかつ UI のスクロール基準とするアイテムが同一ページにない場合には 1 画面以内のスモールジャンプが発生します。
スモールジャンプを防ぐ
これまでのどの方式においても、anchorPosition が UI のスクロール基準とするアイテムより後の場合にスモールジャンプが発生します。ページデータが anchorPosition から後のものが読み込まれ、前にある UI のスクロール基準とするアイテムが含まれないためです。これを防ぐにはいくつかの方法が考えられます。
- refresh key をシフトする
initialPageSizeを 2 倍よりも大きい値とし、余剰分を前にシフトすることでanchorPositionより前のアイテムから読み込みを開始します。 - ページキーをページ中央基準にする
ページキーの前後が読み込まれることで、画面サイズの 2 倍以上のページサイズがあれば必ずスクロール基準となるアイテムが含まれることになります。