Android Academy · 课时

RemoteMediator 与缓存

结合网络分页和数据库分页

第 4 / 4 课13 个步骤

RemoteMediator 与缓存 是 CoddyKit 上的免费 Android Academy 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Android Academy 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Android Academy 课程共包含 4 节课。

离线优先分页

普通的网络 PagingSource 可以正常工作,但连接断开后列表就会变空。要构建健壮的应用,您需要让数据库成为唯一可信数据源,并在后台由网络为其填充数据。

这正是 RemoteMediator 所实现的功能:从本地数据库进行分页,并在数据库中的数据用尽时从网络获取更多数据。

架构

使用 RemoteMediator 时,数据沿一个方向流动:

  • 界面从 Room 的 PagingSource 进行分页
  • 当 Room 中的数据接近末尾时,RemoteMediator 获取网络中的下一页
  • 网络结果被写入 Room
  • Room 发出新增的行,界面随之更新

即使处于离线状态,用户也总能立即看到缓存的数据。

// Network ---> RemoteMediator ---> Room (source of truth) ---> UI
// UI never reads the network directly

免费获得 Room PagingSource

Room 可以自动生成 PagingSource。您只需声明一个返回 PagingSource<Int, Entity> 的查询,Room 就会负责实现它。

界面实际就是从这个数据源进行分页的。

import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query

@Dao
interface ArticleDao {
    @Query("SELECT * FROM articles ORDER BY position ASC")
    fun pagingSource(): PagingSource<Int, ArticleEntity>
}

继承 RemoteMediator

RemoteMediator<Key, Value> 有一个必需的方法 load(),以及一个可选的 initialize()。

load() 接收一个 LoadType(REFRESH、PREPEND 或 APPEND)和当前的 PagingState,然后返回一个 MediatorResult。

import androidx.paging.ExperimentalPagingApi
import androidx.paging.RemoteMediator

@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
    private val api: ArticleApi,
    private val db: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
    // override suspend fun load(loadType, state): MediatorResult
}

处理 LoadType

在 load() 中,您需要根据 LoadType 决定要获取哪一页:

  • REFRESH - 加载第一页(或锚点附近的页面)
  • PREPEND - 通常无需执行任何操作;返回 endOfPaginationReached = true
  • APPEND - 加载当前已有数据之后的页面
val page: Int = when (loadType) {
    LoadType.REFRESH -> 1
    LoadType.PREPEND ->
        return MediatorResult.Success(endOfPaginationReached = true)
    LoadType.APPEND -> {
        val lastKey = db.remoteKeyDao().last()?.nextKey
            ?: return MediatorResult.Success(endOfPaginationReached = true)
        lastKey
    }
}

远程键表

与 PagingSource 不同,RemoteMediator 不能直接返回下一页或上一页的键,这些键必须持久化。请将它们与数据一起存储在一个小型的远程键表中。

这样,应用重启后就可以继续执行 APPEND/PREPEND。

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "remote_keys")
data class RemoteKey(
    @PrimaryKey val articleId: Int,
    val prevKey: Int?,
    val nextKey: Int?
)

将网络结果写入 Room

获取一页数据后,请在事务中将数据项及其键写入 Room。执行 REFRESH 时,应先清除旧表,以保持缓存一致。

db.withTransaction {
    if (loadType == LoadType.REFRESH) {
        db.remoteKeyDao().clearAll()
        db.articleDao().clearAll()
    }
    val nextKey = if (response.items.isEmpty()) null else page + 1
    db.remoteKeyDao().insertAll(
        response.items.map { RemoteKey(it.id, prevKey = page - 1, nextKey = nextKey) }
    )
    db.articleDao().insertAll(response.items.map { it.toEntity() })
}

返回 MediatorResult

通过返回一个 MediatorResult 来结束 load():

  • 没有更多页面时返回 Success(endOfPaginationReached = true)
  • 可能还有更多页面时返回 Success(endOfPaginationReached = false)
  • 失败时返回 Error(e),这会体现为 LoadState.Error
return try {
    val response = api.getArticles(page = page, size = state.config.pageSize)
    db.withTransaction { /* write items + keys */ }
    MediatorResult.Success(
        endOfPaginationReached = response.items.isEmpty()
    )
} catch (e: IOException) {
    MediatorResult.Error(e)
} catch (e: HttpException) {
    MediatorResult.Error(e)
}

将 Pager 与 remoteMediator 连接起来

同时使用 remoteMediator 和 Room 的 pagingSourceFactory 构建 Pager。中介器负责填充 Room;界面从 Room 进行分页。

由于使用了实验性 API,请通过 @OptIn(ExperimentalPagingApi::class) opt in。

@OptIn(ExperimentalPagingApi::class)
fun articles(): Flow<PagingData<ArticleEntity>> = Pager(
    config = PagingConfig(pageSize = 20),
    remoteMediator = ArticleRemoteMediator(api, db),
    pagingSourceFactory = { db.articleDao().pagingSource() }
).flow

查找 APPEND 所需的键

执行 APPEND 加载时,您需要最后一个已加载数据项的 nextKey。请使用 PagingState 的锚点,从远程键表中找到正确的远程键。

通过辅助函数,可以让跨越 REFRESH、APPEND 和 PREPEND 的 load() 保持清晰易读。

private suspend fun lastRemoteKey(
    state: PagingState<Int, ArticleEntity>
): RemoteKey? {
    return state.pages.lastOrNull { it.data.isNotEmpty() }
        ?.data?.lastOrNull()
        ?.let { db.remoteKeyDao().keyFor(it.id) }
}

// In load(): for APPEND
val nextKey = lastRemoteKey(state)?.nextKey
    ?: return MediatorResult.Success(endOfPaginationReached = true)

缓存与新鲜度

由于数据存储在 Room 中,应用可以立即加载数据并支持离线使用。为了避免内容过时,请重写 initialize(),根据缓存存在的时间决定是否在启动时刷新。

override suspend fun initialize(): InitializeAction {
    val lastUpdate = db.remoteKeyDao().lastUpdatedMillis() ?: 0L
    val cacheTimeout = TimeUnit.HOURS.toMillis(1)
    return if (System.currentTimeMillis() - lastUpdate >= cacheTimeout) {
        InitializeAction.LAUNCH_INITIAL_REFRESH
    } else {
        InitializeAction.SKIP_INITIAL_REFRESH
    }
}

快速检查

在 RemoteMediator 架构中,界面进行分页时所依赖的唯一可信数据源是什么?

总结:RemoteMediator 与缓存

您构建了一个离线优先的分页流程:

  • 界面从 Room 的 PagingSource 进行分页,它是唯一可信数据源
  • RemoteMediator.load() 处理 REFRESH/PREPEND/APPEND,并在事务中将结果写入 Room
  • 远程键表会持久化下一页和上一页的键,使其在应用重启后仍然可用
  • Pager 同时接收 remoteMediator 和 pagingSourceFactory
  • initialize() 控制缓存的新鲜度

现在,您可以高效地加载无限列表,无论在线还是离线。恭喜您完成 Paging 3 课程。

免费开始

用 AI 导师学习 Kotlin — 免费

在浏览器中编写并运行真实代码,获得全天候 AI 导师的即时帮助,并在网页或应用中继续学习。

课程
36
课程
152

常见问题解答

「RemoteMediator 与缓存」课时是免费的吗?

是的 — 「RemoteMediator 与缓存」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Android Academy 课程的其余内容,请升级到 CoddyKit PRO。 Android Academy 课程共包含 4 节课。

「RemoteMediator 与缓存」这节课中我会学到什么?

结合网络分页和数据库分页 你通过在浏览器中直接运行的动手代码来练习 Android Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Android Academy 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Android Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。

「RemoteMediator 与缓存」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Android Academy 课中编写并运行代码吗?

能。每节 Android Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 为什么需要分页
  2. PagingSource 与 Pager
  3. 在 Compose 列表中实现分页
  4. RemoteMediator 与缓存
← 返回 Android Academy