从零搭建安卓即时通讯系统:长连接、消息可靠性与UI刷新实践 写这个项目算是被逼出来的。年初团队需要一套带聊天功能的内部协作工具第一时间想到的是接入现成的通用IM SDK结果评估了一圈要么收费模型按日活算、成本扛不住要么开源版本功能残缺、自定义服务端得重写一大半。最后索性把一个成熟方案的核心链路拆开来看——发现常见的IM能力其实就几个关键模块完全可以自己落一套。这篇记录的是整个开发过程中真正踩过的坑、验证过的做法以及我对可靠性、消息顺序、UI刷新这些问题的实际取舍。如果你也在评估自己从零搭一套安卓端即时通讯系统这件事或者已经动手但卡在某个环节可以拿这份记录当作参考。1. 从先用现成的到自己搭一套一次现实的技术选型复盘1.1 现成方案的两头堵做即时通讯系统第一反应永远是看有没有现成方案。我接触过的外部方案大概分两类一类是商业化的IM SDK功能完整、开箱即用消息必达、群组、离线推送都帮你处理好但价格往往按照活跃用户数阶梯收费一旦用户量上去这部分成本就是一笔不小的开支另一类是开源项目可以自建服务端但大多偏向某个具体场景要么没有音视频要么历史消息存储很弱二次开发的工作量并不比从零起步少多少。评估之后我算了一笔账这些产品本身就塞了几十兆的包体积加上依赖它们自带的网络库、数据库结构一旦项目后期需要深度定制消息形态——比如把聊天记录和业务工单打通、按业务字段索引——拼装成本会相当高。而且即时通讯系统最核心的难点不在于消息怎么发送而在于弱网下如何保证消息不丢不乱、客户端和服务端状态如何同步这些面向通用场景的实现一旦遇到自己的特殊需求排查问题会非常痛苦。1.2 为什么不是轮询而是长连接还有一个方向是HTTP短轮询或列表刷新很多聊天室内业务其实用这套就够了。但如果是双向实时聊天轮询的弊端非常明显为了做到尽可能快地收到消息客户端每2秒就得请求一次这个频率在移动网络下浪费电和流量不说消息真正到达的延迟也受轮询周期限制。而即时通讯系统讲究的是事件驱动服务端有消息才推给客户端空闲时候不应该有任何无意义的网络请求。所以最终确定用长连接方案。安卓端的即时通讯系统里长连接主要两条路原生Socket自己设计、协议包或者基于WebSocket把HTTP相关的鉴权和握手交给系统层处理。我们试过自己定义二进制协议确实可控性最高但序列化、分包、粘包都得自己处理开发节奏会拉长。后来换成WebSocket就顺畅很多它天然位于HTTP之上握手阶段可以复用已有的登录鉴权体系传输内容又可以按文本或二进制帧组织完全能满足聊天场景。1.3 定下的架构轮廓整个系统最后是这么分工的安卓客户端维护一个WebSocket长连接负责消息收发、心跳保活、服务端状态感知自定义一套JSON消息协议区分消息类型、携带消息ID和顺序号客户端本地用数据库缓存会话与消息聊天列表页先从数据库读缓存再等待服务端补增量服务端负责消息路由、离线存储、消息回执弱网和断线场景由客户端的一套重连与补偿机制兜底。这个架构看上去不复杂但每个模块深入到细节都有很多东西值得展开。下面就从我最看重的话题开始消息协议和可靠性。2. 传输层选型与消息协议设计一切可靠性问题的起点2.1 WebSocket为什么够用有人可能会说WebSocket是浏览器用的移动端自己搭系统不应该用原生的TCP自定义包吗我的看法是没有绝对好坏只有适不适合。WebSocket本质上是HTTP握手之后升级为长连接通道的一种封装。握手阶段你可以照常带Token、用户ID、设备信息服务端在鉴权通过后才完成协议升级。之后的通信是双向的客户端可以随时给服务端发服务端也可以随时给客户端推。具体到聊天场景我自己看重的点有三个不用考虑粘包拆包。消息以帧的形式传输每帧有明确边界天然避开了原生Socket协议里最累人的处理细节。文本帧直接可以放JSON。业务消息、心跳包、回执包都用JSON表示排查问题非常直观。服务端生态成熟。不管是Java还是Node侧都有现成的WebSocket库接入成本低。不过需要留意WebSocket本身只负责传输了一条消息具体消息给谁、消息是否唯一、如何处理重发都是上下层协议应该定义的。协议设计严谨程度直接决定了系统后面的稳定性。2.2 我使用的协议字段和一条消息长什么样子先给出一份我在实际项目中跑通的JSON消息结构{ type: chat.single, msgId: a1b2c3d4-0001-0002-0003-000000000001, seq: 202406170001, fromUid: 10001, toUid: 10002, channelId: session_10001_10002, ts: 1619200000123, body: { content: 在吗, contentType: text } }这里几个字段是把整个系统的可靠性串起来的关键我逐个说明。msgId是全局唯一消息ID一般用客户端UUID或者服务器统一分配的序列号作用有两个回执和去重。收发双方只要维护住消息的唯一身份后续的ACK确认、重复检测、数据库幂等写入都有依据。seq是顺序号。同一个会话里消息必须是有序的。如果只用时间戳服务器时钟和客户端时钟不同步会产生乱七八糟的先后顺序用seq就可以让接收端按序排列和渲染。seq可以由服务端统一生成客户端在发起消息时不必持有全局顺序号这点在离线消息合并时尤其重要。channelId标识会话单聊用两个用户ID拼成群聊用群ID拉历史消息、管理会话列表都靠它。ts用于展示时间以及作为本地缓存的排序兜底。body里放业务内容。聊天场景不止文本会有图片、文件、语音我们统一用contentType区分后续即使增加业务消息类型也不影响传输协议。2.3 消息类型不只是聊天消息如果只把业务消息编码后扔到通道里系统会陷入混乱。需要建立一套消息类型体系至少包括chat.single/chat.group单聊和群聊业务消息ack消息回执告诉发送方服务端已接收或对端已送达read已读回执用于显示对方是否已读heartbeat/heartbeat.ack心跳及心跳响应server.notify服务端推送的系统通知如被踢下线、版本强制升级、会话变更。把这几种类型分开之后收到一条消息时客户端可以先看type决定走哪条处理路径不用在一个Handler里用if层层判断。这里也体现了为什么不用一个type字段而不用枚举后续加新的行为类型时业务逻辑隔离得干净排查问题也容易定位。3. 消息发出去不等于送达全链路可靠性实现3.1 确认、重发、去重在自己搭建的即时通讯系统中可靠性是最容易被低估的一环。很多刚接触的人觉得有TCP长连接就不会丢消息但现实是——连接不可能永远不断尤其移动网络的形态非常恶劣。CPU休眠、飞行模式、信号波动、WiFi与蜂窝网切换都会让TCP连接无声断掉如果服务端进程异常重启中间经过代理网关、所有在这条连接上尚未成功写入的消息都可能丢失。所以必须建立一套基于ACK确认的机制。思路和快递签收类似客户端发送一条消息时先写入本地数据库标记为发送中消息经长连接发到服务端服务端校验合法性后落库、写入离线消息队列然后立刻返回ack客户端收到ack后把本地消息状态更新为已到达服务端若目标用户长期在线服务端直接推送给对方目标用户收到后返回收到确认服务端再将对端已送达状态反馈给发送方客户端发送消息后启动超时计时器在若干秒内没收到ack则重发。重发这里需要讲究策略最忌讳的无脑每1秒重发一次这叫重发风暴。我验证下来比较稳的是指数退避第一次3秒第二次6秒、12秒、24秒最多重发4-5次就停下来等待用户手动触发或者网络恢复后继续。3.2 msgId的幂等与去重有了重发自然会带来重复。如果客户端重发同一条消息而服务端第一次其实已经收到了只是回执在网络里丢了那第二次收到的就是重复消息。这时候msgId就派上用场了。服务端对msgId做了唯一索引收到一条消息先查是否存在存在就跳过落库但补发一次回执不存在则正常处理。客户端本地存储也会以msgId作为业务主键服务端推送来的消息即使重复也能保证数据库只记录一条。这套机制在线上帮我们挡掉了大量因为重发导致的重复消息。实践里很重要的一句忠告是不要相信网络而要用协议去约束通讯双方的行为。把msgId和去重表设计到位可靠性就有了一半保障。3.3 离线消息与拉取顺序用户不可能永远在线离线消息需要服务端保存。我们的做法是服务端为每个用户维护一个最新seq记录。客户端上线时发起sync请求携带自己拉取到的最大seq服务端把seq大于该值的历史消息全部返回。这里就体现出seq按会话全局递增的优势了。假设用户离线期间同事在多个群和单聊里发了消息这些消息的seq都大于客户端本地的最后seq。重新连接后服务端一次性把这些戏推过来客户端按seq递增逐条写入本地数据库再刷新UI历史顺序完全不会乱。如果设计要求支持多端同步这个seq的思想同样适用只需要另外维护一个端上已同步seq的标记就行。4. 不卡顿的聊天界面线程模型与UI更新策略4.1 主线程守护原则安卓端即时通讯系统最容易出问题的界面是聊天页消息量大、收发频繁、图片多。但真正让聊天页面卡顿的根源往往不是RecyclerView的性能而是有人把网络或者数据库读写放在主线程了。主线程一旦阻塞超过几百毫秒用户就会感觉卡顿和掉帧。所以从项目一开始我就定下一条硬规矩网络收包、数据库写入、未读计数更新全部在后台线程完成任何数据变化完成后通过Handler或接口回调切回UI线程做最小范围的更新。常见的实现方式底层收到的网络包先交给一个独立的消息处理线程统一处理处理完成之后向UI注册的Fragments或Activity发送事件。不要去依赖RxJava或者EventBus这类重量级框架用好Handler和LifecycleObserver照样能管理得很好。少引入依赖后期维护成本反而低。4.2 局部刷新代替全量刷新聊天页里一收到新消息就notifyDataSetChanged这在消息量少的时候问题不大但一旦一个会话有几百条历史消息一旦同时来几条推送整列表刷新的代价很大。实践下来的优化思路是聊天页用ListAdapter或者RecyclerView.Adapter数据变化时用submitList触发diff计算而不是直接全量notifyDataSetChanged收到新消息时只调用adapter.notifyItemInserted(list.size - 1)消息状态变化如发送中变为发送成功时只更新对应那条item如果聊天页不在前台消息只入库不触发刷新再次进入时先加载未读数再加载最新消息。这些是每个安卓开发都会接触到的控件细节但在即时通讯场景下影响被放大了。消息密集的场景一套缺失diff的列表实现几乎必然会卡。4.3 数据库与内存缓存的配合消息不可能全部常驻内存。我从启动时就加载最近20条消息用于显示向下滑动时再从数据库按seq分页加载更早的历史消息新收到的消息写数据库的同时更新内存链表。存储层我选用的是SQLite配合Room封装。聊天消息表的核心字段包括msgId、channelId、seq、type、fromUid、toUid、content、status、ts对channelId seq建立联合索引。这样分页查询和未读统计都有可靠的执行计划。这里有条很实在的建议不要把未读数单独存在某个表里一旦消息删除或同步不一致会有脏数据直接在消息表上用count(*) where channelId? and status未读计算即可。消息量大后可以针对status建立索引但实测中先保证联合索引足够避免为了微小的查询性能把存储结构搞得复杂。5. 真机环境里的三个翻车现场5.1 心跳参数和NAT空闲超时一切看着正常但手机锁屏放一会儿再解锁连接已经断了。后来进一步排查确认是NAT映射超时问题。安卓客户端连接服务端中间要经过移动网络运营商的NAT网关网关会为每条TCP连接维护一个映射表如果连接长时间没有任何数据帧映射会被回收之后服务端向客户端推消息就推不到了。解决方案就是心跳机制。定一个比NAT超时更短的心跳周期比如每30秒发送一个heartbeat包服务端在收到后会返回heartbeat.ack如果连续两次没有收到ack客户端认为连接已经不可用主动关闭重建连接。心跳周期不宜设得太短否则省电和流量很差也不能太长否则NAT可能先于心跳把通道关闭。我们见过很多参数配置有公司用25秒也有用55秒的关键在于根据实际运行网络环境测试调整。团队内部沟通工具的场景下我用30秒作为默认值基本没再出现连接假死的情况。5.2 网络切换WiFi与蜂窝网这是所有移动端长连接系统都逃不过的坑。手机从WiFi切换到4G/5G时旧连接的IP、端口都变了但TCP连接自己感知不到两端都还认为连接存活着。后续消息继续走旧连接自然发不到服务端又不会有及时的报错。处理方式是注册安卓系统的网络状态变化广播一旦检测到网络从WiFi到蜂窝网、蜂窝网到WiFi或网络完全断开又恢复主动断开现有WebSocket连接并立刻发起重连。不要等心跳超时因为网络切换场景下等待超时可能要几十秒用户体验完全不可接受。另外App从后台回到前台时也值得主动检查一下连接状态。后台一段时间内系统可能为了省电冻结了网络或者进程回前台时连接多半已经不可靠强制重建一次连接成本不高收益却很明显。5.3 重连风暴处理断线重连最容易写出一堆问题的地方就是无脑重连。比如服务端重启期间几百个在线客户端同时发现连接断开、同时发起重建服务端还来不及恢复时每个客户端又在拼命重试形成恶性循环。我用过的最有效的方案是全随机退避首次重连延迟在1~3秒之间随机第二次在3~6秒之间随机第三次以后在10~30秒之间随机如果重连成功把退避级别清零如果失败逐级增大。这样能让客户端重连请求在时间轴上均匀错开避免请求同时涌向服务端。同时在服务端侧也做了一层防护启动预热期间对新建连接先返回临时不可用状态并建议客户端几秒后再试。这套组合拳下来服务端发布重启的过程在整个系统里几乎无感知。6. 消息已读、未读与多端同步沟通工具的体验分水岭6.1 已读/未读状态怎么维持聊天工具发展到现在消息已读已经是默认能力。实现上我们通过read这类回执包完成。用户A给用户B发消息B的客户端在页面可见且消息成功渲染后会发送一个read包包里携带我读到了哪个seq的信息。服务端据此更新会话状态并把已读回执转给AA的界面上对应用消息的状态变为已读。这过程中会遇到微妙的问题B如果离线A发出去的消息永远处于未送达或未读状态B重新上线后读到A的消息又自动回执这时A端才更新状态。状态流转要严谨否则容易出现对方明明看了你还显示未读的尴尬。6.2 多端登录下的已读边界如果支持多设备登录手机与平板同时在线已读语义会变得复杂。到底按账号维度还是按端维度同步不同产品可以有不同方案。我采取的是账号维度已读表示这个账号在任意一个端上真正打开了会话并读到了最新消息。处理逻辑上服务端给账号下所有其他在线端广播该消息已被你账号的某设备读取各端收到后再刷新自己界面的消息状态。多端同时在线还会带来msgId的生成冲突问题客户端生成消息ID时一定要包含设备标识或者UUID确保同账号跨设备不会重复。6.3 离线推送怎么补纯粹依赖长连接的一层短板是App进程被杀之后完全收不到消息。这也是自建系统相对商业SDK最麻烦的地方。我们当时的App是内部高频使用的工具用户通常会保持前台或后台存活因此对这一层的需求不如大众社交软件那么高。但为了兜底还是接入了系统推送通道服务端判断用户长连接不在线就转调系统推送服务推的是一条有新消息的轻薄通知用户点击通知后回到AppApp建立长连接拉取具体内容。如果你面向的场景需要保证消息到达率推送通道基本不能缺。这里也提醒一点不要尝试自己搞一个保活Service来维持连接那既费电又容易被系统限制。正确路径是系统推送通知触达。7. 上线之后的一些数字与体会系统上线跑了几个月一些实际数值可以给准备自己搭的人参考。长连接在手机弱网环境下日平均断线次数大约每人3~5次加入网络切换强制重连和指数退避之后消息端到端延迟绝大部分时间维持在1秒以内历史消息拉取、离线消息补偿、已读回执这些链路基本稳定没有出现消息乱序和重复的情况。自己动手前最大的顾虑是会不会做不好做完之后的体会是即时通讯系统的技术门槛并不在于某个算法多难而在于把几十个细节串起来时不能有明显短板。最耗时间的往往不是写代码而是协议设计是否周详、边界情况是否考虑到位重发会不会重复、重连会不会风暴、离线消息能不能同步干净、状态切换会不会漏掉场景。最后再分享一个我做这套系统时给自己定的原则永远先让消息必达和顺序不错成立再考虑花哨功能聊天工具的脸面是消息列表刷得快不快、收消息快不快、准确不准确这是地基。地基稳了后面加什么功能都会有底气。