概要
ソースコードはこちらです。
大量のデータがある場合に、一度に取得せずに必要な分だけ逐次取得してRecyclerViewに表示するような場合のサンプルを作成しました。
挙動としてはメルカリやクックパッド、スマートニュース等のアプリをイメージするとわかりやすいと思います。
議員の一覧をRoomデータベースから取得し、選択した議員の2018/1/1からの国会での発言をWebAPIから取得していますが、ソース元がRoomでもWebAPIでもPageKeyedDataSourceに変換することでUI側では同じように扱えます。
参考にしたコード
Architecture Components:Paging Library - Knowledge Transfer
STAR-ZERO/paging-retrofit-sample: Paging Library + API(Retrofit) Sample
Paging Library + RoomでPagedListの型を変換する – Kenji Abe – Medium
KotlinでRetrofit2/OkHttp3を使ってXMLを取得する - Qiita
Retrofitを使ったAPI呼び出しでリカバリ可能なHTTPエラーをどう扱うか問題 - Qiita
Roomからの取得
RoomDaoからは直接DataSource.Factoryを取得することが可能ですが、前述のようにWebAPIから取得する場合と同じように扱いたかったのと表示用に加工する場合に便利そうだったことから、Listで取ってきたものをPageKeyedDataSourceに渡しています。
@Query("SELECT * FROM diet_member ORDER BY kana LIMIT :limit OFFSET :offset")
abstract fun getDietMembersLimitOffset(limit: Int, offset: Int): List<DietMemberEntity>
@Query("SELECT * FROM diet_member WHERE name LIKE :name OR kana LIKE :name ORDER BY kana LIMIT :limit OFFSET :offset")
abstract fun getDietMembersByNameLimitOffset(name: String, limit: Int, offset: Int): List<DietMemberEntity>
// ローディング表示
val loading: MutableLiveData<Boolean> = MutableLiveData()
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, DietMember>) {
// 最初のページ取得
getDietMember(0) { dietMembers, next ->
callback.onResult(dietMembers, null, next)
}
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, DietMember>) {
// 次のページ取得
getDietMember(params.key) { dietMembers, next ->
callback.onResult(dietMembers, next)
}
}
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, DietMember>) {
// 前のページ取得:利用しない
}
private fun getDietMember(offset: Int, callback: (dietMembers: List<DietMember>, next: Int?) -> Unit) {
loading.postValue(true)
val list = if (name == null || name.trim().isEmpty()) {
database.dietMemberDao().getDietMembersLimitOffset(limit, offset)
} else {
database.dietMemberDao().getDietMembersByNameLimitOffset("$name%", limit, offset)
}.map { it.convert() }
callback(list, offset + limit)
loading.postValue(false)
}
/**
* Roomの議員Entity→表示用議員情報に変換
*/
private fun DietMemberEntity.convert(): DietMember {
// 省略
}
これをViewModelで呼んでLiveDataに変換します。
// 議員リスト
var dietMembers: LiveData<PagedList<DietMember>> = MutableLiveData()
// ローディング表示
var loading: MutableLiveData<Boolean> = MutableLiveData()
fun fetchDietMembers(lifecycleOwner: LifecycleOwner, name: String?) {
val limit = 20
val factory = DietMemberPageKeyedDataSourceFactory(database, limit, name)
val config = PagedList.Config.Builder()
.setInitialLoadSizeHint(limit)
.setPageSize(limit)
.build()
loading.removeObservers(lifecycleOwner)
dietMembers.removeObservers(lifecycleOwner)
loading = factory.source.loading
dietMembers = LivePagedListBuilder(factory, config).build()
View側では検索条件が変更される度(TextWatcher.onTextChangedが呼ばれる度)に先に定義したViewModelのメソッドを呼んでRecyclerViewにsubmitListします。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 省略
binding.recycler.layoutManager = LinearLayoutManager(this)
binding.recycler.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
binding.recycler.adapter = adapter
// まずはしぼりこみ無しで呼んでおく
adapter.fetchDietMembers()
// 省略
binding.etSearch.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
// 特に何もしない
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// 特に何もしない
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
val text = s?.toString()?.trim() ?: ""
adapter.fetchDietMembers(text)
binding.ibClear.visibility = if (text.isNotBlank() && text.isNotEmpty()) View.VISIBLE else View.GONE
}
})
}
private fun DietMemberAdapter.fetchDietMembers(name: String? = null) {
// 入力されたしぼりこみ条件で呼ぶ
viewModel.fetchDietMembers(this@MainActivity, name)
viewModel.loading.observe(this@MainActivity, Observer {
binding.loading.visibility = if (it != false) View.VISIBLE else View.GONE
})
viewModel.dietMembers.observe(this@MainActivity, Observer {
submitList(it)
})
}
WebAPIからの取得
取得元が違うだけで、UI側のコードはほとんど変わりません。ネットワーク状態やAPI側の都合によるエラーが起こり得るのでその考慮は必要です。
データ元には国会会議録検索システム検索用APIを利用していますが、このAPIは要求引数に「開始位置」「取得件数」、応答に「送った検索条件に該当する全件数」「応答に含まれる件数」「次の開始位置」が含まれるため、丁度SQLのLIMIT/OFFSETと同じように扱えるのでサンプルとしても最適でした。
// ネットワーク状態
val networkState: MutableLiveData<NetworkState> = MutableLiveData()
// 全件数
val numberOfRecords: MutableLiveData<Int> = MutableLiveData()
// メッセージ(エラーor件数)
val message: MutableLiveData<String> = MutableLiveData()
// ローディング表示
val loading: MutableLiveData<Boolean> = MutableLiveData()
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, Speech>) {
// 最初のページ取得
message.postValue("読み込み中です")
callApi(1, params.requestedLoadSize) { speeches, next ->
callback.onResult(speeches, null, next)
}
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, Speech>) {
// 次のページ取得
callApi(params.key, params.requestedLoadSize) { speeches, next ->
callback.onResult(speeches, next)
}
}
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, Speech>) {
// 前のページ取得:利用しない
}
private fun callApi(page: Int, perPage: Int, callback: (speechRecords: List<Speech>, next: Int?) -> Unit) {
networkState.postValue(NetworkState.RUNNING)
compositeDisposable.clear()
repository.getSpeech(page, name)
.doOnSubscribe { loading.postValue(true) }
.subscribeOn(schedulerProvider.newThread())
.compose(ApiErrorTransformer(SpeechData::class.java))
.observeOn(schedulerProvider.ui())
.subscribe({ result ->
loading.postValue(false)
numberOfRecords.postValue(result.numberOfRecords)
result.records?.let {
callback(it.map { speechRecord -> speechRecord.convert() }, result.nextRecordPosition)
Timber.d("callApi:%d", it.size)
message.postValue("${result.numberOfRecords}件の発言があります")
networkState.postValue(NetworkState.SUCCESS)
return@subscribe
}
result.diagnostics?.let {
Timber.e(it[0].message)
message.postValue(it[0].message)
} ?: let {
Timber.e("データがありません")
message.postValue("データがありません")
}
numberOfRecords.postValue(0)
networkState.postValue(NetworkState.FAILED)
}, {
Timber.e(it)
loading.postValue(false)
numberOfRecords.postValue(null)
networkState.postValue(NetworkState.FAILED)
})
.addTo(compositeDisposable)
}
/**
* APIの発言応答→表示用発言情報に変換
*/
private fun SpeechRecord.convert(): Speech = Speech(
// 省略
)
ViewModelで呼んでLiveDataに変換するのはRoomの場合と同様です。
// ネットワーク状態
var networkState: LiveData<NetworkState> = MutableLiveData()
// 全件数
var numberOfRecords: MutableLiveData<Int> = MutableLiveData()
// 発言リスト
var speeches: LiveData<PagedList<Speech>> = MutableLiveData()
// メッセージ(エラーor件数)
var message: MutableLiveData<String> = MutableLiveData()
// ローディング表示
var loading: MutableLiveData<Boolean> = MutableLiveData()
fun fetchSpeech(lifecycleOwner: LifecycleOwner, name: String) {
val factory = SpeechPageKeyedDataSourceFactory(repository, schedulerProvider, compositeDisposable, name)
val config = PagedList.Config.Builder()
.setInitialLoadSizeHint(NDL_PAGE_SIZE)
.setPageSize(NDL_PAGE_SIZE)
.build()
loading.removeObservers(lifecycleOwner)
speeches.removeObservers(lifecycleOwner)
networkState.removeObservers(lifecycleOwner)
numberOfRecords.removeObservers(lifecycleOwner)
message.removeObservers(lifecycleOwner)
loading = factory.source.loading
speeches = LivePagedListBuilder(factory, config).build()
networkState = factory.source.networkState
numberOfRecords = factory.source.numberOfRecords
message = factory.source.message
}
View側の処理もほとんど変わりません。検索条件は特に変更しないので、一度呼べばあとはデータが取得しきれるまで取り続けてくれます。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 省略
binding.recycler.layoutManager = LinearLayoutManager(this)
binding.recycler.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
binding.recycler.adapter = adapter
adapter.fetchSpeech(name)
}
private fun SpeechAdapter.fetchSpeech(name: String) {
viewModel.fetchSpeech(this@SpeechActivity, name)
viewModel.loading.observe(this@SpeechActivity, Observer {
binding.loading.visibility = if (it != false) View.VISIBLE else View.GONE
})
viewModel.speeches.observe(this@SpeechActivity, Observer {
submitList(it)
})
viewModel.message.observe(this@SpeechActivity, Observer {
binding.tvNumberOfRecords.setText(it)
})
}