Android minne

Android における Paging 3 を用いたページングの導入

Android minne

9/28 (火) に Reject Conference と言うタイトルであの有名カンファレンスに応募したけど残念ながら採用されなかった内容を供養するイベントに登壇してきました!僕は Paging 3 の導入について話していたので、Android でページングの導入 or ライブラリアップデートを検討している様な人の参考となれば嬉しいです。

資料はこちらです。

導入

それでは本編です。今回は下にスクロールすると可愛いネコチャンが次々ロードされていくサンプルアプリを作ってみましたので、それをベースに解説していきます。可愛いですね。今回のサンプルは GitHub にあげています

ちなみに、ネコチャンデータは The Cat API と言うフリーの Web API サービスを使っています。僕が何か API を用いたサンプルアプリを作ったり OJT とかで勉強してもらうときによく使います。

環境は以下です。

  • Android Studio: Arctic Fox
  • Kotlin: 1.5.0
  • Paging: 3.0.0

Repository からデータを取得する処理がある前提でお話ししていきます。

PagingSource

まずは Paging 3 のメインコンポーネントである PagingSource を作っていきます。

class CatPagingSource(
    private val repository: CatRepository
): PagingSource<Int, Cat>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Cat> {
        val currentKey = params.key ?: 1
        return repository.cats(params.loadSize, currentKey)
            .fold(
                onSuccess = {
                    LoadResult.Page(
                        data = it,
                        prevKey = (currentKey - 1).takeIf { key -> key > 0 },
                        nextKey = (currentKey + 1).takeIf { _ -> it.isNotEmpty() }
                    )
                },
                onFailure = {
                    LoadResult.Error(it)
                }
            )
    }

    override fun getRefreshKey(state: PagingState<Int, Cat>): Int? =
        state.anchorPosition?.let { anchorPosition ->
            val anchorPage = state.closestPageToPosition(anchorPosition)
            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
        }
}

PagingSource のジェネリクス

PagingSource を継承してネコチャン PagingSource を作成します。

このときに指定する型が2つあり、1つ目はページングに使用する key の型です。ページングの際は API に page みたいなパラメータがあって 1 とか 2 みたいな何ページ目が必要かを指定することが多いと思います。ざっくり言うとその page の型がここで言う key の型です。インクリメントしやすいので Int であることが多いでしょうか。2つ目の型は取得するモデルの型です。ここで指定した型のデータを UI レイヤーに流すことになります。

load

PagingSource には2つの abstract 関数があって、1つ目がページングのメインとなる load です。Web API やローカルストレージからデータを取得し、LoadResult 型で返します。LoadResult には2種類あって、LoadResult.Page を返すと成功と共にデータが、LoadResult.Error を返すと失敗と共に Exception が上位のレイヤーに伝達されます。なので、データアクセス時の成功か失敗かをきちんとハンドリングしてあげて適切な LoadResult を返してあげます。今回は前提として Repository から kotlin.Result 型でデータが取れるので、成功時と失敗時でそれぞれどういった LoadResult を返してあげるかがわかりやすくなっています。

ここで嬉しいのは load が suspend なので suspend なデータアクセスを呼べることですね。Jetpack ライブラリ全体に言えますが Coroutine が積極的にサポートされている恩恵を感じます。

key の取り扱い

load の引数に LoadParams が渡ってきますが、この params に key と言うプロパティが存在します。この key に load が呼ばれたタイミング、つまりそのとき取得するのが何ページ目かと言うパラメータが最初に指定した型で入っています。初回ロード時は null で、以降は自分が指定した値が入っています。なのでサンプルでは null だった場合は初期パラメータを指定するようにしています。以降の自分が指定した値とは成功時に渡す LoadResut.Page の nextKey もしくは prevKey と呼ばれるものです。

LoadResult.Page のコンストラクタにはページングに使用するための data の他に2つパラメータが存在し、次に load が呼ばれたときに取得するページのキーを params.key に渡すための値となります。nextKey はスクロール方向のページを、prevKey はスクロール方向とは逆方向のページを取得するために使用されます。スクロールの進行方向に対して後ろと前のデータセットを柔軟に扱える様に next と prev にわかれています。お知らせの様な一般的なアプリの vertical リストだとスクロールは始点から下方向に進んでいくと思うので nextKey がメインになりそうですが、チャットアプリなどで上方向に遡る場合は prevKey が使えそうです。

スクロール方向とkeyの関係

それぞれの key に null を渡すとそこでページングが終わりとみなされるのでその方向にスクロールしても load が呼ばれる事がなくなります。なので最後のデータセットかどうかをチェックして最後なら null を、そうでないならインクリメントしていきます。今回のサンプルでは key は1スタートなので prevKey は 0 より大きい場合、nextKey は通信成功時に空のリストが取得できた場合を終わりとみなしています。

getRefreshKey

最後に2つ目の abstract 関数である getRefreshKey です。これがいつ呼ばれるかと言うと、後で出てくる Adapter の refresh() が呼ばれデータ破棄後の再取得時に、初回の params.key をどうするかと言うときに呼ばれます。PagingStateanchorPosition と言う現在表示されているアイテムの1つ前のポジションを示すプロパティがあって、これを元に key を計算する様にします。ここで null を渡しておくと refresh 後の load は一番最初からとなります。

以上が、Paging 3 最大のコンポーネントの PagingSource の実装でした。今回は触れませんでしたが、2系と比べてかなり記述量やデータの関係がスッキリしていると感じるかと思います。なので2系を導入しているプロダクトが3系にあげるメリットはこの変化をどう捉えるかがポイントになるかなと思います。

ちなみに minne では UseCase パターンを採用しており、Repository はあくまでデータアクセスをするためのレイヤーなので PagingSource では Repository を使用してデータを取得し、UseCase から PagingSource を公開しています。詳しくは以前のテックブログをご覧ください

ViewModel で PagingSource を使う

次に先ほど作成した PagingSource を ViewModel で使用します。

class CatViewModel(
    private val useCase: CatUseCase
): ViewModel() {

    val cats: LiveData<PagingData<Cat>> =
        Pager(PagingConfig(pageSize = useCase.limit, initialLoadSize = useCase.limit)) {
            useCase.cats()
        }.liveData.cachedIn(this)
}

Pager と呼ばれるものを使い PagingSource から流れてくるデータを PagingData として基本的には非同期で取得するため Flow か LiveData 経由で扱う形になります。

Pager のインスタンス生成には PagerConfig が必要になります。ページング時の細々としたパラメータを決めるためのもので pageSize は絶対に指定する必要があります。またローカルキャッシュも対応されていて、cachedIn で指定した CoroutineScope の期間中データセットをキャッシュしてくれます。

PagingDataAdapter

最後に ViewModel から取得した PagingData を使い RecyclerView に表示するために Adapter を作成します。

class CatPagingAdapter: PagingDataAdapter<Cat, CatPagingAdapter.ViewHolder>(diffCallBack) {

    inner class ViewHolder(val binding: ViewCatBinding): RecyclerView.ViewHolder(binding.root)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
        ViewHolder(
            ViewCatBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        )

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.binding.cat = getItem(position)
    }

}

private val diffCallBack = object: DiffUtil.ItemCallback<Cat>() {
    override fun areItemsTheSame(oldItem: Cat, newItem: Cat): Boolean = oldItem.id == newItem.id
    override fun areContentsTheSame(oldItem: Cat, newItem: Cat): Boolean = oldItem == newItem
}

class CatActivity : AppCompatActivity() {

    private val viewModel: CatViewModel by viewModels()

    private val listAdapter = CatPagingAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding.catList.adapter = listAdapter

        viewModel.cats.observe(this) {
            lifecycleScope.launchWhenStarted { 
                listAdapter.submitData(it)
            }
        }
    }
}

基本的な使い方は普段の RecyclerView.Adapter とほぼ変わりありません。コンストラクタに DiffUtil.ItemCallback が必要になったくらいです。データセットの追加が submitData() となっており、suspend 関数なので lifecycleScope 等を使って adapter に PagingData を渡すようにします。

以上が基本的な Paging 3 の実装です。これだけでもかなり簡単にページングが導入できていて findLastItemVisiblePosition から複雑な計算をせずともよく、データセットの管理までライブラリに任せることができ実装コストが大幅に削減されています。

LoadState

ここからはさらに UX を高めるため、下記のようなページング時のローディング中のインジケータ表示・エラー時のリトライ処理をやってみようと思います。一応ローディングの表現には2種類のアプローチがありますが、まずは以下のようにリスト内に append する方法をやっていきます。

ローディングとリトライの表示

LoadStateAdapter

LoadStateAdapter と言う、さっき出てきた PagingDataAdapter と合わせて使うものを用意します。

private typealias RetryListener = () -> Unit

class CatPagingLoadStateAdapter(
    private val retry: RetryListener
): LoadStateAdapter<CatPagingLoadStateAdapter.ViewHolder>() {

    inner class ViewHolder(
        private val binding: ViewCatLoadStateBinding
    ): RecyclerView.ViewHolder(binding.root) {
        fun bind(loadState: LoadState) {
            Log.d(this::class.simpleName, "LoadState: $loadState")

            binding.isLoading = loadState is LoadState.Loading
            binding.isError = loadState is LoadState.Error

            binding.retry.setOnClickListener { retry() }

            binding.executePendingBindings()
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ViewHolder =
        ViewHolder(
            ViewCatLoadStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        )

    override fun onBindViewHolder(holder: ViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }
}

class CatActivity : AppCompatActivity() {

   private val listAdapter = CatPagingAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding.catList.adapter = listAdapter
            .withLoadStateFooter(CatPagingLoadStateAdapter { listAdapter.retry() })
   }
}

bind 時に LoadState が渡ってくるのでそれに合わせて View の visibility を切り替える様にしてあげると簡単にローディング・エラーを表現でき、さらにエラー時にアイコンタップでリトライしたい場合も PagingDataAdapter が必要となるので Listener 経由になってしまいますが用意できます。これを自前で実装しようと思うとデータセットやローディングのステートなど管理コストが大変なことになると思いますが Adapter を用意するだけで解決するのでめちゃくちゃ便利です。

作成した LoadStateAdapter は withLoadStateFooter で先ほどの PagingDataAdapter と合わせて使えます。当然 Header も存在し、それぞれ nextKey, prevKey と対応しています。

また、2つ目のアプローチとして PagingDataAdapter の addLoadStateListener で同じように LoadState が取得できるので、それに合わせて ProgressBarDialog などをオーバーレイしてあげることも可能です。リスト内に表示する方がユーザの体験を妨げないのでいいと思いますがプロダクトのデザインに合わせて実装できそうです。

初回ローディング問題

上記で満足していたんですが、いざ画面を開くと初回ロード時にスライドの様なインジケータが一番上に表示されることを期待していたんですが表示されませんでした…

どうやら LoadState の仕様っぽく、ローディングのステートは LoadTypeLoadState で管理されており、LoadType には APPEND, PREPEND, REFRESH の3種類があって、それぞれどういうタイミングでデータが追加されるかを表しています。

LoadType どういったタイミングか
APPEND スクロールの進行方向にデータを追加する時 (nextKey)
PREPEND スクロールの進行方向にデータを追加する時 (prevKey)
REFRESH 初めてデータセットが追加される時

その上で先ほどの withLoadStateFooter ですが中身は PagingDataAdapter の loadStateListener に合わせて LoadStateAdapter に指令を流す様になっているんですが、LoadType.APPEND しか見ていませんでした。要するに nextKey の時にデータを追加するときだけをハンドリングしています。

fun withLoadStateFooter(
    footer: LoadStateAdapter<*>
): ConcatAdapter {
    addLoadStateListener { loadType, loadState ->
        if (loadType == LoadType.APPEND) {
            footer.loadState = loadState
        }
    }
    return ConcatAdapter(this, footer)
}

また LoadType は enum なので1回のイベントで1つしか表すことができません。なので APPEND かつ REFRESH のような状態が存在しません。その中で LoadType はまず REFRESH から判定されるため初回ロード時のロードステートは LoadType.REFRESH が LoadState.Loading となります。なので APPEND しか見ていない withLoadStateFooter では初めてデータセットが追加されるタイミングである REFRESH の LoadState がフックできず初回ロード時にはインジケータが表示されないようでした。

launch {
    var prev = LoadStates.IDLE
    accessor.state.collect {
        if (prev.refresh != it.refresh) {
            loadStates.set(REFRESH, true, it.refresh)
            dispatchIfValid(REFRESH, it.refresh)
        }
        if (prev.prepend != it.prepend) {
            loadStates.set(PREPEND, true, it.prepend)
            dispatchIfValid(PREPEND, it.prepend)
        }
        if (prev.append != it.append) {
            loadStates.set(APPEND, true, it.append)
            dispatchIfValid(APPEND, it.append)
        }
        prev = it
    }
}

その解決策としていくつか候補はありますが、minne では LoadType.Refresh は SwipeRefreshLayout に任せると言う対策を取りました。

SwipeRefreshLayout

やることは単純で、LoadType.REFRESH の LoadState が Loading の時を SwipeRefreshLayout の isRefreshing と同期させるだけです。それと setOnRefreshListener で PagingDataAdapter の refresh() を呼ぶことで、データセットの初回追加時は SwipeRefreshLayout のインジケータが、それ以降は LoadStateAdapter のインジケータが表示されユーザにローディングの状態を正しく伝えられるようになりました。

class CatActivity : AppCompatActivity() {

    private val listAdapter = CatPagingAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding.catList.adapter = listAdapter
            .withLoadStateFooter(CatPagingLoadStateAdapter { listAdapter.retry() })

        listAdapter.addLoadStateListener {
            binding.refreshLayout.isRefreshing = it.refresh is LoadState.Loading
        }
        binding.refreshLayout.setOnRefreshListener {
            listAdapter.refresh()
        }
    }
}

Jetpack Compose と Paging 3 の相互運用

最後に、発表時は準備の関係で対応してなかったんですが Jetpack Compose で Paging 3 を使用する機会があったのでそちらも紹介しておきます。Compose の Paging 用サポートライブラリがあるのでそれを使用することになりますが、執筆時点で alpha 版のため今後変更があるかもしれません。

implementation 'androidx.paging:paging-compose:1.0.0-alpha12'

// SwipeRefreshLayout を使用する場合
implementation "com.google.accompanist:accompanist-swiperefresh:0.19.0"

基本的な使い方は PagingDataAdapter でやっていたこととそう変わりません。

@Composable
fun CatListScreen(viewModel: CatViewModel) {
    val pagingItems = viewModel.cat.collectAsLazyPagingItems()

    SwipeRefresh(
        state = rememberSwipeRefreshState(pagingItems.loadState.refresh is LoadState.Loading),
        onRefresh = { pagingItems.refresh() }
    ) {
        LazyColumn {
            items(pagingItems) { cat ->
                cat?.let { CatItem(it) }
            }
            item {
                LoadingStateView(state = pagingItems.loadState) {
                    pagingItems.retry()
                }
            }
        }
    }
}
@Composable
fun LoadingStateView(
    state: CombinedLoadStates,
    modifier: Modifier = Modifier
        .fillMaxWidth()
        .padding(24.dp),
    onError: () -> Unit
) {
    if (state.append !is LoadState.NotLoading)
        Column(
            modifier = modifier,
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            when (state.append) {
                is LoadState.Loading -> ProgressIndicator()
                is LoadState.Error -> RetryIcon(onError = onError)
                is LoadState.NotLoading -> { /* Do nothing */ }
            }
        }
}

@Composable
fun ProgressIndicator(
    modifier: Modifier = Modifier.size(36.dp, 36.dp)
) {
    CircularProgressIndicator(
        modifier = modifier,
        color = colorResource(R.color.indicator)
    )
}

@Composable
fun RetryIcon(
    modifier: Modifier = Modifier.padding(6.dp),
    onError: () -> Unit
) {
    IconButton(modifier = modifier, onClick = onError) {
        Image(painterResource(R.drawable.ic_paging_load_state_error), contentDescription = "Retry")
    }
}

まとめ

Paging 3 を使用することで簡単にアプリにページング機能が導入でき非常に実装コストが下がっています。データセットの管理も全て任せられて少ないコードで Load, Error, Retry, Refresh とページングに必要な主要要素が実現できているのは大きな魅力です。

また以下は minne のお知らせ画面ですが、Paging ライブラリ導入前は findLastItemVisiblePosition から色々と計算していたのが内部でいい感じに計算され2~3ページ先まで先手でロードしてくれているのでロード待ちによるスクロールのカクツキみたいなものがなくなり自前で用意した ScrollListener と比較してスムーズなスクロールが表現できているので UX が向上しています。

導入前後のスクロール比較

Jetpack Compose を導入しているプロダクトでも使っていけそうですので、Paging 3 導入を検討している人や初めて知った人の参考となれば嬉しいです。