简介:这是一套面向具备一定开发基础的即时通讯系统搭建与二次开发参考资料,围绕视酷酷信IM源码展开,覆盖安卓、苹果、PC与Web四端互通场景,采用国产tio通信技术,适合需要自建通讯服务、研究多端消息同步或进行商城等功能扩展的技术人员,也适合评估源码安全性与部署可行性。压缩包共9个文件,以6个txt解析文本、1个doc说明、1个html页面和1张jpg预览图为主,整体约99KB,内容侧重源码结构、部署环境与关键实现思路的整理。已有500人学习,读者可从中了解Java、OC、C#、Vue多语言客户端构成,以及MongoDB、CentOS 7.6下的部署线索,并获取四端互通、单机万人并发与集群扩展等系统优势说明,便于后续按自身服务器环境评估搭建方案、核对二次开发边界、私有化部署与安全要点。
1. 消息“消失”的那两分钟,问题往往不在服务端
某团队把 H5 端上线后,Android 和原生 iOS 一切正常,唯独 Safari 上用户切到后台两分钟再回来,聊天列表不刷新,客户以为消息丢了——这类问题在四端互通 IM 里太常见。这套四端互通的 IM 源码就是把这些情况提前踩过的版本:Android、iOS、PC、H5 共用一条消息链路,带完整搭建教程,服务端能起来、四端能同时在线收发。它适合有一定后端基础、想拿一套能改的 IM 骨架做产品底座的开发者;纯新手照教程也能把服务跑起来,但要动业务、加模块,Java 和前端至少得看懂。
2. 架构拆解:一条消息从 Android 发到 Safari 要过几道手
2.1 四端的分工与技术选型
这套源码把“端”拆得比较干净,四端不是四套独立逻辑,而是共用服务端的一套协议。Android 和 iOS 走原生长连接,PC 端通常用桌面壳套 Web 页面或原生客户端,H5 直接跑在浏览器里——Safari、Chrome、微信内置浏览器都算 H5 这一端。之所以强调 Safari,是因为它在移动端浏览器里对后台连接的处理最保守,把它跑通,其余基本不出事。
| 端 | 技术形态 | 主要职责 | 长连接方式 |
|---|---|---|---|
| Android | 原生客户端 | 收发消息、离线补偿、本地库 | WebSocket + 心跳 |
| iOS | 原生客户端 | 收发消息、推送兜底 | WebSocket + APNs |
| PC | 桌面壳 / 原生 | 多窗口、文件传输 | WebSocket |
| H5 | Vue 类 SPA | 免安装访问、分享页 | WebSocket + 前台唤醒 |
服务端常见做法是 Spring Boot 负责 REST 接口(登录、好友、群管理、后台管理),Netty 单独起一个长连接网关负责 WebSocket,两者通过 Redis 共享在线状态。存储侧 MySQL 存用户、会话、消息索引,Redis 存在线表、token、未读数、会话自增序号。这套组合不算新鲜,但胜在职责清晰:业务接口再怎么改,网关层不用动。
选型上有个细节值得说:消息序号没有用数据库自增主键,而是用 Redis 的INCR按会话维度生成。原因是 MySQL 自增在分表之后不连续,四端对“消息顺序”的理解容易打架,用会话内单调递增的seq更稳,客户端拉增量也简单——本地存一个最大 seq,重连时带上去就行。
2.2 长连接接入层与消息投递链路
接入层是整个 IM 里最容易被低估的部分。用户量小的时候,一台机器一个 Netty 实例就能扛,但代码必须按“多节点”写,否则横向扩容时整条链路都要重写。这套源码的网关做了一件很关键的事:连接建立后把用户ID → 节点标识:通道ID写进 Redis 的在线表,投递时先查这张表,知道对方挂在哪台机器上。
投递链路大致是这样:客户端发消息 → 网关校验帧合法性 → 转给业务层的消息服务 → 消息服务先落库、再更新会话摘要 → 查接收方的在线设备 → 在本节点就直接写 Channel,在别的节点就通过 Redis 发布订阅把消息转过去 → 对端回 ACK,把消息状态从“已入库”改成“已送达”。顺序上坚持“先入库后投递”,宁可重复推一次让客户端按msgId去重,也不能出现“推过去了但库里没有”的黑匣子情况。
在线表的结构值得单独看一眼。用 Hash 存,field 是设备类型(1 安卓、2 iOS、3 PC、4 H5),value 是节点和通道。这样同一个账号在四个端同时在线时,投递逻辑可以按需选择:普通消息全端推,保证多端同步;正在输入、已读回执这类状态只推给当前活跃端,减少无谓流量。多端同步的坑基本都出在这一层,后面第 5 章会细讲。
2.3 存储模型:消息表、会话表与在线状态
消息表的设计直接决定后面查询顺不顺手。常见的踩坑是把所有消息塞一张表,几个月后翻页查询慢得离谱。这套源码的写法是按会话维度组织查询,消息表保留session_id + seq的联合索引,翻历史消息走where session_id = ? and seq < ? order by seq desc limit 20,稳定走索引,不会随着数据量增长而雪崩。
-- 消息主表:按会话 + seq 查询,避免全表扫描 CREATE TABLE im_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id VARCHAR(40) NOT NULL COMMENT '全局唯一消息ID,客户端幂等去重用', session_id VARCHAR(64) NOT NULL COMMENT '单聊为 小ID_大ID,群聊为 g_群ID', from_id BIGINT NOT NULL COMMENT '发送者', to_id BIGINT NOT NULL COMMENT '接收者或群ID', msg_type TINYINT NOT NULL DEFAULT 1 COMMENT '1文本 2图片 3语音 4视频 5自定义卡片', content TEXT NOT NULL COMMENT '消息体,媒体类存 JSON', seq BIGINT NOT NULL COMMENT '会话内自增序号,顺序以它为准', status TINYINT NOT NULL DEFAULT 0 COMMENT '0已入库 1已送达 2已读 3已撤回', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_msg_id (msg_id), KEY idx_session_seq (session_id, seq) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;上面这段建表里,msg_id加了唯一索引,是为了兜住客户端重发——网络抖动时同一条消息可能发两次,服务端插入冲突直接返回已有记录,不会产生重复气泡。seq单独建联合索引而不是和created_at混用,是因为客户端排序只认 seq,时间戳只用来展示。status字段承担了送达和已读两个状态,改动时注意别把“已读”写回“已送达”。
会话表则只存摘要:最后一条消息 ID、最后一条内容预览、未读数、更新时间。列表页查的是会话表,点进去才查消息表,这个分层几乎所有成熟 IM 都是这么做的。Redis 里的未读数用 Hash 存im:unread:{userId},field 是 sessionId,读完清零,四端同步时以服务端为准,客户端本地计数只做乐观展示。
3. 跑通顺序:从改配置文件到四端同时在线
3.1 目录结构:各端代码放在哪
拿到源码包先别急着敲启动命令,花十分钟认目录能省掉后面一半的排查时间。常见结构是服务端、四端客户端、数据库脚本、部署文档分开存放,网关和业务接口在服务端目录下再分模块。下面这是一种典型组织方式,实际目录名可能略有差异,按README和启动脚本对齐即可。
im-source/ ├── server/ # 服务端 │ ├── im-api/ # REST 接口:登录、好友、群、后台 │ ├── im-gateway/ # Netty 长连接网关 │ ├── im-common/ # 公共实体、工具、常量 │ └── sql/ # 建库建表脚本 + 初始化数据 ├── client-android/ # 安卓端工程 ├── client-ios/ # iOS 端工程 ├── client-pc/ # PC 端工程 ├── client-h5/ # H5 端工程(Vue 类) └── docs/ # 部署教程、接口说明认目录时重点看三处:sql/里的初始化脚本有没有分区表或存储过程,im-gateway的配置文件里端口和 Redis 地址是不是写死的,client-h5里config或.env系列的接口地址配置。这三处不改,后面启动必翻车。另外注意docs/里的教程版本和代码是否对得上,老项目常见教程写的是旧端口,代码已经改过。
3.2 服务端依赖安装与配置项逐条改
服务端跑起来的前提是 MySQL 和 Redis 先就位。MySQL 建议 5.7 以上或 8.0,字符集统一utf8mb4,否则昵称里的表情符号会变成问号——这个坑很经典,导入脚本时忘了改字符集,注册个带 emoji 的昵称直接报错。Redis 用 5.0 以上即可,主要用到 String、Hash 和发布订阅,不涉及复杂数据结构。
先把 SQL 脚本按顺序导进去,再改配置文件。配置项通常集中在一个application.yml或config.properties里,逐条对一遍最稳:
server: port: 8080 # REST 接口端口,H5 端跨域要对着这个改 im: gateway: port: 9000 # WebSocket 端口,四端都连这个 path: /ws # 握手路径,客户端拼 ws://ip:9000/ws heartbeat-timeout: 90 # 秒,超过这个时间没收到心跳判定离线 datasource: url: jdbc:mysql://127.0.0.1:3306/im_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 改成你自己的 redis: host: 127.0.0.1 port: 6379 database: 0 upload: path: /data/im/upload # 图片语音的落盘目录,必须提前建好且有写权限配置里最容易出事的是heartbeat-timeout和上传目录。心跳超时设得太短,Safari 切后台回来就被判离线,用户看到的是“连接已断开”;设得太长,异常断线的连接会占着在线表不放,消息推到一个已经死掉的通道上。90 秒是个折中值,配合客户端 30 秒一次心跳比较合适。上传目录不提前创建,发第一张图时服务端直接抛异常,报错信息还未必指向目录,容易查半天。
启动顺序建议是先im-api再im-gateway。有些实现里网关启动时会去拉一次接口层的配置(比如敏感词库、群配置缓存),接口没起来网关会报连接拒绝。启动日志里重点看三行:数据源初始化成功、Redis 连接成功、网关端口监听成功。三行齐了再往下走。
3.3 四端启动顺序与联调验证
四端不建议同时开,一台一台来,出问题好定位。先开 H5 端最省事,浏览器里改接口地址即可,不用重新打包。H5 的接口地址要同时改两处:REST 基地址和 WebSocket 地址,前者走http://你的IP:8080,后者走ws://你的IP:9000/ws。用localhost在本机测没问题,但手机连同一局域网时localhost指向手机自己,一定要换成电脑的局域网 IP。
# H5 端本地起服务,端口按项目 package.json 里的配置来 cd client-h5 npm install npm run dev -- --host 0.0.0.0 # 加 host 参数,手机才能通过局域网 IP 访问 # 安卓端如果走模拟器,注意 10.0.2.2 才是宿主机 # 真机调试直接把接口地址改成电脑局域网 IP adb reverse tcp:9000 tcp:9000 # 可选:把真机 9000 端口转发到本机联调验证按四步走,每步都有明确的通过标准,不要跳步。第一步:H5 登录,浏览器 Network 里能看到 WebSocket 状态 101,说明握手成功;第二步:安卓登录同一账号,服务端日志出现两条在线记录(设备类型 1 和 4),说明多端在线表写对了;第三步:H5 发一条文本,安卓立刻收到,返回的 ACK 让消息状态变 1;第四步:安卓发图,H5 能显示,说明上传目录和静态资源映射都通了。
四步都过,基本链路就通了。这之后再测 Safari:iOS 上用 Safari 打开 H5,切后台三十秒再回来,看消息能不能补上。补不上不是网络问题,是前台唤醒和离线增量拉取的逻辑没生效,第 4 章会给出这段代码。另外提醒一句,iOS Safari 上ws://在非 localhost 的 https 页面里会被直接拦掉,本地联调要么用 http 页面,要么配好wss,这一步卡住的人不少。
4. 核心代码:鉴权、落库投递、前台唤醒三段实现
4.1 握手鉴权:把 token 绑到 Channel 上
WebSocket 握手阶段拿不到自定义请求头,浏览器端尤其如此,所以常见做法是连接建立后第一帧发鉴权包,服务端校验通过才允许后续收发。这套源码的网关里有一个独立的鉴权 Handler,逻辑不复杂但顺序很关键:先判帧类型,再查 token,最后写在线表。任何一步失败立刻关连接,不给未鉴权的通道留活口。
@ChannelHandler.Sharable @Component public class AuthHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> { @Resource private StringRedisTemplate redis; @Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { JSONObject json = JSON.parseObject(frame.text()); // 第一帧必须是鉴权帧,其它类型一律断开,防止绕过登录直接发消息 if (!"auth".equals(json.getString("type"))) { ctx.close(); return; } String token = json.getString("token"); Integer device = json.getInteger("device"); // 1安卓 2iOS 3PC 4H5 String deviceId = json.getString("deviceId"); // 设备唯一标识,用于挤下线判断 String uid = redis.opsForValue().get("im:token:" + token); if (uid == null) { ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"auth\",\"code\":401}")); ctx.close(); return; } // 绑定属性,后续 Handler 直接取,不用再解析 ctx.channel().attr(ChannelAttr.UID).set(Long.valueOf(uid)); ctx.channel().attr(ChannelAttr.DEVICE).set(device); // 在线表:field 为设备类型,value 为 节点ID:通道短ID,投递时靠它路由 redis.opsForHash().put("im:online:" + uid, String.valueOf(device), NodeHolder.nodeId() + ":" + ctx.channel().id().asShortText()); ctx.pipeline().remove(this); // 鉴权只走一次,移除自身减少后续开销 ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"auth\",\"code\":200}")); } }这段代码里device参数决定在线表的 field,四端互通靠的就是它。同一个账号在 H5 和安卓同时登录,在线表里是两行,投递时遍历所有设备逐个推,多端同步就成立了。deviceId是为了处理同端重复登录——同一个账号在两台手机上登录,是要互踢还是共存,看业务决定,但设备标识必须带上,否则判断不了。最后pipeline().remove(this)是个小优化,鉴权完成后这个 Handler 不再需要,留着只会增加每次消息的解析开销。
有个细节容易写错:token 存 Redis 时用 token 做 key 还是用用户 ID 做 key。用 token 做 key 的好处是校验时一次get搞定,坏处是没法主动让某个 token 失效(用户改密码要踢线)。常见做法是两套都存:im:token:{token} → uid用来校验,im:uid_token:{uid} → token用来踢线,改密码时拿后者删前者。
4.2 消息落库与可靠投递
消息服务的核心是顺序和状态。先拿序号、再落库、再投递,三步的顺序不能乱。如果先投递再落库,投递成功但入库失败,客户端显示的这条消息在服务端不存在,刷新就消失,用户会以为“消息被吞了”。先落库虽然可能造成重复推送,但客户端按msgId去重就能兜住,代价小得多。
@Service public class MessageService { @Resource private MessageMapper messageMapper; @Resource private StringRedisTemplate redis; @Resource private RouteService routeService; @Transactional(rollbackFor = Exception.class) public Message send(Long fromId, String sessionId, int msgType, String content) { // 会话内自增序号,四端看到的顺序以它为准,避免时间戳误差导致的乱序 Long seq = redis.opsForValue().increment("im:seq:" + sessionId); Message m = new Message(); m.setMsgId(IdGen.next()); // 全局唯一,客户端幂等去重 m.setSessionId(sessionId); m.setFromId(fromId); m.setMsgType(msgType); // 1文本 2图片 3语音 4视频 5自定义卡片 m.setContent(content); m.setSeq(seq); m.setStatus(0); // 0已入库 1已送达 2已读 m.setCreatedAt(new Date()); messageMapper.insert(m); // 会话摘要同步更新,列表页只查这张表 messageMapper.updateSession(sessionId, m.getMsgId(), abbreviate(content), seq); // 投递:本节点直接写 Channel,跨节点走 Redis 发布订阅 routeService.deliver(sessionId, m); return m; } }routeService.deliver里做的事值得单独说清楚。它先查会话成员(单聊两个人,群聊查群成员缓存),再逐个查im:online:{uid}在线表,拿到节点标识和通道 ID。节点标识等于本机就直接从ChannelGroup里取通道写数据,不等就发一条 Redis 消息给对应节点,由那边的订阅者负责写。这样单机和集群跑的是同一套代码,扩容时不用改业务逻辑。
送达状态怎么更新?客户端收到消息后回一个 ACK 帧,网关里另有一个 Handler 接住,按msgId把status从 0 改成 1。已读回执同理,用户在会话里点开,客户端发已读帧带上最大 seq,服务端把小于该 seq 的消息批量改 2,同时清掉 Redis 里对应的未读数。这里可以用延迟双删或者简单的定时任务兜底,避免 ACK 丢包导致状态一直停在“已入库”。
4.3 前端 WebSocket 封装与 Safari 前台唤醒
前端这一层的核心不是“连上”,而是“断了之后怎么像没断过”。Safari 的表现最典型:切到后台后页面的定时器被系统挂起,心跳发不出去,服务端 90 秒后判定离线并清掉在线表;用户切回来,页面恢复,但连接对象还停在 OPEN 状态的旧值上,不发消息看着正常,一发就失败。这段逻辑必须显式处理可见性变化。
class IMClient { constructor({ url, token, device = 4, onMessage }) { this.url = url; this.token = token; this.device = device; // H5 固定为 4 this.onMessage = onMessage; this.retry = 0; // 重连次数,用于指数退避 this.maxSeq = 0; // 本地已收到的最大 seq,重连后据此拉增量 } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.retry = 0; // 第一帧鉴权,服务端校验通过才允许收发 this.ws.send(JSON.stringify({ type: 'auth', token: this.token, device: this.device, deviceId: this.deviceId })); }; this.ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'auth' && msg.code === 200) this.pullOffline(); if (msg.seq && msg.seq > this.maxSeq) this.maxSeq = msg.seq; this.onMessage(msg); }; this.ws.onclose = () => this.reconnect(); } // Safari 切后台会挂起定时器,心跳停发被判离线;回到前台立刻重连并补消息 bindVisibility() { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.reconnect(0); else this.pullOffline(); // 连接还在也要补一次,防止后台期间漏推 } }); } reconnect(delay) { const wait = delay !== undefined ? delay : Math.min(1000 * 2 ** this.retry++, 30000); setTimeout(() => this.connect(), wait); // 指数退避,最多 30 秒一次,避免打爆服务端 } // 用本地最大 seq 增量拉取,只拉缺的那一段 pullOffline() { fetch(`/api/message/offline?maxSeq=${this.maxSeq}`) .then(r => r.json()) .then(list => list.forEach(m => this.onMessage(m))); } }bindVisibility是这段代码的重点。只监听onclose不够,因为 Safari 挂起页面时连接对象不一定触发关闭事件,回来时状态是“假的 OPEN”。所以回到前台要判断两件事:连接不是 OPEN 就重连,是 OPEN 也要主动拉一次离线增量。pullOffline带上本地maxSeq,服务端只返回比它大的消息,避免每次唤醒都全量拉一遍。
reconnect用了指数退避,2 ** retry最多等 30 秒。这个参数别设太小,服务端重启时几十个客户端同时按 1 秒间隔重连,瞬间就把网关打满。另外 Safari 上还有一个隐藏坑:页面被挂起期间WebSocket对象可能已经进入 CLOSING 状态,此时调send直接抛异常,所有发送逻辑都要包一层状态判断,别裸调。
5. 避坑记录:Safari 挂起、节点路由与多端串消息的排查
5.1 Safari 与 H5 端的连接异常
现象一:iOS Safari 切后台两分钟回来,消息列表停在原处,手动下拉才刷新。原因有两层:后台定时器被挂起导致心跳中断,服务端按heartbeat-timeout清掉在线表;同时页面恢复后没有触发重连和增量拉取。解决办法是把visibilitychange监听挂上,回到前台先判连接状态,不是 OPEN 就重连,是 OPEN 也调一次pullOffline,用本地最大seq拉增量。改完还要顺手把服务端心跳超时调到 90 秒以上,别让短暂切后台就判死。
现象二:https 页面里 H5 完全连不上,控制台报混合内容拦截。原因是浏览器安全策略不允许 https 页面建立非加密的ws://连接,Safari 拦得比 Chrome 更早更彻底。解决方式是正式环境统一上wss,本地联调用 http 页面或者localhost调试。别想着在客户端关掉校验绕过,那是把问题推给用户。
现象三:语音消息在 Safari 上点了没声音,安卓和 PC 正常。原因是 Safari 的自动播放策略要求音频播放必须由用户手势触发,页面加载时预创建的AudioContext处于挂起状态。解决是在点击事件里先audioContext.resume()再播放,首次交互时把上下文激活,后面就能正常播。
5.2 服务端启动与节点路由的常见问题
现象:网关启动了,但两个客户端互相收不到消息,日志里投递记录显示“查无在线”。原因多半是 Redis 在线表写进去了但 key 前缀不一致,或者网关和业务接口连的不是同一个 Redis 库(一个database: 0,一个database: 1)。排查顺序是先redis-cli看im:online:{uid}存不存在,再看节点标识是否为空。多节点部署时还要确认每个节点都有唯一 ID,两个节点用同一个 ID,消息就会投到错误的机器上。
现象:群聊发消息,群成员收到重复的两条。原因是投递逻辑里既遍历了在线表,又走了一遍发布订阅的广播,两条路径都写了一次 Channel。解决办法是把投递收口到一个入口:本节点命中就从本地写,未命中才发 Redis 消息,两个分支互斥。客户端也要保留msgId去重,双保险。
现象:服务端起来了,发图报 500,日志里是路径不存在。配置里的upload.path目录没建,或者容器里挂载的卷没写权限。这个错误日志往往只报 IO 异常,不直接指向配置项,容易查偏。建议启动时加一段自检:目录不存在就创建,创建失败直接启动失败,把问题暴露在启动阶段。
5.3 多端同步与二次开发时的数据问题
现象:手机上读了消息,PC 上的未读红点还在。原因是已读回执只清了当前端的本地计数,没同步到服务端。正确做法是已读帧带上会话 ID 和最大seq,服务端改状态、清 Redis 未读数,再给其它在线端推一条已读通知。客户端收到通知后按会话清红点,本地计数只做乐观展示,以服务端推送为准。
现象:二开加了一个新的消息类型,安卓显示正常,H5 直接白屏。原因是前端解析消息体时按固定类型走switch,遇到未知类型没走默认分支,直接抛异常把整个列表渲染打断。改法是解析层加默认分支,未知类型统一渲染成“暂不支持的消息类型”,同时把消息类型常量抽成配置,各端从同一份定义里取。
现象:挤下线逻辑一开,四端互踢,用户手机和电脑反复掉线。原因是挤下线按用户维度判断,没区分设备类型。多端互通的账号本来就该允许多设备同时在线,正确做法是按uid + device维度判断,同设备类型重复登录才踢,不同设备类型共存。这条在二开时特别容易被改坏,改登录逻辑前先想清楚业务要不要支持多端。
6. 二开落地:给 IM 加一个商城入口要动的四处代码
加商城不是加几张表那么简单,得先想清楚它和 IM 的关系。常见的两种做法:一种是独立模块,用户在 IM 里点一个入口跳到商城页面,消息只做通知;另一种是深度整合,商品卡片作为一种消息类型直接发在会话里,点击卡片进详情。后者体验好,但改动面大,建议先跑通前者再演进。
无论哪种,落点都在四处。第一处是消息类型常量,在服务端和四端各有一份定义,新增一个6-商品卡片必须五处同步改,漏一处就会出现某端不认。第二处是消息体解析,卡片消息的content存 JSON,字段至少包含商品 ID、标题、封面、价格,客户端按类型走卡片渲染。第三处是权限和菜单,商城的入口挂在会话页的扩展面板里,需要后台配置项控制开关。第四处是订单表,和 IM 的用户表通过user_id关联,别另外建一套账号体系。
-- 商品卡片消息体示例,存进 im_message.content { "itemId": "10086", "title": "某示例商品", "cover": "/upload/goods/10086.jpg", "price": "199.00", "link": "/mall/detail?id=10086" }这段 JSON 的字段设计有两个原则:一是别把展示需要的全部字段都塞进去,商品改价后历史消息会不一致,只存快照里必须的,点击时再查实时详情;二是link用相对路径,换域名时不用改历史数据。客户端渲染卡片时对cover做懒加载,H5 端在 Safari 上尤其要注意,一次性加载几十张图会把内存顶上去。
验证方式很简单,按四端各走一遍:发送卡片消息,检查四端是否都能渲染;点击卡片跳转是否正常;商品下架后历史消息里的卡片是否还能打开(应该降级成“商品已下架”而不是白屏)。这三步走完,再考虑把订单状态回写成 IM 通知消息,比如发货提醒走系统会话,这块复用现有的系统消息通道就行,不用新增类型。
二次开发最怕的是改一处漏一处,尤其是四端并存的项目。我一般会先把所有消息类型的定义集中到一个枚举或者常量文件里,各端从同一份定义生成,新增类型时用脚本检查五处是否齐全,比人肉对靠谱得多。还有一条血泪经验:动登录和在线表之前,先把现有的多端登录场景全测一遍,别改完才发现挤下线逻辑把 PC 端也踢了。希望帮到你。
本文还有配套的精品资源,点击获取