适用场景:什么项目值得做离线优先同步

如果你的 Android 应用已经不只是演示接口,而是要承接笔记、草稿、巡检记录、任务清单、客户跟进这类真实业务,就不要再把“有网时请求接口,没网时弹 Toast”当成默认方案。Stepnex 方向的个人项目和小团队 App,真正麻烦的往往不是功能写不出来,而是弱网、切后台、进程被杀、设备重启后,数据状态开始飘:列表能看但详情打不开,编辑提示成功却没真正上送,重新联网后旧数据覆盖了新版本,或者补偿任务根本没有被执行。

离线优先不是完全脱离服务端,而是把“读、写、重试、冲突、恢复”拆成一条稳定链路:用户优先读取本地事实源,写操作先落本地,再由后台同步把变更推到服务端。对 Kotlin 项目来说,最稳的基础组合通常就是 Room、DataStore 和 WorkManager。如果你已经做过文件传输,可以再配合站内这篇 Android/Kotlin 图片上传实战,前者解决上传稳定性,后者解决列表、详情、草稿和同步队列的一致性。

技术取舍:Room 做事实源,DataStore 放轻配置

离线优先最容易走偏的地方,是把缓存、状态和同步任务都塞进 ViewModel、内存单例或临时文件。开发初期看起来很快,但应用进后台、系统回收进程、跨版本升级或数据库字段调整后,状态立刻变得不可解释。更稳的技术取舍是:

  1. 结构化业务数据进 Room,本地数据库做唯一事实源。
  2. 少量同步配置进 DataStore,例如“上次成功拉取时间”“是否仅 Wi-Fi 同步大资源”。
  3. 跨进程重启仍要继续执行的后台任务交给 WorkManager。

Android 官方离线优先文档强调:带网络的数据仓库必须至少有一个不依赖网络的本地数据源,而且读路径应优先从本地发出。DataStore 官方文档也明确把它定位为基于协程和 Flow 的异步、事务性轻量存储,适合放配置,不适合承接整张业务表。换句话说,笔记正文、同步状态、冲突标记和待重试队列放数据库;同步开关、最后一次全量拉取时间这种小配置再交给 DataStore。

步骤一:先定义业务表、队列表和同步状态

第一步不要急着写 Worker,而是先把数据模型定清楚。离线优先至少要有三类信息:业务表、待同步队列表、同步审计字段。以笔记或文章草稿场景为例,主表里至少要留出 serverIdupdatedAtsyncStatependingRevisionsyncState 不要只用一个布尔值,至少区分 syncedpending_createpending_updatepending_deleteconflictfailed。只有这样,UI 才能准确显示“已保存到本机”“等待上传”“发生冲突”还是“需要人工重试”。

队列表也不要省。很多项目只在主表里写一个“待同步”标记,结果一旦要记录重试次数、错误原因、幂等键或顺序关系,就只能到日志里猜。更稳的做法是单独建一张 sync_queue,把操作类型、目标主键、尝试次数、最近错误和下次可重试时间写进去。

@Entity(tableName = "notes")
data class NoteEntity(
    @PrimaryKey(autoGenerate = true) val localId: Long = 0,
    val serverId: String?,
    val title: String,
    val body: String,
    val updatedAt: Long,
    val syncState: String,
    val pendingRevision: Long,
)

@Entity(tableName = "sync_queue")
data class SyncQueueEntity(
    @PrimaryKey val opId: String,
    val targetLocalId: Long,
    val action: String,
    val attemptCount: Int,
    val nextRetryAt: Long,
    val lastError: String?,
)

这一步的目标不是字段越多越好,而是让“本地内容是什么、准备同步什么、为什么失败”都能回到数据库解释。后面做日志排查和版本回滚时,这会比只盯接口返回值靠谱得多。

步骤二:把依赖版本和配置边界先对齐

如果你现在新开项目,先确认依赖版本和平台边界,再复制配置。根据当前 Android 官方页面,Room 稳定版是 2.8.4,WorkManager 稳定版是 2.11.2,DataStore 指南中的 Preferences DataStore 依赖是 1.2.1。同时,WorkManager 当前要求 compileSdk 33+,而 Room 2.8 和 WorkManager 2.11 这两条线都已经要求 minSdk 23。所以老项目如果还卡在 minSdk 21/22,不要盲目照抄,应该先升级基线或锁定兼容版本。

plugins {
    id("com.google.devtools.ksp")
    id("androidx.room")
}

dependencies {
    val roomVersion = "2.8.4"
    val workVersion = "2.11.2"

    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion")
    ksp("androidx.room:room-compiler:$roomVersion")

    implementation("androidx.work:work-runtime-ktx:$workVersion")
    androidTestImplementation("androidx.work:work-testing:$workVersion")

    implementation("androidx.datastore:datastore-preferences:1.2.1")
}

room {
    schemaDirectory("$projectDir/schemas")
}

这里最容易忽略的是 schemaDirectory。Room 官方要求使用 Gradle Plugin 时导出 schema,这些 JSON 文件应该直接入库,因为它们不仅服务迁移测试,也是在你给同步表加字段、修复索引或回滚版本时最可靠的对照物。DataStore 依然只放轻配置,不要把整张列表缓存塞进去。

步骤三:Repository 先写本地,再触发唯一同步任务

Repository 是离线优先真正落地的入口。用户点保存时,不应该等远端接口成功才刷新列表,而是先把改动写入 Room,同时插入一条队列记录,再 enqueue 一个唯一的同步任务。这样 UI 立刻就能从本地 Flow 收到更新,用户哪怕马上切后台,待同步任务也还在。

class NotesRepository(
    private val noteDao: NoteDao,
    private val queueDao: SyncQueueDao,
    private val workManager: WorkManager,
) {
    suspend fun saveDraft(input: DraftInput) {
        val localId = noteDao.upsert(input.toEntity(syncState = "pending_update"))
        queueDao.insert(
            SyncQueueEntity(
                opId = "note-$localId-${System.currentTimeMillis()}",
                targetLocalId = localId,
                action = "upsert",
                attemptCount = 0,
                nextRetryAt = 0,
                lastError = null,
            )
        )

        workManager.enqueueUniqueWork(
            "notes-sync",
            ExistingWorkPolicy.KEEP,
            OneTimeWorkRequestBuilder<NotesSyncWorker>()
                .setConstraints(
                    Constraints.Builder()
                        .setRequiredNetworkType(NetworkType.CONNECTED)
                        .build()
                )
                .build()
        )
    }
}

唯一 WorkName 的价值很大。它能避免同一批队列被并发消费两次,也方便你在命令行和调试日志里看到一条稳定链路。真正需要顺序执行时,再用链式任务追加;需要“有新任务就并入同一消费器”时,用唯一任务策略会比自己造线程池稳得多。

步骤四:把重试、冲突和安全边界写成规则

WorkManager 负责可靠执行,但不会替你定义业务语义。真正落地时,最少要回答三件事:什么错误值得 Result.retry(),什么错误应该标记 failed 等人工处理,什么情况必须进入 conflict

网络超时、临时 5xx、断网恢复这类短期错误,适合返回 Result.retry()。官方文档说明 WorkManager 会按 backoff policy 重新调度,最小 backoff 不能低于 10 秒,默认采用指数退避。参数校验错误、401、服务端明确拒绝的业务错误,则不应该无限重试,而要把错误原因写回队列表和主表状态。真正麻烦的是冲突:例如用户在两台设备上都改了同一条笔记,或者服务端版本已经前进。这时不要静默覆盖,应该把记录打成 conflict,保留服务端快照摘要,让 UI 或调试页能明确提示。

安全边界也要一起考虑。离线优先不是把令牌、正文、签名串都明文扔进日志。认证信息按项目要求进入安全存储;数据库和同步日志只记录主键、阶段、错误码和耗时,不要把敏感正文、头像 URL 或服务端签名直接打进 logcat。

步骤五:验证方式要同时看命令、日志、数据库和测试

离线优先最怕“感觉应该能跑”。验证方式必须可复查。基础验证至少做四组:

  1. 断网新增或编辑一条记录,确认列表立即从 Room 刷新。
  2. 检查 sync_queue,确认新增一条可解释的待处理记录。
  3. 恢复网络后确认 Worker 运行,状态从 pending_update 变成 synced
  4. 模拟 500、超时和版本冲突,确认分别进入重试、失败或冲突状态。

命令和日志也要纳入日常排查。Android 官方 WorkManager 调试文档给了两条非常实用的命令:

adb shell dumpsys jobscheduler
adb shell am broadcast -a "androidx.work.diagnostics.REQUEST_DIAGNOSTICS" -p "com.stepnex.app"

前一条适合看系统层面是否真的排到了 JobScheduler,后一条适合在调试版里导出 WorkManager diagnostics。再配合 adb logcat | grep WM-、应用自己的同步日志标签,以及一个只在 debug 构建开放的“同步队列调试页”,你就能回答几个关键问题:Worker 有没有进队、约束是否满足、失败发生在哪一步、失败结果有没有写回 Room。

测试也不要只停在 Repository 单测。WorkManager 官方提供了 work-testing 工件;CoroutineWorker 场景优先用 TestListenableWorkerBuilder,需要更接近真实调度时再用 WorkManagerTestInitHelper。Room 一侧则至少保留迁移测试,确保你新增 syncStatependingRevisionlastError 字段时,不会把旧用户数据库直接打坏。

避坑点:最容易让离线同步失控的几种设计

第一,只用一个“已同步/未同步”布尔值,没有队列表、没有错误原因,最后只能靠日志猜状态。第二,把大对象、分页列表和业务缓存都塞进 DataStore,后期查询、冲突处理和清理都会很难做。第三,Worker 直接依赖页面临时参数或内存对象,进程重启后上下文就丢了。第四,服务端成功后忘记清本地 pending 标记,界面就会永远显示“待同步”。第五,Room schema 不导出、不入库,后面根本说不清是迁移脚本问题还是业务逻辑问题。

另一个常见误区是把周期任务当成主同步链路。周期 Work 适合做补偿拉取,不适合承接每一次用户写操作。写操作更适合本地落库后立即 enqueue 一次性任务;周期任务只负责每天一次全量校准,或者在充电加 Wi-Fi 时补拉大资源。

复盘清单:每次发版前固定检查什么

  • 配置:minSdkcompileSdk 与 Room、WorkManager 版本是否匹配。
  • 数据库:schemas/ 已更新并入库,新增字段有迁移测试。
  • 队列:断网新增、编辑、删除都能生成可解释的待同步记录。
  • 命令:adb shell dumpsys jobscheduler 和 WorkManager diagnostics 能看到任务。
  • 日志:成功、重试、冲突、永久失败都有明确日志标签,但不泄露敏感内容。
  • UI:列表、详情、错误提示和冲突标记都基于 Room 状态渲染,而不是基于一次接口回调。
  • 维护:即时同步任务和周期补偿任务已分开,不会重复消费同一批操作。

把这些项固定成发版前和线上回归的复盘清单后,离线优先就不再是“弱网时碰碰运气”,而会变成一条可维护、可排查、可持续演进的工程路径。