Android の Paging ライブラリで、データベースから取得したリストの途中に別のデータを追加する方法について考えたので紹介します。
例としては、例えば以下のように TODO タスクの一覧の中に広告を表示するというような感じです。 この TODO タスクはデータベースに保存されているものを参照しています。
考えた方法としては以下の2通りになります。
-
PagingData
のinsertSeparators
メソッドを使う - カスタムの
PagingSource
を作成する
この記事を書いた時点では Paging 3.1.0 で試しています。
サンプルプロジェクトを こちら にアップしているので、よかったら参考にしてください。
1. PagingData の insertSeparators を使う方法
これは 公式のドキュメント にも記載されている方法です。
データベースから取得したデータからどの位置に追加データを挿入するかがわかる場合に使える方法です。
まずは Dao で以下のように定義します。
@Dao
interface TodoDao {
@Query("SELECT * FROM todos")
fun getTodos(): PagingSource<Int, TodoEntity>
}
どの位置に追加データを挿入するかを、データからではなく先頭から何番目かで判定したい場合、以下のように SQL を書き換えれば行番号がデータに含まれるようになります。
data class TodoWithNumber(
val id: Int,
val title: String,
val completed: Boolean,
val number: Int
)
@Dao
interface TodoDao {
@Query(
"""
SELECT T1.*, COUNT(*) AS number
FROM todos AS T1
INNER JOIN todos AS T2 ON T1.id >= T2.id
GROUP BY T1.id, T1.title, T1.completed
"""
)
fun getTodos(): PagingSource<Int, TodoWithNumber>
}
SQLite では ROW_NUMBER()
という行番号が取得できるウィンドウ関数がありますが、この関数は SQLite 3.25 から使えます。 ドキュメントに記載されている OS バージョンごとの SQLite のバージョンを見ると API レベル30以降からしか使えないので、このウィンドウ関数を使うのはまだ難しそうです。。
次に UiModel
を作成します。リストに表示する際に使用するデータになります。これは sealed interface(もしくは sealed class) にしておくと RecyclerView で表示する時に扱いやすくなります。
sealed interface UiModel {
data class Todo(val id: Int, val title: String, val completed: Boolean) : UiModel
data class Ad(val id: Int, val title: String) : UiModel
}
以下は ViewModel 内で PagingData
の Flow
を作成する例です。 PagingData
の insertSeparators
メソッドを使って、TODO タスク4つおきに広告を1つ表示するようにデータを挿入しています。
private val pagingConfig = PagingConfig(...)
val todosFlow: Flow<PagingData<Model>> =
Pager(config = pagingConfig) { todoDao.getTodos() }
.flow
.map { pagingData ->
pagingData
.insertSeparators { before: TodoWithNumber?, _ ->
val rowNumber = before?.number ?: return@insertSeparators null
// TODOタスク4つおきに広告を1つ表示する.
if (rowNumber % 4 == 0) UiModel.Ad(id = rowNumber, title = "広告-$rowNumber") else null
}
.map {
// UiModel に変換する.
when (it) {
is TodoWithNumber -> UiModel.Todo(id = it.id, title = it.title, completed = it.completed)
is UiModel.Ad -> it
else -> throw IllegalArgumentException()
}
}
}
.cachedIn(viewModelScope)
以下は RecyclerView で使用する Adapter の例です。 PagingDataAdapter
を継承する必要があります。 先ほど作成した UiModel
の種別によって使用する ViewHolder が変わります。
class TodosAdapter : PagingDataAdapter<UiModel, RecyclerView.ViewHolder>(TodoComparator) {
override fun getItemViewType(position: Int): Int {
// getItem() ではなく peek() を使うことによって、ページの取得・破棄のトリガーを避けることができる.
return when (peek(position)) {
is UiModel.Todo -> R.layout.list_item_todo
is UiModel.Ad -> R.layout.list_item_ad
null -> throw IllegalStateException("Unknown view")
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
R.layout.list_item_todo -> TodoViewHolder(parent)
else -> AdViewHolder(parent)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = getItem(position) ?: return
when (holder) {
is TodoViewHolder -> holder.bind(item as UiModel.Todo)
is AdViewHolder -> holder.bind(item as UiModel.Ad)
}
}
}
private object TodoComparator : DiffUtil.ItemCallback<UiModel>() {
override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
val isSameTodo = oldItem is UiModel.Todo && newItem is UiModel.Todo && oldItem.id == newItem.id
val isSameAd = oldItem is UiModel.Ad && newItem is UiModel.Ad && oldItem.id == newItem.id
return isSameTodo || isSameAd
}
override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
return oldItem == newItem
}
}
2. カスタムの PagingSource を作成する方法
続いてカスタムの PagingSource
を作成する方法です。こちらの方法だと insertSeparators
を使用する方法に比べてより複雑なことが可能になるかと思います。
実装にあたっては LimitOffsetPagingSource を参考にしています。 Dao の戻り値に PagingSource
を設定した時、実体はこのクラスのインスタンスになっています。
まずは Dao で以下のように offset と limit が指定できるメソッドと、TODO タスクの総数を取得するメソッドを用意します。
@Dao
interface TodoDao {
@Query("SELECT * FROM todos LIMIT :limit OFFSET :offset")
suspend fun getTodos(offset: Int, limit: Int): List<TodoEntity>
@Query("SELECT COUNT(*) FROM todos")
suspend fun countTodos(): Int
}
次にカスタムの PagingSource
です。 以下のように PagingSource
を継承したクラスを作成します。(一部抜粋です)
こちらの例では TODO タスク5つおきに広告を1つ挿入しています。
private const val AD_POSITION_INTERVAL = 5
class TodoPagingSource(
private val db: AppDatabase
) : PagingSource<Int, UiModel>() {
private val todoDao: TodoDao = db.todoDao()
private val itemCount: AtomicInteger = AtomicInteger(-1)
private val adPositions = AtomicReference<List<Int>>(emptyList())
override fun getRefreshKey(state: PagingState<Int, UiModel>): Int? {
val initialLoadSize = state.config.initialLoadSize
return state.anchorPosition?.let { anchorPosition -> maxOf(0, anchorPosition - (initialLoadSize / 2)) }
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UiModel> {
val tempCount = itemCount.get()
return if (tempCount < 0) {
initialLoad(params)
} else {
loadFromDb(params, tempCount)
}
}
private suspend fun initialLoad(params: LoadParams<Int>): LoadResult<Int, UiModel> {
return db.withTransaction {
val tempCount = todoDao.countTodos()
itemCount.set(tempCount)
// 広告の位置を5つおきになるようにリストを設定.
adPositions.set((AD_POSITION_INTERVAL..tempCount step AD_POSITION_INTERVAL).toList())
loadFromDb(params, tempCount)
}
}
private fun getAdPositions(startIndex: Int, endIndex: Int): List<Int> {
val positions = adPositions.get()
val range = startIndex until endIndex
return positions.filter { it in range }
}
private suspend fun loadFromDb(
params: LoadParams<Int>,
itemCount: Int,
): LoadResult<Int, UiModel> {
val key = params.key ?: 0
val limit: Int = getLimit(params, key)
val offset: Int = getOffset(params, key, itemCount)
val todos = todoDao.getTodos(offset, limit)
val nextPosToLoad = offset + todos.size
val nextKey =
if (todos.isEmpty() || todos.size < limit || nextPosToLoad >= itemCount) {
null
} else {
nextPosToLoad
}
val prevKey = if (offset <= 0 || todos.isEmpty()) null else offset
val todoModels = todos.map { UiModel.Todo(it.id, it.title, it.completed) }
val adPositions = getAdPositions(offset, (offset + limit))
val uiModels: List<UiModel> = if (adPositions.isNotEmpty()) {
buildList {
var start = 0
// 広告の位置のリストを元に UiModel のリストを作成.
adPositions.forEach { adPosition ->
val end = adPosition - offset
if (start < end) {
addAll(todoModels.subList(start, minOf(end, todoModels.size)))
}
add(UiModel.Ad(id = adPosition, title = "広告-$adPosition"))
start = end
}
if (start < todoModels.size) {
addAll(todoModels.subList(start, todoModels.size))
}
}
} else {
todoModels
}
val itemsBefore = offset + getAdPositions(0, offset - 1).size
val afterAdCount = getAdPositions(nextPosToLoad, itemCount).size
val itemsAfter = maxOf(0, itemCount + afterAdCount - nextPosToLoad)
return LoadResult.Page(
data = uiModels,
prevKey = prevKey,
nextKey = nextKey,
itemsBefore = itemsBefore,
itemsAfter = itemsAfter
)
}
private fun getLimit(params: LoadParams<Int>, key: Int): Int {
return when (params) {
is LoadParams.Prepend -> if (key < params.loadSize) key else params.loadSize
else -> params.loadSize
}
}
private fun getOffset(params: LoadParams<Int>, key: Int, itemCount: Int): Int {
return when (params) {
is LoadParams.Prepend -> if (key < params.loadSize) 0 else (key - params.loadSize)
is LoadParams.Append -> key
is LoadParams.Refresh -> if (key >= itemCount) maxOf(0, itemCount - params.loadSize) else key
}
}
}
PagingSource
を継承したクラスでは load()
と getRefreshKey()
メソッドの2つをオーバーライドする必要があります。 load()
メソッドにおいて、Dao を使ってデータベースからデータを取得し、そのデータに広告を挿入しています。
上記以外にもデータベースの対象テーブルに変更があった場合の処理などを実装する必要があるので、全ての実装は サンプルプロジェクト を確認してみてください。
そして先ほどの例と同じように ViewModel 内で PagingData
の Flow
を作成します。
val todosFlow: Flow<PagingData<UiModel>> =
Pager(config = pagingConfig) { TodoPagingSource(db) }
.flow
.cachedIn(viewModelScope)
Adapter などは先ほどの例と同じものが使用できます。
どちらの方法を使うべきか?
まずは insertSeparators
を使う方法を検討してみるのがよいかと思います。 こちらで実現が可能であれば、おそらくこちらの方が実装がシンプルになるはずです。
insertSeparators
の方法では実現できなければ、カスタムの PagingSource
を作成する方法を検討してみましょう。