
做安卓即时通信项目最典型的翻车方式就是界面飞快画完登录、注册、聊天框一天搞定结果一到联调消息发不出去、对方收不到、手机锁屏十分钟连接就断了。我这次完成基于安卓的即时通信系统的设计与实现项目时换了一个思路——先不碰界面从通信协议、连接管理、离线消息、本地存储这条链路一层层往下设计界面反而是最后做的。整个项目做下来最深的体会是IM 的难点从来不在 UI而在消息能不能可靠到达这件事上。这篇文章会把整个项目的设计思路和实现过程完整梳理一遍重点放在协议帧设计、长连接的建立与保活、服务端消息路由、安卓后台服务适配、Room 本地存储以及联调阶段真实踩过的几个坑。适合正在做安卓网络项目、毕业设计选题是 IM 系统、或者想搞明白长连接到底怎么维护的开发者参考。我会尽量把每一步的为什么这么设计讲清楚而不是只给结论。1. 为什么我把协议设计放在代码之前1.1 自研二进制协议而不是直接套 WebSocket做这个项目之前我纠结过一个事直接用 WebSocket 或者 XMPP 不是更省事吗后来我放弃了这个想法原因有两个。第一这个项目本身是个学习型项目我想通过它把 TCP 长连接、粘包拆包、心跳机制这些底层知识真正过一遍。用现成框架当然快但框架帮你屏蔽掉的细节恰恰是面试和实际工作中最常考的东西。第二自研协议在控制力上更强我可以按自己的需求定义消息格式比如后续要加文件传输、已读回执只需要扩展 type 字段不用去理解第三方协议的完整规范。协议层我用的是二进制协议帧结构如下字段长度说明magic2字节魔数 0x5A5A用于快速校验version1字节协议版本号方便后续升级type1字节消息类型如 0x01 登录、0x02 心跳、0x03 单聊消息sequence4字节消息序号客户端生成用于去重和 ACK 关联payloadLen4字节载荷长度payload变长JSON 格式的业务数据选择 12 字节定长头是因为小字段对齐后解析简单ByteBuffer 直接读就行。payload 用 JSON 而不是再套一层二进制是为了可读性和扩展性——业务字段变多时不用改动帧头只要在 JSON 里加字段就行。1.2 粘包拆包是必须自己处理的坎TCP 是流式协议它不保证你一次 read 就读到完整的一帧数据。可能你发了两条消息服务器一次 read 读到了两条也可能一条消息 3KB分三次才读完。这就是所谓的粘包和半包。解决思路是读够长度再解析。客户端读数据时先读 12 字节的帧头从帧头里拿到 payloadLen再继续读 payloadLen 字节的载荷如果这次没读够就继续阻塞读直到凑满整个帧。拆包的核心代码大致长这样InputStream in socket.getInputStream(); byte[] header new byte[Packet.HEADER_LEN]; while (running) { // 先完整读帧头 readFully(in, header); ByteBuffer buffer ByteBuffer.wrap(header); short magic buffer.getShort(); byte version buffer.get(); byte type buffer.get(); int sequence buffer.getInt(); int payloadLen buffer.getInt(); // 校验魔数 if (magic ! Packet.MAGIC) { throw new IOException(invalid packet magic); } // 再完整读载荷 byte[] payload new byte[payloadLen]; readFully(in, payload); // 分发处理 handlePacket(new Packet(magic, version, type, sequence, payload)); }readFully是我自己封装的方法内部循环调用read直到读满指定字节数为止。我见过不少初次做 Socket 项目的人直接in.read(payload)然后发现数据总是莫名其妙地丢——那就是因为 read 方法不保证一次读完必须循环。1.3 消息类型和心跳的定义要留扩展位置type 字段目前定义了这些值type 值含义方向0x01登录请求/响应双向0x02心跳客户端到服务端0x03单聊消息双向0x04消息 ACK服务端到客户端0x05离线消息推送服务端到客户端0x06退出登录客户端到服务端sequence 是我特别强调的字段。客户端生成消息 ID 时不要用自增 int最好用UUID.randomUUID().toString()或时间戳 随机数的组合保证全局唯一。原因是后面做消息重发时服务端和接收端要靠这个 ID 去重如果 ID 可能重复会造成消息丢失或重复入库。2. 客户端连接层长连接的建立、保活与自动重连2.1 连接生命周期与重连策略IM 的核心是一条 TCP 长连接不是每次发消息都重新建连。新建连接的开销很大——TCP 三次握手、可能的 TLS 握手、服务端的登录鉴权每次都做会明显增加消息延迟而且服务器也没法维持用户的在线状态。连接生命周期我分成了四个状态未连接、连接中、已连接、重连中。用一个状态机管理避免了重复创建线程连接还没建立就开始发消息这类问题。连接建立放在一个独立的线程里避免阻塞 UI 线程连接建立成功后启动读线程读线程负责持续从 Socket 读取数据。所有发消息的操作都通过一个发送队列串行化保证同一时刻只有一个线程在写 Socket避免并发写导致数据交错。2.2 心跳机制与半开连接这是整个项目里我最想强调的部分。很多人以为 TCP 连接断了程序马上就能感知到。实际完全不是这样。手机息屏、WiFi 切换、移动网络 NAT 超时都可能让连接变成半开状态——客户端和服务端都认为连接还在但实际上数据包已经无法双向到达。举个真实例子我用手机连着家里 WiFi锁屏放了一夜。第二天早上打开手机界面上还是在线状态但给这个设备发消息服务端显示发送成功客户端却永远收不到。这就是典型的半开连接。TCP 的 keepalive 默认要等很久才能发现连接失效对于 IM 场景完全不够用必须自己做应用层心跳。我的心跳方案是客户端每 30 秒发送一个心跳包type0x02服务端收到心跳后回应一个心跳 ACK客户端连续 3 次没收到心跳 ACK判定连接失效主动断开并重连服务端如果 90 秒没收到客户端任何数据包括心跳判定对端离线清理在线状态。public class HeartbeatManager { private static final long INTERVAL 30 * 1000L; private static final int MAX_MISSED 3; private int missedCount 0; private ScheduledExecutorService scheduler; public void start() { scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { if (connection.isConnected()) { if (missedCount MAX_MISSED) { connection.forceReconnect(); return; } connection.sendHeartbeat(); missedCount; } }, INTERVAL, INTERVAL, TimeUnit.MILLISECONDS); } public void onHeartbeatAck() { missedCount 0; } }注意心跳包本身也有业务价值——很多移动网络的环境下只要客户端持续有数据收发NAT 映射就不会被回收连接就能一直保持。所以心跳不只是探测存活它也在帮连接续命。2.3 发送消息的可靠性ACK、重试与幂等消息发送不是把数据 write 出去就完事了的。我当时的消息发送流程是客户端构造消息帧存入待确认队列然后通过网络发送服务端收到后落库返回 ACKACK 里带着客户端生成的消息 ID客户端收到 ACK 后从待确认队列移除该消息。如果 10 秒内没收到 ACK客户端自动重发最多重试 3 次。超过 3 次则标记为发送失败并在界面上显示失败状态让用户选择手动重发。这里有一个细节服务端收到重复消息时必须做幂等处理。因为网络可能造成重发消息和服务端 ACK 在传输途中擦肩而过——客户端没收到 ACK触发了重发但服务端其实第一遍就收到了。如果服务端不判断消息 ID 是否已存在就会出现同一条消息存两次、对方收到两次的情况。我当时的做法是在服务端的消息表里给clientMessageId字段加唯一索引插入时先查一次存在就忽略只补发 ACK。这个方案简单可靠也方便排查。3. 服务端连接管理、消息路由与离线消息的取舍3.1 线程模型阻塞 IO 线程池服务端我最初用的是一连接一线程的阻塞 IO 模型。没有引入 Netty因为项目规模并不大我也想把精力集中在业务逻辑上。在每个客户端 Socket 的读线程里循环执行readFully解析帧数据由于每个连接有自己的读线程一个连接阻塞不会影响其他连接。线程池参数我设成了 128 个线程。对于个人电脑上运行、模拟几百个客户端压力测试的场景这个数量足够了。如果是要支撑上万并发那确实应该换 Netty 或者基于 NIO 的框架这是后话。服务端把每个连接封装成了一个ClientConnection对象维护着 Socket、输入输出流、当前登录用户 ID。在线用户表用ConcurrentHashMapInteger, ClientConnection管理key 是用户 IDvalue 是对应的连接对象。登录成功时写入退出或断开时移除。3.2 在线用户表、消息路由与多终端问题消息路由的逻辑说起来很简单A 给 B 发消息服务端收到后查在线用户表找到 B 的连接把消息透传过去。但有一个很隐蔽的问题用户可能在多个设备登录。这个项目里我做了简化处理——同一时间只允许一个设备在线后来登录的会把之前登录的踢下线。这个策略在毕设场景下完全够用也避免了很多复杂问题。被踢下线的设备会收到一条类型为 0x06 的通知帧客户端收到后弹出账号在其他设备登录的提示并断开本地连接。实现上就是在登录接口里检查该用户 ID 是否已存在在线表中存在就先给旧连接发踢下线通知再关闭旧连接然后用新连接替换旧连接。3.3 离线消息和已读/未读的简化方案如果 B 不在线消息怎么办最粗暴的方案是直接丢弃但这样体验太差用户会说你消息丢了。我的方案是服务端先把消息写入离线消息表等 B 登录后服务端查询该用户所有离线消息逐条推送给客户端推送完再删除记录。离线消息表结构很简单消息 ID、接收者 ID、发送者 ID、消息内容、消息类型、创建时间。B 登录成功后服务端在登录响应帧里带上离线消息数量客户端收到后请求拉取离线消息列表。关于已读/未读功能我做了一个很务实的简化把已读状态放在客户端本地。客户端每打开一个会话就把该会话在本地数据库中所有消息标记为已读会话列表的未读数也同步清零。服务端不做已读同步好处是整个服务端逻辑少了一大块复杂度。缺点是多端同步时未读数会不准但这个项目本来就是单端场景不用纠结。4. 安卓系统进程生命周期对连接的影响与前台服务方案4.1 在 Activity 里建连接是错误的做法我最早的原型版本把 Socket 连接写在了 MainActivity 的onCreate里做完就发现严重问题用户退出登录界面、按 Home 键回到桌面Activity 被销毁后连接线程也跟着没了消息自然收不到。甚至屏幕方向一旋转Activity 重建旧连接直接被丢掉了。正确的做法是把连接管理、心跳、消息收发全部封装在一个独立的前台服务里。Service 不依赖 Activity 生命周期即使应用退到后台只要进程还在服务就能继续跑。我新建了一个ConnectionService负责创建连接、维护心跳、接收服务端消息并通过广播或 LiveData 把消息发送给正在前台显示的 Activity。public class ConnectionService extends Service { private ConnectionManager connectionManager; Override public void onCreate() { super.onCreate(); connectionManager new ConnectionManager(); connectionManager.start(); } Override public int onStartCommand(Intent intent, int flags, int startId) { startForeground(NOTIFICATION_ID, buildNotification()); return START_STICKY; } Override public void onDestroy() { connectionManager.stop(); super.onDestroy(); } }START_STICKY表示服务被系统杀掉后系统会尝试重建服务。但这只保证尽力而为不是绝对可靠所以重连机制必须放在应用启动时一起触发。4.2 前台服务、通知渠道与 Android 8.0 适配从 Android 8.0 开始后台应用想要启动服务有严格限制系统不允许应用在后台随意创建服务。如果你直接startService会抛IllegalStateException。唯一的通行证是startForegroundService并在服务启动后的 5 秒内调用startForeground显示一条通知。通知渠道NotificationChannel也是 Android 8.0 之后必须处理的。我把连接服务的通知渠道设成IMPORTANCE_LOW这样不会响铃打扰用户只在通知栏占一个不显眼的位置private Notification buildNotification() { NotificationManager manager (NotificationManager) getSystemService(NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { NotificationChannel channel new NotificationChannel( im_channel, 即时通信服务, NotificationManager.IMPORTANCE_LOW ); manager.createNotificationChannel(channel); } return new Notification.Builder(this, im_channel) .setContentTitle(即时通信) .setContentText(连接服务运行中) .setSmallIcon(R.drawable.ic_launcher) .build(); }这里补充一句部分国产定制系统对后台进程有非常激进的管理策略即使用户开启了前台服务App 仍然可能被杀掉。我在测试阶段就遇到过这种情况手机锁屏半小时后杀进程记录里出现了这个 App。解决思路是提示用户将应用加入电池白名单同时重连逻辑要能在应用重新回到前台时立即触发。4.3 网络切换监听与自动重连WiFi 和移动网络切换时原来的 Socket 会直接断掉或进入半开状态。如果不监听网络变化用户会出现切了 WiFi 之后消息收不到的奇怪现象。我注册了一个ConnectivityManager的网络回调当发现网络从无到有、或者从一种类型切换到另一种类型时主动断开现有连接并触发重连。注意网络刚恢复时 DNS、路由可能还没有完全就绪所以要稍微等一下再重连避免连接失败后进入无限重试循环。我用的策略是网络变化后延迟 2 秒重连重连失败则按指数退避——1 秒、2 秒、4 秒、8 秒最多延迟 60 秒防止在网络不可用时疯狂消耗电量。5. 本地聊天记录的持久化设计Room、索引与分页5.1 消息表、会话表与联系人表的依赖关系聊天记录必须存在本地否则每次打开聊天界面都要从服务端拉历史消息既慢又费流量。我选了 Room 作为 ORM它在 SQLite 之上提供了编译期校验写错了 SQL 编译就直接报错。我设计了三张核心表CREATE TABLE user ( userId INTEGER PRIMARY KEY, username TEXT NOT NULL, nickname TEXT ); CREATE TABLE session ( sessionId TEXT PRIMARY KEY, peerUserId INTEGER, lastMessage TEXT, lastMessageTime INTEGER, unreadCount INTEGER DEFAULT 0 ); CREATE TABLE message ( id INTEGER PRIMARY KEY AUTOINCREMENT, clientMessageId TEXT NOT NULL, sessionId TEXT NOT NULL, senderId INTEGER NOT NULL, content TEXT NOT NULL, messageType INTEGER DEFAULT 1, status INTEGER DEFAULT 0, createTime INTEGER NOT NULL );clientMessageId加唯一索引做本地去重。sessionId加普通索引加速会话内消息的查询。createTime加索引加速按时间排序的分页查询。索引不是越多越好每多一个索引写入就要多维护一份 B 树这里只给最关键的查询字段加索引。5.2 会话列表与未读计数的本地维护会话列表的数据来源很有意思它不完全来自服务端而是从本地消息表聚合出来的。每当收到一条新消息如果本地不存在这个会话就插入新会话如果已存在就更新最后一条消息内容和时间未读计数加 1。当用户点开会话时调用 DAO 将该会话的所有消息标记为已读未读计数清零。这样设计的好处是会话列表完全由本地数据驱动界面打开时直接查本地表不需要额外发网络请求。这个逻辑虽然简单但要处理好事务否则会出现会话已更新、未读数没清零这样的数据不一致问题。Room 的Transaction注解在这个场景很实用。5.3 分页加载与消息去重聊天记录不能一次性全查出来消息表的记录会越积越多。分页我用的方式是倒序查询取最近的 N 条再倒置返回。为什么倒序因为用户翻聊天记录是向上翻的每次查询要取的是当前最旧一条之前的 N 条用倒序查法配合索引LIMIT和OFFSET的效率更高Query(SELECT * FROM message WHERE sessionId :sessionId ORDER BY id DESC LIMIT :limit OFFSET :offset) ListMessageEntity loadPage(String sessionId, int limit, int offset);拿到列表后在内存里反转顺序再交给 RecyclerView 显示。UI 层只负责展示数据层不用保存界面状态。去重逻辑我前后做了两层接收服务端推送时先判断clientMessageId在本地是否存在存在就直接忽略不存在才插入数据库。这样即使服务端因为网络问题重发消息本地的消息列表也不会出现副本。另外自己的消息发送成功后收到 ACK也要把本地状态从发送中更新为已发送。6. 联调阶段踩过的四个典型坑6.1 手机息屏后消息失踪的排查始末这个坑我排查了大半天。现象是手机亮屏时消息收发正常锁屏 20 分钟左右后另一台设备发的消息发送方显示成功接收方毫无反应解锁后消息也不过来直到重启 App 才补齐。一开始我以为是服务端路由问题看服务端日志发现消息确实推送到了客户端的 Socketwrite 也返回了成功。后来在客户端加了数据接收日志才发现锁屏期间连心跳都发不出去连接已经成了半开状态只是还没有触发重连。原因很典型WiFi 在屏幕熄灭后会进入省电模式网络报文发送间隔被拉长心跳包和服务端 ACK 都可能被延迟长时间无有效数据交换后 NAT 会话被回收。我的修复方案有三步心跳间隔从 30 秒调成 20 秒多发心跳保持 NAT 映射新鲜客户端连续 2 次心跳未收到 ACK 就立即重连不再等到 3 次注册屏幕亮起的广播亮屏时主动检查一次连接状态发现异常立即重连。修完之后锁屏一夜第二天解锁基本都是秒收消息。6.2 TCP 粘包导致第一条登录指令被拆成两半这个坑是在模拟器上调试时遇到的。现象是登录时偶发失败服务端报 JSON 解析异常。日志里看到第一条收到的 payload 是{user第二条才是完整 JSON 的另一半。这个问题的根源就是拆包逻辑不严谨。我在代码里虽然写了readFully但个别分支用了单次 read导致数据没读够就进入了业务解析。修复的方式就是全部统一走readFully循环读不允许任何一条路径绕过。调试这种问题的技巧是在拆包后、业务处理前打印帧头信息和 payload 长度对比数据长度是否正确能很快定位是拆包问题还是业务逻辑问题。6.3 模拟器连不上宿主机服务Android 模拟器里的网络环境比较特殊模拟器自身在一个虚拟子网里127.0.0.1指向的是模拟器自己而不是宿主机。想在模拟器里访问宿主机上运行的 IM 服务端需要用特殊地址10.0.2.2这是 Android 模拟器预留的宿主机别名。我第一次跑通时也栽在这里。代码里写死了127.0.0.1模拟器里一直连不上换成10.0.2.2就好了。如果是真机调试则要用电脑在局域网里的实际 IP而且手机和电脑必须处于同一网段。这个细节很小但能卡死人专门记录一笔。6.4 切换 WiFi 后连接挂死不报错也不重连现象是手机连着路由器 A 打开 App聊天正常切换到路由器 B 后App 界面没有任何反应发消息一直转圈但 App 没有崩溃服务端也看不到断开事件。问题是网络切换后旧连接的 Socket 并不会立即报错——它可能处于半开状态也可能在某次 write 时才抛异常完全看时机。而我们的发送队列里消息一旦积累就会发生看起来发送成功但实际上数据根本没出去的假象。我的修复方案是前面提到的ConnectivityManager网络回调监听网络变化检测到变化后主动断开旧连接立刻触发重连。经过这个教训我把被动等 Socket 报错的策略改成了主动感知环境变化并干预这个思路在整个项目的稳定性上起了很大作用。7. 如果再让我做一次我会改掉这几处复盘整个项目有些设计在初始阶段是合理的但如果项目规模继续扩大我会优先替换这几处第一协议层升级为 WebSocket 或自研协议之上的 TLS 加密。当前明文 payload 在局域网里问题不大一旦走公网消息被截获就是裸奔状态。最简单的方式是用 TLS 包裹整条 TCP 连接App 端信任自签名证书服务端用 BoringSSL 或 JDK 内置 SSLContext。第二服务端引入 Netty。当前阻塞 IO 模型能支撑的并发有限Netty 的 Reactor 模型在连接数上有一个数量级的提升。而且 Netty 自带的 LengthFieldBasedFrameDecoder 就是为粘包拆包设计的比自己手写readFully可靠得多。第三消息可靠性和已读回执的设计更完整。目前重发是客户端单方面超时重试没有做消息状态的多端同步。如果要支持手机已读电脑未读这种场景就需要服务端维护一张消息状态表并配合长连接做状态变更推送。第四文件传输。当前只实现了文本消息图片、语音都还没做。扩展思路是用 HTTP 上传文件CDN 或者对象存储返回 URL然后在文本消息里带一个特殊类型的 JSON——{type:image,url:https://...,width:1080}接收端解析到消息类型后加载图片。这个方案比直接走 Socket 传二进制文件要稳得多因为大文件传输不适合长连接通道容易阻塞心跳。最后再分享一个我做这个项目时的小技巧联调阶段一定要写日志而且日志要分级。网络层的收发记录、心跳记录、重连记录全部打点出问题时看日志比猜原因靠谱一百倍。一次完整的排查日志链路上能直接看到连接建立失败-重连-心跳未收到-强制重连的完整轨迹问题定位时间能缩短一个数量级。中间还遇到过厂商系统把后台进程杀掉的情况有日志在我也能快速判断到底是自己代码的问题还是系统收紧进程限制的问题。整个项目做下来我对即时通信系统这个概念的理解发生了很大变化。它不是一个能聊天的 App而是一整套关于状态同步、连接维护、数据可靠性和系统适配的工程实践。每一步都有细节每个细节都藏着坑把这些坑一个个填平这个项目才算真正跑起来。