背景痛点:智能客服的三座“性能大山””
做客服系统最怕什么?不是功能做不出来,而是“用户说一句话,半天没反应”。在uni-app里同时打包到iOS、安卓、H5、小程序四端后,我踩到三个高频坑:
- 消息延迟:安卓端WebSocket断线后重连,平均要等3-5秒,用户以为客服“已读不回”直接差评。
- 多端状态同步:用户手机退到后台再回来,会话列表里出现重复消息,原因是本地Vuex状态与服务器sequence对不上。
- 历史消息加载:一次性拉200条记录,低端安卓机直接卡成PPT,Virtual DOM/虚拟DOM diff时间飙到300 ms。
这三点不解决,客服系统再智能也白搭。
技术选型:为什么放弃MQTT与长轮询
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| MQTT | 协议轻量,QoS等级灵活 | 需额外原生插件,uni-app社区插件年久失修 | 放弃 |
| 长轮询 | 实现简单,兼容老机型 | 每30 s一次HTTP请求,电量与流量双杀 | 放弃 |
| WebSocket | 全双工,uni-app官方维护,支持断线重连 | iOS后台5 min被杀 | 采用+保活策略 |
最终拍板:WebSocket + 业务层心跳(45 s一次ping/pong)+ 指数退避重连,最大重连间隔30 s,既保证实时性,也避免疯狂握手。
核心实现:让消息“不丢、不重、不乱”
1. Vuex消息队列的幂等处理
幂等关键:每条消息带uuid,模块内部用Set做去重。
// store/modules/chat.ts interface ChatState { queue: Map<string, Message>; lastSeq: number; } const mutations = { PUSH_MESSAGE(state: ChatState, msg: Message) { if (state.queue.has(msg.uuid)) return; // 幂等 state.queue.set(msg.uuid, msg); state.lastSeq = Math.max(state.lastSeq, msg.sequence); } }2. 跨平台Push兼容层
uni-app的uni.onPushMessage在H5端压根不存在,封装一个“兜底”函数:
// utils/push.ts /** * 注册推送监听器,不存在时退化为WebSocket * @param callback 收到推送时的回调 */ export function onPushOrWS( callback: (payload: AnyJson) => void ): void { // #ifdef APP-PLUS uni.onPushMessage(res => callback(res.data)); // #endif // #ifndef APP-PLUS ws.addEventListener('message', e => callback(JSON.parse(e.data))); // #endif }3. 消息分片加载策略
下拉历史时,一次只拿15条,预加载下一段,减少白屏。
// services/message.ts interface PageResult<T> { list: T[]; hasMore: boolean; } /** * 分页拉取历史消息 * @param seq 当前最小sequence * @param size 每页条数,默认15 */ export async function fetchHistory( seq: number, size = 15 ): Promise<PageResult<Message>> { try { const { data } = await uni.request({ url: '/api/chat/history', data: { seq, size } }); return data; } catch (e) { console.error('[History] fetch failed', e); throw new Error('网络异常,请重试'); } }列表组件里配合virtual-list做渲染,1000条消息滑动也能稳在60 FPS。
性能优化:本地缓存+增量同步
- 本地缓存:使用
uni.setStorageSync('chat_cache', queue),App启动时先读缓存,200 ms内用户就能看到历史记录,解决“白屏焦虑”。 - 增量同步:WebSocket连上后,拿本地最大
sequence与服务器做diff,只拉“缺失”部分,流量节省70%。 - 内存保护:列表只保留最近200条DOM节点,更早的数据用
<recycle-view>回收,避免低端机崩溃。
避坑指南:iOS后台+Vuex内存泄漏
iOS后台保活策略
- 借助
plus.ios原生接口,在退后台时启动“空白音频”,设置AVAudioSessionCategoryPlayback,系统会多给5 min运行时间。 - 5 min内若收到消息,本地push通知用户;超过5 min,走APNs离线通道,保证不丢信。
Vuex内存泄漏清理
// store/plugins/unsubscribe.ts export const autoUnsub = store => { store.subscribeAction({ after: (action, state) => { if (action.type === 'chat/destroy') { ws.close(); uni.offPushMessage(); // 关键! } } }); };在页面onUnload里this.$store.dispatch('chat/destroy'),彻底释放监听器,避免重复注册导致内存暴涨。
代码规范:JSDoc+async/await示例
/** * 发送文本消息 * @param {string} content 纯文本内容 * @returns {Promise<Message>} 返回带uuid的消息对象 * @throws {Error} 发送失败时抛出 */ export async function sendText(content: string): Promise<Message> { const uuid = generateUUID(); const msg: Message = { uuid, content, type: 'text', sequence: -1 }; try { await ws.send(JSON.stringify(msg)); return msg; } catch (e) { console.error('[Send] failed', e); throw new Error('发送失败'); } }统一用try/catch包裹,拒绝回调地狱,维护性直线上升。
延伸思考:把加密/压缩交给Worker
主线程只负责UI绘制,加解密这种CPU密集任务放到Worker里,避免掉帧。
// workers/crypto.ts self.onmessage = async e => { const { text, key } = e.data; const cipher = await aesEncrypt(text, key); self.postMessage({ cipher }); };页面里new Worker('/workers/crypto.ts'),收发完全异步,实测长文本加密耗时从120 ms降到30 ms,滑动再无“小卡顿”。
整套方案上线后,我们的智能客服在4端平均首响时间从2.1 s降到580 ms,重复消息率低于0.3%,iOS后台5 min存活率100%。如果你也在用uni-app做实时交互,不妨直接拿走代码改两行变量名,基本就能跑起来。下一步我准备把AI意图识别也挪到Worker里,让主线程彻底“躺平”,有进展再来分享。