🍏

[Kotlin]Paging処理(PagingDataAdapter)

2022/04/27に公開

概要

Pagingライブラリを使用したアプリの作成における参考記事として、
Pagingの基本概要からRecyclerViewでの実装部分までを簡潔にまとめました。

Pagingライブラリについて

以下公式からの引用

ページング ライブラリを使用すれば、大規模なデータセットからデータのページをローカル ストレージやネットワーク経由で読み込んで表示できます。このアプローチにより、アプリはネットワーク帯域幅とシステム リソースの両方をより効率的に使用できます。

https://developer.android.com/topic/libraries/architecture/paging/v3-overview

Paging処理とは

このライブラリを使用する目的であるPaging処理とはそもそも何なのか、
公式にもあるようにPaging処理とは、
大きなデータを小さなデータとして分割し、
表示する際には都度その小さなデータを取得して表示していく。
その為アプリはネットワーク帯域幅とシステムリソースの両方をより効率的に使用出来るということ。
ネットワーク帯域幅の削減やシステムリソース不足の解消は、
アプリのパフォーマンスに直結するので重要な項目です。

Paging処理のメリット

公式でページング ライブラリを使用するメリットとして以下が挙げられています。

ページング ライブラリを使用する利点

  • ページング データに対するメモリ内キャッシュ。アプリでシステムリソースを効果的に使用するとともに、ページングデータを操作できるようになります。
  • 組み込みのリクエスト重複排除。アプリでネットワーク帯域幅とシステムリソースを効率的に使用できるようになります。
  • 読み込まれたデータの一番下までスクロールすると、データが自動的にリクエストされる構成可能なRecyclerViewアダプター。
  • Kotlin のコルーチンとフロー、LiveData、RxJava に関する最高級のサポート。
  • 更新機能や再試行機能など、エラー処理の組み込みサポート。

今回はRecyclerViewアダプターとしての適用を考え、
例えば無限スクロールなど大きなデータが必要になる場合、
一度に読み込むとネットワーク帯域幅やシステムリソースに過負荷が掛かるので、
そういったアダプターの実装の際にPaging処理を用いる必要がありそう。

Pagingライブラリのアーキテクチャ

ざっとPagingについて理解したところで、
このPagingライブラリのアーキテクチャを理解する必要があります。
以下公式で記載されている内容から整理していきます。

ページング ライブラリは、Androidアプリで推奨されているアーキテクチャに直接統合されている。
その為以下の3つのレイヤで動作する。

  • Repository
  • ViewModel
  • UI
    ページング ライブラリがアプリのアーキテクチャにどのように適合するかを示す例。

各レイヤーについてどのような責務か公式の記載から整理していきます。

Repository

RepositoryレイヤーでのメインのコンポーネントはPagingSourceです。
PagingSourceオブジェクトは、データのソースと、そのソースからデータを取得する方法を定義します。PagingSourceオブジェクトを使用すると、ネットワークソースやローカルデータベースなど、任意の単一ソースからデータを読み込めます。

他に使用可能なコンポーネントには、RemoteMediatorがあります。
RemoteMediatorオブジェクトは、ローカルデータベース キャッシュを使用する,
ネットワーク データソースなど、階層化されたデータソースからのページングを処理します。

ViewModel

Pagerコンポーネントは、PagingSourceオブジェクトとPagingConfigオブジェクトに基づき、
リアクティブ ストリームで公開されるPagingDataの、
インスタンスを構築するための公開APIを提供します。

ViewModelレイヤーをUIに接続するコンポーネントはPagingDataです。
PagingDataオブジェクトは、ページ分けされたデータのスナップショットを格納するコンテナです。PagingSourceオブジェクトに対してクエリを実行し、結果を保存します。

UI

UIレイヤーのメインのコンポーネントはPagingDataAdapterです。
これは、ページ分けされたデータを処理するRecyclerViewアダプターです。
付属のAsyncPagingDataDifferコンポーネントを使用して、
独自のカスタム アダプターを作成することもできます。

これら公式でもかなり親切に記載されてますが、
よりイメージを付きやすくしたいので、実際にコードを用いて見てみましょう。

実装例

例として使用するアプリは以下の公式のサンプルアプリです。
ソースのコメントを一部和訳しています。
https://github.com/android/architecture-components-samples/tree/main/PagingSample

①RepositoryレイヤーでのPagingSourceコンポーネントの作成

@Dao
interface CheeseDao {

    @Query("SELECT * FROM Cheese ORDER BY name COLLATE NOCASE ASC")
    fun allCheesesByName(): PagingSource<Int, Cheese>

    @Insert
    fun insert(cheeses: List<Cheese>)

    @Insert
    fun insert(cheese: Cheese)

    @Delete
    fun delete(cheese: Cheese)
}

②ViewModelレイヤーでPagerコンポーネントを作成

PagingSourceオブジェクトとPagingConfigオブジェクトに基づき、
PagingDataのインスタンスを構築するための公開APIを提供します。

ViewModelUIに接続するコンポーネントはPagingDataです。
PagingDataオブジェクトは、
ページ分けされたデータのスナップショットを格納するコンテナです。

class CheeseViewModel(private val dao: CheeseDao) : ViewModel() {
     // [Pager]で利用可能なKotlinの[Flow]プロパティを使用
    val allCheeses: Flow<PagingData<CheeseListItem>> = Pager(
        config = PagingConfig(
            /**
             *  良いページサイズとは、
	     *  大きなデバイスで少なくとも数画面分のコンテンツが埋まるような値で、
	     *  ユーザーがNullアイテムを見ることはまずないでしょう。
	     *  この定数は、ページング動作を観察するために使用することができます。
             */
            pageSize = 60,

            /**
             *  プレースホルダが有効な場合、
	     *  PagedList はフルサイズを報告しますが、
	     *  onBind メソッドでは一部のアイテムが null になる可能性がある
	     *  (PagedListAdapter はデータがロードされると再バインドのトリガーが引かれる)
             *
             *  プレースホルダーを無効にすると、onBind は決して null を受け取りませんが、
	     *  より多くのページを読み込むと、新しいページが読み込まれたときにスクロールバーが揺れるので、
	     *  プレースホルダーを無効にしたら、スクロールバーも無効にしたほうが良い。
             */
            enablePlaceholders = true,

            /**
             * PagedListが一度にメモリ上に保持すべき項目の最大数。
             *
             * この数値はPagedListがより多くのページを読み込むと、
	     *  離れたページを落とし始めるトリガーとなる。
             */
            maxSize = 200
        )
    ) {
        dao.allCheesesByName()
    }.flow
        .map { pagingData ->
            pagingData
                // Map cheeses to common UI model.
                .map { cheese -> CheeseListItem.Item(cheese) }
                .insertSeparators { before: CheeseListItem?, after: CheeseListItem? ->
                    if (before == null && after == null) {
                        // List is empty after fully loaded; return null to skip adding separator.
                        null
                    } else if (after == null) {
                        // Footer; return null here to skip adding a footer.
                        null
                    } else if (before == null) {
                        // Header
                        CheeseListItem.Separator(after.name.first())
                    } else if (!before.name.first().equals(after.name.first(), ignoreCase = true)){
                        // Between two items that start with different letters.
                        CheeseListItem.Separator(after.name.first())
                    } else {
                        // Between two items that start with the same letter.
                        null
                    }
                }
        }
        .cachedIn(viewModelScope)

    fun insert(text: CharSequence) = ioThread {
        dao.insert(Cheese(id = 0, name = text.toString()))
    }

    fun remove(cheese: Cheese) = ioThread {
        dao.delete(cheese)
    }
}

③UIレイヤーでPagingDataAdapterを作成

通常のRecyclerViewの実装とあまり変わらないです。
サンプルではdiffCallbackを使用してListとAdapterとの差分を計算し、
異なる項目1つのみを検出し再バインドするだけで良い処理をしています。

class CheeseAdapter : PagingDataAdapter<CheeseListItem, CheeseViewHolder>(diffCallback) {
    override fun onBindViewHolder(holder: CheeseViewHolder, position: Int) {
        holder.bindTo(getItem(position))
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CheeseViewHolder {
        return CheeseViewHolder(parent)
    }

    companion object {
        /**
         * この diff コールバックは、新しい PagedList が到着したときに、
	 *  PagedListAdapter にリストの差分を計算する方法を通知します。
         *
         *  Addボタンで Cheese を追加すると、
	 *  PagedListAdapter は diffCallback を使って、
	 *  以前と異なる項目が1つだけあることを検出し、
	 * 1つのビューをアニメーション化して再バインドするだけです。
         */
        val diffCallback = object : DiffUtil.ItemCallback<CheeseListItem>() {
            override fun areItemsTheSame(oldItem: CheeseListItem, newItem: CheeseListItem): Boolean {
                return if (oldItem is CheeseListItem.Item && newItem is CheeseListItem.Item) {
                    oldItem.cheese.id == newItem.cheese.id
                } else if (oldItem is CheeseListItem.Separator && newItem is CheeseListItem.Separator) {
                    oldItem.name == newItem.name
                } else {
                    oldItem == newItem
                }
            }
	    
            override fun areContentsTheSame(oldItem: CheeseListItem, newItem: CheeseListItem): Boolean {
                return oldItem == newItem
            }
        }
    }
}

まとめ

今回のPaging処理はアプリのパフォーマンスを考慮する場合に、
大量のデータを用いるRecyclerViewの実装では必須な処理かと思います。
公式ではサンプルアプリの他に以下のCodelabもあるのでこちらもオススメです。
https://codelabs.developers.google.com/codelabs/android-paging
また以下の記事ではより詳細な部分まで記載されていて、とても参考になりました。
https://codezine.jp/article/detail/15314

Discussion