即时通讯里图文 / 视频消息的可靠发送:先落库、再上传、杀进程可恢复

项目 social-app

  • 即时通讯
  • 上传
  • 状态机
  • 并发
  • 稳定性

1. 这篇在讲什么

把「这条消息还差哪些资源没传、每个资源传到哪一步、有没有失败」写进消息自己的本地扩展字段,跟着即时通讯本地数据库一起落盘;会话页加载消息时按这些状态懒恢复,只补传缺的资源,全部到位后再真正投递。由此修掉了「发视频时杀掉 App,再打开一直转圈」的问题。

视频消息的导出与重发模型,以及杀进程后永久转圈的修复。 图文混排消息的延迟发送框架是已有实现,修复视频消息时拿它做了对照。


2. 业务背景

这是一款面向日本市场的语音社交 App。私聊和群聊里除了文字,玩家经常发截图、游戏录屏和图文混排的长消息。即时通讯基于第三方 IM SDK,媒体文件存在文件存储服务上,消息里只放文件地址。

玩家选图片或视频本地导出与压缩上传到文件存储拿到远端地址写入消息体投递给对方对方看到图片或视频

对非 iOS 读者的一句话:发一张图要走四步,任何一步都可能因为切后台、被系统杀掉、断网而中断。用户的预期却很简单:点了发送,要么发出去,要么明确告诉发送方失败、可以重发。

两类消息的差异:

消息类型 资源数量 上传方式
图文混排 一条消息含多张图或视频 App 自己上传到文件存储,再发自定义消息
视频消息 一个视频 导出后交给 IM SDK 的视频消息体发送

3. 问题现象

测试反馈过这样的缺陷:发送视频时杀掉程序,再次打开后,这条视频一直处于 loading,既不发出,也不出现重发按钮。

即时通讯服务视频导出本地库会话页用户即时通讯服务视频导出本地库会话页用户上传中 用户杀掉 App选择视频 点发送插入本地占位消息 记录相册标识和导出文件名开始导出导出完成用视频消息体发送 SDK 开始上传重新打开 进入会话读取消息 视频体存在但没有远端地址误判为已发送 清空本地状态气泡一直转圈 没有重发入口

4. 这类杀进程后发送失败的问题怎么复现、怎么定位

具体当时怎么复现、怎么一步步查到根因,已经记不清了。下面不写回忆,只根据这次修复所改掉的判断逻辑,归纳这类问题可以怎么下手。

  1. 可以这样复现:在上传进度还没走完时杀掉进程,再打开并进入会话。如果只在「已经交给 SDK、还没拿到远端地址」这个窗口必现,而在导出阶段杀进程并不复现,就可以把范围缩到这一段。
  2. 看落盘数据:重进会话后查看消息对象——消息体是不是已经变成视频类型、远端地址是不是空的、本地扩展字段还在不在。这类问题里,常见的组合是:视频体还在、地址为空、本地状态已经被清掉。
  3. 定位思路:先找恢复逻辑里「怎样才算发送完成」。如果判断标准是「消息体已经是视频类型」,就很容易误判。被杀进程后,SDK 可能把带本地路径、但没有远端地址的视频体也写进了本地库,于是一进会话就被当成已完成,本地状态被清空,界面既没有进度,也没有失败按钮。
  4. 对照另一种实现:图文混排消息的完成条件是「每个资源都有远端地址」,而不是「消息体类型对不对」。对比两种判断标准,就能看出视频消息这条判断为什么会漏。
否是是 旧逻辑
否
视频 Cell 模型初始化是自己发出的消息不处理消息体是视频类型认为已完成 清空本地状态地址为空时 永久转圈按本地状态导出并重发

5. 方案

5.1 判断标准改为「有没有远端地址」

视频消息只有在远端地址非空时才算完成;地址为空时把残留的视频体丢掉,走「重新导出 → 删除旧占位 → 重新发送」的恢复路径。导出文件还在就直接复用,不在就根据相册标识重新导出。

5.2 修复前后对比

修复前:

本地库视频 Cell 模型本地库视频 Cell 模型不再重试 界面永久 loading读取消息视频体存在 地址为空清空本地状态 返回

修复后:

会话发送视频导出本地文件本地库视频 Cell 模型会话发送视频导出本地文件本地库视频 Cell 模型alt[文件存在][文件不存在]读取消息视频体存在 地址为空丢弃残留视频体导出文件还在吗存在按相册标识重新导出导出完成删除旧的占位消息用导出文件新建视频体并发送正常进入发送中 失败可重发

5.3 图文混排消息的整体状态机

图文消息的设计更完整,也是修复视频消息时参照的模型:

点发送 插入本地消息看到延迟发送标记所有资源导出完成导出失败或相册资源丢失单个成功并落库所有资源都有远端地址任一资源失败用重发接口投递成功被拉黑 不在群内 群已解散等有网时重发重进会话续做本地占位导出中上传中失败待投递已发送

本地扩展字段里存的内容(概念):

字段(示意名) 含义
待延迟发送 这条消息还有资源没传完,不能直接投递
资源 i 的本地文件名 导出后的文件,用于续传
资源 i 的相册标识 导出文件丢了可以重新导出
资源 i 的上传状态 未开始 / 上传中 / 失败 / 文件过大
上传失败 气泡显示重发按钮

图文是一条消息,所有资源都有远端地址之后才投递,对方不会先看到文字、后看到图片。文件过大单独记一种失败原因,是为了提示语不同,而且再试也没有意义。

投递时走即时通讯 SDK 的「重发」而不是「发送」:消息已经在本地库里,再调用发送会生成一条重复消息。

5.4 并发与线程模型

上传并发队列导出并发队列主线程
全部导出
进度 成功 失败完成后释放
导出登记表 按消息 ID进度与状态回调 更新气泡导出并等待全部完成全局信号量 最多 3 路上传任务
  • 按消息 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 把上面的机制拆开看

发一张图固定四步,任何一步中断都要能停在一个明确状态。

选择导出上传投递

界面、会话、本地库和上传队列各管一层。状态写在消息上,不另建任务表。

会话界面消息模型本地库上传队列
Message远端地址本地扩展LocalState导出标记上传进度失败原因Resource下标地址

视频消息按有没有远端地址来分,而不是按「是不是视频体」。

有远端地址失败
无远端地址导出中上传中已投递可重发

图文混排是一组资源,不是一条视频。

某个资源有地址全部有地址某个失败
占位部分完成可投递可重发

杀进程之后只读本地扩展,缺什么补什么。

没有有在不在
重进会话本地扩展还在?当作已完成导出文件还在?直接补传从相册再导出

导出用信号量限制同时进行的路数,登记表只在主线程改。

导出队列同时最多数路主线程登记表

这几步的顺序是后加的能力叠在已有的延迟发送上:先有图文,再补视频导出,再让杀进程后能续上。

已有图文延迟发送补上视频导出与重发收口 杀进程后按本地状态续做可靠发送怎么叠起来

7. 取舍与还能怎么做

为什么把状态放在消息的本地扩展字段,而不是自建一张任务表?

  • 好处:状态和消息同生命周期、同一次写入落盘;消息被删除,状态自然消失;不需要维护两份数据的一致性。
  • 代价:字符串键名没有类型约束,字段多了以后难以维护;也无法脱离会话页统一调度。

为什么是懒恢复(打开会话才续传),而不是启动时扫描?

  • 好处:实现简单,不在启动阶段抢资源。
  • 代价:用户不打开那个会话,消息就一直停在发送中。改进:启动后空闲时扫描「待延迟发送」的消息,交给后台任务。

还能改进的地方(未做):

  1. 后台上传:换成后台 URLSession,App 挂起后上传仍可继续,被杀后由系统回调唤醒。
  2. 自动重试与退避:目前失败后需要用户手动重发,可以按网络恢复事件自动重试,指数退避,超过次数再提示。
  3. 线程安全收敛:上传回调对象的字典目前在多个队列访问,应统一到一个串行队列或 actor。
  4. 信号量风险:若某个任务既不成功也不失败(如回调丢失),上传名额永远不释放,应加超时兜底。
  5. 统一模型:视频消息与图文消息是两套恢复逻辑,可以统一成「资源列表 + 状态机」一套实现。
现在:懒恢复,手动重发启动空闲扫描后台 URLSession网络恢复后自动重试,并做退避统一的媒体发送任务中心