3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Paging3をざっくり理解したい

Posted at

こんにちは。Androidエンジニアとしてインターン中のkitakkunです。
今回は、Android上でのページング処理を実現するためのライブラリ、「Paging3」に関して、Codelabで勉強したのでちょっとまとめてみたいと思います。

はじめに

この記事はGoogleのCodelabに基づき作成されています。あくまで以下のCodelabの理解を補助するための記事です。ところどころ怪しい箇所があるかもしれません。間違ってたら指摘お願いします。

Pagingってなんぞ?

適当なデータをリスト表示するビューがあるとします。また、リストのデータはネットワークを経由してサーバーからフェッチされるものとしましょう。

ここで、一度にすべてのデータを読み込むわけにはいきません。
データが少ないうちはいいですが、データが多くなってくるとアプリが急激に低速になります。

ですから、画面内現れる必要なデータに絞ってフェッチする仕組みを実装する必要があります。そこで必然的にデータを チャンク(塊) ごとに分けて取得する ページング が活躍します。

具体的には、ユーザーがリストを最下部もしくはそれより数アイテム上まで閲覧したタイミングで、再度サーバーにリクエストを送り、リストの追加分データを取得して追加するという実装をするでしょう。

Pagingライブラリを使えば、このようなユースケースを簡単に実現することができます。

PagingSourceを作る

PagingSourceはデータをページングしながら取得する機構持つデータソースです。例えば今、以下のArticleデータクラスがあるとします。

data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val created: LocalDateTime,
)

このArticleのデータをページングしながら取得するためのデータソース、ArticlePagingSourceは次の2つのメソッドを実装する必要があります。

class ArticlePagingSource : PagingSource<Int, Article>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        TODO("Not yet implemented")
    }
}

各メソッドについて説明します。

loadメソッドの実装

loadは、 LoadParamsに基づいてデータをフェッチした結果LoadResultを返します。

データの範囲計算とフェッチ処理

LoadParamsは、以下の2つのデータを持ちます。

  • 読み込み開始アイテムのキー(key)
  • key以降で読み込むアイテム数(loadSize)

keyは初回ロード時はnullなので、開始アイテムのキーを設定してやる必要があります。

// private const val STARTING_KEY = 0
// をファイルのグローバルスコープに定義してます
val start = params.key ?: STARTING_KEY

続いて、loadSizeと併せてロード対象の範囲を割り出します。

val range = start.until(start + params.loadSize)

このrangeを用いて適当にデータをフェッチします。ここはアプリによって違うところだと思いますが、簡単のためにダミーのリストを作ってみます。データに特に意味はないです!

val data = range.map { number ->
     Article(
         id = number,
         title = "Article $number",
         description = "This describes article $number",
         created = firstArticleCreatedTime.minusDays(number.toLong()),
     )
}

ここまででloadメソッドはこんな感じです。この後、読み込んだデータをLoadResultでパックして呼び出し元に返却します。

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val start = params.key ?: STARTING_KEY
        val range = start.until(start + params.loadSize)

        val data = range.map { number ->
            Article(
                id = number,
                title = "Article $number",
                description = "This describes article $number",
                created = firstArticleCreatedTime.minusDays(number.toLong()),
            )
        }
        // TODO: LoadResultにデータをまとめて返す
    }

読み込み結果の返却

LoadResultはロード結果を表すクラスです。ロードの成功・失敗・不整合それぞれの結果に対応する型が存在します。

  • LoadResult.Page: 正常にロードできた場合
  • LoadResult.Error: エラーの場合
  • LoadResult.Invalid: データの整合性が取れなくなったため、PagingSourceを無効にしなければならない場合

Invalidがどういうケースで出てくるのかいまいちまだよくわかりませんが、おそらく通常使うのはPageErrorでしょうか・・?

今回は毎回ロードに成功する想定でダミーリストを返すので、LoadResult.Pageを使います。LoadResult.Pageを作成する際に必要となる引数は以下の通りです。

  • data: 取得したアイテムのList
  • prevKey: 現在のページより前のアイテムを取得する場合に用いるキー
  • nextKey: 現在のページより後のアイテムを取得する場合に用いるキー
  • itemsBefore(省略可能): 読み込んだデータの前に表示するプレースホルダー数
  • itemsAfter(省略可能): 読み込んだデータの後ろに表示するプレースホルダー数

初回ロードの場合、前のページのデータは存在しませんから、prevKeynullになります。それ以外では、今回のロード開始位置range.firstからロードアイテム数params.loadSizeを引いた値がprevKeyとなります。しかし、場合によってはこの値が負となる可能性があるため、STARTING_KEY以上の値となるよう適切な処理を行う必要があります。
例えば、以下のような関数を作成しprevKeySTARTING_KEYより小さくならないようにします。

private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)

nextKeyはフェッチしたデータの終端range.last1を加算した値とすればいいでしょう。

以上の情報を用いてLoadResult.Pageを構成します。

LoadResult.Page(
    data = data,
    prevKey = when(start) {
        STARTING_KEY -> null
        else -> ensureValidKey(key = range.first - params.loadSize)
    },
    nextKey = range.last + 1,
)

loadメソッドの実装例

最終的に以下のような実装となります。エラーがある場合は分岐を増やしてLoadResult.Errorを返すようなパスを作ってやりましょう。

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val start = params.key ?: STARTING_KEY
        val range = start.until(start + params.loadSize)

        val data = range.map { number ->
            Article(
                id = number,
                title = "Article $number",
                description = "This describes article $number",
                created = firstArticleCreatedTime.minusDays(number.toLong()),
            )
        }

        return LoadResult.Page(
            data = data,
            prevKey = when(start) {
                STARTING_KEY -> null
                else -> ensureValidKey(key = range.first - params.loadSize)
            },
            nextKey = range.last + 1,
        )
    }

getRefreshKeyメソッドの実装

もう一つ実装しなければならないメソッドとして、getRefreshKeyがあります。
このメソッドは、PagingSourceのバックエンド側データに変更があり、再度読み込む必要性が発生した場合に呼び出されます。
Twitterでタイムラインから気になったツイートを開いて、いいねをして戻ってきたときを考えてみましょう。いいね数が変わっているので再度読み込む必要がありますよね。
このように今見えている範囲をリフレッシュしたいとき、どのキーを開始位置としてリフレッシュすればいいかを返すのがこのメソッドです(おそらく)。

引数としてPagingStateを受け取りますがこれを用いて次の初回ロード時のキーを計算します。

まず初めにリストで最も最近アクセスのあった要素のindexをstate.anchorPositionで取得します。

val anchorPosition = state.anchorPosition ?: return null

anchorPositionは、読み込み未完了でプレースホルダー状態の要素も込みでindexを出すので、anchorPositionに最も近く読み込み済みの要素を算出する必要があります。具体的には以下のコードで取得できます。

val article = state.closestItemToPosition(anchorPosition) ?: return null

内部実装を見ればなんとなくanchorPositionから上下一番近い読み込み済み要素を探しているのがわかると思います。

これを使ってPagingSourceを再初期化するときに使うべきキーを計算します。

ensureValidKey(key = article.id - (state.config.pageSize) / 2))

まとめると、以下のような実装になります。簡単ですね。

   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        val anchorPosition = state.anchorPosition ?: return null
        val article = state.closestItemToPosition(anchorPosition) ?: return null
        return ensureValidKey(key = article.id - (state.config.pageSize / 2))
    }

UI用のPagingDataを作る

ページングを用いて作成されたリストをUIで使うには、PagingDataとして扱う必要があります。PagingDataは読み込んだデータをラップして、追加データ取得のタイミングを内部的に自動で決定してくれるクラスです。

KotlinのFlowを用いている場合は、Pager.flowというビルダーメソッドを用いてPagingDataへ変換します。

ビルダーを呼び出すにあたり、以下のパラメータ指定が必要です。

  • PagingConfig: 先読み量や初期の読み込みサイズなどPagingSourceからコンテンツを読み込む方法を決定します。必須パラメータはページサイズのみで、その他のパラメータは省略可能です。
    デフォルトでは読み込んだデータ全てをメモリに保持しますが、スクロール時のメモリ浪費を回避するにはmaxSizeを指定します。
    また、enablePlaceholders = trueとすると未読み込みのコンテンツに対してはnullを返すようになります。
  • PagingSourceの作成方法を定義する関数。

PagingConfig.pageSizeはどのくらいに設定すべきか

ページが小さすぎる→リストのちらつきが起こる可能性がある
ページが大きすぎる→リスト更新時の待ち時間が長くなる

ので、画面数枚分のアイテム数を設定するのが望ましいようです。

リポジトリクラスには以下のようにPagingSourceの生成関数だけ定義しておきます。PagingConfigはViewModel側で設定します。

class ArticleRepository() {
    fun articlePagingSource() = ArticlePagingSource()
}

ViewModel側の実装

PagingConfigPagingSourceのfactoryラムダ関数を指定してPagerを作り、.flowFlowに変換します。

    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = {
            repository.articlePagingSource()
        }
    )
        .flow
        // 続きあります

このままだとアクティビティ再生成時などにページング状態が消滅してしまうので、viewModelScopeに紐づけます。このとき、stateInsharedInの代わりにcachedInを使います。

stateInsharedInを使ってはいけない理由

stateInはコールドなFlowStateFlowに、
sharedInはコールドなFlowSharedFlowにそれぞれ変換します。

StateFlowSharedFlowはホットなFlow、すなわちオブザーバーがいなくても値が流れます。

ただ、Pager.flowで生成されるflowはコールドではないので、cachedInを使いましょうということらしいです。

ただ、Pager.flowのところに A cold [Flow] of [PagingData], which ...みたいな記述があるので、どういうこと?ってなってます。詳しい人教えてください。。

改めて、itemの保持部はこんな感じになります。

    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = {
            repository.articlePagingSource()
        }
    )
        .flow
        .cachedIn(scope = viewModelScope)

あとはこのアイテムをcollectLatestして使います。なお、Composeの場合はcollectAsLazyPagingItems()を使うようです。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?