
简介一款基于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 ChannelInitializerSocketChannel() { 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 一个信封字段搞定消息类型、去重和状态追踪简易聊天不需要上 protobufJSON 字符串足够调试时还能直接用 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 MapString, 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。// 参数说明readerIdleTimeSeconds60, writerIdleTimeSeconds45, allIdleTimeSeconds30 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 空闲连接的回收时间通常在 25 分钟所以客户端心跳间隔不能超过 120 秒服务端读空闲设 60 秒能保证在运营商掐断前先发现异常。写空闲设 45 秒是给客户端一个主动发消息的余量全空闲 30 秒是兜底——如果服务端既收不到数据又发不出数据这个连接基本是废的关掉重来比干等划算。客户端配合逻辑我一般会这么做收到 PING 后立即回 PONGPONG 里带上客户端当前电量或网络状态字段服务端不仅能保活还能顺带做在线状态感知。服务端连续两次 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 好友关系表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 消息表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;三张表已经覆盖了聊天服务端最核心的三个领域用户身份、好友关系、消息内容。密码字段必须存哈希用 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;public ListMessage 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 秒加 03 秒随机值之后按指数退避最多不超过 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 打满。从那以后我所有的改动都先跑模拟客户端压一宿再发真机验证。这个习惯帮我挡掉了不少线上事故希望帮到你。本文还有配套的精品资源点击获取