Android ページングの高度な Codelab

1. はじめに

学習内容

  • Paging 3 の主なコンポーネントについて
  • プロジェクトに Paging 3 を追加する方法
  • Paging 3 API を使用してヘッダーやフッターをリストに追加する方法
  • Paging 3 API を使用してリスト セパレータを追加する方法
  • ネットワークとデータベースからページングする方法

作成するアプリの概要

この Codelab では、すでに GitHub リポジトリのリストを表示するようになっているサンプルアプリを使用します。表示されているリストの最後にスクロールすると、新しいネットワーク リクエストがトリガーされ、その結果が画面に表示されます。

各ステップでコードを追加しながら、以下のことを行います。

  • Paging ライブラリ コンポーネントに移行します。
  • 読み込みステータスのヘッダーとフッターをリストに追加します。
  • 新規のリポジトリ検索ごとにその間の読み込みの進行状況を表示します。
  • セパレータをリストに追加します。
  • ネットワークとデータベースからのページングにデータベース サポートを追加します。

最終的なアプリの外観は次のようになります。

23643514cb9cf43e.png

必要なもの

アーキテクチャ コンポーネントの概要については、Room と View の Codelab をご覧ください。Flow の概要については、Kotlin Flow と LiveData による高度なコルーチンの Codelab をご覧ください。

2. 環境をセットアップする

このステップでは、Codelab 全体のコードをダウンロードし、その後、簡単なサンプルアプリを実行します。

できるだけすぐに開始できるように、たたき台として利用できるスターター プロジェクトを用意しています。

git がインストールされている場合は、以下のコマンドをそのまま実行できます(ターミナル / コマンドラインで「git --version」と入力して、コマンドが正しく実行されているかどうかを確認できます)。

 git clone https://github.com/googlecodelabs/android-paging

初期状態は master ブランチにあります。以下のステップでは、それぞれ解答を確認できます。

  • ブランチ step5-9_paging_3.0 - Paging の最新バージョンをプロジェクトに追加する、ステップ 5 から 9 の解答があります。
  • ブランチ step10_loading_state_footer - 読み込み状態の表示用のフッターを追加する、ステップ 10 の解答があります。
  • ブランチ step11_loading_state - クエリとクエリの間で読み込み状態を表示する、ステップ 11 の解答があります。
  • ブランチ step12_separators - アプリにセパレータを追加する、ステップ 12 の解答があります。
  • ブランチ step13-19_network_and_database - アプリにオフライン サポートを追加する、ステップ 13 から 19 の解答があります。

git がない場合は、次のボタンをクリックして、この Codelab のすべてのコードをダウンロードできます。

  1. コードの ZIP ファイルを展開し、プロジェクトを Android Studio で開きます。
  2. デバイスまたはエミュレータで app 実行構成を実行します。

89af884fa2d4e709.png

アプリが実行され、次のような GitHub リポジトリのリストが表示されます。

50d1d2aa6e79e473.png

3.プロジェクト概要

このアプリでは、名前や説明に指定した単語を含むリポジトリを GitHub から検索できます。リポジトリのリストは、スターの数の多い順、その次に名前のアルファベット順という順番で表示されます。

このアプリは、「アプリ アーキテクチャ ガイド」で推奨されているアーキテクチャに沿っています。各パッケージの内容を以下に示します。

  • api - Github API の呼び出し。Retrofit を使用。
  • data - API リクエストをトリガーし、メモリ内にレスポンスをキャッシュするリポジトリ クラス。
  • model - Room データベース内のテーブルでもある Repo データモデル。また、検索結果のデータとネットワーク エラーの両方を監視する UI で使用される RepoSearchResult クラス。
  • ui - RecyclerView を使った Activity の表示に関連するクラス。

GithubRepository クラスは、ユーザーがリストの最後にスクロールするたびに、またはユーザーが新しいリポジトリを検索したときに、ネットワークからリポジトリ名のリストを取得します。クエリ結果のリストは、ConflatedBroadcastChannelGithubRepository にメモリ上で保持され、Flow として公開されます。

SearchRepositoriesViewModelGithubRepository からのデータをリクエストし、それを SearchRepositoriesActivity に公開します。構成変更の際(回転など)にデータを複数回リクエストしないように、liveData() ビルダー メソッドを使用して ViewModel 内で FlowLiveData に変換しています。これにより、LiveData は最新の結果のリストをメモリにキャッシュし、SearchRepositoriesActivity が再作成されたときに LiveData の内容が画面に表示されます。ViewModel は以下を公開します。

  1. LiveData<UiState>
  2. (UiAction) -> Unit 関数

UiState は、アプリの UI をレンダリングするために必要なすべての要素を表現したもので、各フィールドがそれぞれ異なる UI コンポーネントに対応しています。不変のオブジェクトなので、変更することはできません。ただし、UI でこのオブジェクトの新しいバージョンを作成して監視できます。この例では、ユーザー操作(新しいクエリを検索するか、リストをスクロールしてさらにコンテンツを取得する)の結果として新しいバージョンが生成されます。

ユーザー操作は UiAction 型で適切に表されます。ViewModel を操作するための API を単一の型で囲むと、次のメリットがあります。

  • 小さな API サーフェス: 操作を追加、削除、変更できますが、ViewModel のメソッド シグネチャは変更されません。これにより、リファクタリングをローカルで行うことができ、抽象化やインターフェースの実装がリークする可能性が低くなります。
  • 簡単になった同時実行管理: この Codelab で後述するように、特定のリクエストの実行順序を保証できるようにすることが重要です。UiAction で API を厳密に入力することにより、何がいつ行われるのかについて厳しい要件のあるコードを記述できます。

ユーザビリティの観点からは、次の問題があります。

  • リスト読み込みの状態に関する情報がありません。新しいリポジトリを検索したときには、何もない画面が表示され、同じクエリの結果がさらに読み込まれている間には、単にリストの最後が表示されます。
  • クエリがエラーになっても再試行できません。
  • 画面の向きの変更後やプロセスの終了後、リストは常に一番上にスクロールされます。

実装の観点からは、次の問題があります。

  • リストがメモリ上で無制限に肥大化し、スクロールするとメモリが無駄になります。
  • 結果をキャッシュするために Flow から LiveData に変換する必要があり、コードが複雑になります。
  • アプリで複数のリストを表示する必要がある場合は、リストごとに多数のボイラープレートを書く必要があります。

以上の問題に対して Paging ライブラリがどのように役立つか、またそれに含まれているコンポーネントについて見ていきましょう。

4. Paging ライブラリのコンポーネント

Paging ライブラリを使用すると、アプリの UI 内でデータを段階的かつ適切に読み込むことが容易になります。Paging API は、ページにデータを読み込むときに手動で実装する必要があった次の機能をサポートしています。

  • 次ページと前ページの取得に使用するキーを管理します。
  • リストの最後にスクロールしたときに、正しいページを自動的にリクエストします。
  • 複数のリクエストが同時にトリガーされるのを防ぎます。
  • データをキャッシュできるようにします。Kotlin を使用している場合は、CoroutineScope で行い、Java を使用している場合は、LiveData で行います。
  • 読み込み状態を管理して、RecyclerView リスト項目やその他の UI に表示し、失敗した読み込みを簡単に再試行できます。
  • FlowLiveData、または RxJava FlowableObservable のどれを使用しているかに関係なく、表示されるリストに mapfilter のような一般的な操作を実行できます。
  • リスト セパレータを簡単に実装できます。

アプリ アーキテクチャ ガイドでは、次の主要コンポーネントを使ったアーキテクチャを紹介しています。

  • ユーザーに提示され、ユーザーが操作するデータの信頼できる唯一のソースとなるローカル データベース。
  • ウェブ API サービス。
  • データベースおよびウェブ API サービスと連携し、統一されたデータ インターフェースを提供するリポジトリ。
  • UI に固有のデータを提供する ViewModel
  • ViewModel 内のデータを視覚的に表現する UI。

Paging ライブラリは、以上のすべてのコンポーネントと連携し、それら同士のやり取りを調整して、データソースからのコンテンツの「ページ」を読み込んで、そのコンテンツを UI に表示できるようにします。

この Codelab では、Paging ライブラリと次の主要コンポーネントを紹介しています。

  • PagingData - ページングされたデータのコンテナです。データの更新ごとに対応する PagingData があります。
  • PagingSource - PagingSource は、データのスナップショットを PagingData のストリームに読み込むための基本クラスです。
  • Pager.flow - PagingConfig と、実装された PagingSource の構築方法を定義する関数をベースにして、Flow<PagingData> を作成します。
  • PagingDataAdapter - RecyclerView 内で PagingData を表示する RecyclerView.AdapterPagingDataAdapter は、Kotlin FlowLiveData、RxJava Flowable、RxJava Observable に接続できます。PagingDataAdapter は、ページが読み込まれると、内部の PagingData 読み込みイベントをリッスンし、新しい PagingData オブジェクトの形式で更新されたコンテンツが受信されると、バックグラウンド スレッドで DiffUtil を使用してきめ細かく更新を計算します。
  • RemoteMediator - ネットワークとデータベースからのページングの実装に役立ちます。

この Codelab では、上記の各コンポーネントの例を実装します。

5. データのソースを定義する

PagingSource の実装では、データのソースと、そのソースからデータを取得する方法を定義します。PagingData オブジェクトが、RecyclerView でのスクロールで生成される読み込みヒントに応答して、PagingSource からデータをクエリします。

現在、GithubRepository には、追加後に Paging ライブラリが処理するデータソースに関して、次の役割があります。

  • 複数のリクエストが同時にトリガーされないようにしながら、GithubService からデータを読み込む。
  • 取得したデータのメモリ内キャッシュを保持する。
  • リクエストされたページを管理する。

PagingSource を構築するには、以下を定義する必要があります。

  • ページングキーのタイプ - 今回、Github API はページに 1 から始まるインデックス番号を使用するため、タイプは Int です。
  • 読み込むデータの種類 - 今回は Repo 項目を読み込みます。
  • データの取得元 - GithubService からデータを取得します。データソースはクエリに固有のものであるため、クエリ情報を GithubService に渡す必要があります。

そのため、以下のように、data パッケージで GithubPagingSource という PagingSource の実装を作成します。

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        TODO("Not yet implemented")
    }

}

PagingSource には、load()getRefreshKey() の 2 つの関数を実装する必要があります。

load() 関数は Paging ライブラリによって呼び出され、ユーザーがスクロールしたときに表示されるデータを非同期でフェッチします。LoadParams オブジェクトは、次の読み込み操作に関する情報を保持します。

  • 読み込むページのキー。初めて読み込む場合、LoadParams.keynull になります。今回は、最初のページキーを定義する必要があります。このプロジェクトでは、GITHUB_STARTING_PAGE_INDEXGithubRepository から PagingSource の実装に移動する必要があります。これが最初のページキーであるためです。
  • 読み込みサイズ - リクエストされた読み込む項目数です。

読み込み関数は LoadResult を返します。LoadResult は次のいずれかのタイプを取るため、アプリでの RepoSearchResult の使用を置き換えます。

  • LoadResult.Page: 結果が成功の場合。
  • LoadResult.Error: エラーの場合。

LoadResult.Page を構築する際、読み込みができない場合には、読み込みの方法に応じて nextKey または prevKeynull を渡します。たとえば、今回のケースでは、ネットワーク レスポンスは正常だったにもかかわらず、リストが空の場合には、読み込むデータが残っていないという場合があり、その場合には nextKeynull になります。

上記のすべての情報に基づけば、load() 関数を実装できるはずです。

次に、getRefreshKey() を実装します。更新キーは、PagingSource.load() に対する後続の更新呼び出しに使用されます(最初の呼び出しは、Pager が提供する initialKey を使用した初期読み込みです)。更新は、Paging ライブラリが、現在のリストと置き換えるために新しいデータを読み込もうとするときに発生します。たとえば、スワイプによる更新や、データベースの更新、設定の変更、プロセスの終了などの理由で無効になった場合です。通常、以降の更新呼び出しでは、最後にアクセスされたインデックスを表す PagingState.anchorPosition を中心にデータの読み込みを再開します。

GithubPagingSource の実装は、次のようになります。

// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
        val apiQuery = query + IN_QUALIFIER
        return try {
            val response = service.searchRepos(apiQuery, position, params.loadSize)
            val repos = response.items
            val nextKey = if (repos.isEmpty()) {
                null
            } else {
                // initial load size = 3 * NETWORK_PAGE_SIZE
                // ensure we're not requesting duplicating items, at the 2nd request
                position + (params.loadSize / NETWORK_PAGE_SIZE)
            }
            LoadResult.Page(
                    data = repos,
                    prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
                    nextKey = nextKey
            )
        } catch (exception: IOException) {
            return LoadResult.Error(exception)
        } catch (exception: HttpException) {
            return LoadResult.Error(exception)
        }
    }
    // The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
    override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        // We need to get the previous key (or next key if previous is null) of the page
        // that was closest to the most recently accessed index.
        // Anchor position is the most recently accessed index
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }

}

6. PagingData を構築して設定する

現在の実装では、GitHubRepositoryFlow<RepoSearchResult> を使用してネットワークからデータを取得し、ViewModel に渡しています。次に、ViewModel がそれを LiveData に変換し、UI に公開します。表示されているリストの最後に到達し、ネットワークからさらに多くのデータが読み込まれると、Flow<RepoSearchResult> には、最新のデータに加えて、そのクエリで以前に取得されたデータのリスト全体が含まれるようになります。

RepoSearchResult は、成功とエラーの両方のケースをカプセル化しています。成功の場合は、リポジトリ データが保持されています。エラーの場合は、Exception の理由が含まれています。Paging 3 では、ライブラリが LoadResult で成功とエラーの両方のケースをモデル化しているため、RepoSearchResult は必要ありません。RepoSearchResult は、次のステップで置き換えるので、削除可能です。

PagingData を構築するには、まず PagingData をアプリの他のレイヤに渡すために使用する API を、次のように決める必要があります。

  • Kotlin Flow - Pager.flow を使用
  • LiveData - Pager.liveData を使用
  • RxJava Flowable - Pager.flowable を使用
  • RxJava Observable - Pager.observable を使用

すでにアプリで Flow を使用しているので、このアプローチで続けますが、Flow<RepoSearchResult> を使用する代わりに Flow<PagingData<Repo>> を使用します。

どの PagingData ビルダーを使用する場合でも、次のパラメータを渡す必要があります。

  • PagingConfig。このクラスは、先読みの量や初期読み込みのサイズ リクエストなど、PagingSource からコンテンツを読み込む方法のオプションを設定します。設定が必須なのは、ページサイズ(各ページに読み込まれる項目の数)のみです。デフォルトで、Paging は読み込んだページをすべてメモリに保持します。スクロールしたときにメモリが無駄にならないようにするには、PagingConfigmaxSize パラメータを設定します。デフォルトでは、Paging が読み込まれていない項目をカウントでき、かつ enablePlaceholders 設定フラグが true の場合、Paging はまだ読み込まれていないコンテンツのプレースホルダとして null 項目を返します。このようにして、アダプタにプレースホルダ ビューを表示できます。この Codelab では作業を簡単にするために、enablePlaceholders = false を渡して、プレースホルダを無効にしましょう。
  • PagingSource の作成方法を定義する関数。今回は、新しいクエリごとに新しい GithubPagingSource を作成します。

では、GithubRepository を変更してみましょう。

GithubRepository.getSearchResultStream を更新する

  • suspend 修飾子を削除します。
  • Flow<PagingData<Repo>> を返します。
  • Pager を構築します。
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
    return Pager(
          config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
          pagingSourceFactory = { GithubPagingSource(service, query) }
    ).flow
}

GithubRepository をクリーンアップする

Paging 3 では、さまざまな処理を行っています。

  • メモリ内キャッシュの処理
  • リストの最後に近づいたときのデータのリクエスト

したがって、GithubRepositorygetSearchResultStreamNETWORK_PAGE_SIZE が定義されているコンパニオン オブジェクト以外はすべて削除できます。GithubRepository は次のようになります。

class GithubRepository(private val service: GithubService) {

    fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
        return Pager(
                config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false
             ),
                pagingSourceFactory = { GithubPagingSource(service, query) }
        ).flow
    }

    companion object {
        const val NETWORK_PAGE_SIZE = 50
    }
}

SearchRepositoriesViewModel でコンパイル エラーが表示されるはずです。ここでどのような変更が必要か見てみましょう。

7. ViewModel で PagingData をリクエストしてキャッシュする

コンパイル エラーに対処する前に、ViewModel の型を確認してみましょう。

sealed class UiAction {
    data class Search(val query: String) : UiAction()
    data class Scroll(
        val visibleItemCount: Int,
        val lastVisibleItemPosition: Int,
        val totalItemCount: Int
    ) : UiAction()
}

data class UiState(
    val query: String,
    val searchResult: RepoSearchResult
)

UiState では searchResult を公開しています。searchResult の役割は、構成変更後も維持される検索結果用のメモリ内キャッシュであることです。Paging 3 では、FlowLiveData に変換する必要がなくなりました。代わりに SearchRepositoriesViewModelStateFlow<UiState> を公開するようになりました。さらに、searchResult の値を完全に破棄し、代わりに searchResult と同じ目的を果たす別の Flow<PagingData<Repo>> を公開します。

PagingData は自己完結型であり、RecyclerView に表示されるデータの更新の可変ストリームを含むものです。PagingData の出力はそれぞれ完全に独立しており、1 つのクエリに対して複数の PagingData が出力される場合があります。そのため、PagingDataFlows は他の Flows とは独立して公開する必要があります。

さらに、ユーザー エクスペリエンス特典として、新しいクエリが入力されるたびに、リストの一番上までスクロールされるようにして最初の検索結果を表示します。ただし、ページング データは複数回出力される可能性があるため、ユーザーがスクロールを開始していない場合にのみ、リストの一番上にスクロールされるようにします。

そのために、UiState を更新して lastQueryScrolledhasNotScrolledForCurrentSearch のフィールドを追加しましょう。これらのフラグは、リストの一番上までスクロールしてはならないときにこの動作を防ぐものです。

data class UiState(
    val query: String = DEFAULT_QUERY,
    val lastQueryScrolled: String = DEFAULT_QUERY,
    val hasNotScrolledForCurrentSearch: Boolean = false
)

アーキテクチャについて復習しましょう。ViewModel へのリクエストはすべて単一のエントリ ポイント((UiAction) -> Unit として定義される accept フィールド)を経由するため、次の操作を行う必要があります。

  • そのエントリ ポイントを、目的の型を含むストリームに変換する。
  • これらのストリームを変換する。
  • ストリームを結合して StateFlow<UiState> に戻す。

より機能的な観点から、UiAction の出力を UiStatereduce します。これは組み立てラインのようなものです。UiAction 型は供給される原材料で、効果(ミューテーションとも呼ばれます)をもたらします。UiState は UI にバインドする準備ができた最終的な出力です。このプロセスは「UI を UiState の関数にする」と呼ばれることもあります。

ViewModel を書き換えて、2 つの異なるストリームで各 UiAction 型を処理し、次にいくつかの Kotlin Flow 演算子を使用してそれらの型を StateFlow<UiState> に変換しましょう。

まず、ViewModelstate の定義を更新して、LiveData ではなく StateFlow を使用するようにし、PagingDataFlow を公開するためのフィールドも追加します。

   /**
     * Stream of immutable states representative of the UI.
     */
    val state: StateFlow<UiState>

    val pagingDataFlow: Flow<PagingData<Repo>>

次に、UiAction.Scroll サブクラスの定義を更新します。

sealed class UiAction {
    ...
    data class Scroll(val currentQuery: String) : UiAction()
}

UiAction.Scroll データクラスのすべてのフィールドが削除され、単一の currentQuery 文字列に置き換えられていることに注意してください。これにより、スクロール操作を特定のクエリと関連付けることができます。また、shouldFetchMore 拡張機能は使用されなくなるため、削除します。この機能はプロセス終了後に復元する必要もあるため、SearchRepositoriesViewModelonCleared() メソッドを必ず更新するようにします。

class SearchRepositoriesViewModel{
  ...
   override fun onCleared() {
        savedStateHandle[LAST_SEARCH_QUERY] = state.value.query
        savedStateHandle[LAST_QUERY_SCROLLED] = state.value.lastQueryScrolled
        super.onCleared()
    }
}

// This is outside the ViewModel class, but in the same file
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"

この時点で、実際に GithubRepository から pagingData Flow を作成するメソッドを導入する必要もあります。

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {

    override fun onCleared() {
        ...
    }

    private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
        repository.getSearchResultStream(queryString)
}

Flow<PagingData> には、CoroutineScope の中で Flow<PagingData> のコンテンツをキャッシュできる便利な cachedIn() メソッドが用意されています。今回は ViewModel の中なので、androidx.lifecycle.viewModelScope を使用します。

これで、ViewModel の accept フィールドを UiAction ストリームに変換できるようになりました。SearchRepositoriesViewModelinit ブロックを次のように置き換えます。

class SearchRepositoriesViewModel(
    ...
) : ViewModel() {
    ...
    init {
        val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
        val actionStateFlow = MutableSharedFlow<UiAction>()
        val searches = actionStateFlow
            .filterIsInstance<UiAction.Search>()
            .distinctUntilChanged()
            .onStart { emit(UiAction.Search(query = initialQuery)) }
        val queriesScrolled = actionStateFlow
            .filterIsInstance<UiAction.Scroll>()
            .distinctUntilChanged()
            // This is shared to keep the flow "hot" while caching the last query scrolled,
            // otherwise each flatMapLatest invocation would lose the last query scrolled,
            .shareIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                replay = 1
            )
            .onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
   }
}

上記のコード スニペットを見てみましょう。2 つの項目から始めます。initialQuery は保存された状態またはデフォルトから取得される String で、lastQueryScrolled はユーザーがリストを操作した最後の検索キーワードを表す String です。次に、Flow を特定の UiAction 型に分割します。

  1. UiAction.Search: ユーザーが特定のクエリを入力するたびに発生します。
  2. UiAction.Scroll: ユーザーがフォーカスされている特定のクエリを含むリストをスクロールするたびに発生します。

UiAction.Scroll Flow には、いくつかの追加の変換が適用されています。確認してみましょう。

  1. shareIn: この Flow が最終的に使用されるときに flatmapLatest 演算子を通じて使用されるため必要です。アップストリームでの出力のたびに、flatmapLatest は最後に処理していた Flow をキャンセルし、与えられた新しいフローに従って動作を開始します。この例では、ユーザーが最後にスクロールしたクエリの値が失われることになります。したがって、replay 値が 1 の Flow 演算子を使用して最後の値をキャッシュに保存し、新しいクエリを受信したときにその値が失われないようにします。
  2. onStart: キャッシュにも使用されます。アプリが強制終了されたが、ユーザーがすでにクエリをスクロールしていた場合は、リストが一番上までスクロールされないようにします。これでユーザーが閲覧していた位置から再び外れることはありません。

statepagingDataFlowaccept の各フィールドはまだ定義されていないため、コンパイル エラーが引き続き発生するはずです。これを修正しましょう。各 UiAction に変換を適用したら、その変換を使用して PagingDataUiState の両方のフローを作成できるようになりました。

init {
        ...
        pagingDataFlow = searches
            .flatMapLatest { searchRepo(queryString = it.query) }
            .cachedIn(viewModelScope)

        state = combine(
            searches,
            queriesScrolled,
            ::Pair
        ).map { (search, scroll) ->
            UiState(
                query = search.query,
                lastQueryScrolled = scroll.currentQuery,
                // If the search query matches the scroll query, the user has scrolled
                hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
            )
        }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
                initialValue = UiState()
            )

        accept = { action ->
            viewModelScope.launch { actionStateFlow.emit(action) }
        }
    }
}

新しい検索クエリごとに新しい Pager を作成する必要があるため、searches フローで flatmapLatest 演算子を使用します。次に、cachedIn 演算子を PagingData フローに適用して viewModelScope 内でアクティブな状態に保ち、その結果を pagingDataFlow フィールドに割り当てます。UiState 側では、結合演算子を使用して必須の UiState フィールドに値を入力し、その結果の Flow を公開された state フィールドに割り当てます。また、accept を、ステートマシンにフィードする suspend 関数を起動するラムダとして定義します。

これで完了です。リテラルとリアクティブ プログラミングの両方の観点から、機能的な ViewModel が作成されました。

8. Adapter を PagingData と連携させる

PagingDataRecyclerView にバインドするには、PagingDataAdapter を使用します。PagingDataAdapter は、PagingData コンテンツが読み込まれると通知を受け、RecyclerView に更新するよう伝えます。

ui.ReposAdapter を更新して PagingData ストリームと連携させる:

  • 現在、ReposAdapterListAdapter を実装していますが、これを PagingDataAdapter を実装するようにします。クラス本体の残りは変更しません。
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}

ここまでに多くの変更を加えてきましたが、もう一歩でアプリを実行できるところまで来ました。あとは UI を接続するだけです。

9. ネットワーク更新をトリガーする

LiveData を Flow に置き換える

SearchRepositoriesActivity を更新して Paging 3 で動作するようにしましょう。Flow<PagingData> を使用できるようにするために、新しいコルーチンを開始する必要があります。これは、アクティビティの再作成時にリクエストをキャンセルする役割を持つ lifecycleScope で行います。

幸い、大きな変更を行う必要はありません。LiveDataobserve() するのではなく、coroutinelaunch() して Flowcollect() します。UiStatePagingAdapter LoadState Flow と組み合わせることで、ユーザーがすでにスクロールしてしまった場合に、リストがスクロールされて一番上まで戻され PagingData が新しく出力されることがないようにします。

まず、状態を LiveData ではなく StateFlow として返すため、Activity から LiveData へのすべての参照を StateFlow に置き換え、pagingData Flow の引数も必ず追加する必要があります。最初の場所は bindState メソッドです。

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        ...
    }

ここでは bindSearch()bindList() を更新する必要があるため、この変更にはカスケード効果があります。変化が小さい bindSearch() から見ていきましょう。

   private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener {...}
        searchRepo.setOnKeyListener {...}

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

ここでの主な変更点は、コルーチンを起動して、UiState Flow からクエリの変更を収集する必要があることです。

スクロールの問題に対応し、データをバインドする

次にスクロール部分について説明します。まず、最後の 2 つの変更と同様に、LiveDataStateFlow に置き換え、pagingData Flow の引数を追加します。これで、スクロール リスナーに移ることができます。以前は、RecyclerView に接続されている OnScrollListener を使用して、追加データの取得をトリガーするタイミングが判断されていました。ページング ライブラリによってリスト スクロールが処理されますが、ユーザーが現在のクエリのためにリストをスクロールした場合のシグナルとして、引き続き OnScrollListener が必要になります。bindList() メソッドで、setupScrollListener() をインライン RecyclerView.OnScrollListener に置き換えてみましょう。また、setupScrollListener() メソッドを完全に削除します。

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        // the rest of the code is unchanged
    }

次に、shouldScrollToTop ブール値フラグを作成するようにパイプラインを設定します。これで、collect できる 2 つのフロー、PagingData FlowshouldScrollToTop Flow が作成されます。

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(...)
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., NotLoading.
            .map { it.source.refresh is LoadState.NotLoading }

        val hasNotScrolledForCurrentSearch = uiState
            .map { it.hasNotScrolledForCurrentSearch }
            .distinctUntilChanged()

        val shouldScrollToTop = combine(
            notLoading,
            hasNotScrolledForCurrentSearch,
            Boolean::and
        )
            .distinctUntilChanged()

        lifecycleScope.launch {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }

上記の例では、pagingData FlowcollectLatest を使用しているため、pagingData の新しい出力に基づいて pagingData の以前の出力の収集をキャンセルできます。shouldScrollToTop フラグの場合、PagingDataAdapter.loadStateFlow の出力は UI に表示される出力と同期されるため、出力されたブール値フラグが true であればすぐに list.scrollToPosition(0) を呼び出しても安全です。

LoadStateFlow の型は CombinedLoadStates オブジェクトです。

CombinedLoadStates を使用すると、次の 3 種類の読み込み操作での読み込み状態を取得できます。

  • CombinedLoadStates.refresh - PagingData を初めて読み込む際の読み込み状態を表します。
  • CombinedLoadStates.prepend - リストの先頭にデータを読み込む際の読み込み状態を表します。
  • CombinedLoadStates.append - リストの最後にデータを読み込む際の読み込み状態を表します。

今回は、更新が完了したときにだけスクロール位置をリセットします。したがって LoadStaterefreshNotLoading です。

これで、updateRepoListFromInput() から binding.list.scrollToPosition(0) を削除できるようになりました。

以上が完了すると、アクティビティは次のようになります。

class SearchRepositoriesActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        // get the view model
        val viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(owner = this))
            .get(SearchRepositoriesViewModel::class.java)

        // add dividers between RecyclerView's row items
        val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        binding.list.addItemDecoration(decoration)

        // bind the state
        binding.bindState(
            uiState = viewModel.state,
            pagingData = viewModel.pagingDataFlow,
            uiActions = viewModel.accept
        )
    }

    /**
     * Binds the [UiState] provided  by the [SearchRepositoriesViewModel] to the UI,
     * and allows the UI to feed back user actions to it.
     */
    private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter

        bindSearch(
            uiState = uiState,
            onQueryChanged = uiActions
        )
        bindList(
            repoAdapter = repoAdapter,
            uiState = uiState,
            pagingData = pagingData,
            onScrollChanged = uiActions
        )
    }

    private fun ActivitySearchRepositoriesBinding.bindSearch(
        uiState: StateFlow<UiState>,
        onQueryChanged: (UiAction.Search) -> Unit
    ) {
        searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }
        searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput(onQueryChanged)
                true
            } else {
                false
            }
        }

        lifecycleScope.launch {
            uiState
                .map { it.query }
                .distinctUntilChanged()
                .collect(searchRepo::setText)
        }
    }

    private fun ActivitySearchRepositoriesBinding.updateRepoListFromInput(onQueryChanged: (UiAction.Search) -> Unit) {
        searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                list.scrollToPosition(0)
                onQueryChanged(UiAction.Search(query = it.toString()))
            }
        }
    }

    private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
            }
        })
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., NotLoading.
            .map { it.source.refresh is LoadState.NotLoading }

        val hasNotScrolledForCurrentSearch = uiState
            .map { it.hasNotScrolledForCurrentSearch }
            .distinctUntilChanged()

        val shouldScrollToTop = combine(
            notLoading,
            hasNotScrolledForCurrentSearch,
            Boolean::and
        )
            .distinctUntilChanged()

        lifecycleScope.launch {
            pagingData.collectLatest(repoAdapter::submitData)
        }

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }
}

アプリのコンパイルと実行はできるはずですが、読み込み状態のフッターと、エラーで表示される Toast はありません。次のステップでは、読み込み状態のフッターを表示する方法を説明します。

ここまでのステップで完成したコード全体は、ブランチ step5-9_paging_3.0 にあります。

10. 読み込み状態をフッターに表示する

今回のアプリでは、読み込みステータスに基づいてフッターを表示する必要があります。つまり、リストの読み込み時には、進行状況スピナーを表示する必要があります。エラーの場合は、エラーと再試行ボタンを表示します。

3f6f2cd47b55de92.png 661da51b58c32b8c.png

作成する必要があるヘッダーやフッターは、現在表示されている項目の実際のリストの先頭(ヘッダーの場合)または最後(フッターの場合)に追加する必要があるリストという概念に合わせます。ヘッダーやフッターは 1 つの要素だけで構成されるリストで、Paging LoadState に基づいて、進行状況バー、またはエラーを再試行ボタンとともに表示するビューになります。

読み込み状態に基づいたヘッダーやフッターの表示と、再試行メカニズムの実装は一般的なタスクなので、Paging 3 API を両方の処理に利用できます。

ヘッダーやフッターの実装には、LoadStateAdapter を使用します。この RecyclerView.Adapter の実装には、読み込み状態の変化が自動的に通知されます。LoadingError の状態でのみ項目が表示され、LoadState に応じて、項目が削除、挿入、変更されたときに RecyclerView に通知されるようにします。

再試行メカニズムには、adapter.retry() を使用します。内部的には、このメソッドは適切なページに対して PagingSource の実装を呼び出します。レスポンスは Flow<PagingData> を経由して自動的に伝播されます。

ヘッダーとフッターの実装例を見てみましょう。

他のリストと同じように、次の 3 つのファイルを作成します。

  • レイアウト ファイル: 進行状況、エラー、再試行ボタンを表示する UI 要素を含んでいます。
  • **ViewHolder** **ファイル**: Paging LoadState に基づいて UI 項目を表示します。
  • アダプタ ファイル: ViewHolder を作成してバインドする方法を定義します。RecyclerView.Adapter を拡張する代わりに、Paging 3 の LoadStateAdapter を拡張します。

ビュー レイアウトを作成する

リポジトリの読み込み状態のための repos_load_state_footer_view_item レイアウトを作成します。ProgressBarTextView(エラー表示用)、再試行 Button が必要です。必要な文字列とディメンションは、プロジェクトで宣言済みです。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:padding="8dp">
    <TextView
        android:id="@+id/error_msg"
        android:textColor="?android:textColorPrimary"
        android:textSize="@dimen/error_text_size"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAlignment="center"
        tools:text="Timeout"/>
    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"/>
    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="@string/retry"/>
</LinearLayout>

ViewHolder を作成する

ui フォルダ内に ReposLoadStateViewHolder という新しい ViewHolder を作成します。これは、再試行関数をパラメータとして受け取り、再試行ボタンが押されたときに呼び出されます。LoadState をパラメータとして受け取り、LoadState に応じて各ビューの可視性を設定する bind() 関数を作成します。ViewBinding を使用した ReposLoadStateViewHolder の実装は、次のようになります。

class ReposLoadStateViewHolder(
        private val binding: ReposLoadStateFooterViewItemBinding,
        retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    init {
        binding.retryButton.setOnClickListener { retry.invoke() }
    }

    fun bind(loadState: LoadState) {
        if (loadState is LoadState.Error) {
            binding.errorMsg.text = loadState.error.localizedMessage
        }
        binding.progressBar.isVisible = loadState is LoadState.Loading
        binding.retryButton.isVisible = loadState is LoadState.Error
        binding.errorMsg.isVisible = loadState is LoadState.Error
    }

    companion object {
        fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.repos_load_state_footer_view_item, parent, false)
            val binding = ReposLoadStateFooterViewItemBinding.bind(view)
            return ReposLoadStateViewHolder(binding, retry)
        }
    }
}

LoadStateAdapter を作成する

ui フォルダに LoadStateAdapter を拡張する ReposLoadStateAdapter も作成します。再試行関数を構築時に ViewHolder に渡すため、アダプタはパラメータとして再試行関数を受け取る必要があります。

他の Adapter と同じように、onBind() メソッドと onCreate() メソッドを実装する必要があります。LoadStateAdapter は両方の関数で LoadState を渡すので、この実装が簡単になります。onBindViewHolder() では、ViewHolder をバインドします。onCreateViewHolder() では、親 ViewGroup と再試行関数に基づいて ReposLoadStateViewHolder を作成する方法を定義します。

class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<ReposLoadStateViewHolder>() {
    override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
        return ReposLoadStateViewHolder.create(parent, retry)
    }
}

フッター アダプタをリストにバインドする

これで、フッターのすべての要素ができたので、それらをリストにバインドしましょう。これを行うために、PagingDataAdapter には次の 3 つの便利なメソッドがあります。

  • withLoadStateHeader - ヘッダーのみを表示する場合。リストが先頭への項目の追加のみをサポートする場合に使用してください。
  • withLoadStateFooter - フッターのみを表示する場合。リストが末尾への項目の追加のみをサポートする場合に使用してください。
  • withLoadStateHeaderAndFooter - ヘッダーとフッターを表示する場合。リストが両方向にページングできる場合です。

ActivitySearchRepositoriesBinding.bindState() メソッドを更新し、アダプタで withLoadStateHeaderAndFooter() を呼び出します。再試行関数として、adapter.retry() を呼び出すことができます。

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { repoAdapter.retry() },
            footer = ReposLoadStateAdapter { repoAdapter.retry() }
        )
        ...
    }

無限スクロール リストがあるので、簡単にフッターを表示するには、スマートフォンまたはエミュレータを機内モードにして、リストの最後までスクロールします。

アプリを実行しましょう。

ここまでのステップで完成したコード全体は、ブランチ step10_loading_state_footer にあります。

11. Activity に読み込み状態を表示する

現在、以下のような 2 つの問題を抱えていることにお気づきでしょうか。

  • Paging 3 への移行に伴い、結果のリストが空の場合にメッセージを表示する機能が失われました。
  • 新しいクエリを検索するたびに、ネットワーク レスポンスが返されるまで現在のクエリ結果が画面に表示されます。これは好ましいユーザー エクスペリエンスとは言えません。進行状況バーや再試行ボタンを表示する必要があります。

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

この 2 つの問題を解決するには、SearchRepositoriesActivity で読み込み状態の変化に対応します。

空のリスト メッセージを表示する

まず、空のリスト メッセージを元に戻しましょう。このメッセージは、リストが読み込まれてリストのアイテムの数が 0 の場合にのみ表示されます。リストがいつ読み込まれたかを調べるには、PagingDataAdapter.loadStateFlow プロパティを使用します。この Flow は、読み込み状態の変更があるたびに、CombinedLoadStates オブジェクト経由で出力します。

CombinedLoadStates は、定義した PageSource の読み込み状態、またはネットワークとデータベースの場合に必要な RemoteMediator の読み込み状態を提供します(詳細は後述)。

SearchRepositoriesActivity.bindList() では、loadStateFlow から直接データを収集します。CombinedLoadStatesrefresh 状態が NotLoadingadapter.itemCount == 0 の場合、リストは空です。次に、emptyListlist の公開設定をそれぞれ切り替えます。

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !isListEmpty
                }
            }
        }
    }

読み込み状態を表示する

再試行ボタンと進行状況バーの UI 要素が含まれるように activity_search_repositories.xml を更新しましょう。

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.SearchRepositoriesActivity">
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/input_layout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginLeft="8dp"
        android:layout_marginRight="8dp"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">
        <EditText
            android:id="@+id/search_repo"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/search_hint"
            android:imeOptions="actionSearch"
            android:inputType="textNoSuggestions"
            android:selectAllOnFocus="true"
            tools:text="Android"/>
    </com.google.android.material.textfield.TextInputLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:paddingVertical="@dimen/row_item_margin_vertical"
        android:scrollbars="vertical"
        app:layoutManager="LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/input_layout"
        tools:ignore="UnusedAttribute"/>

    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/retry"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <TextView android:id="@+id/emptyList"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="@string/no_results"
        android:textSize="@dimen/repo_name_size"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

再試行ボタンは、PagingData の再読み込みをトリガーする必要があります。このために、ヘッダーやフッターの場合と同様に、onClickListener の実装で adapter.retry() を呼び出します。

// SearchRepositoriesActivity.kt

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        retryButton.setOnClickListener { repoAdapter.retry() }
        ...
}

次に、SearchRepositoriesActivity.bindList で読み込み状態の変化に反応するようにします。今回は新しいクエリで進行状況バーが表示されるだけでよいので、必要なのはページング ソースからの読み込みタイプ(具体的には CombinedLoadStates.source.refresh)と、LoadStateLoading または Error)です。また、前のステップでコメントアウトした機能に、エラー発生時の Toast の表示があったので、それも取り込みます。エラー メッセージを表示するために、LoadState.ErrorCombinedLoadStates.prependCombinedLoadStates.append のどちらの場合であるかを確認し、そのエラーからエラー メッセージを取得します。

SearchRepositoriesActivity メソッドの ActivitySearchRepositoriesBinding.bindList を更新して、この機能を実装しましょう。

private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...
        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds.
                list.isVisible = !isListEmpty
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.source.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                retryButton.isVisible = loadState.source.refresh is LoadState.Error

                // Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
                val errorState = loadState.source.append as? LoadState.Error
                    ?: loadState.source.prepend as? LoadState.Error
                    ?: loadState.append as? LoadState.Error
                    ?: loadState.prepend as? LoadState.Error
                errorState?.let {
                    Toast.makeText(
                        this@SearchRepositoriesActivity,
                        "\uD83D\uDE28 Wooops ${it.error}",
                        Toast.LENGTH_LONG
                    ).show()
                }
            }
        }
    }

アプリを実行して、動作を確認しましょう。

これで完了です。現在の構成では、各 Paging ライブラリ コンポーネントは、適切なタイミングで API リクエストをトリガーするコンポーネント、メモリ内キャッシュを処理するコンポーネント、データを表示するコンポーネントとなっています。アプリを実行して、リポジトリを検索してみてください。

ここまでのステップで完成したコード全体は、ブランチ step11_loading_state にあります。

12. セパレータの追加

セパレータを追加すると、リストが読みやすくなります。今回のアプリの場合、リポジトリはスターが多い順に並べられているので、スター 10,000 個ごとにセパレータを使用します。これを実装できるように、Paging 3 API でセパレータを PagingData に挿入できるようになっています。

573969750b4c719c.png

PagingData にセパレータを追加すると、画面に表示するリストが変更されます。Repo オブジェクトだけを表示するのではなく、セパレータ オブジェクトも表示するようにします。そのため、ViewModel から公開する UI モデルを、Repo から、RepoItemSeparatorItem をカプセル化できる別の型に変更する必要があります。次に、セパレータをサポートするよう、UI を更新する必要があります。

  • セパレータ用のレイアウトと ViewHolder を追加する。
  • RepoAdapter を更新して、セパレータとリポジトリの両方を作成しバインドできるようにする。

それでは手順ごとに、実装を見ていきましょう。

UI モデルを変更する

現在、SearchRepositoriesViewModel.searchRepo()Flow<PagingData<Repo>> を返します。リポジトリとセパレータの両方をサポートするために、SearchRepositoriesViewModel と同じファイル内に UiModel シールドクラスを作成します。UiModel オブジェクトには、RepoItemSeparatorItem の 2 種類があります。

sealed class UiModel {
    data class RepoItem(val repo: Repo) : UiModel()
    data class SeparatorItem(val description: String) : UiModel()
}

ここでは、スター 10,000 個ごとにリポジトリを分けるため、スター数を切り上げる拡張プロパティを RepoItem に作成しましょう。

private val UiModel.RepoItem.roundedStarCount: Int
    get() = this.repo.stars / 10_000

セパレータを挿入する

SearchRepositoriesViewModel.searchRepo()Flow<PagingData<UiModel>> を返すようになりました。

class SearchRepositoriesViewModel(
    private val repository: GithubRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    ...

    fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
        ...
    }
}

実装がどのように変わるかを見てみましょう。現在、repository.getSearchResultStream(queryString)Flow<PagingData<Repo>> を返すため、最初に追加する必要がある操作は、各 RepoUiModel.RepoItem に変換することです。このために、Flow.map 操作を使用して、マップ操作で PagingData ごとに現在の Repo 項目から新しい UiModel.Repo を作成し、結果を Flow<PagingData<UiModel.RepoItem>> に代入します。

...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
                .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
...

これでセパレータの挿入ができるようになりました。Flow からの出力ごとに、PagingData.insertSeparators() が呼び出されます。このメソッドは、元の要素に加え、前後の要素に基づいて、必要に応じて生成されるセパレータも含んだ PagingData を返します。境界条件(リストの先頭または末尾)では、前または後の要素は null になります。セパレータの作成が必要ない場合は、null を返します。

PagingData 要素の型を UiModel.Repo から UiModel に変更しようとしているので、insertSeparators() メソッドの型引数は明示的に設定してください。

searchRepo() メソッドは次のようになります。

   private fun searchRepo(queryString: String): Flow<PagingData<UiModel>> =
        repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators { before, after ->
                    if (after == null) {
                        // we're at the end of the list
                        return@insertSeparators null
                    }

                    if (before == null) {
                        // we're at the beginning of the list
                        return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                    }
                    // check between 2 items
                    if (before.roundedStarCount > after.roundedStarCount) {
                        if (after.roundedStarCount >= 1) {
                            UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                        } else {
                            UiModel.SeparatorItem("< 10.000+ stars")
                        }
                    } else {
                        // no separator
                        null
                    }
                }
            }

複数のビュータイプをサポートする

SeparatorItem オブジェクトを RecyclerView に表示する必要があります。ここでは文字列しか表示しないので、res/layout フォルダ内に TextViewseparator_view_item レイアウトを作成しましょう。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/separatorBackground">

    <TextView
        android:id="@+id/separator_description"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:padding="@dimen/row_item_margin_horizontal"
        android:textColor="@color/separatorText"
        android:textSize="@dimen/repo_name_size"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="10000+ stars" />
</androidx.constraintlayout.widget.ConstraintLayout>

ui フォルダに SeparatorViewHolder を作成しましょう。ここでは、文字列を TextView にバインドするだけです。

class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    private val description: TextView = view.findViewById(R.id.separator_description)

    fun bind(separatorText: String) {
        description.text = separatorText
    }

    companion object {
        fun create(parent: ViewGroup): SeparatorViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.separator_view_item, parent, false)
            return SeparatorViewHolder(view)
        }
    }
}

Repo の代わりに UiModel をサポートするように ReposAdapter を更新します。

  • PagingDataAdapter パラメータを Repo から UiModel に更新します。
  • UiModel コンパレータを実装し、REPO_COMPARATOR をそれで置き換えます。
  • SeparatorViewHolder を作成し、それを UiModel.SeparatorItem の説明にバインドします。

2 つの異なる ViewHolder を表示する必要があるので、RepoViewHolder を ViewHolder に置き換えます。

  • PagingDataAdapter パラメータを更新します。
  • onCreateViewHolder の戻り値の型を更新します。
  • onBindViewHolderholder パラメータを更新します。

ReposAdapter は次のようになります。

class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return if (viewType == R.layout.repo_view_item) {
            RepoViewHolder.create(parent)
        } else {
            SeparatorViewHolder.create(parent)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is UiModel.RepoItem -> R.layout.repo_view_item
            is UiModel.SeparatorItem -> R.layout.separator_view_item
            null -> throw UnsupportedOperationException("Unknown view")
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val uiModel = getItem(position)
        uiModel.let {
            when (uiModel) {
                is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo)
                is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description)
            }
        }
    }

    companion object {
        private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() {
            override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
                return (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem &&
                        oldItem.repo.fullName == newItem.repo.fullName) ||
                        (oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem &&
                                oldItem.description == newItem.description)
            }

            override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean =
                    oldItem == newItem
        }
    }
}

これで完了です。アプリを実行すると、セパレータの表示を確認できるはずです。

ここまでのステップで完成したコード全体は、ブランチ step12_separators にあります。

13. ネットワークとデータベースからのページング

ローカル データベースにデータを保存して、アプリにオフライン サポートを追加しましょう。そうすることで、データベースがアプリの信頼できるソースになり、常にそこからデータを読み込むようになります。それ以上データがない場合には、ネットワークにリクエストしてデータベースに保存します。データベースが信頼できるソースであるため、さらにデータが保存されると UI が自動的に更新されます。

オフライン サポートを追加するには、次が必要です。

  1. Room データベース、Repo オブジェクトを保存するテーブル、Repo オブジェクトを処理する DAO をそれぞれ作成すること。
  2. RemoteMediator を実装することにより、データベース内のデータの最後に到達したときに、ネットワークからデータを読み込む方法を定義すること。
  3. データソースとして Repo テーブルに基づく Pager を作成し、データの読み込みと保存用に RemoteMediator を作成すること。

以上を手順ごとに進めていきましょう。

14. Room データベース、テーブル、DAO を定義する

Repo オブジェクトをデータベースに保存する必要があるので、まず Repo クラスを、tableName = "repos"Repo.id を主キーとするエンティティにします。そのために、Repo クラスに @Entity(tableName = "repos") アノテーションを付け、@PrimaryKey アノテーションを id に追加します。Repo クラスは次のようになります。

@Entity(tableName = "repos")
data class Repo(
    @PrimaryKey @field:SerializedName("id") val id: Long,
    @field:SerializedName("name") val name: String,
    @field:SerializedName("full_name") val fullName: String,
    @field:SerializedName("description") val description: String?,
    @field:SerializedName("html_url") val url: String,
    @field:SerializedName("stargazers_count") val stars: Int,
    @field:SerializedName("forks_count") val forks: Int,
    @field:SerializedName("language") val language: String?
)

新しいパッケージ db を作成します。ここで、データベースのデータにアクセスするクラスと、データベースを定義するクラスを実装します。

@Dao アノテーションが付いた RepoDao インターフェースを作成して、repos テーブルにアクセスするデータ アクセス オブジェクト(DAO)を実装します。Repo に対して、次の作業が必要です。

  • Repo オブジェクトのリストを挿入します。テーブルにすでに Repo オブジェクトがある場合は、それらを置き換えます。
 @Insert(onConflict = OnConflictStrategy.REPLACE)
 suspend fun insertAll(repos: List<Repo>)
  • クエリ文字列を名前または説明に含むリポジトリをクエリして、その結果をスター数の降順、その次に名前のアルファベット順で並び替えます。List<Repo> を返すのではなく、PagingSource<Int, Repo> を返します。これにより、Paging のデータソースが repos テーブルになります。
@Query("SELECT * FROM repos WHERE " +
  "name LIKE :queryString OR description LIKE :queryString " +
  "ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
  • Repos テーブルのデータをすべて消去します。
@Query("DELETE FROM repos")
suspend fun clearRepos()

RepoDao は次のようになります。

@Dao
interface RepoDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(repos: List<Repo>)

    @Query("SELECT * FROM repos WHERE " +
   "name LIKE :queryString OR description LIKE :queryString " +
   "ORDER BY stars DESC, name ASC")
    fun reposByName(queryString: String): PagingSource<Int, Repo>

    @Query("DELETE FROM repos")
    suspend fun clearRepos()
}

Repo データベースを実装します。

  • RoomDatabase を拡張する抽象クラス RepoDatabase を作成します。
  • クラスに @Database アノテーションを付け、エンティティのリストに Repo クラスを追加して、データベースのバージョンを 1 に設定します。この Codelab では、スキーマをエクスポートする必要はありません。
  • ReposDao を返す抽象関数を定義します。
  • RepoDatabase オブジェクトが存在しない場合にそれを作成する getInstance() 関数を companion object に作成します。

RepoDatabase は次のようになります。

@Database(
    entities = [Repo::class],
    version = 1,
    exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao

    companion object {

        @Volatile
        private var INSTANCE: RepoDatabase? = null

        fun getInstance(context: Context): RepoDatabase =
                INSTANCE ?: synchronized(this) {
                    INSTANCE
                            ?: buildDatabase(context).also { INSTANCE = it }
                }

        private fun buildDatabase(context: Context) =
                Room.databaseBuilder(context.applicationContext,
                        RepoDatabase::class.java, "Github.db")
                        .build()
    }
}

データベースの設定が終わったので、次はネットワークからのデータをリクエストし、データベースに保存する方法を確認しましょう。

15. データのリクエストと保存 - 概要

Paging ライブラリは、UI に表示する必要があるデータの信頼できるソースとしてデータベースを使用します。データベースにそれ以上データがない場合は、ネットワークからのデータをリクエストする必要があります。これを実現するため、Paging 3 では RemoteMediator 抽象クラスを定義します。このクラスには load() というメソッドを実装する必要があります。このメソッドは、ネットワークからのデータをさらに読み込むときに呼び出されます。このクラスは、次のいずれかの MediatorResult オブジェクトを返します。

  • Error - ネットワークからのデータのリクエスト中にエラーが発生した場合。
  • Success - ネットワークからのデータが正常に取得された場合。ここでは、さらにデータを読み込むことが可能かどうかを示す情報を渡す必要があります。たとえば、ネットワーク レスポンスは成功でも、リポジトリのリストが空の場合、それ以上読み込むデータがないということです。

data パッケージでは、RemoteMediator を拡張する GithubRemoteMediator という新しいクラスを作成します。このクラスは新規のクエリごとに再作成されるため、次をパラメータとして受け取ります。

  • String - クエリ文字列です。
  • GithubService - ネットワーク リクエストを行うためです。
  • RepoDatabase - ネットワーク リクエストから取得したデータを保存するためです。
@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
    private val query: String,
    private val service: GithubService,
    private val repoDatabase: RepoDatabase
) : RemoteMediator<Int, Repo>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {

   }
}

ネットワーク リクエストを作成できるように、読み込みメソッドには、必要な情報をすべて提供する 2 つのパラメータがあります。

  • PagingState - 以前に読み込まれたページ、最後にアクセスされたリストのインデックス、ページング ストリームの初期化時に定義した PagingConfig のそれぞれに関する情報を提供します。
  • LoadType - 前回読み込んだデータの末尾にデータを読み込む必要があるのか(LoadType.APPEND)、そのデータの先頭に読み込む必要があるのか(LoadType.PREPEND)、初めてデータを読み込むのか(LoadType.REFRESH)を示します。

たとえば、読み込みタイプが LoadType.APPEND の場合は、PagingState から読み込まれた最後の項目を取得します。その結果に基づき、次に読み込むページを計算することで、Repo オブジェクトの次のバッチを読み込む方法がわかります。

次のセクションでは、読み込む次のページと前のページのキーを計算する方法について説明します。

16. リモート ページキーの計算と保存

Github API の目的上、リポジトリのページをリクエストするために使用するページキーは、次のページを取得する際にインクリメントされるページ インデックスにすぎません。つまり、Repo オブジェクトの場合、ページ インデックス + 1 を起点にして、Repo オブジェクトの後方バッチをリクエストします。Repo オブジェクトの前方バッチは、ページ インデックス - 1 を起点にしてリクエストします。特定のページ レスポンスで受信したすべての Repo オブジェクトは、同じ次キーと前キーを持ちます。

最後の項目を PagingState から読み込んだ場合、それが属しているページのインデックスを知る方法はありません。この問題を解決するために、Repo ごとに次ページキーと前ページキーを格納する別のテーブルを追加します。これを remote_keys とします。これは Repo テーブルでも可能ですが、Repo に関連付けられた次リモートキーと前リモートキー用に新しいテーブルを作成すると、分離の問題が改善します。

db パッケージで RemoteKeys という新しいデータクラスを作成し、@Entity アノテーションを付け、3 つのプロパティ、リポジトリ id(主キーでもある)、前キーと次キー(データを前または後に追加できない場合には null)を追加します。

@Entity(tableName = "remote_keys")
data class RemoteKeys(
    @PrimaryKey
    val repoId: Long,
    val prevKey: Int?,
    val nextKey: Int?
)

RemoteKeysDao インターフェースを作成しましょう。次の機能が必要です。

  • **RemoteKeys** のリストを挿入する。ネットワークから Repos を取得するたびに、それらからリモートキーが生成されるため。
  • Repo id に基づく **RemoteKey** を取得する
  • **RemoteKeys** をクリアする。新しいクエリがあるときに使用。
@Dao
interface RemoteKeysDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(remoteKey: List<RemoteKeys>)

    @Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
    suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?

    @Query("DELETE FROM remote_keys")
    suspend fun clearRemoteKeys()
}

RemoteKeys テーブルをデータベースに追加して、RemoteKeysDao にアクセスできるようにしましょう。このために、RepoDatabase を次のように更新します。

  • RemoteKeys をエンティティのリストに追加します。
  • RemoteKeysDao を抽象関数として公開します。
@Database(
        entities = [Repo::class, RemoteKeys::class],
        version = 1,
        exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao
    abstract fun remoteKeysDao(): RemoteKeysDao

    ...
    // rest of the class doesn't change
}

17. データのリクエストと保存 - 実装

リモートキーを保存したので、GithubRemoteMediator に戻って、使い方を確認しましょう。このクラスは、GithubPagingSource に代わるものです。GithubPagingSource から GITHUB_STARTING_PAGE_INDEX の宣言を GithubRemoteMediator にコピーして、GithubPagingSource クラスを削除します。

GithubRemoteMediator.load() メソッドの実装方法を見てみましょう。

  1. LoadType に基づいて、ネットワークから読み込む必要のあるページを割り出します。
  2. ネットワーク リクエストをトリガーします。
  3. ネットワーク リクエストが完了して、受信したリポジトリのリストが空でない場合は、次を行います。
  4. Repo ごとに RemoteKeys を計算します。
  5. これが新しいクエリ(loadType = REFRESH)である場合は、データベースをクリアします。
  6. RemoteKeysRepos をデータベースに保存します。
  7. MediatorResult.Success(endOfPaginationReached = false) を返します。
  8. リポジトリのリストが空の場合は、MediatorResult.Success(endOfPaginationReached = true) を返します。データのリクエスト中にエラーが発生した場合は、MediatorResult.Error を返します。

コード全体は次のようになります。TODO は後で置き換えます。

override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
    val page = when (loadType) {
        LoadType.REFRESH -> {
         // TODO
        }
        LoadType.PREPEND -> {
        // TODO
        }
        LoadType.APPEND -> {
        // TODO
        }
    }
    val apiQuery = query + IN_QUALIFIER

    try {
        val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)

        val repos = apiResponse.items
        val endOfPaginationReached = repos.isEmpty()
        repoDatabase.withTransaction {
            // clear all tables in the database
            if (loadType == LoadType.REFRESH) {
                repoDatabase.remoteKeysDao().clearRemoteKeys()
                repoDatabase.reposDao().clearRepos()
            }
            val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
            val nextKey = if (endOfPaginationReached) null else page + 1
            val keys = repos.map {
                RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
            }
            repoDatabase.remoteKeysDao().insertAll(keys)
            repoDatabase.reposDao().insertAll(repos)
        }
        return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
    } catch (exception: IOException) {
        return MediatorResult.Error(exception)
    } catch (exception: HttpException) {
        return MediatorResult.Error(exception)
    }
}

LoadType に基づいて読み込むページを割り出す方法を確認しましょう。

18. LoadType に基づいてページを取得する

ページキーがあると GithubRemoteMediator.load() メソッドで何が起きるかがわかったので、次はその計算方法を見てみましょう。これは LoadType によって異なります。

LoadType.APPEND

現在読み込まれているデータセットの最後にデータを読み込む必要がある場合、読み込みパラメータは LoadType.APPEND です。したがって、データベースの最後の項目をベースにしてネットワーク ページキーを計算する必要があります。

  1. データベースから読み込まれた最後Repo 項目のリモートキーを取得する必要があります。これを関数として分離しましょう。
    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Repo>): RemoteKeys? {
        // Get the last page that was retrieved, that contained items.
        // From that last page, get the last item
        return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
                ?.let { repo ->
                    // Get the remote keys of the last item retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
                }
    }
  1. remoteKeys が null の場合は、まだ更新結果がデータベースに存在しません。RemoteKeys が null でない場合、Paging はこのメソッドを再度呼び出すので、endOfPaginationReached = false で Success を返すことができます。remoteKeys が null ではなく nextKeynull の場合は、APPEND のページ分けが最後に到達していることを表します。
val page = when (loadType) {
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        // We can return Success with endOfPaginationReached = false because Paging
        // will call this method again if RemoteKeys becomes non-null.
        // If remoteKeys is NOT NULL but its nextKey is null, that means we've reached
        // the end of pagination for append.
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
      ...
  }

LoadType.PREPEND

現在読み込まれているデータセットの先頭にデータを読み込む場合、読み込みパラメータは LoadType.PREPEND です。データベースの最初の項目に基づいて、ネットワーク ページキーを計算する必要があります。

  1. データベースから読み込まれた最初Repo 項目のリモートキーを取得する必要があります。これを関数として分離しましょう。
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Repo>): RemoteKeys? {
    // Get the first page that was retrieved, that contained items.
    // From that first page, get the first item
    return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
            ?.let { repo ->
                // Get the remote keys of the first items retrieved
                repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
            }
}
  1. remoteKeys が null の場合は、まだ更新結果がデータベースに存在しません。RemoteKeys が null でない場合、Paging はこのメソッドを再度呼び出すので、endOfPaginationReached = false で Success を返すことができます。remoteKeys が null ではなく prevKeynull の場合は、PREPEND のページ分けが最後に到達していることを表します。
val page = when (loadType) {
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }

      ...
  }

LoadType.REFRESH

LoadType.REFRESH は、データを初めて読み込むとき、または PagingDataAdapter.refresh() が呼ばれたときに呼び出されます。そのため、データ読み込みの参照ポイントは state.anchorPosition になります。これが最初の読み込みの場合、anchorPositionnull です。PagingDataAdapter.refresh() が呼ばれた場合、表示されたリスト内で最初の可視になっている位置が anchorPosition になるため、その項目を含むページを読み込む必要があります。

  1. stateanchorPosition に基づいて、state.closestItemToPosition() を呼び出すことで、その位置に最も近い Repo 項目を取得できます。
  2. Repo 項目に基づいて、データベースから RemoteKeys を取得できます。
private suspend fun getRemoteKeyClosestToCurrentPosition(
        state: PagingState<Int, Repo>
): RemoteKeys? {
    // The paging library is trying to load data after the anchor position
    // Get the item closest to the anchor position
    return state.anchorPosition?.let { position ->
        state.closestItemToPosition(position)?.id?.let { repoId ->
   repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId)
        }
    }
}
  1. remoteKey が null ではない場合は、そこから nextKey を取得できます。Github API では、ページキーが連続的にインクリメントされます。したがって、現在の項目が含まれているページを取得するには、remoteKey.nextKey から 1 を引くだけです。
  2. RemoteKeynull の場合(anchorPositionnull だったため)、読み込むページは最初のページ(GITHUB_STARTING_PAGE_INDEX)です。

ページの計算全体は次のようになります。

val page = when (loadType) {
    LoadType.REFRESH -> {
        val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
        remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX
    }
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
}

19. ページング Flow の作成を更新する

ReposDaoGithubRemoteMediatorPagingSource を実装したので、それらを使用するように GithubRepository.getSearchResultStream を更新する必要があります。

これには、GithubRepository がデータベースにアクセスできる必要があります。コンストラクタにデータベースをパラメータとして渡しましょう。このクラスが GithubRemoteMediator を使用することも、その理由です。

class GithubRepository(
        private val service: GithubService,
        private val database: RepoDatabase
) { ... }

次のように Injection ファイルを更新します。

  • provideGithubRepository メソッドはコンテキストをパラメータとして受け取り、GithubRepository コンストラクタで RepoDatabase.getInstance を呼び出します。
  • provideViewModelFactory メソッドはコンテキストをパラメータとして受け取り、それを provideGithubRepository に渡す必要があります。
object Injection {
    private fun provideGithubRepository(context: Context): GithubRepository {
        return GithubRepository(GithubService.create(), RepoDatabase.getInstance(context))
    }

    fun provideViewModelFactory(context: Context, owner: SavedStateRegistryOwner): ViewModelProvider.Factory {
        return ViewModelFactory(owner, provideGithubRepository(context))
    }
}

SearchRepositoriesActivity.onCreate() メソッドを更新して、コンテキストを Injection.provideViewModelFactory() に渡します。

       // get the view model
        val viewModel = ViewModelProvider(
            this, Injection.provideViewModelFactory(
                context = this,
                owner = this
            )
        )
            .get(SearchRepositoriesViewModel::class.java)

GithubRepository に戻りましょう。まず、リポジトリを名前で検索できるように、クエリ文字列の先頭と末尾に % を追加する必要があります。次に、reposDao.reposByName を呼び出すとき、PagingSource を取得します。データベースに変更を加えるたびに PagingSource が無効にされるため、PagingSource の新しいインスタンスを取得する方法を Paging に教える必要があります。このために、データベース クエリを呼び出す関数を作成します。

// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory =  { database.reposDao().reposByName(dbQuery)}

ここで、GithubRemoteMediatorpagingSourceFactory を使用するように Pager ビルダーを変更します。Pager は試験運用版の API であるため、@OptIn アノテーションを付ける必要があります。

@OptIn(ExperimentalPagingApi::class)
return Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
        remoteMediator = GithubRemoteMediator(
                query,
                service,
                database
        ),
        pagingSourceFactory = pagingSourceFactory
).flow

これで完了です。アプリを実行しましょう。

RemoteMediator 使用時の読み込み状態への対応

これまで、CombinedLoadStates からの読み取りでは、常に CombinedLoadStates.source からの読み取りを行っていました。ただし、RemoteMediator を使用する場合、正確な読み込み情報を取得するには、CombinedLoadStates.sourceCombinedLoadStates.mediator の両方を確認する必要があります。具体的には、現在、source LoadStateNotLoading のときに新しいクエリのリストの一番上までスクロールされるようトリガーしています。新たに追加した RemoteMediatorLoadStateNotLoading であることを確認する必要もあります。

そのためには、Pager が取得するリストの状態をまとめた列挙型を定義します。

enum class RemotePresentationState {
    INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}

上記の定義により、CombinedLoadStates の連続した出力を比較し、それらを使ってリスト内の項目の正確な状態を特定できます。

@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<CombinedLoadStates>.asRemotePresentationState(): Flow<RemotePresentationState> =
    scan(RemotePresentationState.INITIAL) { state, loadState ->
        when (state) {
            RemotePresentationState.PRESENTED -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.INITIAL -> when (loadState.mediator?.refresh) {
                is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
                else -> state
            }
            RemotePresentationState.REMOTE_LOADING -> when (loadState.source.refresh) {
                is LoadState.Loading -> RemotePresentationState.SOURCE_LOADING
                else -> state
            }
            RemotePresentationState.SOURCE_LOADING -> when (loadState.source.refresh) {
                is LoadState.NotLoading -> RemotePresentationState.PRESENTED
                else -> state
            }
        }
    }
        .distinctUntilChanged()

上記の例で、notLoading Flow の定義を更新して、リストの一番上までスクロールできるかどうかをチェックできます。

       val notLoading = repoAdapter.loadStateFlow
            .asRemotePresentationState()
            .map { it == RemotePresentationState.PRESENTED }

同様に、最初のページ読み込み中(SearchRepositoriesActivitybindList 拡張機能内)に読み込みスピナーを表示する場合も、アプリは LoadState.source に依存します。ここでの目的は、RemoteMediator からの読み込みに限り、読み込みスピナーを表示することです。表示が LoadStates に依存する他の UI 要素でも、この問題は共有されています。そのため、LoadStates の UI 要素へのバインディングを次のように更新します。

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                ...
                val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
                // show empty list
                emptyList.isVisible = isListEmpty
                // Only show the list if refresh succeeds, either from the the local db or the remote.
                list.isVisible =  loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
                // Show loading spinner during initial load or refresh.
                progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
                // Show the retry state if initial load or refresh fails.
                retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
                }
            }
        }
    }

さらに、データベースを信頼できる唯一の情報源として使用しているため、データベース内にデータがある状態でアプリを起動することは可能ですが、RemoteMediator を使用した更新は失敗します。これは興味深いエッジケースですが、処理は難しくありません。その際は、ヘッダー LoadStateAdapter への参照を保持し、更新状態にエラーがある場合のみ、その LoadState を RemoteMediator の状態に置き換えることができます。それ以外の場合はデフォルト値が使用されます。

private fun ActivitySearchRepositoriesBinding.bindList(
        header: ReposLoadStateAdapter,
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        onScrollChanged: (UiAction.Scroll) -> Unit
    ) {
        ...

        lifecycleScope.launch {
            repoAdapter.loadStateFlow.collect { loadState ->
                // Show a retry header if there was an error refreshing, and items were previously
                // cached OR default to the default prepend state
                header.loadState = loadState.mediator
                    ?.refresh
                    ?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
                    ?: loadState.prepend
                ...
            }
        }
    }

ここまでのステップで完成したコード全体は、ブランチ step13-19_network_and_database にあります。

20. まとめ

すべてのコンポーネントを追加したところで、学んだことを復習しましょう。

  • PagingSource は、定義したソースからデータを非同期で読み込みます。
  • Pager.flow は、PagingSource のインスタンス化方法を定義する設定と関数に基づいて Flow<PagingData> を作成します。
  • PagingSource が新しいデータを読み込むたびに、Flow が新しい PagingData を出力します。
  • UI は PagingData の変更を監視し、PagingDataAdapter を使用して、データを提示する RecyclerView を更新します。
  • UI から失敗した読み込みを再試行するには、PagingDataAdapter.retry メソッドを使用します。内部的には、Paging ライブラリが PagingSource.load() メソッドをトリガーします。
  • セパレータをリストに追加するには、セパレータをサポートされているタイプの一つとして、上位レベルのタイプを作成します。次に、PagingData.insertSeparators() メソッドを使用して、セパレータ生成ロジックを実装します。
  • 読み込み状態をヘッダーまたはフッターとして表示するには、PagingDataAdapter.withLoadStateHeaderAndFooter() メソッドを使用し、LoadStateAdapter を実装します。読み込み状態に基づいて別のアクションを実行する場合は、PagingDataAdapter.addLoadStateListener() コールバックを使用します。
  • ネットワークとデータベースを扱うには、RemoteMediator を実装します。
  • RemoteMediator を追加すると、LoadStatesFlowmediator フィールドが更新されます。