Android ページング

学習内容

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

作成するアプリの概要

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

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

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

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

e662a697dd078356.png

必要なもの

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

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

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

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

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

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

  • ブランチ step5-9_paging_3.0 - Paging 3.0 をプロジェクトに追加する、ステップ 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 バージョン 3.6.1 以降で開きます。
  2. デバイスまたはエミュレータで app 実行構成を実行します。

b3c0dfdb92dfed77.png

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

86fcb1b9b845c2f6.png

このアプリでは、名前や説明に指定した単語を含むリポジトリを 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 の内容が画面に表示されます。

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

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

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

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

以上の問題に対して 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 では、上記の各コンポーネントの例を実装します。

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 には、loadgetRefreshKey の 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)
        }
    }

}

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

RepoSearchResult は、成功とエラーの両方のケースをカプセル化しています。成功の場合は、リポジトリ データが保持されています。エラーの場合は、Exception の理由が含まれています。Paging 3.0 では、ライブラリが 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.0 では、さまざまな処理を行っています。

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

したがって、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 {
        private const val NETWORK_PAGE_SIZE = 50
    }
}

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

SearchRepositoriesViewModel から repoResult: LiveData<RepoSearchResult> を公開しています。repoResult の役割は、構成変更後も維持される検索結果用のメモリ内キャッシュであることです。Paging 3.0 では、FlowLiveData に変換する必要がなくなりました。代わりに、SearchRepositoriesViewModel には、repoResult と同じ目的を果たす非公開の Flow<PagingData<Repo>> メンバーを追加します。

新しいクエリごとに LiveData オブジェクトを使用する代わりに、String を使用します。これにより、新たに与えられた検索クエリが現在のクエリと同じ場合に、既存の Flow が返されるようになります。repository.getSearchResultStream() を呼び出す必要があるのは、新しい検索クエリが異なる場合だけです。

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

Paging 3.0 の組み込みの機能を活用するために、SearchRepositoriesViewModel の大部分を書き換えます。SearchRepositoriesViewModel は次のようになります。

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<Repo>>? = null

    fun searchRepo(queryString: String): Flow<PagingData<Repo>> {
        val lastResult = currentSearchResult
        if (queryString == currentQueryValue && lastResult != null) {
            return lastResult
        }
        currentQueryValue = queryString
        val newResult: Flow<PagingData<Repo>> = repository.getSearchResultStream(queryString)
                .cachedIn(viewModelScope)
        currentSearchResult = newResult
        return newResult
    }
}

SearchRepositoriesViewModel の変更内容を確認しましょう。

  • 新しいクエリ String と検索結果 Flow メンバーを追加しました。
  • 前述の機能で searchRepo() メソッドを更新しました。
  • queryLiveDatarepoResult の目的は、Paging 3.0 と Flow で達成されているため、それらを削除しました。
  • Paging ライブラリで処理されるため、listScrolled() を削除しました。
  • VISIBLE_THRESHOLD が不要になるため、companion object を削除しました。

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

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

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

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

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

また、新しいクエリで検索されたとき、前のクエリをキャンセルする必要もあります。このために、新しいクエリを検索するたびにキャンセルされる新しい Job への参照を SearchRepositoriesActivity に保持します。

クエリをパラメータとして取得する新しい検索関数を作成しましょう。この関数は、次の処理を行う必要があります。

  • 前の検索ジョブをキャンセルする。
  • lifecycleScope で新しいジョブを開始する。
  • viewModel.searchRepo を呼び出す。
  • PagingData の結果を収集する。
  • adapter.submitData(pagingData) を呼び出して PagingDataReposAdapter に渡す。
private var searchJob: Job? = null

private fun search(query: String) {
   // Make sure we cancel the previous job before creating a new one
   searchJob?.cancel()
   searchJob = lifecycleScope.launch {
       viewModel.searchRepo(query).collectLatest {
           adapter.submitData(it)
       }
   }
}

検索関数は、SearchRepositoriesActivity 内の onCreate() メソッドで呼び出す必要があるので、updateRepoListFromInput() の中で viewModeladapter の呼び出しを search() に置き換えます。

private fun updateRepoListFromInput() {
    binding.searchRepo.text.trim().let {
        if (it.isNotEmpty()) {
            binding.list.scrollToPosition(0)
            search(it.toString())
        }
    }
}

以前は、新規の検索ごとにスクロール位置をリセットしたかったので、binding.list.scrollToPosition(0) としていました。しかし、今回は、新規の検索で位置をリセットするのではなく、リストアダプタが新しい検索の結果に更新されたときに位置をリセットする必要があります。これを実現するために、PagingDataAdapter.loadStateFlow API を使用します。この Flow は、読み込み状態の変更があるたびに、CombinedLoadStates オブジェクト経由で出力します。

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

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

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

検索の初期化時にこのフローから収集し、initSearch メソッドで、フローが新規に出力するたびに位置 0 にスクロールしましょう。

private fun initSearch(query: String) {
    ...
    // First part of the method is unchanged

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                   // Only emit when REFRESH LoadState changes.
                   .distinctUntilChangedBy { it.refresh }
                   // Only react to cases where REFRESH completes i.e., NotLoading.
                   .filter { it.refresh is LoadState.NotLoading }
                   .collect { binding.list.scrollToPosition(0) }
        }
}

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

現在、RecyclerView に接続されている OnScrollListener を使用して、追加データの取得をトリガーするタイミングがわかるようにしています。Paging ライブラリがリストのスクロールを処理するようにします。setupScrollListener() メソッドとそれへの参照をすべて削除します。

repoResult の使用も削除しましょう。アクティビティは次のようになります。

class SearchRepositoriesActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySearchRepositoriesBinding
    private lateinit var viewModel: SearchRepositoriesViewModel
    private val adapter = ReposAdapter()

    private var searchJob: Job? = null

    private fun search(query: String) {
        // Make sure we cancel the previous job before creating a new one
        searchJob?.cancel()
        searchJob = lifecycleScope.launch {
            viewModel.searchRepo(query).collect {
                adapter.submitData(it)
            }
        }
    }

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

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

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

        initAdapter()
        val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        search(query)
        initSearch(query)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString(LAST_SEARCH_QUERY, binding.searchRepo.text.trim().toString())
    }

    private fun initAdapter() {
        binding.list.adapter = adapter
    }

    private fun initSearch(query: String) {
        binding.searchRepo.setText(query)

        binding.searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }
        binding.searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                    // Only emit when REFRESH LoadState for RemoteMediator changes.
                    .distinctUntilChangedBy { it.refresh }
                    // Only react to cases where Remote REFRESH completes i.e., NotLoading.
                    .filter { it.refresh is LoadState.NotLoading }
                    .collect { binding.list.scrollToPosition(0) }
        }
    }

    private fun updateRepoListFromInput() {
        binding.searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                search(it.toString())
            }
        }
    }

    private fun showEmptyList(show: Boolean) {
        if (show) {
            binding.emptyList.visibility = View.VISIBLE
            binding.list.visibility = View.GONE
        } else {
            binding.emptyList.visibility = View.GONE
            binding.list.visibility = View.VISIBLE
        }
    }

    companion object {
        private const val LAST_SEARCH_QUERY: String = "last_search_query"
        private const val DEFAULT_QUERY = "Android"
    }
}

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

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

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

3f6f2cd47b55de92.png 661da51b58c32b8c.png

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

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

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

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

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

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

  • レイアウト ファイル: 進行状況、エラー、再試行ボタンを表示する UI 要素を含んでいます。
  • ViewHolder ファイル: Paging LoadState に基づいて UI 項目を表示します。
  • アダプタ ファイル: ViewHolder を作成してバインドする方法を定義します。RecyclerView.Adapter を拡張する代わりに、Paging 3.0 の 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 - ヘッダーとフッターを表示する場合。リストが両方向にページングできる場合です。

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

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
}

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

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

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

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

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

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

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

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

まず、空のリスト メッセージを元に戻しましょう。このメッセージは、リストが読み込まれてリストのアイテムの数が 0 の場合にのみ表示されます。リストがいつ読み込まれたかを調べるには、PagingDataAdapter.addLoadStateListener() メソッドを使用します。このコールバックは、読み込み状態の変更のたびに CombinedLoadStates オブジェクト経由で通知します。

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

SearchRepositoriesActivity.initAdapter()addLoadStateListener を呼び出します。CombinedLoadStatesrefresh 状態が NotLoadingadapter.itemCount == 0 の場合、リストは空です。次に showEmptyList を呼び出します。

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(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

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    binding.retryButton.setOnClickListener { adapter.retry() }
}

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

SearchRepositoriesActivity.initAdapter メソッドを更新して、この機能を実装します。

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isListEmpty)

        // Only show the list if refresh succeeds.
        binding.list.isVisible = loadState.source.refresh is LoadState.NotLoading
        // Show loading spinner during initial load or refresh.
        binding.progressBar.isVisible = loadState.source.refresh is LoadState.Loading
        // Show the retry state if initial load or refresh fails.
        binding.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,
                    "\uD83D\uDE28 Wooops ${it.error}",
                    Toast.LENGTH_LONG
            ).show()
        }
    }
}

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

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

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

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

170f5fa2945e7d95.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>> を返すようになりました。currentSearchResult を同じ型にします。

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<UiModel>>? = null

    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() メソッドは次のようになります。

fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
    val lastResult = currentSearchResult
    if (queryString == currentQueryValue && lastResult != null) {
        return lastResult
    }
    currentQueryValue = queryString
    val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators<UiModel.RepoItem, UiModel> { 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
                    }
                }
            }
            .cachedIn(viewModelScope)
    currentSearchResult = newResult
    return newResult
}

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

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 にあります。

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

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

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

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

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()
    }
}

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

Paging ライブラリは、UI に表示する必要があるデータの信頼できるソースとしてデータベースを使用します。データベースにそれ以上データがない場合は、ネットワークからのデータをリクエストする必要があります。これを実現するため、Paging 3.0 では 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 オブジェクトの次のバッチを読み込む方法がわかります。

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

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
}

リモートキーを保存したので、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 に基づいて読み込むページを割り出す方法を確認しましょう。

ページキーがあると 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 ではなく prevKeynull の場合は、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 prevKey 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 の場合は、APPEND のページ分けが最後に到達していることを表します。
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.
        // 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 prevKey is null, that means we've reached
        // the end of pagination for prepend.
        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
    }
}

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): ViewModelProvider.Factory {
        return ViewModelFactory(provideGithubRepository(context))
    }
}

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

// get the view model
viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(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

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

読み込み状態ソースの更新

現時点では、アプリはネットワークからデータを読み込み、データベースに保存します。ただし、最初のページの読み込み中(SearchRepositoriesActivity.initAdapter 内)に読み込みスピナーを表示する場合は、そのまま LoadState.source が使用されます。ここでの目的は、RemoteMediator からの読み込みに限り、読み込みスピナーを表示することです。そのためには、LoadState.source から LoadState.mediator に変更する必要があります。

private fun initAdapter() {
         ...
        adapter.addLoadStateListener { loadState ->
            // Only show the list if refresh succeeds.
            binding.list.isVisible = loadState.mediator?.refresh is LoadState.NotLoading
            // Show loading spinner during initial load or refresh.
            binding.progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
            // Show the retry state if initial load or refresh fails.
            binding.retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error
            ... // everything else stays the same
    }

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

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

  • 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 を実装します。