IM 客户端和普通业务客户端有一个根本区别:普通页面是「我问你答」,连接断了下次请求自然会重连,断连这件事根本不需要感知;IM 是服务端主动往下推,连接一断,用户就静默地收不到任何消息,而界面上什么异常都看不出来。所以「连接到底还活着吗」从一个不存在的问题变成了端上必须自己维护的核心状态。
第一版就是 uni.connectSocket 连上、监听 onMessage 就完事,上线后问题一个接一个。
一是假连接,这是最隐蔽也最致命的。移动网络下运营商的 NAT 会话超时、中间设备静默丢弃连接,TCP 层面收不到 FIN,客户端的 socket 状态还是 open,但消息再也收不到了。用户的反馈是「消息要杀进程重开才收到」。这说明只监听 onClose 来触发重连是不够的——很多断开根本不会触发 onClose。
二是前后台切换。App 切到后台,系统会挂起 JS 线程,心跳定时器停摆;回到前台时 socket 状态显示还是 open,实际早就断了。小程序端切后台一段时间会被平台主动断开。「回前台后假设连接可用」是错的。
三是网络切换。从 Wi-Fi 切到蜂窝网络,底层 socket 已经失效,但断开事件不一定及时到达,甚至根本不到。
四是重连风暴。断开后立刻重连,服务端发版或抖动时所有客户端在同一时刻一起重连,把网关压满,反而让恢复更慢。
五是小程序的并发 socket 上限。微信小程序对同时存在的 WebSocket 连接数有限制,早期每个聊天页各自建一条连接,打开几个会话之后新连接直接失败。
所以四个设计:连接做成应用级单例、用应用层心跳判活而不信 socket 状态、退避加抖动重连并区分断开原因、持续不可用时降级轮询兜底。
onOpen 只代表通道建立,要收到服务端的鉴权成功帧才算可用。两个状态混在一起就会出现「以为连上了却一直收不到消息」。心跳间隔是一个纯粹的权衡,没有正确答案。间隔短:断连发现快,但耗电耗流量,弱信号下频繁唤醒射频模块的电量代价很明显;间隔长:省电,但断连后用户可能几十秒收不到消息还不知道。我们的做法是前台用较密的间隔、后台大幅降频、并且让业务消息顺延心跳,等于在「有人正在聊」的时候判活最灵敏,在挂着不用的时候最省电。面试里被问到这个,关键不是报一个秒数,而是说清你在权衡什么。
为什么用 WebSocket 而不是 SSE 或纯轮询。SSE 在小程序端不可用,直接排除;纯轮询做不到实时且空轮询浪费严重。WebSocket 是 uni-app 多端里支持一致性最好的选择。但轮询没有完全丢掉——它作为长连接不可用时的降级路径保留着,这两者不是二选一,是主路径和兜底路径的关系。
踩过的坑:只监听 onClose 触发重连,假连接下永远不重连。用户反馈「消息要杀进程重开才收到」,查了很久才定位到连接在运营商侧被静默丢弃,TCP 没有 FIN,客户端的 socket 状态一直是 open,我们的重连逻辑挂在 onClose 上,所以永远不触发。修法是加应用层心跳,连续丢失响应就主动 close 再重连。教训是:不能信任底层状态上报,要用应用层探针主动验证——这条在长连接、数据库连接池、RPC 长链接上都成立,凡是「连接复用」的场景都有假连接问题。
踩过的坑二:每个聊天页各建一条连接,小程序端超上限后新连接静默失败。用户打开几个会话之后,后面的会话收不到任何消息,而且没有任何报错(连接创建失败没有被处理)。修法是连接收敛为应用级单例,页面只订阅事件。教训是:平台的资源配额必须提前查清楚,把应用级资源当页面级资源用,迟早撞上限。
踩过的坑三:断线立刻重连,服务端发版时被客户端二次打挂。网关滚动重启,所有在线客户端在同一瞬间检测到断开并立即重连,重连请求量远超正常连接量,刚起来的实例又被打挂,形成拉锯。修法是指数退避加随机抖动。教训是:客户端的重试策略是服务端容量的一部分,端上不做退避,服务端的优雅重启就无从谈起。
踩过的坑四:被踢下线后无限重连。另一台设备登录把这台踢了,客户端把踢下线当成普通断开,立刻重连、鉴权、又被踢、再重连,几秒一轮直到用户杀进程。修法是服务端在踢下线时下发明确原因,客户端按原因决定不重连。教训是:断开原因必须结构化下发,「断开」这一个事件不足以决定下一步动作。
没做的部分:没做消息的端到端加密。私信场景有这个需求,但涉及密钥协商、多端密钥同步、历史消息在换设备后的可读性,链路很长,当时优先级排在后面。
断连发现时长:这个数不是「实测平均值」,而是由心跳间隔乘以失败阈值决定的设计上界,要这样说才准确。验证方式是人为制造假连接:用工具把 TCP 连接静默丢弃(只丢包不发 RST/FIN,模拟运营商 NAT 超时),然后计时到客户端判定断开并完成重连。关键是必须造「静默丢弃」而不是「直接断网」——直接断网会触发平台的网络变化事件,走的是另一条快速路径,测不到假连接这条路。
重连恢复时长:多轮人为断网,记录从恢复网络到「连接可用且增量拉取完成」的时长,取中位数。要报的是「可用且数据补齐」而不是「socket 连上」——后者对用户没有意义。
「杀进程才收到消息」这类反馈:这是个定性结果,诚实的表述是「改造后未再出现该类反馈」,而不是编一个「假连接率下降 N%」——假连接在改造前根本没有被度量,没有基线就没有比率。
后台耗电:同一机型对照,App 挂后台一小时的电量差,改造前后各测几轮。必须写清机型和系统版本,因为不同厂商的后台策略差异极大,一个数字脱离机型没有意义。
不要报「消息到达率 100%」或「长连接稳定性 99.99%」。端上根本统计不到「服务端发了但我没收到」的那部分——你不知道的消息你也统计不到,这个指标在客户端天然算不准。正确表述是「心跳判活的发现上界是多少、重连的恢复时长是多少、降级路径覆盖了哪些场景」,都是可核查的。
onClose 上,而这种断开永远不触发 onClose,用户的反馈是「消息要杀进程重开才收到」。修法是前台每 N 秒发 ping,连续 M 次收不到 pong 就判定假连接,主动 close 再走重连。两个优化:收到任何业务消息也算连接活着(可顺延下一次心跳,消息密集时省电);心跳间隔跟随前后台状态(后台大幅降频,反正也收不到)。教训是不能信任底层状态上报,要用应用层探针主动验证——这条在数据库连接池、RPC 长链接上同样成立,凡是连接复用的场景都有假连接问题。
IM 对正确性的要求比一般业务高一个档次,原因很直接:错误是用户可见的。库存少算一个用户看不出来,消息少一条、多一条、顺序颠倒,用户立刻就发现了,而且会截图发到群里。
而客户端要同时面对三个不可靠:网络不可靠(随时断、随时慢)、进程不可靠(随时被系统杀)、多端并存(手机和 Pad 同时登录)。第一版的实现几乎每一条都踩了。
一是发送中的消息会凭空消失。点了发送,网络卡住,用户切到别的页面再回来,那条消息不见了——因为它只存在于内存里的列表,页面重建就没了。用户以为发出去了,对方根本没收到。
二是重发产生重复。网络超时后重发,其实第一条服务端已经收到了,结果会话里出现两条一模一样的消息。
三是漏消息,而且完全不知道自己漏了。断连期间对方发的消息,重连后没有任何机制去补——客户端不知道「我不知道什么」。用户是过了很久点进会话才发现中间少了一段。
四是顺序错乱。早期用客户端时间排序,某个用户的手机系统时间快了一天,他发的所有消息永远排在会话最上面,谁都看不懂发生了什么。
五是多端已读不一致。手机上读完了,Pad 上还是一堆红点。逐条消息存已读标记的方案在多端下根本收敛不了。
所以这一层的设计围绕四个点:本地先落盘再上屏(解决消失)、clientMsgId 幂等(解决重复)、服务端 seq 作为唯一顺序与完整性依据(解决顺序和漏消息)、readSeq 位点式已读(解决多端)。
serverMsgId 命中就丢弃;clientMsgId 命中就更新那条而不是新增——自己发的消息被服务端回推时要认出「这就是我刚发的那条」,否则会看到自己的消息出现两遍。为什么用 readSeq 位点而不是逐条已读标记。逐条标记的表达能力更强,但代价是一次已读要上报 N 条记录、多端合并时要逐条求并集、未读数要逐条统计。位点方案只需要一个数字:上报简单、多端取较大值就收敛、未读数一次减法算出来。代价是无法表达「跳读」(读了第 10 条但没读第 5 条),但 IM 场景里用户是顺序阅读的,这个能力用不上。这是一个典型的「用业务事实换实现简化」的决策,面试里值得主动讲。
乐观上屏的边界要划清:上屏可以乐观,「已发送」这个状态不能乐观。先落盘再上屏保证了用户看到的东西不会丢,但那条消息必须显示为「发送中」而不是已送达。这和骑手端、审核台的判断是同一条线——客户端已经做出的动作可以乐观呈现,但需要服务端确认的结果不能乐观呈现。IM 里这一点尤其重要,因为「对方到底收到没有」是用户唯一真正关心的事。
踩过的坑:重发时重新生成了 clientMsgId,服务端当成两条独立消息。弱网下用户连点几次重发,会话里出现三四条一样的消息,而且对方也收到了三四条。修法是clientMsgId 在消息创建时确定并落盘,重发只是把同一条重新发一次。教训是:幂等键必须和「业务上的同一次操作」绑定,而不是和「本次网络请求」绑定——这两者的区别就是幂等做对和做错的分界线。
踩过的坑二:用客户端时间排序,一个用户的手机时间快了一天。他发的所有消息永远排在会话最顶部,其他人完全看不懂对话在讲什么,还以为是 bug 在刷屏。修法是排序一律用服务端 seq,展示时间用服务端时间,客户端时间只用于发送中的临时占位。教训是:凡是来自客户端的数据都是不可信输入,时间戳也不例外,任何跨用户比较的顺序都必须由服务端定。
踩过的坑三:离线三天回来一次拉全量,客户端卡死几十秒然后崩溃。拉回来几千条消息,一次性落库加渲染直接把主线程占满。修法是缺口超过阈值就只拉最近 N 条并标记「有更早的消息」,其余按需往上翻。教训是:增量同步一定要设上界,「增量」不代表「量小」,离线时间越长增量越接近全量。
没做的部分:没做消息的本地全文搜索。需要在端上建倒排索引并随消息增量维护,小程序端的存储和计算配额下收益不明显,当时改成了服务端搜索接口。
不丢消息:用确定性场景验证,不要用比率。断网,让对端发 N 条,恢复网络后检查本地是否正好 N 条、seq 是否连续无空洞。这类正确性问题的正确表述是「构造了哪几个场景、每个场景的预期是什么、跑了多少轮无偏差」,而不是「消息到达率 99.99%」——端上统计不到「服务端发了但我没收到」的那部分,这个比率天然算不准。
不重复:弱网下对同一条消息连续触发重发若干次,检查会话里只有一条,且对端也只收到一条。这个必须两端一起看,只看自己这端会漏掉「服务端真的存了两条」的情况。
空洞补齐:人为丢弃中间的几条推送(或在断连窗口里让对端发消息),验证客户端能检测到 seq 不连续并把缺失区间补回来。这是模块里最该演示的一条,因为它证明的是「系统能发现自己的错误」。
发送中消息不丢:发一条消息时立刻切页面/杀进程,重新进入后检查那条消息还在且状态是发送中或失败(有重发入口),而不是凭空消失。
重连后首屏可用时长:从网络恢复到会话列表和当前会话都补齐完成的时长。要说明测试条件——离线多久、缺口多少条,因为缺口大小直接决定这个数。
不要报「消息零丢失」「消息准确率 100%」。绝对化断言一问就穿,而且只要出一次就被推翻。正确表述是「seq 空洞检测 + 区间二次拉取 + 重连增量对齐三层,构造了哪些场景、多轮验证未出现残留空洞」——说清机制和验证方式,比给一个漂亮数字可信得多。
serverMsgId 已存在就丢弃;clientMsgId 命中本地记录就更新那条而不是新增——自己发的消息被服务端回推时要认出「这就是我刚发的那条」,否则用户会看到自己的消息出现两遍。另外 seq 小于等于 lastSeq 的推送直接丢,这能兜住服务端重复推送。
IM 是客户端里最容易卡的页面类型,因为三件难事同时叠在一起:数据量大(一个活跃会话几千条历史)、更新极其频繁(来一条消息要同时更新会话列表、未读数、聊天页三处)、富媒体密集(图片语音视频混排,高度还都不一样)。任何一件单独出现都好办,叠在一起就很难。
第一版的问题很集中。
一是冷启动白屏。等网络拉回会话列表才渲染,弱网下白屏好几秒,用户以为 App 挂了。而其实本地数据库里明明有上次的会话列表。
二是进入聊天页卡顿。一次把几百条历史全渲染出来,低端机上明显卡顿,而且滚动到底部的过程用户能看见列表在跳。
三是上翻加载历史后滚动位置跳走。触顶加载更早的一页,插入到列表头部后视觉位置整个跳掉,用户刚才在看的那条消息不知道去哪了,必须重新找。这个体验问题被反馈得最多。
四是高频更新引起整列表重渲染。群里消息一密,会话列表每来一条就整体重排一次,加上未读数是逐条统计的,会话多了以后每条消息都要全量算一遍。
五是本地存储爆掉。小程序端 storage 有硬容量上限,早期一个 key 存整个会话的消息,会话长了以后每次写入都要序列化几 MB,写入本身就卡;更糟的是写超限是静默失败的,用户重启后最近的消息全没了。
六是图片消息导致列表跳动。图片加载完成后高度变化,整个列表重新布局,正在看的位置又跳了。
所以这一层围绕四件事:本地优先首屏、分段保留控制渲染与内存规模、高频更新收敛、存储分片与容量治理。
为什么不做完整的虚拟列表,这是这个模块最该讲清的决策。虚拟列表的前提是能预估每一项的高度,而 IM 消息的高度是不定的:一行文字和一段长文差十倍,图片按比例撑开,语音、视频、卡片各不相同。高度估错的直接后果就是滚动跳动和滚动条抽搐,而这恰恰是我们要解决的问题。加上 uni-app 多端的现实——小程序端的长列表回收组件在动态高度下表现不稳定,App 端和小程序端行为还不一致,一套代码要同时调好两端的成本很高。我们的选择是「分段保留 + 稳定 key」:内存里只保留当前若干段消息,翻太远的段卸载,这样内存不随历史条数线性增长,同时完全避开高度预估问题。取舍是:极长会话连续快速上翻时,卸载与重载会有可感知的加载态,但这比全程滚动跳动可接受得多。面试里这道题的价值在于说清「为什么不上更复杂的方案」,而不是证明自己会写虚拟列表。
本地优先渲染的代价是可能先展示旧数据。用户看到的会话列表可能是几分钟前的状态,增量回来后才更新。缓解手段是增量做局部更新而不是整体替换,用户看到的是「个别条目变化」而不是「整个列表闪一下」。这个取舍是明确划算的——展示稍旧的真实数据远好过白屏,而且 IM 的会话列表本身就是一个不断变化的视图,用户对它有「随时在变」的心理预期。
踩过的坑:未读数逐条统计,会话一多每条新消息都触发全量计算。表现是群聊活跃时整个会话列表卡顿,而且卡顿程度随会话数量增长。查下来是每来一条消息就遍历所有会话的所有未读消息重新计数。修法是改成 maxSeq 减 readSeq 的位点相减。教训是:任何「随数据量增长的重复计算」都要提前设计成增量或常量代价的形式,尤其是挂在高频事件上的计算。
踩过的坑二:一个 storage key 存整个会话,写入卡主线程。活跃会话攒到几千条后,每收一条新消息都要把整个会话序列化再写一遍,几 MB 的 JSON 序列化直接卡住主线程,表现是收消息时界面掉帧。修法是按「会话 + 时间段」分片,新消息只写最新的那一片。教训是:本地存储的读写粒度要和更新粒度对齐,用大颗粒存高频更新的数据,成本是乘法级的。
踩过的坑三:storage 写超限静默失败,用户重启后最近的消息全没了。小程序端 storage 到达容量上限后写入失败,而我们没有检查写入结果,所以一切看起来正常——直到用户重启,那些「以为存下来了」的消息全部消失。修法是捕获写入失败、触发清理、重试,并且给发送中的消息最高优先级。教训是:有配额上限的资源,写入结果必须检查,「静默失败」比「明确报错」危险得多,因为它推迟了问题暴露的时间。
踩过的坑四:图片消息没有预存尺寸,加载完成后列表跳动。用户正在看的位置因为上方某张图片加载完成撑高而整个跳走。修法是消息里预存缩略图宽高,占位框先按比例撑开。教训是:任何会改变已渲染元素高度的异步过程都会引起跳动——这和上翻加载历史是同一类问题,解决思路都是「让高度提前确定」。
没做的部分:没做本地全文搜索。需要在端上建倒排索引并随消息增量维护,小程序端的存储与计算配额下收益不明显,改成了走服务端搜索接口。
冷启动到会话列表可见:埋点记录启动到列表首次渲染完成的时长。必须区分两个场景分别报——「本地有缓存」(走本地优先路径)和「首次安装无缓存」(只能等网络)。只报有缓存的那个数字是选择性呈现,会被追问穿。
聊天页首屏可交互时长:从点击会话到列表可滚动的时长。要说明会话的历史条数和消息类型构成(纯文本会话和图片密集的会话差别很大),脱离这些条件的数字没有意义。
上翻加载的位置保持:这一条是确定性验证而不是数字——构造场景:在长会话中滚到中部,记住某条消息的位置,触发上翻加载,检查该消息仍在视觉上的同一位置。改造前每次都跳,改造后不跳。这类体验问题用「能不能复现」描述比用毫秒数描述更有说服力。
长会话内存占用:连续上翻到 N 条时的内存占用,对比分段保留开启前后。关键是要说明它「不随历史条数线性增长」这个性质,而不只是给一个峰值数字。必须写清机型,低端机才是真正的约束条件。
存储治理:可以报本地存储的占用量、清理触发的频率、以及写入失败被捕获并成功恢复的次数。最后这个数字尤其值得报——它证明这条兜底路径是真的在生效,而不是写了没跑过。
不要报「列表帧率稳定 60fps」。帧率高度依赖机型、会话长度、消息类型构成,而且「稳定」这个词一问就穿(快速滚动时必然有掉帧)。正确表述是「在哪个机型、多长的会话、什么消息构成下,滚动的掉帧情况如何」,并且主动说出最差的场景。也不要报「性能提升 N 倍」——首屏、滚动、内存是三个不同的指标,混成一个倍数只会让人怀疑。
maxSeq - readSeq 之后是一次减法。教训是任何「随数据量增长的重复计算」都要提前设计成增量或常量代价的形式,尤其是挂在高频事件上的计算。另外「对方正在输入」这类高频信令要节流,而且绝不落盘——它是纯瞬时状态,落盘既没意义又增加写入压力。
没有匹配的内容,换个关键词试试。
项目拆解 · 私信与聊天(uni-app)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据