即时通讯自定义消息协议复盘:五十多种类型码、跨端对齐与向后兼容
项目 social-app
1. 背景与问题
这是一款面向日本市场的语音社交 App。聊天基于第三方 IM SDK,SDK 原生只提供文本、图片、语音、视频这些基础类型;而产品里大量的内容是业务卡片:分享的动态和话题、语音房邀请、关注通知、活动预约、系统欢迎语、房间内的信令……
这些都通过 SDK 的「自定义消息」承载:消息体是一段 JSON,里面有一个整型的 type 字段和对应的业务数据。随之而来的问题是:
- 五十多种类型,怎么组织、怎么分发到正确的气泡?
- 老版本收到新版本才有的类型,怎么不崩、不乱?
- iOS 和 Android 两端各自定义类型码,一旦对不上怎么办、改码后历史消息怎么办?
2. 整体设计
2.1 类型码分段
目前一共 55 个类型码,按号段大致分组:
| 号段 | 用途 |
|---|---|
| 102 | 本地提示类,只在本端展示 |
| 1002–1034 | 业务卡片、房间相关(中间有空号) |
| 1100–1131 | 推荐动态、房间信令、分享、状态墙等 |
| 1301 | 匹配退出 |
| 10000–10005 | 系统欢迎、引导、关注通知、验证等 |
号段的价值在于:新增类型时先看它属于哪一类,再在对应号段里取号;排查问题时看到类型码就大致知道是哪个模块。
2.2 从收到消息到渲染气泡
- 解码器注册到 IM SDK:读
type,命中则反序列化成对应模型;不认识就记一条日志并返回空,而不是抛异常。 - 气泡工厂按「先特殊会话、再 SDK 消息类型、再自定义附件类型」的顺序一一映射到不同的 Cell 模型(工厂 + 多态)。
- 兜底:附件为空、模型找不到、类型不认识,统一落到「当前版本不支持」气泡;本地表情识别不了时则降级成纯文本,用表情的展示名,体验更温和。
// 示意,非原始代码
func decode(_ json: [String: Any]) -> CustomAttachment? {
guard let raw = json["type"] as? Int,
let type = CustomType(rawValue: raw) else {
log.warning("unknown custom type")
return nil // 交给工厂兜底
}
return type.modelClass.deserialize(from: json["data"])
}
func cellModel(for msg: Message) -> CellModel {
guard let att = msg.customAttachment else { return UnsupportedCell(msg) }
switch att {
case let a as ShareMoment: return ShareMomentCell(msg, a)
case let a as RoomInvite: return RoomInviteCell(msg, a)
// ... 其余类型
default: return UnsupportedCell(msg)
}
}
3. 关键实现
3.1 双端类型码对齐
早期「分享动态」这类消息,iOS 用的类型码和 Android 不一致:一端发出、另一端按自己的表去解,就会落到「不支持」或者显示异常。
与 Android 重新商定这几类消息的类型码,把分享动态迁到双端约定的号段,并打通动态分享到会话;分享话题的类型码统一成 1115。现在分享动态是 1113,分享话题是 1115。
改码之后,历史消息怎么办? 解码端一旦只认新码,本地历史里那些用旧码发出的消息就会变成「不支持」气泡。这个问题在后续版本里补上了旧消息的兼容显示;因为发现得早、修得及时,受影响的消息不多,影响面不大。
更稳妥的通用做法是:编码端切到新码的同时,解码端把旧码作为只读别名保留,映射到同一个模型,至少覆盖到绝大多数用户升级之后。
3.2 消息去重与排序
- 会话页用消息 ID 字典去重,同一条消息不会因为「云端同步 + 本地读取」出现两次。
- 会话列表:置顶优先,组内按最后一条消息时间倒序。
- 预览公开群时,消息和图片的顺序曾错乱。标识改为客户端生成的消息 ID 之后,预览顺序和相关历史展示一起修好了。
3.3 历史消息:先同步、再读本地
系统账号只读本地;「标记删除」以本地库为准。先同步一小批再读本地窗口,而不是一次拉全量,是在「打开会话的速度」和「消息完整性」之间的折中。
3.4 未读电梯
未读很多时,用户希望一键跳到「上次看到的位置」。这里分两种情况:
会话列表角标取全部未读数,超过 99 显示省略号,前台时同步到 App 图标。
3.5 群助手卡片
群助手里的文本消息和活动预约卡片。它们在气泡工厂里被最先处理:先判断是不是群助手会话,再走通用的类型分发。活动预约的类型码写在枚举末尾,但数值仍落在 10xx 号段里,号段约定比枚举里的书写顺序更重要。
4. 边界与失败处理
4.1 登录、重试与被踢
自定义消息能收发的前提是 IM 登录正常。登录链路的几个边界:
- 自动登录失败时,若干需要换票的错误会先清掉本地凭证,再重新走一遍准备登录;账号封禁则直接登出;操作过于频繁时,会尝试回传 IM SDK 的日志用于排查。
- 被踢时按原因区分封禁和其它设备登录,不再一律显示「已在其他设备登录」。登出走全局统一的去重通知,避免重复弹窗。
4.2 一个值得留意的卡死风险
重试超过 5 次停下时,「正在登录」这个标志可能没有被清掉,之后每次准备登录都会因为「正在登录」直接返回,表现为 IM 再也连不上,只能杀进程。
这类状态标志问题可以这样排查:
- 怎么复现:用代理工具让换票接口持续失败,触发 5 次以上重试;之后恢复网络,观察是否还能重新登录。
- 怎么定位:给「正在登录」标志的每一次置位和复位打日志,看是否存在「置位后没有任何复位」的路径。凡是
return提前退出的分支都是重点。 - 怎么修:用
defer保证所有出口都复位标志;或把登录过程建模成状态机,「失败终止」也是一个明确的状态。
5. 演进
- 起步:解码器和类型表建立,号段约定逐步形成。
- 与 Android 重新商定类型码,动态分享到会话。
- 分享话题的类型码统一为 1115。
- 群助手文本和活动预约卡片。
- 公开群预览的消息顺序与历史展示。
- 之后:旧类型码消息的兼容显示。
5.1 类型到气泡
号段把五十多种类型分成几段,工厂只认类型码和附件。
未知类型不能把老版本打崩。
历史是先同步一小窗,再读本地,不是一次拉完。
类型表、对齐、群助手、顺序修复,是叠在同一套工厂上的。
6. 复盘与可改进之处
做对了的地方
- 未知类型统一降级为「不支持」气泡,老客户端碰到新类型不会崩,也不会显示空白。
- 号段约定让类型码有了「语义」,跨端沟通时只要说号段,大家就知道是哪类消息。
- 发现双端不一致后很快对齐,并在后续版本补上旧消息兼容,影响面控制得很小。
还可以做得更好
- 单一事实来源:类型码表由一份共享定义(比如 JSON Schema 或 Protobuf 枚举)生成两端代码,而不是双端各写一份再人工对齐。
- 改码流程化:任何改码都按「新增新码 → 解码端旧码保留只读别名 → 观察若干版本 → 再下线旧码」执行。
- 协议版本字段:在消息体里带上
schema版本,同一类型的字段演进可以按版本解析,而不必每次都占用新类型码。 - 解析库:HandyJSON 依赖 Swift 的内存布局,存在随编译器升级失效的风险,可以逐步迁移到
Codable。 - 登录状态机:把「准备登录 / 换票 / 登录中 / 已登录 / 失败终止」显式建模,消除标志位漏复位一类问题。