27、Flow与数据同步:离线优先策略、网络与本地数据同步、冲突解决

说实话,离线优先这个事,我踩过不少坑。

早些年做的一个社交App,用户发动态时如果网络不好,直接就弹Toast说"网络异常"。结果呢?用户骂声一片。后来我学乖了——先存本地,再发网络。这就是离线优先的核心思路。

今天咱们就聊聊,怎么用Flow把这套逻辑做得优雅。

离线优先策略:为什么非要"优先"?

离线优先不是"没网也能用"那么简单。它背后有个关键假设:本地数据才是真理

你想想看,用户在地铁里刷到一条内容,点了个赞。这时候网络断了,如果App直接忽略这个操作,用户会怎么想?"这App有bug吧?"

正确的做法是:

  • 先更新本地数据库(Room)
  • 再尝试同步到服务器
  • 如果网络失败,把操作放进"待同步队列"

我在项目中遇到过一种情况:用户离线状态下连续操作了10条数据,网络恢复后一次性同步。如果同步过程中某条失败了,不能把前面成功的也回滚。所以每条操作都得独立处理

核心原则:本地数据库是"源",网络是"目标"。用户永远操作本地数据,网络只是同步的通道。

网络与本地数据同步:用Flow搭桥

同步这件事,说白了就是本地数据变化 → 触发网络请求 → 更新本地结果。用Flow来做,天然合适。

我习惯把同步流程拆成三层:

  1. 数据层:Room DAO 返回 Flow,监听本地变化
  2. 同步层:监听本地变化,触发网络请求
  3. 展示层:ViewModel 收集 Flow,更新 UI

举个例子,一个简单的笔记同步:

// Room DAO
@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY updatedAt DESC")
    fun observeAll(): Flow<List<Note>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsert(note: Note)
}

// Repository
class NoteRepository(
    private val dao: NoteDao,
    private val api: NoteApi
) {
    fun getNotes(): Flow<List<Note>> {
        return dao.observeAll()
            .onEach { localNotes ->
                // 每次本地数据变化,尝试同步
                syncIfNeeded(localNotes)
            }
    }

    private suspend fun syncIfNeeded(localNotes: List<Note>) {
        // 找出需要同步的笔记(比如未同步的)
        val pending = localNotes.filter { !it.synced }
        if (pending.isEmpty()) return

        try {
            val result = api.syncNotes(pending)
            // 更新本地同步状态
            dao.upsertAll(result)
        } catch (e: Exception) {
            // 同步失败,保留本地数据,下次再试
            Log.e("Sync", "同步失败", e)
        }
    }
}

这里有个细节:syncIfNeeded 是在 onEach 里调用的。这意味着每次本地数据变化都会触发同步。嗯,这里要注意——如果用户连续修改10次,就会触发10次同步。我一般会加个防抖:

.debounce(500) // 500ms 内的变化合并成一次同步

小技巧:同步状态可以用一个单独的字段标记,比如 syncStatus: PENDING / SYNCING / SYNCED / FAILED。这样 UI 层可以展示同步进度,用户体验会好很多。

冲突解决:谁才是对的?

冲突,说白了就是本地和服务器对同一条数据做了不同的修改。比如用户离线时改了笔记标题,服务器端另一个设备也改了同一篇笔记的标题。网络恢复后,以谁为准?

我常用的策略有三种:

策略 适用场景 缺点
最后写入者胜出 数据不敏感,比如阅读进度 可能丢失较早的修改
时间戳合并 字段级冲突,比如联系人信息 需要精确的时间同步
用户手动解决 关键数据,比如订单金额 用户体验差,需要弹窗

我个人习惯用最后写入者胜出 + 版本号的组合。每条数据带一个 version 字段,每次修改递增。同步时带上版本号,服务器判断:

  • 如果本地版本 > 服务器版本 → 以本地为准
  • 如果服务器版本 > 本地版本 → 以服务器为准
  • 如果版本相同 → 不处理

代码实现大概是这样的:

data class Note(
    val id: String,
    val title: String,
    val content: String,
    val version: Int,
    val updatedAt: Long
)

// 同步时的冲突检测
fun resolveConflict(local: Note, remote: Note): Note {
    return when {
        local.version > remote.version -> local
        remote.version > local.version -> remote
        else -> local // 版本相同,以本地为准
    }
}

注意:版本号必须严格递增,不能回退。我曾经遇到过一个问题:服务器重启后版本号重置了,导致大量数据被错误覆盖。后来我改用时间戳 + UUID 的组合,才彻底解决。

用 SVG 画一张同步流程图

下面这张图展示了离线优先策略下,数据从用户操作到最终同步的完整流程:

离线优先同步流程 用户操作 本地数据库 (Room) 写入 待同步队列 标记待同步 网络请求 触发同步 远程服务器 冲突检测 同步成功 冲突,重试 用户操作 本地存储 同步队列 网络请求 远程服务器 冲突检测

避坑指南:我踩过的几个坑

讲几个实战中容易翻车的地方:

  • 同步顺序问题:如果用户先删了一条数据,又新建了一条同ID的数据,同步时顺序错了就会出问题。我建议每条操作都带一个时间戳,服务器按时间戳排序处理。
  • 重复同步:网络恢复时,如果同时触发多个同步任务,可能导致重复请求。用 Mutex 或者 Channel 做串行化处理。
  • 数据膨胀:待同步队列如果一直不成功,会越积越多。我设了一个最大重试次数,超过次数就标记为"同步失败",让用户手动处理。

我的习惯:在 Repository 层暴露两个 Flow——一个用于展示(只读),一个用于同步(带状态)。这样 ViewModel 可以分别收集,互不干扰。

好了,离线优先的核心思路就这些。说白了就是本地优先、异步同步、冲突可解。用 Flow 来做,代码干净,逻辑清晰。下次遇到网络不稳定的场景,试试这套方案,你会发现用户反馈会好很多。

公众号:蓝海资料掘金营,微信deep3321