1. はじめに
学習内容
- Paging 3 の主なコンポーネントについて
- プロジェクトに Paging 3 を追加する方法
- Paging 3 API を使用してヘッダーやフッターをリストに追加する方法
- Paging 3 API を使用してリスト セパレータを追加する方法
- ネットワークとデータベースからページングする方法
作成するアプリの概要
この Codelab では、すでに GitHub リポジトリのリストを表示するようになっているサンプルアプリを使用します。表示されているリストの最後にスクロールすると、新しいネットワーク リクエストがトリガーされ、その結果が画面に表示されます。
各ステップでコードを追加しながら、以下のことを行います。
- Paging ライブラリ コンポーネントに移行します。
- 読み込みステータスのヘッダーとフッターをリストに追加します。
- 新規のリポジトリ検索ごとにその間の読み込みの進行状況を表示します。
- セパレータをリストに追加します。
- ネットワークとデータベースからのページングにデータベース サポートを追加します。
最終的なアプリの外観は次のようになります。
必要なもの
- Android Studio Arctic Fox
- 次のアーキテクチャ コンポーネントに精通していること: LiveData、ViewModel、ビュー バインディング、および「アプリ アーキテクチャ ガイド」で提案されているアーキテクチャ
- コルーチンと Kotlin Flow に精通していること
アーキテクチャ コンポーネントの概要については、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 のすべてのコードをダウンロードできます。
- コードの ZIP ファイルを展開し、プロジェクトを Android Studio で開きます。
- デバイスまたはエミュレータで
app
実行構成を実行します。
アプリが実行され、次のような GitHub リポジトリのリストが表示されます。
3.プロジェクト概要
このアプリでは、名前や説明に指定した単語を含むリポジトリを GitHub から検索できます。リポジトリのリストは、スターの数の多い順、その次に名前のアルファベット順という順番で表示されます。
このアプリは、「アプリ アーキテクチャ ガイド」で推奨されているアーキテクチャに沿っています。各パッケージの内容を以下に示します。
- api - Github API の呼び出し。Retrofit を使用。
- data - API リクエストをトリガーし、メモリ内にレスポンスをキャッシュするリポジトリ クラス。
- model - Room データベース内のテーブルでもある
Repo
データモデル。また、検索結果のデータとネットワーク エラーの両方を監視する UI で使用されるRepoSearchResult
クラス。 - ui -
RecyclerView
を使ったActivity
の表示に関連するクラス。
GithubRepository
クラスは、ユーザーがリストの最後にスクロールするたびに、またはユーザーが新しいリポジトリを検索したときに、ネットワークからリポジトリ名のリストを取得します。クエリ結果のリストは、ConflatedBroadcastChannel
の GithubRepository
にメモリ上で保持され、Flow
として公開されます。
SearchRepositoriesViewModel
は GithubRepository
からのデータをリクエストし、それを SearchRepositoriesActivity
に公開します。構成変更の際(回転など)にデータを複数回リクエストしないように、liveData()
ビルダー メソッドを使用して ViewModel
内で Flow
を LiveData
に変換しています。これにより、LiveData
は最新の結果のリストをメモリにキャッシュし、SearchRepositoriesActivity
が再作成されたときに LiveData
の内容が画面に表示されます。ViewModel
は以下を公開します。
LiveData<UiState>
(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 に表示し、失敗した読み込みを簡単に再試行できます。 Flow
、LiveData
、または RxJavaFlowable
、Observable
のどれを使用しているかに関係なく、表示されるリストにmap
やfilter
のような一般的な操作を実行できます。- リスト セパレータを簡単に実装できます。
アプリ アーキテクチャ ガイドでは、次の主要コンポーネントを使ったアーキテクチャを紹介しています。
- ユーザーに提示され、ユーザーが操作するデータの信頼できる唯一のソースとなるローカル データベース。
- ウェブ 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.Adapter
。PagingDataAdapter
は、KotlinFlow
、LiveData
、RxJavaFlowable
、RxJavaObservable
に接続できます。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.key
はnull
になります。今回は、最初のページキーを定義する必要があります。このプロジェクトでは、GITHUB_STARTING_PAGE_INDEX
をGithubRepository
からPagingSource
の実装に移動する必要があります。これが最初のページキーであるためです。 - 読み込みサイズ - リクエストされた読み込む項目数です。
読み込み関数は LoadResult
を返します。LoadResult
は次のいずれかのタイプを取るため、アプリでの RepoSearchResult
の使用を置き換えます。
LoadResult.Page
: 結果が成功の場合。LoadResult.Error
: エラーの場合。
LoadResult.Page
を構築する際、読み込みができない場合には、読み込みの方法に応じて nextKey
または prevKey
に null
を渡します。たとえば、今回のケースでは、ネットワーク レスポンスは正常だったにもかかわらず、リストが空の場合には、読み込むデータが残っていないという場合があり、その場合には nextKey
は null
になります。
上記のすべての情報に基づけば、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 を構築して設定する
現在の実装では、GitHubRepository
の Flow<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 は読み込んだページをすべてメモリに保持します。スクロールしたときにメモリが無駄にならないようにするには、PagingConfig
でmaxSize
パラメータを設定します。デフォルトでは、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 では、さまざまな処理を行っています。
- メモリ内キャッシュの処理
- リストの最後に近づいたときのデータのリクエスト
したがって、GithubRepository
の getSearchResultStream
と NETWORK_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 では、Flow
を LiveData
に変換する必要がなくなりました。代わりに SearchRepositoriesViewModel
が StateFlow<UiState>
を公開するようになりました。さらに、searchResult
の値を完全に破棄し、代わりに searchResult
と同じ目的を果たす別の Flow<PagingData<Repo>>
を公開します。
PagingData
は自己完結型であり、RecyclerView
に表示されるデータの更新の可変ストリームを含むものです。PagingData
の出力はそれぞれ完全に独立しており、1 つのクエリに対して複数の PagingData
が出力される場合があります。そのため、PagingData
の Flows
は他の Flows
とは独立して公開する必要があります。
さらに、ユーザー エクスペリエンス特典として、新しいクエリが入力されるたびに、リストの一番上までスクロールされるようにして最初の検索結果を表示します。ただし、ページング データは複数回出力される可能性があるため、ユーザーがスクロールを開始していない場合にのみ、リストの一番上にスクロールされるようにします。
そのために、UiState
を更新して lastQueryScrolled
と hasNotScrolledForCurrentSearch
のフィールドを追加しましょう。これらのフラグは、リストの一番上までスクロールしてはならないときにこの動作を防ぐものです。
data class UiState(
val query: String = DEFAULT_QUERY,
val lastQueryScrolled: String = DEFAULT_QUERY,
val hasNotScrolledForCurrentSearch: Boolean = false
)
アーキテクチャについて復習しましょう。ViewModel
へのリクエストはすべて単一のエントリ ポイント((UiAction) -> Unit
として定義される accept
フィールド)を経由するため、次の操作を行う必要があります。
- そのエントリ ポイントを、目的の型を含むストリームに変換する。
- これらのストリームを変換する。
- ストリームを結合して
StateFlow<UiState>
に戻す。
より機能的な観点から、UiAction
の出力を UiState
に reduce
します。これは組み立てラインのようなものです。UiAction
型は供給される原材料で、効果(ミューテーションとも呼ばれます)をもたらします。UiState
は UI にバインドする準備ができた最終的な出力です。このプロセスは「UI を UiState
の関数にする」と呼ばれることもあります。
ViewModel
を書き換えて、2 つの異なるストリームで各 UiAction
型を処理し、次にいくつかの Kotlin Flow
演算子を使用してそれらの型を StateFlow<UiState>
に変換しましょう。
まず、ViewModel
の state
の定義を更新して、LiveData
ではなく StateFlow
を使用するようにし、PagingData
の Flow
を公開するためのフィールドも追加します。
/**
* 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
拡張機能は使用されなくなるため、削除します。この機能はプロセス終了後に復元する必要もあるため、SearchRepositoriesViewModel
の onCleared()
メソッドを必ず更新するようにします。
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
ストリームに変換できるようになりました。SearchRepositoriesViewModel
の init
ブロックを次のように置き換えます。
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
型に分割します。
UiAction.Search
: ユーザーが特定のクエリを入力するたびに発生します。UiAction.Scroll
: ユーザーがフォーカスされている特定のクエリを含むリストをスクロールするたびに発生します。
UiAction.Scroll Flow
には、いくつかの追加の変換が適用されています。確認してみましょう。
shareIn
: このFlow
が最終的に使用されるときにflatmapLatest
演算子を通じて使用されるため必要です。アップストリームでの出力のたびに、flatmapLatest
は最後に処理していたFlow
をキャンセルし、与えられた新しいフローに従って動作を開始します。この例では、ユーザーが最後にスクロールしたクエリの値が失われることになります。したがって、replay
値が 1 のFlow
演算子を使用して最後の値をキャッシュに保存し、新しいクエリを受信したときにその値が失われないようにします。onStart
: キャッシュにも使用されます。アプリが強制終了されたが、ユーザーがすでにクエリをスクロールしていた場合は、リストが一番上までスクロールされないようにします。これでユーザーが閲覧していた位置から再び外れることはありません。
state
、pagingDataFlow
、accept
の各フィールドはまだ定義されていないため、コンパイル エラーが引き続き発生するはずです。これを修正しましょう。各 UiAction
に変換を適用したら、その変換を使用して PagingData
と UiState
の両方のフローを作成できるようになりました。
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 と連携させる
PagingData
を RecyclerView
にバインドするには、PagingDataAdapter
を使用します。PagingDataAdapter
は、PagingData
コンテンツが読み込まれると通知を受け、RecyclerView
に更新するよう伝えます。
ui.ReposAdapter
を更新して PagingData
ストリームと連携させる:
- 現在、
ReposAdapter
はListAdapter
を実装していますが、これをPagingDataAdapter
を実装するようにします。クラス本体の残りは変更しません。
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}
ここまでに多くの変更を加えてきましたが、もう一歩でアプリを実行できるところまで来ました。あとは UI を接続するだけです。
9. ネットワーク更新をトリガーする
LiveData を Flow に置き換える
SearchRepositoriesActivity
を更新して Paging 3 で動作するようにしましょう。Flow<PagingData>
を使用できるようにするために、新しいコルーチンを開始する必要があります。これは、アクティビティの再作成時にリクエストをキャンセルする役割を持つ lifecycleScope
で行います。
幸い、大きな変更を行う必要はありません。LiveData
を observe()
するのではなく、coroutine
を launch()
して Flow
を collect()
します。UiState
を PagingAdapter
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 つの変更と同様に、LiveData
を StateFlow
に置き換え、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
Flow
と shouldScrollToTop
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
Flow
で collectLatest
を使用しているため、pagingData
の新しい出力に基づいて pagingData
の以前の出力の収集をキャンセルできます。shouldScrollToTop
フラグの場合、PagingDataAdapter.loadStateFlow
の出力は UI に表示される出力と同期されるため、出力されたブール値フラグが true であればすぐに list.scrollToPosition(0)
を呼び出しても安全です。
LoadStateFlow の型は CombinedLoadStates
オブジェクトです。
CombinedLoadStates
を使用すると、次の 3 種類の読み込み操作での読み込み状態を取得できます。
CombinedLoadStates.refresh
-PagingData
を初めて読み込む際の読み込み状態を表します。CombinedLoadStates.prepend
- リストの先頭にデータを読み込む際の読み込み状態を表します。CombinedLoadStates.append
- リストの最後にデータを読み込む際の読み込み状態を表します。
今回は、更新が完了したときにだけスクロール位置をリセットします。したがって LoadState
は refresh
、NotLoading
です。
これで、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. 読み込み状態をフッターに表示する
今回のアプリでは、読み込みステータスに基づいてフッターを表示する必要があります。つまり、リストの読み込み時には、進行状況スピナーを表示する必要があります。エラーの場合は、エラーと再試行ボタンを表示します。
作成する必要があるヘッダーやフッターは、現在表示されている項目の実際のリストの先頭(ヘッダーの場合)または最後(フッターの場合)に追加する必要があるリストという概念に合わせます。ヘッダーやフッターは 1 つの要素だけで構成されるリストで、Paging LoadState
に基づいて、進行状況バー、またはエラーを再試行ボタンとともに表示するビューになります。
読み込み状態に基づいたヘッダーやフッターの表示と、再試行メカニズムの実装は一般的なタスクなので、Paging 3 API を両方の処理に利用できます。
ヘッダーやフッターの実装には、LoadStateAdapter
を使用します。この RecyclerView.Adapter
の実装には、読み込み状態の変化が自動的に通知されます。Loading
と Error
の状態でのみ項目が表示され、LoadState
に応じて、項目が削除、挿入、変更されたときに RecyclerView
に通知されるようにします。
再試行メカニズムには、adapter.retry()
を使用します。内部的には、このメソッドは適切なページに対して PagingSource
の実装を呼び出します。レスポンスは Flow<PagingData>
を経由して自動的に伝播されます。
ヘッダーとフッターの実装例を見てみましょう。
他のリストと同じように、次の 3 つのファイルを作成します。
- レイアウト ファイル: 進行状況、エラー、再試行ボタンを表示する UI 要素を含んでいます。
- **
ViewHolder
** **ファイル**: PagingLoadState
に基づいて UI 項目を表示します。 - アダプタ ファイル:
ViewHolder
を作成してバインドする方法を定義します。RecyclerView.Adapter
を拡張する代わりに、Paging 3 のLoadStateAdapter
を拡張します。
ビュー レイアウトを作成する
リポジトリの読み込み状態のための repos_load_state_footer_view_item
レイアウトを作成します。ProgressBar
、TextView
(エラー表示用)、再試行 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 への移行に伴い、結果のリストが空の場合にメッセージを表示する機能が失われました。
- 新しいクエリを検索するたびに、ネットワーク レスポンスが返されるまで現在のクエリ結果が画面に表示されます。これは好ましいユーザー エクスペリエンスとは言えません。進行状況バーや再試行ボタンを表示する必要があります。
この 2 つの問題を解決するには、SearchRepositoriesActivity
で読み込み状態の変化に対応します。
空のリスト メッセージを表示する
まず、空のリスト メッセージを元に戻しましょう。このメッセージは、リストが読み込まれてリストのアイテムの数が 0 の場合にのみ表示されます。リストがいつ読み込まれたかを調べるには、PagingDataAdapter.loadStateFlow
プロパティを使用します。この Flow
は、読み込み状態の変更があるたびに、CombinedLoadStates
オブジェクト経由で出力します。
CombinedLoadStates
は、定義した PageSource
の読み込み状態、またはネットワークとデータベースの場合に必要な RemoteMediator
の読み込み状態を提供します(詳細は後述)。
SearchRepositoriesActivity.bindList()
では、loadStateFlow
から直接データを収集します。CombinedLoadStates
の refresh
状態が NotLoading
で adapter.itemCount == 0
の場合、リストは空です。次に、emptyList
と list
の公開設定をそれぞれ切り替えます。
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
)と、LoadState
(Loading
または Error
)です。また、前のステップでコメントアウトした機能に、エラー発生時の Toast
の表示があったので、それも取り込みます。エラー メッセージを表示するために、LoadState.Error
が CombinedLoadStates.prepend
と CombinedLoadStates.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
に挿入できるようになっています。
PagingData
にセパレータを追加すると、画面に表示するリストが変更されます。Repo
オブジェクトだけを表示するのではなく、セパレータ オブジェクトも表示するようにします。そのため、ViewModel
から公開する UI モデルを、Repo
から、RepoItem
と SeparatorItem
をカプセル化できる別の型に変更する必要があります。次に、セパレータをサポートするよう、UI を更新する必要があります。
- セパレータ用のレイアウトと
ViewHolder
を追加する。 RepoAdapter
を更新して、セパレータとリポジトリの両方を作成しバインドできるようにする。
それでは手順ごとに、実装を見ていきましょう。
UI モデルを変更する
現在、SearchRepositoriesViewModel.searchRepo()
は Flow<PagingData<Repo>>
を返します。リポジトリとセパレータの両方をサポートするために、SearchRepositoriesViewModel
と同じファイル内に UiModel
シールドクラスを作成します。UiModel
オブジェクトには、RepoItem
と SeparatorItem
の 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>>
を返すため、最初に追加する必要がある操作は、各 Repo
を UiModel.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
フォルダ内に TextView
で separator_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
の戻り値の型を更新します。onBindViewHolder
のholder
パラメータを更新します。
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 が自動的に更新されます。
オフライン サポートを追加するには、次が必要です。
- Room データベース、
Repo
オブジェクトを保存するテーブル、Repo
オブジェクトを処理する DAO をそれぞれ作成すること。 RemoteMediator
を実装することにより、データベース内のデータの最後に到達したときに、ネットワークからデータを読み込む方法を定義すること。- データソースとして 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()
メソッドの実装方法を見てみましょう。
LoadType
に基づいて、ネットワークから読み込む必要のあるページを割り出します。- ネットワーク リクエストをトリガーします。
- ネットワーク リクエストが完了して、受信したリポジトリのリストが空でない場合は、次を行います。
Repo
ごとにRemoteKeys
を計算します。- これが新しいクエリ(
loadType = REFRESH
)である場合は、データベースをクリアします。 RemoteKeys
とRepos
をデータベースに保存します。MediatorResult.Success(endOfPaginationReached = false)
を返します。- リポジトリのリストが空の場合は、
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
です。したがって、データベースの最後の項目をベースにしてネットワーク ページキーを計算する必要があります。
- データベースから読み込まれた最後の
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)
}
}
remoteKeys
が null の場合は、まだ更新結果がデータベースに存在しません。RemoteKeys が null でない場合、Paging はこのメソッドを再度呼び出すので、endOfPaginationReached = false
で Success を返すことができます。remoteKeys がnull
ではなくnextKey
がnull
の場合は、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
です。データベースの最初の項目に基づいて、ネットワーク ページキーを計算する必要があります。
- データベースから読み込まれた最初の
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)
}
}
remoteKeys
が null の場合は、まだ更新結果がデータベースに存在しません。RemoteKeys が null でない場合、Paging はこのメソッドを再度呼び出すので、endOfPaginationReached = false
で Success を返すことができます。remoteKeys がnull
ではなくprevKey
がnull
の場合は、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
になります。これが最初の読み込みの場合、anchorPosition
は null
です。PagingDataAdapter.refresh()
が呼ばれた場合、表示されたリスト内で最初の可視になっている位置が anchorPosition
になるため、その項目を含むページを読み込む必要があります。
state
のanchorPosition
に基づいて、state.closestItemToPosition()
を呼び出すことで、その位置に最も近いRepo
項目を取得できます。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)
}
}
}
remoteKey
が null ではない場合は、そこからnextKey
を取得できます。Github API では、ページキーが連続的にインクリメントされます。したがって、現在の項目が含まれているページを取得するには、remoteKey.nextKey
から 1 を引くだけです。RemoteKey
がnull
の場合(anchorPosition
がnull
だったため)、読み込むページは最初のページ(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 の作成を更新する
ReposDao
に GithubRemoteMediator
と PagingSource
を実装したので、それらを使用するように 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)}
ここで、GithubRemoteMediator
と pagingSourceFactory
を使用するように 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.source
と CombinedLoadStates.mediator
の両方を確認する必要があります。具体的には、現在、source
LoadState
が NotLoading
のときに新しいクエリのリストの一番上までスクロールされるようトリガーしています。新たに追加した RemoteMediator
の LoadState
が NotLoading
であることを確認する必要もあります。
そのためには、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 }
同様に、最初のページ読み込み中(SearchRepositoriesActivity
の bindList
拡張機能内)に読み込みスピナーを表示する場合も、アプリは 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
を追加すると、LoadStatesFlow
のmediator
フィールドが更新されます。