简介:一款基于Java的安卓简易聊天应用服务端源码,面向初学安卓服务端开发的开发者,用于理解和实现用户管理、消息传递、状态同步等核心功能。压缩包共三十八个文件,其中二十九个Java源文件实现用户认证与消息分发等业务逻辑,另附有XML配置文件、属性文件、编译后的代码包、版本控制忽略文件、命令行脚本及说明文档,整体约一百三十三KB,文件结构清晰,便于按需查阅。目前已有三百一十九人学习,代码目录组织规范,覆盖从项目构建到服务端部署的完整流程。通过研读源码,可以学习如何设计网络通信协议、处理并发请求,并借助项目管理工具完成依赖管理与自动化构建,对准备踏入移动后端开发的初学者有很好的参考价值。
1. 简易聊天服务端:没有云厂商,自己写 Java 服务端到底值不值
接手一个安卓简易聊天 app,很多人第一反应是接腾讯云 IM、环信这类现成服务。但真到落地会发现,免费额度卡得难受,用户量一大就按日活计费,而且聊天记录、离线消息、好友关系全押在别人的协议和后台里。把目光转回“基于 Java 的安卓简易聊天 app 服务端设计源码”时,你其实是在问一件事:自己用 Java 写聊天服务端,到底能不能做到可控、省线、跑得稳。这个方向适合团队内部工具、校园项目、毕业设计,也适合想真正搞懂 IM 服务端原理的 Android 开发者。自己写服务端,换来的是对消息路由和离线逻辑的控制权,代价则是把网络编程的坑全背到自己身上。这篇文章就把选型、协议、建表、踩坑一路讲到底。
2. 服务端选型与启动骨架:Netty 长连接最小可跑代码
做聊天服务端,第一步不是写代码,是先定连接模型。简易聊天看似能直接用 HTTP 轮询糊一个,但手机上轮询消息的体验相当糟糕——电量掉得快、消息有延迟,服务端还要被无效请求打满。既然标题写着“服务端设计源码”,我假设你已经做好自己写服务端的准备,那这一步就得把长连接方案选明白。
2.1 连接方式对比:轮询 / 长轮询 / WebSocket / TCP 长连接
先给一份我常用的选型对照,四种方案里真正适合自建 IM 的只有两种。
| 方案 | 实时性 | 服务端开发成本 | 主要问题 |
|---|---|---|---|
| HTTP 轮询 | 秒级延迟 | 最低 | 每次请求都是新连接,服务和流量浪费严重 |
| HTTP 长轮询 | 准实时 | 较低 | 请求挂起占用连接,服务端并发模型容易吃紧 |
| WebSocket | 实时 | 中等 | 协议本身没有心跳保活,需要自己做心跳与重连 |
| Netty TCP 长连接 | 实时 | 较高 | 网络编程门槛高,但连接管理和性能上限最好 |
WebSocket 适合给有状态的前端页面接实时消息,安卓客户端里用也不是不行。但简易聊天这类 app 天生是长连接场景:客户端要一直在线收消息,还要配合锁屏、切 WiFi 后的重连机制。用原生 TCP 长连接做的服务端,连接状态、心跳、重连全部自己控制,出问题能直接看协议日志,而不是在一个又一个 WebSocket 框架的封装层里翻文档。Netty 在 Java 生态里做这件事最顺手,源码本身就是很好的学习材料,这也是我把标题里的“服务端设计源码”理解成 Netty 方案的原因,我一般会这么搭。
2.2 Netty 服务端启动的最小代码
先给一个真正能跑起来的最小服务端骨架,它包含 boss 线程、worker 线程、管道初始化三个核心部分。代码不长,但每个参数都值得按你的部署环境重调。
public class ChatServer { private final int port = 8080; public void start() throws Exception { // bossGroup 负责接收新连接,workerGroup 负责 IO 读写 EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.TCP_NODELAY, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { // 半包粘包处理:前 4 个字节是消息长度,后面是内容 ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(65536, 0, 4, 0, 4)); ch.pipeline().addLast(new StringDecoder(StandardCharsets.UTF_8)); ch.pipeline().addLast(new StringEncoder(StandardCharsets.UTF_8)); ch.pipeline().addLast(new ChatServerHandler()); } }); ChannelFuture future = bootstrap.bind(port).sync(); System.out.println("chat server started at port " + port); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } public static void main(String[] args) throws Exception { new ChatServer().start(); } }这段代码里有几个参数设计意图要说明。bossGroup 线程数设成 1 就够了,它只干一件事:把连接注册到 workerGroup,不需要多个线程空等。workerGroup 不传参时默认线程数是 CPU 核数的两倍,小型聊天服务端这个默认值没问题,但如果同一台服务器上还跑了 MySQL,建议用new NioEventLoopGroup(8)显式控制线程数,避免 IO 线程和数据库抢 CPU。
SO_BACKLOG 是操作系统未处理连接队列的长度,设 128 是本地开发保守值,线上根据并发可以拉到 1024,但别超过内核参数somaxconn,否则不生效。SO_KEEPALIVE 和 TCP_NODELAY 这两个参数最容易被人忽略,前者让 TCP 层在连接空闲时发探测包,后者禁掉 Nagle 算法,聊天消息都是小包,不关 Nagle 就会出现毫秒级延迟——体验上就是对方“正在输入”半天消息才过来。
2.3 客户端连不上时第一项排查
服务端启动后客户端连不上,先按下面顺序排查,别一上来就怀疑代码:
netstat -lntp | grep 8080看端口是否处于 LISTEN 状态。如果端口是通的,再在服务端日志里搜exception,最常见的两个原因是防火墙没放行端口和服务器安全组没加规则。这一步往往比调试业务逻辑更快,我在这里翻过几次车,都是本地能连上了,部署到云服务器才发现安全组只放行了 22 和 80 端口。
3. 协议与消息路由:用 JSON 信封包住单聊、群聊和系统通知
连接建立之后,服务端要解决的第一个问题是:客户端发来的一段字节流,怎么转成一条业务消息。很多简易聊天源码把功夫花在连接上,协议设计却草草了事,结果一聊起来就出现消息串台、丢消息、时间错乱。协议是 IM 服务端的灵魂,这一步做厚了,后面功能都好加。
3.1 一个信封字段搞定消息类型、去重和状态追踪
简易聊天不需要上 protobuf,JSON 字符串足够,调试时还能直接用 tcpdump 抓包看明文。我常用的做法是给每条消息套一个“信封”,统一字段结构,服务端拿到后先做类型路由。
{ "msg_id": "uuid-xxxx-20240115-001", "type": "PRIVATE", "from_uid": 1001, "to_uid": 1002, "conversation_id": "single_1001_1002", "timestamp": 1705305600000, "payload": { "content": "晚上一起吃饭吗" } }public class MessageRouter { private static final Map<String, MsgHandler> HANDLERS = new HashMap<>(); static { // 注册处理器:单聊、群聊、系统通知、心跳、客户端 ACK HANDLERS.put("PRIVATE", new PrivateChatHandler()); HANDLERS.put("GROUP", new GroupChatHandler()); HANDLERS.put("SYSTEM", new SystemNoticeHandler()); HANDLERS.put("HEARTBEAT", new HeartbeatHandler()); HANDLERS.put("ACK", new ClientAckHandler()); } public static void route(ChannelHandlerContext ctx, String rawMsg) { JSONObject json = JSONObject.parseObject(rawMsg); String type = json.getString("type"); MsgHandler handler = HANDLERS.get(type); if (handler == null) { ctx.writeAndFlush(buildErrorResponse("unsupported message type: " + type)); return; } handler.handle(ctx, json); } }这里最关键的是msg_id,它必须全局唯一。我见过有同学用System.currentTimeMillis()加随机数生成,多客户端同时发消息时撞了,导致离线消息去重失败。常见做法是用 UUID,或者自己拼“用户ID + 时间戳 + 自增序号”,服务端内存里用一个 ConcurrentHashMap 做消息去重,收到重复 msg_id 直接丢弃。
信封字段拆成两层是有意的:外层放路由信息(type、from_uid、to_uid),内层 payload 放业务数据。这样服务端解析时只用读外层就能决定消息去向,payload 里塞图片链接、语音地址、文件路径都不影响路由逻辑。群里发消息时,服务端把一条消息复制 N 份投递给 N 个接收者,副本的 msg_id 保持一致,这样客户端可以根据 msg_id 剔除重复消息。
3.2 心跳消息与超时踢人:IdleStateHandler 参数怎么设
TCP 层虽然有 SO_KEEPALIVE,默认空闲两小时才发探测包,对聊天 app 来说太慢了,手机切到后台一小时,服务端还以为连接活着。业务层必须自己做心跳,Netty 里直接用 IdleStateHandler。
// 参数说明:readerIdleTimeSeconds=60, writerIdleTimeSeconds=45, allIdleTimeSeconds=30 ch.pipeline().addLast(new IdleStateHandler(60, 45, 30, TimeUnit.SECONDS));public class HeartbeatHandler extends ChannelInboundHandlerAdapter { @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event = (IdleStateEvent) evt; switch (event.state()) { case READER_IDLE: // 60 秒没收到客户端任何字节,先给客户端发一个 PING,再等下一轮 ctx.writeAndFlush("{\"type\":\"PING\"}\n"); break; case ALL_IDLE: // 30 秒内既没收到数据也没发出数据,说明连接半死,直接关闭 ctx.close(); break; default: break; } } else { super.userEventTriggered(ctx, evt); } } }这里的三个时间参数是血泪经验换来的结果。运营商 NAT 对 TCP 空闲连接的回收时间通常在 2~5 分钟,所以客户端心跳间隔不能超过 120 秒,服务端读空闲设 60 秒,能保证在运营商掐断前先发现异常。写空闲设 45 秒是给客户端一个主动发消息的余量,全空闲 30 秒是兜底——如果服务端既收不到数据又发不出数据,这个连接基本是废的,关掉重来比干等划算。
客户端配合逻辑我一般会这么做:收到 PING 后立即回 PONG,PONG 里带上客户端当前电量或网络状态字段,服务端不仅能保活,还能顺带做在线状态感知。服务端连续两次 PING 都没收到 PONG,就判定连接死亡,触发离线消息流程。
3.3 消息乱序的边界:为什么不建议在协议里放客户端时间戳
聊天服务端最容易闹出的笑话是:A 手机发的消息比 B 手机先到服务端,但 A 手机本机时间慢了 10 分钟,服务端把 A 的消息时间写成客户端时间,B 看到消息列表整体乱序。解决方案很简单:服务端收到消息的那一刻自己生成服务器时间,覆盖客户端传上来的 timestamp。客户端时间戳只保留在 payload 里作为“用户发送时间”展示,排序一律用服务端字段。这里的取舍必须在协议设计阶段定死,不然后面所有客户端都要改。
4. 数据表设计与离线消息补拉:让聊天记录能跨天、跨端找回
聊天的数据层不像普通业务系统那么复杂,但表结构设计错了,消息量大起来会非常难受。简易聊天服务端不需要上消息队列、不需要 Redis 缓存历史消息,先把三张表设计好,离线消息补拉逻辑写对,就已经能支撑几百人同时在线。
4.1 user、friend、message 三张表的最小字段设计
-- 用户表 CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(32) NOT NULL COMMENT '登录名,唯一', `password_hash` varchar(128) NOT NULL COMMENT '密码哈希,不要存明文', `nickname` varchar(64) DEFAULT '' COMMENT '昵称', `avatar_url` varchar(255) DEFAULT '' COMMENT '头像地址', `created_at` bigint(20) NOT NULL COMMENT '创建时间,epoch 毫秒', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 好友关系表 CREATE TABLE `friend` ( `user_id` bigint(20) NOT NULL, `friend_id` bigint(20) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1-正常,0-已删除', `created_at` bigint(20) NOT NULL, PRIMARY KEY (`user_id`,`friend_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 消息表 CREATE TABLE `message` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `msg_id` varchar(64) NOT NULL COMMENT '客户端生成,全局唯一', `from_uid` bigint(20) NOT NULL, `to_uid` bigint(20) NOT NULL COMMENT '单聊是对方,群聊是群 ID', `type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1-单聊,2-群聊,3-系统', `content` text NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-未读,1-已读', `created_at` bigint(20) NOT NULL COMMENT '服务端时间,epoch 毫秒', PRIMARY KEY (`id`), UNIQUE KEY `uk_msg_id` (`msg_id`), KEY `idx_to_uid_created` (`to_uid`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三张表已经覆盖了聊天服务端最核心的三个领域:用户身份、好友关系、消息内容。密码字段必须存哈希,用 BCrypt 或 SHA-256 加盐,不要拿明文往数据库里塞,安卓客户端请求登录接口时也别把原始密码直接写日志,我见过 DEBUG 日志把整个请求体打印出来然后密码泄露的案例。
msg_id的唯一索引是离线去重的关键,这一行索引能省掉大量重复消息带来的判断逻辑。idx_to_uid_created索引则让“拉取某个人最近 20 条消息”这种高频查询走索引,避免大数据量下全表扫描。
不用建会话表(conversation),我一般这么设计:会话列表由客户端从本地消息表聚合出来,服务端只负责把消息按to_uid存好。这样服务端少维护一张表,客户端也更灵活——会话排序依据最后一条消息时间即可。
4.2 离线消息增量拉取:用 msg_id 游标而不是时间戳
用户上线后,服务端要补发离线期间的消息。常见做法有两种:按时间戳拉取和按游标拉取,我强烈推荐后者。
-- 游标表:记录每个用户每个会话已读到哪里 CREATE TABLE `user_cursor` ( `user_id` bigint(20) NOT NULL, `conversation_id` varchar(64) NOT NULL, `last_read_msg_id` varchar(64) NOT NULL COMMENT '最后已读消息的全局 ID', `updated_at` bigint(20) NOT NULL, PRIMARY KEY (`user_id`,`conversation_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;public List<Message> pullOfflineMessages(long userId, String conversationId, String lastReadMsgId, int limit) { String sql = "SELECT * FROM message WHERE to_uid = ? AND conversation_id = ? " + "AND msg_id > ? ORDER BY created_at ASC LIMIT ?"; // 这里使用 msg_id 作为游标,而不是 timestamp return jdbcTemplate.query(sql, userId, conversationId, lastReadMsgId, limit); }用msg_id做游标是因为它全局唯一且递增,不存在两个消息时间一样导致漏拉的边界。按时间戳拉取会遇到一个隐蔽问题:服务端落库的消息里,如果同一毫秒内有两条消息,时间戳相同,WHERE created_at > ?就会漏掉其中一条。用字符串比较msg_id就没有这个烦恼。服务端生成 msg_id 时可以带上自增序号,保证同一会话内的消息 id 严格递增。
提示:离线消息的批量大小我一般设 50 条一次。超过 50 条就多拉几轮,避免一次性把几百条消息全塞给客户端导致界面卡顿。
4.3 数据库连接池参数与“too many connections”处理
服务端消息量不大时,每次请求都新建一个 Connection 也能跑,但一旦消息频率上来,MySQL 会直接报Too many connections。具体现象是服务端日志出现Communications link failure,客户端表现为消息发出去没有回执。原因基本是数据库连接没有复用,或者连接池配置过大。
spring: datasource: hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000Java 服务端用 HikariCP 时,maximumPoolSize 不是越大越好——10 个连接足够支撑几百人在线的聊天负载,每个连接处理消息的速度远快于业务逻辑自身的耗时。设大了反而把 MySQL 的连接数占满,殃及同一台服务器上其他应用。另外,每轮消息处理后要确保连接归还,用了 try-with-resources 就能避免忘记释放。
5. 服务端避坑排查:断连、丢消息、重连风暴和时区错乱
任何 IM 服务端都会遇到这些坑,很多问题不是代码逻辑错了,而是协议设计阶段埋下的隐患。这一章是我翻车最多的地方,按现象到原因再到解决的方式整理成几条踩坑记录,你可以直接对照排查。
5.1 现象:安卓端锁屏 15 分钟后消息收不到,解锁瞬间刷出一堆
原因:手机锁屏后应用进程被系统冻结,长连接没有数据流动,运营商 NAT 设备在几分钟内回收了空闲连接。服务端这边以为连接还在,实际数据包已经到不了客户端。
解决:服务端 IdleStateHandler 的读空闲设短一点,50 秒收不到任何数据就主动发探测包,客户端收到探测包后立刻回 PONG 并唤醒网络。安卓客户端这边还需要一个前台服务维持进程优先级,配合高精度定时心跳。注意锁屏时 WiFi 可能断开,这时候客户端要自动切换到移动网络重连,服务端要允许同一用户 ID 的旧连接被新连接顶掉,否则用户会看到“上线下线”反复横跳。
5.2 现象:两台手机互发,一条消息偶发丢失
原因:发送端 UI 上显示“已发送”,但消息其实只是到了服务端内存,服务端在写入数据库之前崩溃了,接收端自然收不到。另一个隐蔽场景是客户端发消息后立即杀进程,本地消息没来得及同步到服务端。
解决:强制“先落库,再路由”。服务端收到消息后第一步写 message 表,写成功后才查目标用户的 Channel 并投递。投递成功后客户端回一个 ACK,服务端收到 ACK 才认为消息真正到达。如果客户端一段时间没收到 ACK,就把本地消息标记为“未送达”,下次重连时重新发送。这个设计的代价是每条消息多一次数据库写操作,但对简易聊天来说完全值得。
5.3 现象:服务端突然收到上千个连接,CPU 打满,客户端集体掉线
原因:某个区域网络抖动,几百台手机几乎同时断开又同时重连——没有加随机延迟的重连逻辑会让所有客户端以相同间隔重试,服务端瞬间被握手风暴打垮。
解决:客户端重连间隔必须加随机抖动。第一次重连等待 5 秒加 0~3 秒随机值,之后按指数退避,最多不超过 60 秒。服务端这边还要在 ChannelHandler 里加一个并发连接数计数器,超过设定阈值(比如 500)时对新连接直接返回忙并关闭,保护已有连接不被拖垮。
public class ConnectionLimitHandler extends ChannelInboundHandlerAdapter { private static final int MAX_CONNECTIONS = 500; private static final AtomicInteger currentConnections = new AtomicInteger(0); @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { if (currentConnections.incrementAndGet() > MAX_CONNECTIONS) { currentConnections.decrementAndGet(); ctx.writeAndFlush("{\"type\":\"SYSTEM\",\"payload\":\"server busy\"}\n"); ctx.close(); return; } ctx.fireChannelActive(); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { currentConnections.decrementAndGet(); ctx.fireChannelInactive(); } }这个限制器放在管道最前面,含义很明确:保护服务端不被瞬间高并发拖垮,宁可拒绝新用户,也不能让老用户全部掉线。生产环境里“拒绝 + 客户端退避重试”比“硬扛然后崩溃”体验好得多。
5.4 现象:服务端日志时间比北京时间差 8 小时,消息列表时间乱跳
原因:云服务器默认时区是 UTC,而 Java 的new Date()打印时用系统时区,日志里看到的时间全是 UTC。如果服务端存库时用了带时区的字符串,客户端再转一次时区,时间就乱了。
解决:所有时间统一用 epoch 毫秒存,数据库字段用bigint,日志读取时显式转字符串。服务端部署完成后第一件事是把系统时区改掉:timedatectl set-timezone Asia/Shanghai。不要依赖服务器本身的时区设置,代码里一律用System.currentTimeMillis(),展示层再格式化。
6. 上线前的一次自检压测:用 Java 模拟客户端验证源码可靠性
服务端源码本地能跑通不代表线上扛得住。真机测试只能覆盖一两台手机,但聊天服务端最容易出的问题恰恰是并发上来之后才暴露的。我养成了一个习惯:发布前用一段 Java 写的模拟客户端做一轮自检压测,把连接管理、消息路由、离线补拉三条链路都打一遍。
public class MockClient { private static final int THREAD_COUNT = 50; private static final int MESSAGE_INTERVAL_MS = 2000; public static void main(String[] args) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(THREAD_COUNT); for (int i = 0; i < THREAD_COUNT; i++) { final int userId = 1000 + i; pool.submit(() -> { Socket socket = new Socket("127.0.0.1", 8080); OutputStream out = socket.getOutputStream(); BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); String loginMsg = "{\"type\":\"LOGIN\",\"user_id\":" + userId + "}"; out.write((loginMsg + "\n").getBytes(StandardCharsets.UTF_8)); out.flush(); while (true) { String msg = "{\"type\":\"PRIVATE\",\"from_uid\":" + userId + ",\"to_uid\":1001,\"payload\":{\"content\":\"hello\"}}"; out.write((msg + "\n").getBytes(StandardCharsets.UTF_8)); out.flush(); // 模拟接收对端消息 if (in.ready()) { String response = in.readLine(); System.out.println("client " + userId + " got: " + response); } Thread.sleep(MESSAGE_INTERVAL_MS); } }); } } }压测脚本核心指标看三个:一是全部连接建立后服务端 CPU 是否稳定,二是消息发送后回执是否在合理时间内到达,三是拔掉一台模拟客户端的网线,重连后离线消息是否完整补拉。我在本地用 50 个线程跑一夜,能暴露出不少偶发断连和消息丢失问题。
验证顺序也很关键:先跑通单客户端收发,再用模拟客户端做并发压测,最后才拿真机安装包做体验测试。服务端接口测试我在这一步不是用 Postman 一个个点,而是直接写一个小的调用脚本批量打接口,确保鉴权、离线拉取、好友列表三个核心接口的响应时间都在 200ms 以内。
我刚接手这类聊天服务端源码时,觉得只要能连上、能收发就是完成任务,结果第一次压测就翻车在重连风暴上——模拟客户端没加随机退避,服务端直接 CPU 打满。从那以后,我所有的改动都先跑模拟客户端压一宿,再发真机验证。这个习惯帮我挡掉了不少线上事故,希望帮到你。
本文还有配套的精品资源,点击获取