[Android]Paging3でDBから取得したリストの途中に別のデータを追加する

Android の [Paging](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) ライブラリで、データベースから取得したリストの途中に別のデータを追加する方法について考えたので紹介します。

例としては、例えば以下のように TODO タスクの一覧の中に広告を表示するというような感じです。 この TODO タスクはデータベースに保存されているものを参照しています。

| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190187/c9a82d3a-772a-b04c-74d9-ad5727883aaa.png" width=320> |
| --- |


考えた方法としては以下の2通りになります。

1. `PagingData` の `insertSeparators` メソッドを使う
2. カスタムの `PagingSource` を作成する

この記事を書いた時点では Paging 3.1.0 で試しています。

サンプルプロジェクトを [こちら](https://github.com/watabee/Paging3Sample) にアップしているので、よかったら参考にしてください。

# 1. PagingData の insertSeparators を使う方法

これは [公式のドキュメント](https://developer.android.com/topic/libraries/architecture/paging/v3-transform#separators) にも記載されている方法です。

データベースから取得したデータからどの位置に追加データを挿入するかがわかる場合に使える方法です。

まずは Dao で以下のように定義します。

```kotlin
@Dao
interface TodoDao {
    @Query("SELECT * FROM todos")
    fun getTodos(): PagingSource<Int, TodoEntity>
}
```

どの位置に追加データを挿入するかを、データからではなく先頭から何番目かで判定したい場合、以下のように SQL を書き換えれば行番号がデータに含まれるようになります。

```kotlin
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>
}
```

:::note warn
SQLite では `ROW_NUMBER()` という行番号が取得できるウィンドウ関数がありますが、この関数は SQLite 3.25 から使えます。 [ドキュメント](https://developer.android.com/reference/android/database/sqlite/package-summary)に記載されている OS バージョンごとの SQLite のバージョンを見ると API レベル30以降からしか使えないので、このウィンドウ関数を使うのはまだ難しそうです。。
:::


次に `UiModel` を作成します。リストに表示する際に使用するデータになります。これは sealed interface（もしくは sealed class） にしておくと RecyclerView で表示する時に扱いやすくなります。

```kotlin
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つ表示するようにデータを挿入しています。

```kotlin
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 が変わります。

```kotlin
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](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/room-paging/src/main/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt) を参考にしています。 Dao の戻り値に `PagingSource` を設定した時、実体はこのクラスのインスタンスになっています。

まずは Dao で以下のように offset と limit が指定できるメソッドと、TODO タスクの総数を取得するメソッドを用意します。

```kotlin
@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つ挿入しています。

```kotlin
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 を使ってデータベースからデータを取得し、そのデータに広告を挿入しています。

上記以外にもデータベースの対象テーブルに変更があった場合の処理などを実装する必要があるので、全ての実装は [サンプルプロジェクト](https://github.com/watabee/Paging3Sample) を確認してみてください。

そして先ほどの例と同じように ViewModel 内で `PagingData` の `Flow` を作成します。

```kotlin
val todosFlow: Flow<PagingData<UiModel>> =
    Pager(config = pagingConfig) { TodoPagingSource(db) }
        .flow
        .cachedIn(viewModelScope)
```

Adapter などは先ほどの例と同じものが使用できます。

# どちらの方法を使うべきか？

まずは `insertSeparators` を使う方法を検討してみるのがよいかと思います。 こちらで実現が可能であれば、おそらくこちらの方が実装がシンプルになるはずです。

`insertSeparators` の方法では実現できなければ、カスタムの `PagingSource` を作成する方法を検討してみましょう。

# 参考

- [Transform data streams](https://developer.android.com/topic/libraries/architecture/paging/v3-transform)
- [LimitOffsetPagingSource.kt](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/room-paging/src/main/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt)