【Android】Paging 3で実装するEndless Grid Design

:::note info
この記事は、[ラクスアドベントカレンダー 2023](https://qiita.com/advent-calendar/2023/rakus) の 24 日目の記事です 🎄 
:::

## はじめに

最近チームの育成で使用するサンプルアプリを作成してますが、育成カリキュラムの中でページングの実装を取り入れようと検討しています。モバイル開発では、API からデータを取得し、リスト表示するようなユースケースも多いと思いますので、今回はAndroid の Paging 3 ライブラリの基本的な使い方を紹介したいと思います。

## ページングライブラリの概要

ページングライブラリは、[Android Jetpack](https://developer.android.com/jetpack) で提供されるライブラリの １ つです。現在のバージョンは3系なので、Paging 3 と呼ばれたりします。

さて、このページングライブラリですが、使用することで大規模なデータセットからローカルストレージやネットワーク経由でデータのページを読み込んで表示させることができます。これにより、アプリがネットワーク帯域幅とシステムリソースの両方をより効率的に使用できるようになることが期待されます。他にも、ページングライブラリのコンポーネントは、Android アプリアーキテクチャに適合すると同時に、Jetpack Compose との統合により、Kotlin によるサポートが十分提供されるように設計されています。

## ページングライブラリの主な機能

ページングライブラリの主な機能としては、以下の5つの機能があります。

* ページングデータに対するメモリ内キャッシュ
* リクエスト重複排除
* 画面スクロールに連動した際に自動的なリクエスト
* ページングの状態管理
* Coroutine、Flow、LiveData、RxJava に対応

これらの機能により、リソースを効率化したページングデータの操作を実現します。

## ページングライブラリの主コンポーネント

今日紹介するサンプルアプリでも登場しますが、ページングライブラリの実装に登場する主なコンポーネントについてまとめるとこのような感じになります。

| クラス名           | 説明                                               |
|--------------------|----------------------------------------------------|
| PagingData         | ページングされたデータのコンテナ                   |
| Pager              | PagingData のリアクティブストリームの生成を行うクラス |
| PagingConfig       | ページング動作を決定するパラメータを定義するクラス  |
| PagingSource       | 特定のページクエリのデータチャンクを読み込むためのクラス |
| LazyPagingItems    | PagingData のフローから取得したデータを表示するためのクラス |

## 今回実装するサンプルアプリ

では、実際に以下のようなグリッドデザインのページングデザインのページを実装して行きます。

![screenshot.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2299135/0673ab2f-8a3c-0899-50e0-2c23e265c2fd.gif)

実装にあたり使用しているライブラリは、以下を使用しています。

* paging 3
* paging-compose 3
* Coil
* Unsplash Image API

ページングの実装には、paging と paging-compose を使用していますが、画像情報の取得は [Unsplash API](https://unsplash.com/developers)を使用しています。Unsplash APIは、ユーザーのためのあらゆる体験を構築するために必要なすべての情報を提供する現代的な JSON API です。非常に使いやすい API として[unsplash.com](https://unsplash.com/ja) でも利用されています。

[Coil](https://coil-kt.github.io/coil/README-ja/) は Kotlin Coroutines で作られた Android 用の画像読み込みライブラリです。

### アーキテクチャ

![スクリーンショット 2023-12-21 21.39.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2299135/8de0c9f7-25c4-e965-8f5e-ec445124ac4f.png)

アーキテクチャは、Android アプリ開発ではお馴染みの MVVM アーキテクチャです。Data 層の Repository から取得したデータをリアクティブに画面に反映させます。実装にあたり、依存関係の注入は [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) を使用しています。

 ### Repository

まずは、Repository の実装から見ていきます。
```kotlin
class UnsplashSearchRepositoryImpl @Inject constructor(
   private val service: UnsplashService
): UnsplashSearchRepository {

   override fun searchPhotos(query: String): Flow<PagingData<UnsplashPhoto>> {
       return Pager(
           config = PagingConfig(enablePlaceholders = false, pageSize = PAGE_SIZE),
           pagingSourceFactory = { UnsplashPagingSource(service, query) }
       ).flow
   }

   companion object {
       private const val PAGE_SIZE = 25
   }
}
```

Repository では`Flow<PagingData<T>>`型の関数を公開しています。
PagingData は読み込んだデータをラップし、ページングライブラリが追加のデータを取得するタイミングを決定します。また、同じページを再リクエストしないようにしてくれます。

#### PagingConfig

PagingConfig には、先読みの量や初期読み込みのサイズ、リクエストなど、PagingSource からコンテンツを読み込む方法のオプションを設定します。

#### PagingSource

今回の実装では、PagingSource を継承した UnsplashPagingSource クラスを作成し、PagingSource の作成方法をページングライブラリに伝える関数として設定します。

```kotlin
class UnsplashPagingSource(
   private val service: UnsplashService,
   private val query: String
) : PagingSource<Int, UnsplashPhoto>() {

   override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UnsplashPhoto> {
       val page = params.key ?: UNSPLASH_STARTING_PAGE_INDEX
       return try {
           val response = service.searchPhotos(query, page, params.loadSize)
           val photos = response.results
           LoadResult.Page(
               data = photos,
               prevKey = if (page == UNSPLASH_STARTING_PAGE_INDEX) null else page - 1,
               nextKey = if (page == response.totalPages) null else page + 1
           )
       } catch (exception: Exception) {
           LoadResult.Error(exception)
       }
   }
}
```

PagingSource では、load() と getRefreshKey() という2つの関数を実装する必要があります。
load()関数はページングライブラリによって呼び出され、ユーザーがスクロールしたときに表示される追加のデータを非同期で取得します。LoadParams オブジェクトは、読み込み操作に関する情報を保持します。

#### 読み込みのページのキー (params.key)

load() が初めて呼び出された場合、LoadParams.key は null になります。そのため、今回は最初のページキーを定義しています。

#### 読み込みサイズ (params.loadSize)

`params.loadSize` は読み込むアイテムのリクエスト数です。ページが小さすぎると、ページのコンテンツが全画面に満たないため、リストがチラつく可能性があります。ページサイズを大きくすると、読み込みの効率は高くなりますが、リストが更新されたときの待ち時間が長くなる可能性があるので注意が必要です。

### ViewModel

次は、ViewModel から PagingData を取得する実装を見ていきたいと思います。

```kotlin
@HiltViewModel
class MainViewModel @Inject constructor(
   searchRepository: UnsplashSearchRepository
): ViewModel() {

   val photos = searchRepository.searchPhotos(QUERY).cachedIn(viewModelScope)

   companion object {
       private const val QUERY = "fruit"
   }
}
```

ViewModel では、PagingData をリクエストして cache するシンプルな実装になっています。リクエストを再度トリガーする必要がないよう、リクエスト後に終了演算子として cachedIn を行う必要があります。今回は検索クエリを`fruit`固定にしていますが、検索フィールドを追加すれば、検索文字でリクエストするような実装も可能です。

### Composable

キャッシュした画像を表示する実装は、以下になります。

```kotlin
@Composable
fun MainScreen(viewModel: MainViewModel) {
   Scaffold { paddingValues ->
   
       val lazyPagingItems = viewModel.photos.collectAsLazyPagingItems()
       
       LazyVerticalGrid(
           columns = GridCells.Adaptive(minSize = 128.dp),
           modifier = Modifier.padding(paddingValues)
       ) {
           items(
               count = lazyPagingItems.itemCount,
               key = lazyPagingItems.itemKey()
           ) { index ->
               val photo = lazyPagingItems[index] ?: return@items
               AsyncImage(
                   model = photo.urls.small,
                   contentDescription = null,
                   modifier = Modifier.fillMaxWidth().height(128.dp),
                   contentScale = ContentScale.FillWidth
               )
           }
       }
    }
}
```

データ表示を行う Composable では、ViewModel から公開された PagingDataのFlow データ`Flow<PagingData<T>>` を LazyPagingItems として保持します。今回のサンプルアプリでは、LazyVerticalGrid で LazyPagingItems の要素を表示しています。画像は Coil の AsyncImage を利用しており、model に URL を渡すだけで画像を読み込んで表示してくれます。

このようにページングライブラリを使うことで、この位のコード量でエンドレススクロールデザインを実装することができます。

## ページングライブラリの状態管理

ページングライブラリの状態管理では、CombinedLoadStates 型で以下の読み込み状態にアクセスすることが可能です。

| State                 | 説明                                                    |
|------------------------|---------------------------------------------------------|
| LoadState.append       | ユーザーの現在位置より後に取得されるアイテムの LoadState   |
| LoadState.prepend      | ユーザーの現在位置より前に取得されるアイテムの LoadState   |
| LoadState.refresh      | 初期読み込みの LoadState                                 

LoadState から取得できる状態としては、以下の 3 種類の状態を取得できます。これらの状態を組み合わせることで柔軟に状態管理による実装を実現することができます。`NotLoading`については、初期状態も含まれるので使い方を工夫する必要がある場面もあるかと思います。

| State          | 説明                   |
|-----------------|------------------------|
| Loading         | アイテムを読み込み中     |
| NotLoading      | アイテムを読み込んでいない（初期状態も含まれる） |
| Error           | 読み込みエラーが発生     |

参考 : [Manage and present loading states](https://developer.android.com/topic/libraries/architecture/paging/load-state)

読み込み中に`CircularProgressIndicator`を表示させるような実装であれば、このように実装することで実現できます。

```kotlin
when (lazyPagingItems.loadState.refresh) {
   is LoadState.Loading -> {
       Column(
           modifier = Modifier.fillMaxSize(),
           horizontalAlignment = Alignment.CenterHorizontally,
           verticalArrangement = Arrangement.Center,
       ) {
           Text(
               modifier = Modifier.padding(8.dp),
               text = "Loading"
           )
           CircularProgressIndicator(
               modifier = Modifier.padding(top = 8.dp),
               color = Color.LightGray
           )
       }
   }
   else -> {}
}
```
プレビューするとこのようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2299135/20817ed1-7a52-8165-9e56-2f1e6638b2d5.png" width=272>

## まとめ

今回は、Android の Paging 3 ライブラリの基本的な使い方と、実際のサンプルアプリの実装方法について紹介しました。ページングライブラリを使用することで、大規模なデータセットを効率的に扱い、ユーザーエクスペリエンスを向上させることが期待されます。

ページングライブラリは、Android アプリ開発において頻繁に利用される機能であり、本記事がその基本的な理解と実装に役立てば幸いです。


ページングを導入することで、アプリのパフォーマンス向上やユーザビリティの向上に寄与することができるでしょう。是非、実際のプロジェクトにおいても活用してみてください。