[Android] 031. RemoteMediator(Paging)

PagingするたびローカルDBにデータがなかったらWebApiで取得してローカルDBに保存する処理をRemoteMediatorを使って実装してみました

使い方は簡単でPager作成時にRemoteMediatorを指定するだけです

developerの説明は以下
https://developer.android.com/topic/libraries/architecture/paging/v3-network-db?hl=ja

まずは最もシンプルな構成で実装してみます
※ 図のPagingDataAdapterは実装しません

処理の見通しをわかりやすくするためPokeApiのポケモンリストだけを取得する内容としてます
そこそこの図鑑の実装は別途やります

依存関係の設定

WebApiはPokeApiを使用します
以下からPokeApiとHttpClientの.aarをダウンロードして(project)/app/libsに置いてください

https://github.com/bps-e/PokeApi/releases
https://github.com/bps-e/HttpClient/releases

今回はJetPack Compose + Hilt + Room + Paging3の構成でkotlinx-serialization.Jsonを使用するので以下を設定します
またHiltはα版ですがkspで使用します

[versions]
com-google-devtools-ksp = "1.9.0-1.0.13"
androidx-room = "2.6.0-beta01"
androidx-paging = "3.2.0"
dagger-hilt = "2.48"

[libraries]
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.6.0" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "androidx-room" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidx-room" }
androidx-room-paging = { group = "androidx.room", name = "room-paging", version.ref = "androidx-room" }
androidx-paging-runtime-ktx = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "androidx-paging" }
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "androidx-paging" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger-hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "dagger-hilt" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.1.0-alpha01" }

[plugins]
com-google-devtools-ksp = {id = "com.google.devtools.ksp", version.ref = "com-google-devtools-ksp" }
com-google-dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "dagger-hilt" }
plugins {
    kotlin("plugin.serialization") version "1.9.0"
    alias(libs.plugins.com.google.devtools.ksp) apply false
    alias(libs.plugins.com.google.dagger.hilt.android) apply false
}
plugins {
    id("kotlinx-serialization")
    alias(libs.plugins.com.google.devtools.ksp)
    alias(libs.plugins.com.google.dagger.hilt.android)
}

dependencies {
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.androidx.room.ktx)
    implementation(libs.androidx.room.runtime)
    implementation(libs.androidx.room.paging)
    implementation(libs.androidx.paging.runtime.ktx)
    implementation(libs.androidx.paging.compose)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    implementation(libs.androidx.hilt.navigation.compose)
    ksp(libs.androidx.room.compiler)
    implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
}

実装

まずはApplicationクラス作ってHiltを有効にする

/**
 * ```AndroidManifest.xml
 * <application
 *     android:name=".Application"
 * ```
 */
@HiltAndroidApp
class Application : android.app.Application() {
    override fun onCreate() {
        super.onCreate()
    }
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    ...
}

Roomまわり

const val TABLE_POKEMON = "pokemon"
@Entity(tableName = TABLE_POKEMON)
data class Pokemon(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val name: String,
)

@Dao
interface PokemonDao {
    @Query("SELECT * FROM $TABLE_POKEMON")
    fun pagingSource(): PagingSource<Int, Pokemon>
    @Query("SELECT COUNT(*) FROM $TABLE_POKEMON")
    suspend fun count(): Long
    @Query("DELETE FROM $TABLE_POKEMON")
    suspend fun deleteAll()
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(data: Pokemon)
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(list: List<Pokemon>)
}

@Database(entities = [Pokemon::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun dao(): PokemonDao
}

class PokemonRepository @Inject constructor(
    private val pager: Pager<Int, Pokemon>,
) {
    fun getPagingStream() = pager.flow
}

RemoteMediator

MediatorResult.Success(true)を指定すると読み込みを終了します
ここではdbのサイズがPokeApiのcount以上が終了条件です

const val PAGE_SIZE = 20

@OptIn(ExperimentalPagingApi::class)
class PokemonRemoteMediator @Inject constructor(
    private val db: AppDatabase,
) : RemoteMediator<Int, Pokemon>() {
    override suspend fun initialize(): InitializeAction {
        return InitializeAction.SKIP_INITIAL_REFRESH
    }

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Pokemon>,
    ): MediatorResult {
        return try {
            val dao = db.dao()
            val count = dao.count()
            val offset = when (loadType) {
                LoadType.REFRESH -> if (count == 0L) 0L else return MediatorResult.Success(true)
                LoadType.PREPEND -> return MediatorResult.Success(true)
                LoadType.APPEND -> count
            }

            var poke_count = 0
            val result = mutableListOf<Pokemon>()
            PokeApi.Pokemon(limit = PAGE_SIZE, offset = offset.toInt(), onError = {
                Log.e("App", it.localizedMessage ?: "")
            }) { poke ->
                poke_count = poke.count
                result.addAll(poke.results.map {
                    Pokemon(name = it.name)
                })
            }

            if (result.isNotEmpty()) {
                db.withTransaction {
                    dao.insert(result)
                }
            }

            return MediatorResult.Success(poke_count < count + PAGE_SIZE)
        }
        catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

DIまわり

@Module
@InstallIn(ViewModelComponent::class)
object PokemonModule {
    @Provides
    fun provideDatabase(
        @ApplicationContext context: Context
    ) = Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()

    @Provides
    fun provideDao(db: AppDatabase) = db.dao()

    @OptIn(ExperimentalPagingApi::class)
    @Provides
    fun providePager(
        db: AppDatabase,
    ): Pager<Int, Pokemon> {
        return Pager(
            config = PagingConfig(pageSize = PAGE_SIZE),
            remoteMediator = PokemonRemoteMediator(
                db = db
            ),
            pagingSourceFactory = {
                db.dao().pagingSource()
            },
        )
    }

    @Provides
    fun provideRepository(pager: Pager<Int, Pokemon>) = PokemonRepository(pager)
}

UIまわり

リストを下にスクロールしていくとRemoteMediatorが実行されてリストが追加されていきます

@HiltViewModel
class PokeViewModel @Inject constructor(
    private val repo: PokemonRepository,
) : ViewModel() {
    val pokemon = repo.getPagingStream().cachedIn(viewModelScope)
}

@Composable
fun AppScreen(
    modifier: Modifier = Modifier,
    viewModel: PokeViewModel = hiltViewModel(),
) {
    val items = viewModel.pokemon.collectAsLazyPagingItems()

    LazyColumn(modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        items(
            count = items.itemCount,
            key = items.itemKey { it.id },
        ) { index ->
            val poke = items[index]
            poke?.apply {
                ListItem(headlineContent = {
                    Text("$index. $name")
                })
                Divider(color = Color.LightGray)
            }
        }
    }
}

Android Studio Giraffe 2022.3.1 Patch 1 built on August 17, 2023