こんにちは。Androidエンジニアとしてインターン中のkitakkunです。
今回は、Android上でのページング処理を実現するためのライブラリ、「Paging3」に関して、Codelabで勉強したのでちょっとまとめてみたいと思います。
はじめに
この記事はGoogleのCodelabに基づき作成されています。あくまで以下のCodelabの理解を補助するための記事です。ところどころ怪しい箇所があるかもしれません。間違ってたら指摘お願いします。
Pagingってなんぞ?
適当なデータをリスト表示するビューがあるとします。また、リストのデータはネットワークを経由してサーバーからフェッチされるものとしましょう。
ここで、一度にすべてのデータを読み込むわけにはいきません。
データが少ないうちはいいですが、データが多くなってくるとアプリが急激に低速になります。
ですから、画面内現れる必要なデータに絞ってフェッチする仕組みを実装する必要があります。そこで必然的にデータを チャンク(塊) ごとに分けて取得する ページング が活躍します。
具体的には、ユーザーがリストを最下部もしくはそれより数アイテム上まで閲覧したタイミングで、再度サーバーにリクエストを送り、リストの追加分データを取得して追加するという実装をするでしょう。
Pagingライブラリを使えば、このようなユースケースを簡単に実現することができます。
PagingSourceを作る
PagingSourceはデータをページングしながら取得する機構持つデータソースです。例えば今、以下のArticleデータクラスがあるとします。
data class Article(
val id: Int,
val title: String,
val description: String,
val created: LocalDateTime,
)
このArticleのデータをページングしながら取得するためのデータソース、ArticlePagingSourceは次の2つのメソッドを実装する必要があります。
class ArticlePagingSource : PagingSource<Int, Article>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
TODO("Not yet implemented")
}
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
TODO("Not yet implemented")
}
}
各メソッドについて説明します。
loadメソッドの実装
load
は、 LoadParams
に基づいてデータをフェッチした結果LoadResult
を返します。
データの範囲計算とフェッチ処理
LoadParams
は、以下の2つのデータを持ちます。
- 読み込み開始アイテムのキー(
key
) -
key
以降で読み込むアイテム数(loadSize
)
key
は初回ロード時はnull
なので、開始アイテムのキーを設定してやる必要があります。
// private const val STARTING_KEY = 0
// をファイルのグローバルスコープに定義してます
val start = params.key ?: STARTING_KEY
続いて、loadSize
と併せてロード対象の範囲を割り出します。
val range = start.until(start + params.loadSize)
このrange
を用いて適当にデータをフェッチします。ここはアプリによって違うところだと思いますが、簡単のためにダミーのリストを作ってみます。データに特に意味はないです!
val data = range.map { number ->
Article(
id = number,
title = "Article $number",
description = "This describes article $number",
created = firstArticleCreatedTime.minusDays(number.toLong()),
)
}
ここまででloadメソッドはこんな感じです。この後、読み込んだデータをLoadResultでパックして呼び出し元に返却します。
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
val start = params.key ?: STARTING_KEY
val range = start.until(start + params.loadSize)
val data = range.map { number ->
Article(
id = number,
title = "Article $number",
description = "This describes article $number",
created = firstArticleCreatedTime.minusDays(number.toLong()),
)
}
// TODO: LoadResultにデータをまとめて返す
}
読み込み結果の返却
LoadResult
はロード結果を表すクラスです。ロードの成功・失敗・不整合それぞれの結果に対応する型が存在します。
-
LoadResult.Page
: 正常にロードできた場合 -
LoadResult.Error
: エラーの場合 -
LoadResult.Invalid
: データの整合性が取れなくなったため、PagingSource
を無効にしなければならない場合
Invalid
がどういうケースで出てくるのかいまいちまだよくわかりませんが、おそらく通常使うのはPage
とError
でしょうか・・?
今回は毎回ロードに成功する想定でダミーリストを返すので、LoadResult.Page
を使います。LoadResult.Page
を作成する際に必要となる引数は以下の通りです。
-
data
: 取得したアイテムのList
-
prevKey
: 現在のページより前のアイテムを取得する場合に用いるキー -
nextKey
: 現在のページより後のアイテムを取得する場合に用いるキー -
itemsBefore
(省略可能): 読み込んだデータの前に表示するプレースホルダー数 -
itemsAfter
(省略可能): 読み込んだデータの後ろに表示するプレースホルダー数
初回ロードの場合、前のページのデータは存在しませんから、prevKey
はnull
になります。それ以外では、今回のロード開始位置range.first
からロードアイテム数params.loadSize
を引いた値がprevKey
となります。しかし、場合によってはこの値が負となる可能性があるため、STARTING_KEY
以上の値となるよう適切な処理を行う必要があります。
例えば、以下のような関数を作成しprevKey
がSTARTING_KEY
より小さくならないようにします。
private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)
nextKey
はフェッチしたデータの終端range.last
に1
を加算した値とすればいいでしょう。
以上の情報を用いてLoadResult.Page
を構成します。
LoadResult.Page(
data = data,
prevKey = when(start) {
STARTING_KEY -> null
else -> ensureValidKey(key = range.first - params.loadSize)
},
nextKey = range.last + 1,
)
loadメソッドの実装例
最終的に以下のような実装となります。エラーがある場合は分岐を増やしてLoadResult.Error
を返すようなパスを作ってやりましょう。
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
val start = params.key ?: STARTING_KEY
val range = start.until(start + params.loadSize)
val data = range.map { number ->
Article(
id = number,
title = "Article $number",
description = "This describes article $number",
created = firstArticleCreatedTime.minusDays(number.toLong()),
)
}
return LoadResult.Page(
data = data,
prevKey = when(start) {
STARTING_KEY -> null
else -> ensureValidKey(key = range.first - params.loadSize)
},
nextKey = range.last + 1,
)
}
getRefreshKeyメソッドの実装
もう一つ実装しなければならないメソッドとして、getRefreshKey
があります。
このメソッドは、PagingSource
のバックエンド側データに変更があり、再度読み込む必要性が発生した場合に呼び出されます。
Twitterでタイムラインから気になったツイートを開いて、いいねをして戻ってきたときを考えてみましょう。いいね数が変わっているので再度読み込む必要がありますよね。
このように今見えている範囲をリフレッシュしたいとき、どのキーを開始位置としてリフレッシュすればいいかを返すのがこのメソッドです(おそらく)。
引数としてPagingState
を受け取りますがこれを用いて次の初回ロード時のキーを計算します。
まず初めにリストで最も最近アクセスのあった要素のindexをstate.anchorPosition
で取得します。
val anchorPosition = state.anchorPosition ?: return null
anchorPosition
は、読み込み未完了でプレースホルダー状態の要素も込みでindexを出すので、anchorPosition
に最も近く読み込み済みの要素を算出する必要があります。具体的には以下のコードで取得できます。
val article = state.closestItemToPosition(anchorPosition) ?: return null
内部実装を見ればなんとなくanchorPositionから上下一番近い読み込み済み要素を探しているのがわかると思います。
これを使ってPagingSourceを再初期化するときに使うべきキーを計算します。
ensureValidKey(key = article.id - (state.config.pageSize) / 2))
まとめると、以下のような実装になります。簡単ですね。
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
val anchorPosition = state.anchorPosition ?: return null
val article = state.closestItemToPosition(anchorPosition) ?: return null
return ensureValidKey(key = article.id - (state.config.pageSize / 2))
}
UI用のPagingDataを作る
ページングを用いて作成されたリストをUIで使うには、PagingData
として扱う必要があります。PagingData
は読み込んだデータをラップして、追加データ取得のタイミングを内部的に自動で決定してくれるクラスです。
KotlinのFlowを用いている場合は、Pager.flow
というビルダーメソッドを用いてPagingData
へ変換します。
ビルダーを呼び出すにあたり、以下のパラメータ指定が必要です。
-
PagingConfig
: 先読み量や初期の読み込みサイズなどPagingSourceからコンテンツを読み込む方法を決定します。必須パラメータはページサイズのみで、その他のパラメータは省略可能です。
デフォルトでは読み込んだデータ全てをメモリに保持しますが、スクロール時のメモリ浪費を回避するにはmaxSize
を指定します。
また、enablePlaceholders = true
とすると未読み込みのコンテンツに対してはnullを返すようになります。 -
PagingSource
の作成方法を定義する関数。
PagingConfig.pageSize
はどのくらいに設定すべきか
ページが小さすぎる→リストのちらつきが起こる可能性がある
ページが大きすぎる→リスト更新時の待ち時間が長くなる
ので、画面数枚分のアイテム数を設定するのが望ましいようです。
リポジトリクラスには以下のようにPagingSource
の生成関数だけ定義しておきます。PagingConfig
はViewModel側で設定します。
class ArticleRepository() {
fun articlePagingSource() = ArticlePagingSource()
}
ViewModel側の実装
PagingConfig
とPagingSource
のfactoryラムダ関数を指定してPager
を作り、.flow
でFlow
に変換します。
val items: Flow<PagingData<Article>> = Pager(
config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
pagingSourceFactory = {
repository.articlePagingSource()
}
)
.flow
// 続きあります
このままだとアクティビティ再生成時などにページング状態が消滅してしまうので、viewModelScopeに紐づけます。このとき、stateIn
やsharedIn
の代わりにcachedIn
を使います。
stateIn
やsharedIn
を使ってはいけない理由
stateIn
はコールドなFlow
をStateFlow
に、
sharedIn
はコールドなFlow
をSharedFlow
にそれぞれ変換します。
StateFlow
とSharedFlow
はホットなFlow
、すなわちオブザーバーがいなくても値が流れます。
ただ、Pager.flow
で生成されるflowはコールドではないので、cachedIn
を使いましょうということらしいです。
ただ、Pager
の.flow
のところに A cold [Flow] of [PagingData], which ...
みたいな記述があるので、どういうこと?ってなってます。詳しい人教えてください。。
改めて、itemの保持部はこんな感じになります。
val items: Flow<PagingData<Article>> = Pager(
config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
pagingSourceFactory = {
repository.articlePagingSource()
}
)
.flow
.cachedIn(scope = viewModelScope)
あとはこのアイテムをcollectLatest
して使います。なお、Composeの場合はcollectAsLazyPagingItems()
を使うようです。