🥉

AndroidのPaging3を理解する

2023/06/08に公開

初めに

ポートでAndroid開発をしている @shxun6934 です。

業務内で、Paging3を導入し、ページングでのリスト表示を実装しました。
本記事では、Paging3について紹介しつつ、理解していこうと思います。

Google公式のAndroidドキュメントは豊富でわかりやすいので、そちらも参考にしてください。

https://developer.android.com/topic/libraries/architecture/paging/v3-overview?hl=ja

また、Codelabでコードを書きながら学ぶこともできます。

Paging3とは

ページングを実装する際に使用するAndroidのライブラリーです。
APIから取得するデータやDBに保存しているデータを、RecyclerViewやComposeのLazyListのようなリストで、ページネーションとして表示するために使用します。

(他の言語・フレームワークでは、railsのkaminariやreactのreact-paginateのようなライブラリーと似ています。)

FlowやLiveDataなどのObserveをサポートしているので状態を監視することができたり、更新処理や再試行処理などを簡単に行うことができます。

androidx.pagingのライブラリーで、バージョンを3.x.xにすると使用できます。

Paging3に関するクラス

コードを見ていく前に、Paging3でよく使用するクラスと簡単な概要をまとめておきます。
(公式ドキュメントや実際のライブラリーのコードを読んで、自分なりに解釈しました。間違ってたらすいません。。。)

クラス 用途
PagingSource 表示するデータのソース(取得先)を定義するクラス。ソースからの取得方法や次のデータリストを取得するためのkeyを設定する。
RemoteMediator リモートから取得したデータをローカルデータに入れ込みながら、データをページングするために使用するクラス。例えば、APIから取得したデータをDBにキャッシュして、データを表示したい時に使用する。
PagingData ページングによってロードされたデータのコンテナクラス。このクラスをFlowやLiveDataでラップすることでデータを監視することができる。
PagingConfig PagingSourceで設定した取得方法でデータをロードする際の設定をするクラス。初期取得件数やページサイズなどを設定できる。
Pager PagingDataのリアクティブストリームを生成するためのクラス。PagingSourceで設定した取得方法とPagingConfigで設定した取得設定でロードし、PagingDataを生成する。
LazyPagingItems FlowでラップされたPagingDataへのアクセスを担当するクラス。このクラスのインスタンスをLazyListScope.itemsで使用できる。(Composeのみ。)
PagingDataAdapter RecyclerViewでPagingDataを表示するためのAdapterクラス。RecyclerViewで表示するデータの設定を行う。(xml、data-bindingのみ。)
LoadState ページングのロードの状態を表すクラス。NotLoading、Loading、Errorの3種類が存在する。
LoadStates LoadStateのコレクションクラス。refresh、prepend、appendごとのLoadStateを保持している。

LoadState

LoadStateは以下の3種類の状態を持っています。

状態 説明
NotLoading ロードもされておらず、エラーもない状態。endOfPaginationReachedプロパティを持っていて、このプロパティで最後のデータまで達したかどうかがわかる。
Loading ロードしている状態。
Error ロードした結果、エラーになった状態。errorプロパティを持っていて、このプロパティでなんのエラーが発生したかがわかる。

LoadStates

LoadStatesは以下の3種類のプロパティを持っています。
これらのプロパティは、LodeTypeの3種類に該当します。

種類 説明
REFRESH コンテンツの更新。ただし、初期ロードやPagingSourceの無効化も含む。
PREPEND 現在のPagingDataの先頭にデータを追加する。
APPEND 現在のPagingDataの末尾にデータを追加する。

Paging3の実装

クラスについて簡単に理解したところで、実際にコードを書きながら、実装方法をみていこうと思います。

前提

「ユーザー一覧を表示する」機能を作成すると仮定して、実装していきます。

その際、キャッシュデータを使用しない方針で進めていこうと思うので、RemoteMediatorを使用せず、PagingSourceのみの実装になります。
また、UIの描画は、Composeでの実装になります。

データソースの定義

まず、データのソースの定義をします。

今回は、以下のような要件があるとします。

  1. WebのREST APIでデータを取得できる。
  2. IDと名前のデータを持つユーザーのデータリストが取得できる。エンドポイントは、/users
  3. APIにもページネーションが実装されていて、ページングに関する情報はURLクエリで渡す。
  4. 整数で次のページングができる。初期値は1。ページングがnullの場合、ページングできるデータがないことを指す。
  5. 取得できるページサイズも指定できる。

Userモデル

「IDと名前のデータを持つユーザーのデータ」の受け皿を用意するために、idnameをプロパティに持つUserデータクラスを作成しておきます。

User.kt
@Serializable
data class User(
    val id: Int,
    val name: String
)

GET Users

RetrofitのようなAPIClientでは、以下のようにRESTのGETメソッドを呼ぶことができると思います。(詳しい実装は省きます。)

Response.kt
// APIレスポンス用のクラス
data class Response<T: Any>(
    val data: T,
    val nextPage: Int
)
UserApi.kt
interface UserApi {
    @GET("/users")
    suspend fun getUsers(@Query("page") page: Int, @Query("pageSize") pageSize: Int): Response<List<User>>
}

UserPagingSource

ユーザー一覧を取得するソースと取得方法を定義するため、UserPagingSourceを作成します。

UserPagingSourceに、UserApiを依存させ、PagingSourceを継承させます。

PagingSourceのジェネリクスは、<ページングに使用するkeyの型, ページングに使用するデータの型> を定義します。
今回は、IntUserを指定します。

loadメソッドgetRefreshKeyメソッドabstructなので、必ず宣言します。

loadメソッドでは、APIからデータを取得し、成功した場合はLoadResult.Pageを、失敗した場合はLoadResult.Errorを返すようにします。
getRefreshKeyメソッドでは、データを破棄する前のkeyに最も近いkeyを返すようにします。(公式ドキュメントより。)

UserPagingSource.kt
class UserPagingSource(
    private val api: UserApi
) : PagingSource<Int, User>() {

    // データをソースからロードした結果を返す。
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
        try {
            val response = api.getUsers(page = params.key ?: 1, pageSize = params.loadSize)
          
            return LoadResult.Page(
                data = response.data,
                prevKey = params.key,
                nextKey = response.nextPage
            )
        } catch (e: Exception) {
            return LoadResult.Error(e)
        }
    }
  
    // データを破棄した後、再取得する(再びloadを呼び出す)際に使用するkeyを設定する。
    override fun getRefreshKey(state: PagingState<Int, User>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            val anchorPage = state.closestPageToPosition(anchorPosition)
            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
        }
    }
}

PagingDataの設定

PagingSourceの設定ができたので、PagingSourceをloadした結果をUIに渡すメソッドを作成します。

PagerPagingConfigUserPagingSourceのインスタンスを渡してPagerのインスタンスを作成し、.flowによってPagingDataをFlowでラップし、その値を返すようにします。

PagingConfigは、初期取得数ページサイズ を設定できるので、10件に設定しておきます。

UserList.kt
object UserList {
    
    fun getUsers(api: UserApi): Flow<PagingData<User>> {
        return Pager(
            config = PagingConfig(
                initialLoadSize = 10,  // 初期取得数、ページサイズを10件に設定。
                pageSize = 10
            )
        ) {
            UserPagingSource(api)
        }.flow
    }
}

ページングを表示

UIへFlow<PagingData<User>>を渡すようにできたので、これをLazyPagingItemsに変換し、LazyListScopeコンポーネントで表示します。

LazyPagingItemsのインスタンスはLoadStatesを参照できるので、LoadStatesの各プロパティの状態によって画面を出し分けるように制御します。
ローディング中はローディング画面を、エラーの場合は空の画面を、取得成功の場合はリスト画面を表示するようにします。

UserListViewModel

ユーザー一覧画面用のViewModel、UserListViewModelを作成します。

ViewModelにUIで使用するデータを持たせるようにします。

UserListViewModel.kt
class UserListViewModel : ViewModel() {

    private val retrofit = Retrofit.Builder().~~~~.build()
    private val api: UserApi = retrofit.create(UserApi::class.java)

    val paging: Flow<PagingData<User>> = UserList.getUsers(api).cachedIn(viewModelScope)
}

UserListActivity

ユーザー一覧画面用のActivity、UserListActivityを作成します。

Composeでの使用を想定しているので、以下のようなコンポーネントを作成します。

  • UserListScreen: ユーザー一覧画面の親コンポーネント。このコンポーネント内で、ページングの状態によってコンポーネントの表示制御を行う。
  • LoadingScreenView: 全画面ローディング。データの取得中に表示する。
  • EmptyView: 空の画面。データの中身が空の場合やエラーの場合に表示する。
  • UserColumnList: ユーザーリスト。データを正常に取得できた場合に表示する。
  • LoadingIndicator: ローディングインディケータ。

UserListViewModelpagingcollectAsLazyPagingItemsメソッドを使用して、LazyPagingItemsに変換します。
LazyPagingItemsのインスタンスから、REFRESHPREPENDAPPENDのそれぞれの状態を保持し、それぞれの状態をみて、コンポーネントの表示を切り替えるようにします。

class UserListActivity : ComponentActivity() {

    private val viewModel: UserListViewModel by viewModels()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                UserListScreen()
            }
        }
    }
    
    @Composable
    private fun UserListScreen() {
        val lazyPagingItems = viewModel.paging.collectAsLazyPagingItems()  // LazyPagingItemsのインスタンスに変換
        
        val pagingLoadStates = lazyPagingItems.loadState.source
        
        val refreshLoadState = pagingLoadStates.refresh  // 更新時
        val prependLoadState = pagingLoadStates.prepend  // 前データを読み込む時
        val appendLoadState = pagingLoadStates.append    // 後データを読み込む時
        
        when {
            refreshLoadState is LoadState.Error || prependLoadState is LoadState.Error || appendLoadState is LoadState.Error -> {
                // 何かしらのエラーが発生した
                EmptyView()
            }
            refreshLoadState is LoadState.Loading -> {
                // 初期ローディング中または再更新でのローディング中
                LoadingScreenView()
            }
            refreshLoadState is LoadState.NotLoading -> {
                // データを取得できた
                LazyColumn {
                    if (prependLoadState is LoadState.Loading) {
                        // 前データを読み込み中
                        item {
                            LoadingIndicator()
                        }
                    }

                    UserColumnList(lazyPagingItems)
                    
                    if (appendLoadState is LoadState.Loading) {
                        // 後データを読み込み中
                        item {
                            LoadingIndicator()
                        }
                    }
                }
            }
        }
    }
}

これでページングのリスト表示ができると思います。

終わりに

Paging3の実装(PagingSourceのみ)を書きながら、みてきました。

操作するクラス・オブジェクトが多く、クラス・オブジェクトの意味や用途をしっかり理解していないと、正しい挙動にならず、理解するまでは苦戦すると思います。
(自分は結構理解するまで苦戦しました。特にREFRESHPREPENDAPPENDの部分。)

ですが、理解した時はページングの実装がより簡単にできるものでもあると思うので、細かいクラス・オブジェクトの仕様やメソッドの挙動をもう少し深く理解できたら、難しいページングも実装できるのではないかなと思います。

おまけ

REFRESHPREPENDAPPENDの状態の変化のログ。(後データのみ取得していく機能の場合)

REFRESH

PREPEND

APPEND

Discussion