Paging3をざっくり理解したい

こんにちは。Androidエンジニアとしてインターン中のkitakkunです。
今回は、Android上でのページング処理を実現するためのライブラリ、「Paging3」に関して、Codelabで勉強したのでちょっとまとめてみたいと思います。

## はじめに

この記事はGoogleのCodelabに基づき作成されています。あくまで以下のCodelabの理解を補助するための記事です。ところどころ怪しい箇所があるかもしれません。間違ってたら指摘お願いします。

- https://developer.android.com/codelabs/android-paging-basics

## Pagingってなんぞ？

適当なデータをリスト表示するビューがあるとします。また、リストのデータはネットワークを経由してサーバーからフェッチされるものとしましょう。

ここで、一度にすべてのデータを読み込むわけにはいきません。
データが少ないうちはいいですが、データが多くなってくるとアプリが急激に低速になります。

ですから、画面内現れる必要なデータに絞ってフェッチする仕組みを実装する必要があります。そこで必然的にデータを **チャンク（塊）** ごとに分けて取得する **ページング** が活躍します。

具体的には、ユーザーがリストを最下部もしくはそれより数アイテム上まで閲覧したタイミングで、再度サーバーにリクエストを送り、リストの追加分データを取得して追加するという実装をするでしょう。

Pagingライブラリを使えば、このようなユースケースを簡単に実現することができます。

## PagingSourceを作る

PagingSourceはデータをページングしながら取得する機構持つデータソースです。例えば今、以下のArticleデータクラスがあるとします。

```kotlin
data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val created: LocalDateTime,
)
```

このArticleのデータをページングしながら取得するためのデータソース、ArticlePagingSourceは次の2つのメソッドを実装する必要があります。

```kotlin
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`なので、開始アイテムのキーを設定してやる必要があります。
```kotlin
// private const val STARTING_KEY = 0
// をファイルのグローバルスコープに定義してます
val start = params.key ?: STARTING_KEY
```
続いて、`loadSize`と併せてロード対象の範囲を割り出します。
```kotlin
val range = start.until(start + params.loadSize)
```
この`range`を用いて適当にデータをフェッチします。ここはアプリによって違うところだと思いますが、簡単のためにダミーのリストを作ってみます。データに特に意味はないです！
```kotlin
val data = range.map { number ->
     Article(
         id = number,
         title = "Article $number",
         description = "This describes article $number",
         created = firstArticleCreatedTime.minusDays(number.toLong()),
     )
}
```
ここまででloadメソッドはこんな感じです。この後、読み込んだデータをLoadResultでパックして呼び出し元に返却します。
```kotlin
    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`を無効にしなければならない場合

:::note warn
`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`より小さくならないようにします。
```kotlin
private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)
```
`nextKey`はフェッチしたデータの終端`range.last`に`1`を加算した値とすればいいでしょう。

以上の情報を用いて`LoadResult.Page`を構成します。
```kotlin
LoadResult.Page(
    data = data,
    prevKey = when(start) {
        STARTING_KEY -> null
        else -> ensureValidKey(key = range.first - params.loadSize)
    },
    nextKey = range.last + 1,
)
```

#### loadメソッドの実装例
最終的に以下のような実装となります。エラーがある場合は分岐を増やして`LoadResult.Error`を返すようなパスを作ってやりましょう。
```kotlin
    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`で取得します。

```kotlin
val anchorPosition = state.anchorPosition ?: return null
```
`anchorPosition`は、読み込み未完了でプレースホルダー状態の要素も込みでindexを出すので、`anchorPosition`に最も近く読み込み済みの要素を算出する必要があります。具体的には以下のコードで取得できます。
```kotlin
val article = state.closestItemToPosition(anchorPosition) ?: return null
```
内部実装を見ればなんとなくanchorPositionから上下一番近い読み込み済み要素を探しているのがわかると思います。

これを使ってPagingSourceを再初期化するときに使うべきキーを計算します。
```kotlin
ensureValidKey(key = article.id - (state.config.pageSize) / 2))
```

まとめると、以下のような実装になります。簡単ですね。

```kotlin
   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`の作成方法を定義する関数。

:::note info
`PagingConfig.pageSize`はどのくらいに設定すべきか

ページが小さすぎる→リストのちらつきが起こる可能性がある
ページが大きすぎる→リスト更新時の待ち時間が長くなる

ので、画面数枚分のアイテム数を設定するのが望ましいようです。
:::

リポジトリクラスには以下のように`PagingSource`の生成関数だけ定義しておきます。`PagingConfig`はViewModel側で設定します。

```kotlin
class ArticleRepository() {
    fun articlePagingSource() = ArticlePagingSource()
}
```

## ViewModel側の実装
`PagingConfig`と`PagingSource`のfactoryラムダ関数を指定して`Pager`を作り、`.flow`で`Flow`に変換します。
```kotlin
    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = {
            repository.articlePagingSource()
        }
    )
        .flow
        // 続きあります
```
このままだとアクティビティ再生成時などにページング状態が消滅してしまうので、viewModelScopeに紐づけます。このとき、`stateIn`や`sharedIn`の代わりに`cachedIn`を使います。

:::note warn
`stateIn`や`sharedIn`を使ってはいけない理由

`stateIn`はコールドな`Flow`を`StateFlow`に、
`sharedIn`はコールドな`Flow`を`SharedFlow`にそれぞれ変換します。

`StateFlow`と`SharedFlow`はホットな`Flow`、すなわちオブザーバーがいなくても値が流れます。

ただ、`Pager.flow`で生成されるflowはコールドではないので、`cachedIn`を使いましょうということらしいです。

ただ、`Pager`の`.flow`のところに `A cold [Flow] of [PagingData], which ...`みたいな記述があるので、どういうこと？ってなってます。詳しい人教えてください。。
:::

改めて、itemの保持部はこんな感じになります。
```kotlin
    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = {
            repository.articlePagingSource()
        }
    )
        .flow
        .cachedIn(scope = viewModelScope)
```

あとはこのアイテムを`collectLatest`して使います。なお、Composeの場合は`collectAsLazyPagingItems()`を使うようです。