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 つのフロー、