27、Flow与数据同步:离线优先策略、网络与本地数据同步、冲突解决
说实话,离线优先这个事,我踩过不少坑。
早些年做的一个社交App,用户发动态时如果网络不好,直接就弹Toast说"网络异常"。结果呢?用户骂声一片。后来我学乖了——先存本地,再发网络。这就是离线优先的核心思路。
今天咱们就聊聊,怎么用Flow把这套逻辑做得优雅。
离线优先策略:为什么非要"优先"?
离线优先不是"没网也能用"那么简单。它背后有个关键假设:本地数据才是真理。
你想想看,用户在地铁里刷到一条内容,点了个赞。这时候网络断了,如果App直接忽略这个操作,用户会怎么想?"这App有bug吧?"
正确的做法是:
- 先更新本地数据库(Room)
- 再尝试同步到服务器
- 如果网络失败,把操作放进"待同步队列"
我在项目中遇到过一种情况:用户离线状态下连续操作了10条数据,网络恢复后一次性同步。如果同步过程中某条失败了,不能把前面成功的也回滚。所以每条操作都得独立处理。
核心原则:本地数据库是"源",网络是"目标"。用户永远操作本地数据,网络只是同步的通道。
网络与本地数据同步:用Flow搭桥
同步这件事,说白了就是本地数据变化 → 触发网络请求 → 更新本地结果。用Flow来做,天然合适。
我习惯把同步流程拆成三层:
- 数据层:Room DAO 返回 Flow,监听本地变化
- 同步层:监听本地变化,触发网络请求
- 展示层: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 画一张同步流程图
下面这张图展示了离线优先策略下,数据从用户操作到最终同步的完整流程:
避坑指南:我踩过的几个坑
讲几个实战中容易翻车的地方:
- 同步顺序问题:如果用户先删了一条数据,又新建了一条同ID的数据,同步时顺序错了就会出问题。我建议每条操作都带一个时间戳,服务器按时间戳排序处理。
- 重复同步:网络恢复时,如果同时触发多个同步任务,可能导致重复请求。用
Mutex或者Channel做串行化处理。 - 数据膨胀:待同步队列如果一直不成功,会越积越多。我设了一个最大重试次数,超过次数就标记为"同步失败",让用户手动处理。
我的习惯:在 Repository 层暴露两个 Flow——一个用于展示(只读),一个用于同步(带状态)。这样 ViewModel 可以分别收集,互不干扰。
好了,离线优先的核心思路就这些。说白了就是本地优先、异步同步、冲突可解。用 Flow 来做,代码干净,逻辑清晰。下次遇到网络不稳定的场景,试试这套方案,你会发现用户反馈会好很多。