Pagingライブラリを利用してRecyclerViewに情報を表示するサンプル


# 概要

ソースコードは[こちら](https://github.com/teracy/RoomPagingSample)です。

大量のデータがある場合に、一度に取得せずに必要な分だけ逐次取得してRecyclerViewに表示するような場合のサンプルを作成しました。

挙動としてはメルカリやクックパッド、スマートニュース等のアプリをイメージするとわかりやすいと思います。

議員の一覧をRoomデータベースから取得し、選択した議員の2018/1/1からの国会での発言をWebAPIから取得していますが、ソース元がRoomでもWebAPIでもPageKeyedDataSourceに変換することでUI側では同じように扱えます。

# 参考にしたコード

[android-architecture-components/PagingWithNetworkSample at master · googlesamples/android-architecture-components](https://github.com/googlesamples/android-architecture-components/tree/master/PagingWithNetworkSample)

[Architecture Components:Paging Library - Knowledge Transfer](http://androidkt.com/paging-library/)

[STAR-ZERO/paging-retrofit-sample: Paging Library + API(Retrofit) Sample](https://github.com/STAR-ZERO/paging-retrofit-sample)

[Paging Library + RoomでPagedListの型を変換する – Kenji Abe – Medium](https://medium.com/@star_zero/paging-library-room%E3%81%A7pagedlist%E3%81%AE%E5%9E%8B%E3%82%92%E5%A4%89%E6%8F%9B%E3%81%99%E3%82%8B-c3901f45547d)

[KotlinでRetrofit2/OkHttp3を使ってXMLを取得する - Qiita](https://qiita.com/rei-m/items/ff49e3fddfd8b6f5311c)

[Retrofitを使ったAPI呼び出しでリカバリ可能なHTTPエラーをどう扱うか問題 - Qiita](https://qiita.com/FumihikoSHIROYAMA/items/65d52aea1a9f324d347e)

# Roomからの取得

![from_room.gif](https://qiita-image-store.s3.amazonaws.com/0/21354/4f444754-c7ce-9611-626d-d7ecadd46474.gif)

RoomDaoからは[直接DataSource.Factoryを取得することが可能](https://developer.android.com/reference/android/arch/paging/DataSource.Factory)ですが、前述のようにWebAPIから取得する場合と同じように扱いたかったのと表示用に加工する場合に便利そうだったことから、Listで取ってきたものをPageKeyedDataSourceに渡しています。

```kotlin:DietMemberDao.ktの抜粋
@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>
```

```kotlin:DietMemberPageKeyedDataSource.ktの抜粋
// ローディング表示
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に変換します。

```kotlin:MainViewModel.ktの抜粋
// 議員リスト
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します。

```kotlin:MainActivity.ktの抜粋
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からの取得

![from_webapi.gif](https://qiita-image-store.s3.amazonaws.com/0/21354/9b23db91-b2d8-65c0-8c9a-5dbfacb736bb.gif)

取得元が違うだけで、UI側のコードはほとんど変わりません。ネットワーク状態やAPI側の都合によるエラーが起こり得るのでその考慮は必要です。

データ元には[国会会議録検索システム検索用API](http://kokkai.ndl.go.jp/api.html)を利用していますが、このAPIは要求引数に「開始位置」「取得件数」、応答に「送った検索条件に該当する全件数」「応答に含まれる件数」「次の開始位置」が含まれるため、丁度SQLのLIMIT/OFFSETと同じように扱えるのでサンプルとしても最適でした。

```kotlin:SpeechPageKeyedDataSource.ktの抜粋
// ネットワーク状態
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の場合と同様です。

```kotlin:SpeechViewModel.ktの抜粋
// ネットワーク状態
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側の処理もほとんど変わりません。検索条件は特に変更しないので、一度呼べばあとはデータが取得しきれるまで取り続けてくれます。

```kotlin:SpeechActivity.ktの抜粋
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)
    })
}
```