[Kotlin]Paging処理(PagingDataAdapter)
概要
Pagingライブラリを使用したアプリの作成における参考記事として、
Pagingの基本概要からRecyclerViewでの実装部分までを簡潔にまとめました。
Pagingライブラリについて
以下公式からの引用
ページング ライブラリを使用すれば、大規模なデータセットからデータのページをローカル ストレージやネットワーク経由で読み込んで表示できます。このアプローチにより、アプリはネットワーク帯域幅とシステム リソースの両方をより効率的に使用できます。
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
コンポーネントを使用して、
独自のカスタム アダプターを作成することもできます。
これら公式でもかなり親切に記載されてますが、
よりイメージを付きやすくしたいので、実際にコードを用いて見てみましょう。
実装例
例として使用するアプリは以下の公式のサンプルアプリです。
ソースのコメントを一部和訳しています。
PagingSource
コンポーネントの作成
①Repositoryレイヤーでの@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)
}
Pager
コンポーネントを作成
②ViewModelレイヤーでPagingSource
オブジェクトとPagingConfig
オブジェクトに基づき、
PagingData
のインスタンスを構築するための公開APIを提供します。
ViewModel
をUI
に接続するコンポーネントは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)
}
}
PagingDataAdapter
を作成
③UIレイヤーで通常の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もあるのでこちらもオススメです。
また以下の記事ではより詳細な部分まで記載されていて、とても参考になりました。
Discussion