☰
仿微信App开发架构设计:单聊消息可靠收发到朋友圈实战
2026/10/12 1:16:24 网站建设 项目流程

简介:一份基于 Java 的高仿微信 6.5.7 Android 项目源码包,由开发者个人独立完成,依托融云 SDK 实现即时通讯能力,并采用 Rxjava+Retrofit+MVP+Glide 等热门技术栈。项目在还原微信核心交互的基础上,进一步加入发送位置消息、红包消息等进阶功能,整体代码按 MVP 分层组织,适合有一定安卓基础、希望系统学习 IM 应用架构与主流框架组合的开发者参考。资源以 ZIP 压缩包形式提供,整体约 43.89MB;平台未提供有效文件统计,具体源码、资源文件与目录结构需下载解压后查看。目前已有 311 人学习浏览,具备一定参考价值。通过这份项目,读者既能梳理高仿微信产品的模块划分,也能看到 Retrofit+RxJava 如何驱动网络请求、Glide 如何处理图片加载、融云 SDK 如何完成消息收发,同时借鉴位置与红包消息等功能的实现思路,对完成课程设计、毕业设计或独立开发同类 App 都很有帮助。

1. 仿微信的 app 到底在仿什么:先搞清楚要交付的核心闭环

一个仿微信的 app,听的、看的都是聊天和朋友圈,但真正决定项目生死的是消息的可靠收发。很多人一上来就做界面,会话列表、聊天气泡、朋友圈九宫格做得有模有样,结果两个人互相发消息都收不到,或者消息重复、乱序、一退后台就失联。仿微信的 app 不是一个 UI 项目,它是一个链路项目:从登录鉴权、长连接、消息投递、离线补拉,到本地存储和未读角标,任何一环断了,产品就废了。这一篇就按这条链路拆开讲,读者定位是两类人:一是拿这个题目做课程设计或毕业设计的学生,二是想自建即时通讯能力的小团队。不管是哪类,核心诉求一致——用最小成本把单聊闭环跑通,再谈群聊、朋友圈和音视频。

2. 技术选型与总体架构:为什么先做单聊而不是直接上朋友圈

仿微信的 app 第一版的功能边界非常关键。我见过太多项目从朋友圈做起,理由是朋友圈“展示起来好看”,结果聊天链路一塌糊涂。正确顺序是:先打通用户体系和单聊消息,再做群聊和已读回执,最后才是朋友圈。因为朋友圈本质是一个带权限控制的 feed 流,它不依赖实时性,技术上反而是整个项目里最简单的一块。实时消息链路才是地基。

2.1 客户端方案:原生还是跨平台,第一版怎么选

如果团队只有一个人,或者两个人配合,第一版我一般建议走 Android 原生,Kotlin 开发。原因不是跨平台不好,而是即时通讯客户端有一个跨平台框架照顾不到的地带:长连接的后台存活策略、系统推送通道的接入、本地数据库的并发写入。这些在原生环境里都有成熟的系统级方案,在跨平台环境里往往要靠插件拼凑,一旦踩坑,排查链路非常长。

Flutter 和 React Native 当然能做 IM,但代价是:WebSocket 连接管理要自己写平台通道,厂商推送要分别调 Android 和 iOS 的原生 API,本地数据库要么用第三方库要么自己桥接。这些工作叠加起来,比原生开发多出 30% 到 50% 的工程量。如果团队里有人已经熟练跨平台,那就另说;如果没有,第一版老老实实做原生,把时间花在消息链路上。

服务端的语言选型,Java 配 Netty 或者 Spring Boot 加 WebSocket 都是常见做法。小规模项目直接用 Spring Boot 的 WebSocket 模块就够了,省掉 Netty 的学习成本;等在线用户量到了几万级别,再考虑把长连接网关拆成独立的 Netty 服务。

2.2 服务端架构:长连接网关与业务服务分离的基本形态

即便第一版功能再少,服务端也建议分成两层:连接网关和业务服务。连接网关只管客户端的连接、心跳、消息转发;业务服务管登录、注册、好友关系、历史消息查询。两层之间用内部接口通信。

这么分的原因是连接网关必须无状态。网关不保存任何业务数据,只维护“哪个连接对应哪个用户”,这样当一台网关扛不住时,可以直接加一台机器,客户端重连过去就行。如果业务逻辑混在网关里,扩容时连会话数据都要迁移,复杂度直接翻倍。

一个最小可用的服务端目录结构,长这样:

server/ ├── gateway/ # 长连接网关 │ ├── WebSocketServer.java │ ├── SessionManager.java │ └── HeartbeatHandler.java ├── biz/ # 业务服务 │ ├── UserService.java │ ├── FriendService.java │ └── MessageService.java ├── common/ │ ├── Packet.java # 消息协议 │ └── ResultCode.java └── storage/ ├── UserRepository.java └── MessageRepository.java

网关与业务服务的通信协议,第一版直接用 JSON over HTTP 即可,不需要引入 RPC 框架。网关收到客户端消息后,调用 MessageService 的接口完成消息落库,然后从 SessionManager 找到接收方的连接,把消息推过去。这段逻辑清晰简单,出了问题也好查日志。

2.3 数据库与存储:消息、关系、状态的三种数据怎么放

仿微信的 app 涉及三类核心数据:用户与好友关系、聊天消息、在线状态。它们的数据特征完全不同,不应该放在同一个存储里。

用户和好友关系,用 MySQL 这类关系型数据库。好友关系是一张典型的关联表,字段就两个:user_id 和 friend_id,再加上关系状态和时间字段。这类数据量级不大,单表百万以内没什么压力。

聊天消息,建议用 MongoDB 这类文档型数据库。理由很直接:消息数据的写入量远大于读取量,单聊用户一天产生的消息几十条,群聊可能几百条,而且消息结构简单,用文档存储天然适合按会话维度查询。MongoDB 的写入吞吐和水平扩展能力,远好于 MySQL 单表。如果项目部署环境受限,只有 MySQL 可用,也可以先用一张 message 表硬扛,但要提前做分表设计,按 chat_id 哈希分表。

在线状态和未读计数,用 Redis。在线状态是一个 key-value:key 是 user_id,value 是连接所在的网关节点 ID,配合过期时间处理掉线场景。未读计数用 Hash 结构,field 是会话 ID,value 是未读数。

本地客户端这边,SQLite 是唯一选择。需要建的核心表就两张:message 表和 conversation 表。message 表存当前用户收到的所有消息,conversation 表存会话列表和会话摘要。这样做的目的是保证 UI 渲染永远走本地数据库,不走网络接口——聊天界面打开要立即出内容,网络请求是来不及的。

3. 把单聊跑通:从注册登录到消息可靠收发的最小实现

单聊是整个仿微信的 app 的核心闭环。这一章从登录态开始,把消息发送、接收、离线补拉完整走一遍。所有代码都按最小实现来写,不引入多余依赖。

3.1 用户体系与登录态:JWT token 怎么签发与续期

用户体系第一版用手机号加密码就能跑,不需要做验证码流程,因为验证码要接短信服务商,成本不高但流程繁琐,课程设计或内部项目完全没必要。如果上架应用市场再做验证码登录。

登录成功以后,服务端签发一个 JWT token 给客户端。JWT 的无状态特性很适合这个场景:网关不用查数据库就能确认用户身份,只需要校验签名。token 里只放 user_id 和过期时间,不要放手机号、昵称这类可变动信息。

// 签发 JWT:payload 只放 user_id 和会话版本号 String token = Jwts.builder() .setSubject(userId.toString()) .claim("sessionVersion", user.getSessionVersion()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000L)) .signWith(secretKey) .compact();

这里有两个关键参数:过期时间设为 7 天,这是移动端应用的常见做法。sessionVersion 的用途是踢人下线:如果用户在别的设备登录,服务端提升 sessionVersion,旧 token 直接失效。这个机制第一版就要加上,否则无法处理“手机丢了”“密码找回”这类场景。

客户端存储 token 用系统的 Keychain 或 Keystore,不要用 SharedPreferences 明文存。代码逻辑上,每次建立长连接时带上 token,网关校验通过后建立会话。token 过期前的一天内,客户端调用续期接口换取新 token,避免用户正在聊天时突然掉线。

3.2 消息发送链路:客户端本地落库加确认重发,服务端幂等去重

消息发送是整个链路里最容易出问题的地方。新手最常见的写法是:用户点发送,客户端把消息通过网络发出去,服务端收到后转发给对方,完事。这个流程在弱网环境下必丢消息。

正确的发送链路是这样的:客户端先给消息生成一个全局唯一的 clientMsgId,然后把消息写入本地 SQLite,状态是 SENDING,同时 UI 立即显示这条消息。然后才通过网络发送。服务端收到后,按 clientMsgId 做幂等处理,落库,转发给接收方,最后回一个 ACK。客户端收到 ACK,把本地消息状态更新为 SENT。

// 客户端发送消息:先落库,再发网络,收到 ack 再更新状态 fun sendMessage(chatId: String, content: String) { val msg = Message( clientMsgId = UUID.randomUUID().toString(), chatId = chatId, senderId = currentUserId(), content = content, status = MessageStatus.SENDING, createTime = System.currentTimeMillis() ) // 第一步:写本地库,保证 UI 立即展示 messageDao.insert(msg) // 第二步:进入待发送队列,由连接管理器统一发出 sendQueue.enqueue(msg) } // 连接管理器收到 ACK 后的处理 fun onMessageAck(clientMsgId: String) { messageDao.updateStatus(clientMsgId, MessageStatus.SENT) sendQueue.remove(clientMsgId) }

发送队列是一个常驻内存的待确认集合,每隔一段时间扫描一次,把超过 5 秒还没收到 ACK 的消息重新发送。重发次数上限设为 3 次,超过后标记为 FAILED,在 UI 上显示红色感叹号。这个“发送超时重试”的机制是整个链路可靠性的基石。

服务端这边,幂等去重用 clientMsgId 做唯一键。最简单的实现是维护一张已处理消息表,或者用 Redis 的 SETNX 命令。收到消息后先查 clientMsgId 是否存在,存在说明是重发,直接返回成功 ACK,不再重复落库和转发。

// 服务端处理消息:clientMsgId 幂等,重复消息直接返回 ACK public SendResult handleSend(MessagePacket packet) { String key = packet.getClientMsgId(); // SETNX 成功说明是第一次收到;失败说明是重发,直接返回成功 Boolean firstTime = redis.setIfAbsent("msg:" + key, "1", Duration.ofHours(24)); if (firstTime) { messageRepository.save(packet); dispatchToReceiver(packet); } return SendResult.success(packet.getClientMsgId()); }

参数说明:幂等键在 Redis 里的保留时间设为 24 小时,原因是一个小时内的重发都已经结束,留 24 小时是给时钟偏差留余地。服务端落库和转发之间如果失败,客户端会重发,重发走到幂等判断直接返回成功,不会重复发送给对方。

3.3 消息接收链路:WebSocket 分发、离线消息补拉与本地写入顺序

接收链路的核心是长连接。客户端登录后立即建立 WebSocket 连接,之后所有在线消息都通过这个连接实时推送。不可达的接收方,消息进离线队列。

服务端分发的伪代码逻辑是:

public void dispatchToReceiver(MessagePacket packet) { String receiverId = packet.getReceiverId(); String gatewayNode = redis.get("online:" + receiverId); if (gatewayNode != null) { // 在线:通过接收方所在网关节点,找到连接并推送 gatewaySession.push(receiverId, packet); } else { // 离线:写入离线队列,等用户上线后补拉 redis.lpush("offline:" + receiverId, packetJson); } }

离线队列用 Redis 的 List 结构,上限可以设 500 条,超出部分直接丢弃最早的。这个策略的原因是:离线时间越长,补拉的历史消息越没有意义,用户上线后想看历史记录会主动去翻聊天记录,而不会盯着补拉的消息列表。

客户端收到 WebSocket 推送后,不能直接写 UI,而是先写本地数据库,再刷新 UI。数据库写入必须是串行化的,由一个单线程的写入队列处理,否则多条消息并发到达时,SQLite 写入顺序不可控,就会出现消息乱序。

// 客户端消息写入:单线程队列串行落库,保证本地顺序 private val writeExecutor = Executors.newSingleThreadExecutor() fun onMessageReceived(packet: MessagePacket) { writeExecutor.execute { messageDao.insert(Message.fromPacket(packet)) conversationDao.updateLastMessage(packet) // 写入完成后再发通知刷新 UI runOnUiThread { chatAdapter.notifyDataSetChanged() } } }

每次客户端重新建立连接时,服务端会下发一条 SyncMsg,包含最近的消息序号。客户端拿到序号后发现本地有缺口,就主动调 HTTP 接口补拉离线消息。这里不要用“全量拉取本地没有的消息”这种思路,而是用增量同步:服务端为每个用户维护一个单调递增的消息序号计数器(连续值),客户端记录自己处理到的最大序号。这个方案后续做多端同步时也不用推翻重做。

最后提一个参数细节:WebSocket 连接时的超时时间设 10 秒,心跳间隔设 45 秒,60 秒未收到 Pong 判定连接断开。这个参数组合后面第 6 章展开讲。

4. 从单聊走向完整产品:群聊、已读回执与朋友圈

单聊跑通之后,仿微信的 app 才真正算有了骨架。这一章聊三个往上加的高频功能:群聊、已读回执、朋友圈。每个功能都有不止一种实现方式,下面给出的是小团队最务实的选型。

4.1 群聊的实现与消息扩散方式

群聊的第一件事是决定消息扩散模型。常见的有两种:写扩散和读扩散。

写扩散的意思是:群成员发送一条消息,服务端为群里的每个成员各存一份消息副本。优点是每个用户的收件箱是独立的,拉取逻辑和单聊完全一致;缺点是群人数一多,写放大非常严重。读扩散的意思是:消息只存一份,每个成员拉取时过滤出自己可见的消息。优点是存储省,缺点是每个成员都拉全群消息,查不到哪些是新的,需要额外的游标记录。

务实的选择是:小群(100 人以内)用写扩散,大群(1000 人以上)用读扩散。第一版仿微信的 app,群上限就控制在 100 人,直接用写扩散,实现最简单,逻辑和单聊共用一套代码。

群聊的客户端展示和单聊共用一个会话模型。会话表里加一个 type 字段,1 是单聊,2 是群聊。消息表按 chat_id 区分会话,不区分单群。这是最重要的设计决策:从协议层就把单聊和群聊统一成“一个会话维度”,大幅度减少客户端和服务端的重复代码。

群成员管理是三张表:group 表、group_member 表、group_message 表(如果用写扩散,就是 group_message_member 表)。拉人、踢人、退群都是对 group_member 表的增删改,操作后往群里发一条系统消息,由客户端解析后显示在聊天界面里。

4.2 已读回执与未读数:别让角标把你带偏

已读回执是这个项目里最容易被过度设计的功能。到底要不要做“每条消息已读”,要看产品需要。仿微信的参考产品的做法是不显示单条消息的已读,只显示会话级的未读数量。这个策略是对的,单条已读在服务端和客户端的成本都非常高:每条消息都要记录读状态,群聊里还要按人存储,数据量瞬间爆炸。

会话级未读数的实现非常轻量:服务端维护一个 Redis Hash,key 是 receiver_id,field 是 chat_id,value 是未读消息数。客户端进入会话页面时,把该会话的未读数清零,并上报服务端。

// 会话已读上报:进入聊天页时上报 public void reportConversationRead(String userId, String chatId, long lastReadMsgSeq) { // 删除 Redis 中该会话的未读计数 redis.hdel("unread:" + userId, chatId); // 记录该用户在该会话已读到的消息序号 redis.hset("read_seq:" + userId, chatId, String.valueOf(lastReadMsgSeq)); }

注意这里引入了一个 read_seq 的概念。它是该用户在这个会话里读到的最新消息序号。后续如果要做多端同步,read_seq 就是唯一的依据,不需要重新设计。未读角标的数法不要去遍历消息表,那是典型的反模式——未读数应该是一个缓存的聚合值,而不是查出来的。

客户端会话列表界面的未读角标,用本地 conversation 表的 unread_count 字段展示。收到新消息推送时,如果用户不在该会话页面,unread_count 加 1;进入会话页面时清零。这个逻辑在客户端做,不同步到服务端,响应速度最快。

4.3 朋友圈 feed 流:游标分页与可见性过滤

朋友圈和聊天的技术栈完全不同,它是一个典型的 feed 流系统。第一版不要引入复杂的推荐算法和内容审核,就做最朴素的:好友动态按时间倒序,分页拉取。

分页这里有一个非常常见的坑:用页码分页。朋友圈数据是持续增长的,用 LIMIT OFFSET 分页时,如果第一页和第二页之间有人发了新动态,第二页会出现重复数据。正确做法是游标分页:用当前时间作为游标,每次拉取在这个时间之前的动态。

-- 朋友圈 feed 流查询:游标分页,避免 OFFSET 翻页导致的数据重复 SELECT f.feed_id, f.content, f.images, f.create_time, u.nickname FROM feed f JOIN friend_relation fr ON f.author_id = fr.friend_id AND fr.status = 1 JOIN user u ON f.author_id = u.user_id WHERE fr.user_id = #{currentUserId} AND f.create_time < #{cursorTime} AND f.visibility IN (0, 1) ORDER BY f.create_time DESC LIMIT 20

这段 SQL 有两个关键点。第一,游标是 cursorTime,第一页请求传当前服务器时间,下一页传上一页最后一条的 create_time。第二,visibility 字段做可见性控制,0 是公开,1 是好友可见,2 是私密。这里省略掉了“不给某人看”和“不看某人”这两个功能,它们在数据模型上要加黑名单表,第一版不做。

朋友圈的图片存储不要存本地。第一版直接用对象存储,上传完成后返回 URL 存入 feed 表。图片压缩在客户端做,1080p 的分辨率上传前压到 720p,能省一半以上的流量和存储。

5. 仿微信的 app 常见问题排查与避坑:五个血泪级踩坑记录

这一章是整篇文章最值钱的部分。下面的五个问题,是我在不同项目里真实撞过的墙,每个都按现象、原因、解决三段展开。前四个问题出现在单聊链路里,第五个出现在群聊场景。

5.1 现象一:消息丢了,对方说没收到

明明客户端显示“已发送”,对方却说什么都没收到。打开数据库看,消息状态是 SENT,但服务端消息表里根本没有这条记录。

原因很简单:客户端发出消息后,网络超时触发了重发机制,但因为某些异常(比如应用被系统杀掉后恢复),消息进入了一个“死信”状态——客户端认为自己发了,服务端根本没见过。更深层的原因是发送和确认之间没有状态机,发送、重发、确认三个操作没有统一入口,各写各的。

解决方式是统一消息发送状态机:SENDING、SENT、FAILED 三个状态,外加一个重发扫描器。扫描器每 5 秒扫一次发送队列,凡是 SENDING 且超过 5 秒的消息,全部重发。重发 3 次仍失败,置为 FAILED。不要手动点“重发”按钮,那是用户体验层面的补充,不是可靠性机制。

5.2 现象二:消息乱序,后发的先显示

本地数据库里相邻两条消息的 create_time 顺序和 UI 显示顺序不一致,后发的那条排在了前面。服务端的消息序号是对的,问题出在客户端写入。

原因是客户端的 WebSocket 消息回调线程是多线程的,消息到达后并发执行 SQLite 写入。SQLite 的事务是串行的,但多条线程的提交顺序不按照消息到达顺序,最终落库的先后就乱了。

解决方式是引入一个单线程的写入队列。所有从网络层进来的消息,先入队,由一个专用线程串行处理:落库、刷新 UI。顺序问题就彻底消失了。这里有个额外收益:单线程写入还能减少 SQLite 的锁竞争,数据库操作的耗时会明显下降。

5.3 现象三:App 切后台再回来,消息收不到

App 在前台时消息一切正常,一旦切到后台过几分钟,再回来会发现连接断了,期间的消息全部没有收到。

原因:移动系统会基于省电策略杀掉后台进程的 socket 连接。这不是代码 bug,是整个移动平台的资源管理机制。纯 WebSocket 方案根本没有办法保证后台长连接永远存活,这是物理层面的限制。

解决方式:分两步走。第一步,接入系统级的厂商推送通道。用户被杀死后,服务端检测到连接断开,消息改走推送通道,用户点击推送通知后拉起应用,再补拉离线消息。第二步,客户端从后台回前台时,主动检查连接状态并快速重连,同时触发离线消息补拉。不要试图让 WebSocket 在后台永不掉线,那是和系统机制对着干,翻车只是时间问题。

5.4 现象四:数据库越来越大,聊天打开越来越慢

应用运行一个月后,聊天界面打开耗时从 200ms 涨到 2 秒,存储空间也涨了好几百 MB。

原因是消息表全量加载。打开聊天界面时,把该会话的所有历史消息一次性查出来渲染。会话越多、消息越多,查询越慢,内存占用越高。

解决方式是分页加载。每次打开聊天界面只加载最近 20 条消息,向上滑动时按旧的消息 ID 再拉 20 条。本地数据库的分页查询用 LIMIT 就可以了,因为消息创建时间是单调递增的,不需要游标。

-- 打开聊天界面:只加载最近 20 条,向上翻页时再拉更早的 SELECT * FROM message WHERE chat_id = #{chatId} ORDER BY create_time DESC LIMIT 20 OFFSET #{pageOffset}

这里 LIMIT 固定 20 是本地渲染的合适窗口大小,少于 15 条会导致翻页过多,多于 30 条会增加一次性渲染的耗时。OFFSET 在本地数据库里性能是够的,因为一个会话的消息通常不会超过几千条,不需要游标优化。

5.5 现象五:大群消息一多,界面就卡顿

在 100 人的群里,消息一秒钟好几条,聊天界面的滚动明显掉帧,输入法都跟着卡。

原因是每条消息到达都触发了一次完整的 UI 刷新。消息写入、查询会话、更新适配器,全链路都在主线程执行,消息密集时主线程被塞满。

解决方式是批量渲染加节流。消息到达后先入内存队列,以 500ms 为窗口,把窗口内的消息合并成一次 UI 刷新。聊天界面的消息列表用 DiffUtil 只刷新变化部分,不要 notifyDataSetChanged 刷新全部。

// 批量渲染:合并 500ms 窗口内的消息,减少 UI 刷新次数 private val refreshWindowMs = 500L private val pendingMessages = mutableListOf<Message>() fun onMessagesReceived(messages: List<Message>) { pendingMessages.addAll(messages) // 窗口期内只调度一次刷新 handler.postDelayed(::flushPendingMessages, refreshWindowMs) } private fun flushPendingMessages() { val batch = pendingMessages.toList() pendingMessages.clear() adapter.appendMessages(batch) // append 而不是全局刷新 }

参数说明:refreshWindowMs 设 500ms,低于 300ms 起不到合并效果,高于 1000ms 会让用户明显感觉到消息延迟。这个数字是我见过比较均衡的一个值,但也看具体场景,消息密度特别高的群可以放宽到 800ms。

6. 收尾技巧:一条心跳参数的调优之路

最后分享一个具体的调优过程。长连接的心跳参数看起来无关紧要,但它影响的是最恶劣的体验:消息不实时、耗电变快、假在线。

刚开始做第一个 IM 项目时,我随手把心跳间隔设成了 15 秒,理由是“勤快点总没错”。结果线上问题不断:手机电量掉得快,用户网络流量跑得凶,运营商网关也因为频繁的 ping-pong 消息直接把连接掐了。服务端日志里全是连接被动断开和重连的记录。后来把心跳调到 60 秒,又出现了新问题:运营商 NAT 网关的空闲超时通常在 30 到 120 秒之间,60 秒的间隔意味着有些网络环境里连接其实早就断了,但客户端还误以为活着,直到发消息失败才反应出问题。

最后落在的组合是:心跳间隔 45 秒,超时判定期限 60 秒。45 秒大于绝大多数 NAT 网关的 30 秒超时,60 秒的超时判定又保证连接假死后最多 1 分钟内能被发现。这个参数组后来跑了两年的线上,没有因为心跳策略出过一次连接故障。

调优方法论比参数本身更值钱:先抓现场,再改参数。遇到连接问题时,第一件事看服务端的连接日志,统计从这个客户端 IP 发来的心跳消息频率、断开原因码、期望重连间隔。不要连原因都没查清就调参数,那就是玄学调参。我现在的习惯是:新项目的调试页里第一屏就放连接状态、心跳耗时、最近一次断线原因,这些数据暴露在眼前,比任何日志都好使。

希望这个思路帮你在做仿微信的 app 时,少走一圈这些弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询