Flutter 断线重连后的消息补偿
之前写过 Flutter 的渲染机制,这次记一下聊天数据的处理。
商业 IM 软件的 App 用 XMPP 收发消息。手机切网,或者从后台回到前台,除了重新连接,还要把离线期间漏掉的消息拉回来。连接到了 ready,后面这部分工作才刚开始。
先把连接和消息补偿分开
项目里有两层职责。
XMPP 协议层处理连接状态和协议能力;App 里的 XmppClientStore、房间消息逻辑处理账号、生命周期以及业务恢复。
离线补偿会调用 HTTP 历史消息接口,把返回的数据转换为客户端消息,再交给后续存储和状态更新流程。
这样可以分别回答两个问题:
- XMPP 现在能不能继续收发?
- 本地缺少的消息有没有补回来?
比如,XMPP 已经恢复,但历史接口请求失败。这时继续把连接标成断开,并不能准确表达问题;反过来,只显示在线也不能代表同步完成。
从排障角度看,至少要能区分连接恢复失败和补偿失败。否则日志里一句“重连成功”,很容易让人忽略后面的数据缺口。
补偿需要一个起点
如果每次回到前台都拉全部历史,逻辑虽然直接,但历史越多,重复工作也越多。
项目的离线同步入口会接收房间、最后消息 ID、最后消息时间,以及可选的 sourceGroupJid。HTTP 请求使用 afterOriginId 表达增量边界。
其中,来源群也属于同步范围。相同聊天对象在不同群业务下的私聊,不能共用一个模糊的“最后一条消息”。
房间层还会计算补偿起点,某些分支会往前回退一段时间。这里反映了一种取舍:允许同步区间有重叠,再按消息身份合并,给边界附近的消息留出补偿空间。
但回退多久属于项目策略,不能照搬成所有聊天系统的固定值。判断策略是否够用,需要结合服务端历史查询的排序和边界语义。
同一个房间,不要同时跑几套补偿
恢复连接、进入会话等入口,都可能触发同步。如果不协调,它们可能同时读取同一个旧边界,再请求同一批消息。
当前 App 的 syncOfflineMessage 按房间和来源群取锁。下面是源码中的关键片段:
final lock = _roomSyncLocks.putIfAbsent(
(roomId, sourceGroupJid?.isEmpty == true ? null : sourceGroupJid),
Lock.new);
后续同步逻辑放在 lock.synchronized 里面执行。同一范围的调用会串行进入这段逻辑。
这里有个容易混淆的地方:串行执行,并不等于多个调用自动共用同一次请求结果。
如果两个调用都已经拿到了旧边界,排队以后仍然可能各请求一次。是否需要进一步合并任务,或者执行时重新读取边界,是另一层优化,不能仅凭加了一把锁就认为重复请求已经全部消失。
请求返回时,账号可能已经变了
这个场景更值得单独说一下:
账号 A 发起历史消息请求
→ 用户退出账号 A
→ 用户登录账号 B
→ 账号 A 的请求现在才返回
HTTP 请求成功了,数据也能正常解析,但它已经不属于当前账号。
所以异步处理除了检查“有没有报错”,还要检查“结果现在还有没有资格继续生效”。
App 在同步开始时记录任务代次 generation 和账号 accountJid,并在多个异步边界后检查。下面是项目中的校验方法:
void _checkOfflineSyncGeneration(int generation, String? accountJid) {
if (generation != _offlineSyncGeneration ||
accountJid != _offlineSyncAccountJid) {
throw StateError('离线消息请求所属账号已失效');
}
}
当同步失效时,_offlineSyncGeneration 会递增,并清理房间同步锁的映射。旧任务恢复执行后,就能发现自己的代次已经过期。
同时比较账号和代次也有意义。账号标识相同,不一定还是同一轮会话,例如退出后又登录同一个账号。代次可以表达这种生命周期变化。
这种检查不会让已经发出的网络请求凭空消失。它控制的是后续处理:在检查点发现任务失效,就不再继续把旧结果当作当前结果使用。
类似问题也会出现在搜索联想、切换详情页和附件处理里,只是聊天系统的账号与会话边界,让这个问题更明显。
请求失败,不能伪装成没有新消息
历史接口返回空数组,可以表示这一轮没有更多数据。网络请求失败,表达的却是这一轮没有拿到可靠结果。
如果两者最后都变成 [],上层就无法知道该结束同步还是等待重试。
当前 syncOfflineMessageFromApi 使用严格请求模式,失败会继续抛出。房间层负责安排有限重试,而不是让失败批次以空数组或部分结果的形式冒充成功。
这里处理的是请求错误,没有把整个同步过程放进数据库事务。
请求失败就让上层重试;账号失效则停止处理,不能再往当前账号写数据。
排查同步问题
我会沿着下面几个节点检查一次恢复:连接进入可用状态、确定会话范围和起点、获取历史、校验任务有效性、转换并保存消息、更新窗口与未读状态。
尤其是退出账号后旧请求才返回的情况,光看请求有没有成功很容易漏掉。账号和 generation 的检查需要跟着异步处理一起看。