即时通讯里图文 / 视频消息的可靠发送:先落库、再上传、杀进程可恢复
项目 social-app
1. 这篇在讲什么
把「这条消息还差哪些资源没传、每个资源传到哪一步、有没有失败」写进消息自己的本地扩展字段,跟着即时通讯本地数据库一起落盘;会话页加载消息时按这些状态懒恢复,只补传缺的资源,全部到位后再真正投递。由此修掉了「发视频时杀掉 App,再打开一直转圈」的问题。
视频消息的导出与重发模型,以及杀进程后永久转圈的修复。 图文混排消息的延迟发送框架是已有实现,修复视频消息时拿它做了对照。
2. 业务背景
这是一款面向日本市场的语音社交 App。私聊和群聊里除了文字,玩家经常发截图、游戏录屏和图文混排的长消息。即时通讯基于第三方 IM SDK,媒体文件存在文件存储服务上,消息里只放文件地址。
对非 iOS 读者的一句话:发一张图要走四步,任何一步都可能因为切后台、被系统杀掉、断网而中断。用户的预期却很简单:点了发送,要么发出去,要么明确告诉发送方失败、可以重发。
两类消息的差异:
| 消息类型 | 资源数量 | 上传方式 |
|---|---|---|
| 图文混排 | 一条消息含多张图或视频 | App 自己上传到文件存储,再发自定义消息 |
| 视频消息 | 一个视频 | 导出后交给 IM SDK 的视频消息体发送 |
3. 问题现象
测试反馈过这样的缺陷:发送视频时杀掉程序,再次打开后,这条视频一直处于 loading,既不发出,也不出现重发按钮。
4. 这类杀进程后发送失败的问题怎么复现、怎么定位
具体当时怎么复现、怎么一步步查到根因,已经记不清了。下面不写回忆,只根据这次修复所改掉的判断逻辑,归纳这类问题可以怎么下手。
- 可以这样复现:在上传进度还没走完时杀掉进程,再打开并进入会话。如果只在「已经交给 SDK、还没拿到远端地址」这个窗口必现,而在导出阶段杀进程并不复现,就可以把范围缩到这一段。
- 看落盘数据:重进会话后查看消息对象——消息体是不是已经变成视频类型、远端地址是不是空的、本地扩展字段还在不在。这类问题里,常见的组合是:视频体还在、地址为空、本地状态已经被清掉。
- 定位思路:先找恢复逻辑里「怎样才算发送完成」。如果判断标准是「消息体已经是视频类型」,就很容易误判。被杀进程后,SDK 可能把带本地路径、但没有远端地址的视频体也写进了本地库,于是一进会话就被当成已完成,本地状态被清空,界面既没有进度,也没有失败按钮。
- 对照另一种实现:图文混排消息的完成条件是「每个资源都有远端地址」,而不是「消息体类型对不对」。对比两种判断标准,就能看出视频消息这条判断为什么会漏。
5. 方案
5.1 判断标准改为「有没有远端地址」
视频消息只有在远端地址非空时才算完成;地址为空时把残留的视频体丢掉,走「重新导出 → 删除旧占位 → 重新发送」的恢复路径。导出文件还在就直接复用,不在就根据相册标识重新导出。
5.2 修复前后对比
修复前:
修复后:
5.3 图文混排消息的整体状态机
图文消息的设计更完整,也是修复视频消息时参照的模型:
本地扩展字段里存的内容(概念):
| 字段(示意名) | 含义 |
|---|---|
| 待延迟发送 | 这条消息还有资源没传完,不能直接投递 |
| 资源 i 的本地文件名 | 导出后的文件,用于续传 |
| 资源 i 的相册标识 | 导出文件丢了可以重新导出 |
| 资源 i 的上传状态 | 未开始 / 上传中 / 失败 / 文件过大 |
| 上传失败 | 气泡显示重发按钮 |
图文是一条消息,所有资源都有远端地址之后才投递,对方不会先看到文字、后看到图片。文件过大单独记一种失败原因,是为了提示语不同,而且再试也没有意义。
投递时走即时通讯 SDK 的「重发」而不是「发送」:消息已经在本地库里,再调用发送会生成一条重复消息。
5.4 并发与线程模型
- 按消息 ID 去重:页面刷新会重建 Cell 模型。如果这条消息已经在导出或上传,只把回调对象换成最新的模型,避免同一条消息被导出两次,也避免回调打到已经被释放的旧模型上。
- 登记表只在主线程读写:读写处加了主线程断言,避免并发修改字典。
- 上传限流:全局计数信号量(值为 3),每个任务结束(成功或失败)都释放一次。
信号量和串行队列怎么选:这里用信号量做的是「最多同时跑 N 路」和「等一组导出全部结束」,不是用来保护共享字典。共享字典(导出登记表)用主线程串行化,而不是再加一把锁。信号量在主线程上 wait 会有死锁风险,所以等待发生在导出队列里,不发生在主线程。
5.5 伪代码(示意,非原始代码)
// 视频消息恢复
func recoverVideoMessageIfNeeded(msg) {
if !msg.isOutgoing { return }
if msg.body is Video {
if msg.body.remoteURL is not empty {
msg.localState = nil // 真正完成
return
}
msg.body = nil // 残留体丢弃,进入恢复
}
guard let assetId, fileName = msg.localState else { return }
if fileExists(exportPath(fileName)) {
resend(msg, file: exportPath(fileName))
} else {
exportFromAlbum(assetId, to: fileName) { resend(msg, file: $0) }
}
}
func resend(msg, file) {
msg.body = Video(localFile: file)
localDB.delete(oldPlaceholder(msg))
session.send(msg) // 失败会显示重发按钮
}
// 图文消息:单个资源上传成功
func onResourceUploaded(msg, index, url) {
msg.attachment.resources[index].url = url
msg.localState.remove(uploadState(index))
if msg.attachment.resources.allSatisfy({ $0.url is not empty }) {
msg.localState.remove(delaySend, uploadFailed)
localDB.update(msg)
im.resend(msg) // 消息已在本地库,用重发接口投递
} else {
localDB.update(msg) // 每步都落盘,杀进程可续传
}
}
6. 效果
- 按修复后的逻辑,杀进程、断网、切后台之后,媒体消息只应剩下两种终态:发出,或者显示失败可重发,不再出现悬挂的转圈。
- 重发只补传没有地址的资源,多图消息不会因为一张失败而全部重传。
5.6 把上面的机制拆开看
发一张图固定四步,任何一步中断都要能停在一个明确状态。
界面、会话、本地库和上传队列各管一层。状态写在消息上,不另建任务表。
视频消息按有没有远端地址来分,而不是按「是不是视频体」。
图文混排是一组资源,不是一条视频。
杀进程之后只读本地扩展,缺什么补什么。
导出用信号量限制同时进行的路数,登记表只在主线程改。
这几步的顺序是后加的能力叠在已有的延迟发送上:先有图文,再补视频导出,再让杀进程后能续上。
7. 取舍与还能怎么做
为什么把状态放在消息的本地扩展字段,而不是自建一张任务表?
- 好处:状态和消息同生命周期、同一次写入落盘;消息被删除,状态自然消失;不需要维护两份数据的一致性。
- 代价:字符串键名没有类型约束,字段多了以后难以维护;也无法脱离会话页统一调度。
为什么是懒恢复(打开会话才续传),而不是启动时扫描?
- 好处:实现简单,不在启动阶段抢资源。
- 代价:用户不打开那个会话,消息就一直停在发送中。改进:启动后空闲时扫描「待延迟发送」的消息,交给后台任务。
还能改进的地方(未做):
- 后台上传:换成后台
URLSession,App 挂起后上传仍可继续,被杀后由系统回调唤醒。 - 自动重试与退避:目前失败后需要用户手动重发,可以按网络恢复事件自动重试,指数退避,超过次数再提示。
- 线程安全收敛:上传回调对象的字典目前在多个队列访问,应统一到一个串行队列或
actor。 - 信号量风险:若某个任务既不成功也不失败(如回调丢失),上传名额永远不释放,应加超时兜底。
- 统一模型:视频消息与图文消息是两套恢复逻辑,可以统一成「资源列表 + 状态机」一套实现。