离线优先不是加缓存,是改写真相的归属
UI 永远读本地真相;网络不是另一份 UI 数据,而是负责把本地意图与服务端事实对账。
客户端提交消息后超时,不知道服务端是否已写入。最安全的重试依据是什么?
- 换一个新消息 ID 再发
- 用同一个客户端幂等键重试
- 比较消息文本是否重复
- 永不重试,避免重复
缓存模式有两条读路径
常见「有网读 API,没网读缓存」把 UI 分成两条路径:网络成功时展示 DTO,离线时展示 Entity。映射、排序与错误行为很容易分叉。离线优先把路径收成一条:UI 始终观察本地数据库;同步器从网络拿到事实后事务写库;Room Flow 再把变化送给 UI。
这不是说本地永远比服务端权威,而是说对当前设备 UI 而言,本地是唯一可观察入口。服务端仍决定全局顺序、权限与最终确认,本地保存已知服务端事实与尚未对账的用户意图。
发送消息是一笔本地事务
- 客户端生成稳定
clientMessageId。 - 一个数据库事务写入消息行
Pending与 outbox 命令。 - UI 从 Room 立刻看到待发气泡。
- WorkManager 在有网时读取 outbox,带同一个幂等键提交。
- 服务端若第一次见该键,创建消息;若已处理,返回同一结果。
- 客户端事务更新
serverId、服务端序号与状态,并删除 outbox 项。
关键是第 2 步原子化。若先写消息、后写队列,中间崩溃会留下永不发送的 Pending;反过来则可能有工作找不到消息。Room 事务把两项承诺绑在一起。
@Transaction
suspend fun queueMessage(entity: MessageEntity, command: OutboxEntity) {
messageDao.insert(entity)
outboxDao.insert(command)
}
重试不是重复执行
移动网络最麻烦的不是明确失败,而是结果未知:服务端已经提交,响应却在路上丢了。幂等键让同一业务动作重放多次,服务端效果仍只有一次。文本、时间戳、文件哈希都不能替代幂等键,因为不同合法消息可能相同。
重试要指数退避并加抖动,避免断网恢复时所有设备同时冲击服务端;永久错误如权限撤销应停止重试并进入可见失败状态;认证过期可以刷新一次令牌,但必须防止多个请求同时触发刷新风暴。
顺序由谁定义
客户端时间不可信:时钟会漂移,设备会离线,多端会并发。会话内顺序应由服务端单调序号或可排序游标定义。本地 Pending 尚无服务端序号时,需要明确展示政策,例如放在尾部并标记「发送中」,确认后按服务端序号归位。
推送只是一声门铃,不是真相本身。FCM 通知可能延迟、合并或丢失;收到后用增量游标拉取缺口并写库。应用回前台也做轻量对账,不能把每一条消息的可靠性寄托在每一条推送上。
用纯 Kotlin 模拟「服务端已收、响应丢失、客户端重试」。同一个幂等键两次提交,只产生一条服务端消息:
first attempt: server stored s-1, response lost retry: server returns existing s-1 server message count=1
data class ServerMessage(val serverId: String, val text: String)
class FakeServer {
private val byKey = mutableMapOf<String, ServerMessage>()
fun send(key: String, text: String): ServerMessage =
byKey.getOrPut(key) {
ServerMessage("s-${byKey.size + 1}", text)
}
fun size() = byKey.size
}
fun main() {
val server = FakeServer()
val first = server.send("c-42", "urgent")
println("first attempt: server stored ${first.serverId}, response lost")
val retry = server.send("c-42", "urgent")
println("retry: server returns existing ${retry.serverId}")
println("server message count=${server.size()}")
}在 Kotlin Playground 运行。把重试 key 改成新值,观察为什么「请求超时就生成新 ID」会制造重复消息。
医疗沟通的弱网与多设备并非边角场景。消息可能包含临床决策,不能靠「多数时候顺序对」;但客户端也不能声称自己能保证全球严格顺序。合理合同是会话内服务端序号、明确的待发状态、可恢复重试与缺口同步。
「监听 ConnectivityManager,有网就表示请求会成功。」网络能力是提示,不是承诺;DNS、TLS、门户认证与服务端仍可能失败。
直接尝试请求,按错误分类处理;Connectivity 只用于调度优化与界面提示,不能替代幂等、退避和持久队列。
答案是第 2 项。相同客户端幂等键让服务端识别「这是同一个业务动作的再次尝试」,无论第一次响应是否丢失,都返回同一条消息。换 ID 会重复,比较文本会误伤合法重复内容,永不重试则会丢承诺。
这一章的一句话
把意图先原子地写进本地,再用幂等协议与服务端对账,离线才从异常变成正常状态。
下一章处理工程边界:Repository、依赖注入和模块化经常一起出现,但三者分别管理数据来源、对象装配和构建变化,不能用「都能解耦」含糊带过。