【Android】Paging3を用いてページングを実装してみた

## 初めに
本記事では、Paging3というライブラリを用いたページング処理の実装について自分の理解をまとめておこうと思います。
## ページングとは
ページングは**大量のデータを少しづつ読み込むことでアプリのパフォーマンスを向上させる仕組み**です。
### ページングのメリット
ページング導入のメリットとして、**UXの向上**が挙げられます。APIを叩いてのデータ取得や、ローカルに保存したデータの取得を行う際に、大量のデータを一度にすべて読み込もうとするとユーザーを長時間待たせることになります。しかし、Pagingを用いるとすぐに必要になる(=画面に表示する必要のある)データのみを取得するため、アプリの応答速度を改善することができます。

## Paging3に関するクラス
Paging3における主な構成要素となるクラスについて、以下に簡単な概要をまとめておきます。
| 名前| 概要・用途 |
|---------|---------|
| PagingSource | データソースからのデータの取得方法を定義するクラス | 
| PagingData | ページングによってロードするデータのコンテナとなるクラス | 
| Pager | PagingSourceからデータを取得してPagingDataを作成するクラス |
|PagingConfig | PagingSourceで定義した方法でデータを取得する際の設定をするクラス|
|LazyPagingItems| PagingDataにアクセスし、UIに表示するために使用するクラス |

## ページングの実装
今回は、RetrofitでJSONPlaceholderというモックAPIを叩いて情報を取得し、それをUI側で表示するというケースを例にして実装を行います。
[JSONPlaceholderの詳細](https://jsonplaceholder.typicode.com/)
### PagingSourceの実装
```Kotlin
class PostPagingSource(
    private val apiService: APIService
) : PagingSource<Int, Post>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Post> {
        return try {
            // 次にロードするページのキー
            val page = params.key ?: 1
            // 1ページあたりのアイテム数
            val limit = params.loadSize
            
            val response = apiService.getPosts(page = page, limit = limit)
            LoadResult.Page(
                data = response,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Post>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }
}
```
PagingSourceの拡張クラスにおいては、**loadメソッド**と**getRefreshKeyメソッド**の2つをオーバーライドする必要があります。
### loadメソッド
loadメソッドはページングデータのロードを行うためのメソッドです。
引数として、`LoadParams`オブジェクトを受け取りますが、これはメンバとして`key`(現在のページ番号)と`loadSize`(1回のリクエストで取得するアイテムの数)を持っています。なお、`loadSize`は後述する`pagingConfig`によって決定されます。

オーバーライドしたloadメソッド内では、まず次にロードするページのキーと1ページ当たりのアイテム数を取得し、これをもとに非同期にAPIを叩いてデータをロードしています。

なお、今回APIを叩くときのリクエストは以下のようになっています。(例：page=1,limit=10のとき)
```
https://jsonplaceholder.typicode.com/posts?_page=1&_limit=10
```
このとき、**1ページ目の最初の10件**のデータを取得することができます。

なお、ロードした結果は`LoadResult`オブジェクトで返されます。これは、データの取得に成功したら`LoadResult.Page`を、失敗したら`LoadResult.Error`を返します。`LoadResult.Page`は以下の3つのメンバが含まれます。
- data：取得したアイテムのリスト
- prevKey：現在のページの1つ前のページをロードするときに使用するキー
- nextKey：現在のページの1つ後のページをロードするときに使用するキー

上記のコードでは、APIを叩いて取得したresponseをdataに格納し、ページが先頭だったときはprevKeyを、ページが最後尾だったときはnextKeyをnullにしています。

### getRefreshKeyメソッド
`PagingSource`の拡張クラスは、これに加えてgetRefreshKeyメソッドもオーバーライドします。引数として、`PagingState`オブジェクトを受け取りますが、これはページングの現在の状態を管理するオブジェクトです。このメソッドでは、`PagingState`の持つ情報をもとにしてどのページをリロードするのかを決定します。
getRefreshKeyメソッドを呼び出す必要のある状況としては、以下のようなものが挙げられます。
- ユーザーが手動でリフレッシュ(再読み込み)した場合
- 初回のデータ読み込みのとき
- Roomデータベースが変更された場合(PagingSourceがローカルのDBであるケース)

このような状況では、現在表示されている位置を考慮して適切なページを再取得する必要があります。
上記のコードでは、PagingStateが持つ`anchorPosition`(現在のスクロール位置)の情報をもとにそれに最も近いページ情報を`closestPagetoPosition`から取得、そしてそのページを基準としてprevKey,nextKeyを更新します。

### ViewModelの作成
```Kotlin
class PostsViewModel() : ViewModel() {
    private val pagingConfig = PagingConfig(
        pageSize = 5, // 1回のデータ取得で取得するアイテム数
        enablePlaceholders = false, // プレースホルダーを無効にする
        initialLoadSize = 10, // 最初の読み込みで取得するデータ数
        prefetchDistance = 5 // あらかじめデータを読み込む距離
    )

    val items: Flow<PagingData<Post>> = Pager(
        config = pagingConfig,
        pagingSourceFactory = { PostPagingSource(Retrofit.api) }
    ).flow.cachedIn(viewModelScope)　//画面回転などでもデータを保持
}
```
`PagingConfig`では、ページングの動作を制御するための各種設定を行っています。
上記のコードにおいては、`pageSize`で1回のデータ取得で取得するアイテム数を、`initialloadsize`で初回の読み込みで取得するデータ数を指定します。`prefetchDistance`ではユーザーのスクロールで次のページまでのアイテム数が5件になったらあらかじめ次のページをダウンロードするという設定にしています。

また、その`PagingConfig`での設定に従って`Pager`で`PagingSource`からロードしたデータをitemsに格納しています。このitemsの型は`Flow<PagingData<Post>>`となっています。これにより、非同期にデータの更新・取得を行い、UI側に反映することができます。

### UI上での表示
```Kotlin
@Composable
fun PostsScreen(viewmodel: PostsViewModel) {
    val posts = viewmodel.items.collectAsLazyPagingItems()

    LazyColumn(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        items(posts.itemCount) { index ->
            val post = posts[index]
            PostCard(
                post = post!!
            )
        }
        posts.apply {
            when {
                loadState.refresh is LoadState.Loading -> {
                    item { CircularProgressIndicator() }
                }

                loadState.append is LoadState.Loading -> {
                    item { CircularProgressIndicator() }
                }

                loadState.append is LoadState.Error -> {
                    item {
                        Text(text = "Error loading data")
                    }
                }
            }
        }
    }
}


@Composable
fun PostCard(
    post: Post
) {
    Card(
        modifier = Modifier
            .padding(16.dp)
            .fillMaxWidth()
    ) {
        Column {
            Text(
                text = "No.${post.id}",
                modifier = Modifier.padding(8.dp),
            )
            Text(
                text = "タイトル\n${post.title}",
                modifier = Modifier.padding(8.dp),
            )
            Text(
                text = "内容\n${post.body}",
                modifier = Modifier.padding(8.dp),
            )
        }
    }
}
```
`viewmodel.items` は `Flow<PagingData<Post>>` なので、これを`collectAsLazyPagingItems()` で UI 側で利用できる形に変換し、`LazyColumn`で縦に並べています。
## 動作例
上記のコードでの動作例は以下の通りです。
![無題の動画 ‐ Clipchampで作成.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3796037/1e6dc6b2-be37-43c2-aae5-4dd97cdeaf27.gif)
数十件のアイテムの情報を取得しているものの、データ取得により発生する待ち時間がほとんどないことが分かります。

## 最後に
本記事ではPaging3を用いたページングの実装方法についてまとめました。
Android開発初学者ですので、間違い等ありましたら指摘していただけると助かります。