☰
安卓即时通信系统设计:从长连接到消息可靠送达的完整实现
2026/9/30 3:30:14 网站建设 项目流程

做安卓即时通信项目,最典型的翻车方式就是:界面飞快画完,登录、注册、聊天框一天搞定,结果一到联调,消息发不出去、对方收不到、手机锁屏十分钟连接就断了。我这次完成"基于安卓的即时通信系统的设计与实现"项目时,换了一个思路——先不碰界面,从通信协议、连接管理、离线消息、本地存储这条链路一层层往下设计,界面反而是最后做的。整个项目做下来,最深的体会是: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 秒发送一个心跳包(type=0x02);
  • 服务端收到心跳后回应一个心跳 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 出去就完事了的。我当时的消息发送流程是:客户端构造消息帧,存入"待确认队列",然后通过网络发送;服务端收到后落库,返回 ACK,ACK 里带着客户端生成的消息 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。在线用户表用ConcurrentHashMap<Integer, ClientConnection>管理,key 是用户 ID,value 是对应的连接对象。登录成功时写入,退出或断开时移除。

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") List<MessageEntity> loadPage(String sessionId, int limit, int offset);

拿到列表后,在内存里反转顺序再交给 RecyclerView 显示。UI 层只负责展示,数据层不用保存界面状态。

去重逻辑我前后做了两层:接收服务端推送时,先判断clientMessageId在本地是否存在,存在就直接忽略;不存在才插入数据库。这样即使服务端因为网络问题重发消息,本地的消息列表也不会出现副本。另外,自己的消息发送成功后收到 ACK,也要把本地状态从"发送中"更新为"已发送"。

6. 联调阶段踩过的四个典型坑

6.1 手机息屏后消息"失踪"的排查始末

这个坑我排查了大半天。现象是:手机亮屏时消息收发正常,锁屏 20 分钟左右后,另一台设备发的消息发送方显示成功,接收方毫无反应,解锁后消息也不过来,直到重启 App 才补齐。

一开始我以为是服务端路由问题,看服务端日志发现消息确实推送到了客户端的 Socket,write 也返回了成功。后来在客户端加了数据接收日志才发现,锁屏期间连心跳都发不出去,连接已经成了半开状态,只是还没有触发重连。

原因很典型: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,而是一整套关于状态同步、连接维护、数据可靠性和系统适配的工程实践。每一步都有细节,每个细节都藏着坑,把这些坑一个个填平,这个项目才算真正跑起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询