简介:这套基于UniApp框架开发的类微信即时通讯应用工程源码,面向需要快速搭建IM聊天功能的产品与开发者,完整覆盖单聊、群聊、朋友圈、摇一摇、附近的人、收藏、扫码、机器人以及实时音视频通话等主流模块。压缩包共651个文件,大小63.08MB,其中147个vue页面构成界面与业务逻辑,112个png和47个jpg提供大量聊天界面与功能图标素材,90个md文档辅助理解模块设计,85个json与55个js文件承载页面配置与交互脚本,另含imsdk_plus、txliteavsdk_trtc等音视频SDK及相关扩展库,可支撑高可用的即时通讯能力。项目借助UniApp一次编码、多端编译的特性,可快速产出iOS、Android、H5及各类小程序版本。目前已有142人学习浏览。这套完整工程既可作为从零搭建IM应用的参考样板,也能帮助开发者深入理解聊天消息流、实时音视频通话与跨平台工程组织方式,尤其适合中高级前端开发者结合官方文档做二次开发与研究。
1. “聊天IM,精仿微信”:这个功能清单到底在考什么
很多人在 GitHub 上看到“聊天IM,精仿微信”这种标题,第一反应是“这不就是个套着微信皮的表层项目吗”。实际动手才发现,朋友圈是最容易写的,摇一摇也不难,最难的是底层那条消息通道:单聊不能丢、群聊不能乱、断网重连不能花屏,再加上实时音视频通话,复杂度立刻上一个台阶。这个标题适合两类人:一类是拿它做毕设、作品集或创业 MVP 的开发者,另一类是想在公司内部搭建私有化 IM、又暂时不想付几十万商业授权费的团队。它能帮你把“仿微信”从一句口号,变成一张可以照着实施的技术清单。如果你只想看界面还原度,这篇文章帮不到你;如果你想搞清楚消息怎么送达、朋友圈怎么扩散、通话怎么拨通,按下面的顺序做,能少踩一半坑。
2. 先搭消息内核:从单聊群聊的消息协议到机器人接入
2.1 选型:WebSocket 与开源内核的组合,怎么选不后悔
标题里功能列了一长串,但消息通道是地基。地基没打牢,朋友圈做得再像也是白搭。通信方式我一般直接选 WebSocket,移动端 App、Web 和微信小程序都有现成的客户端实现,不需要自己维护 TCP 协议栈。WebSocket 握手阶段复用 HTTP 层,NAT 穿透相对容易,浏览器控制台还能直接看到收发帧,调试体验比裸 TCP 好太多。有些团队纠结要不要自研协议栈,我的建议是:除非要在弱网性能上跟微信正面 PK,否则别自研,WebSocket 的协议开销在 IM 场景里完全可接受。
鉴权放在握手阶段最省事:连接地址带上 uid 和 token,服务端在校验失败时直接拒绝握手,无效连接根本进不了消息循环。如果目标是高并发 IM,连接网关必须设计成无状态,连接映射全部放 Redis,多个网关副本之间通过 Redis Pub/Sub 或消息队列转发消息,这样扩缩容才不用迁移长连接。
开源 IM 内核可以省掉一部分脏活。常见做法是把连接管理、离线存储、消息推送这些通用能力交给开源内核,自己专心写业务字段、群成员、朋友圈、扫码这些“精仿微信”特有的部分。要不要用内核取决于交付周期:几个月内要出完整项目,用内核更稳;如果是毕业论文要求从零实现协议,裸写更有价值。但选了内核不代表不用理解协议,后面讲的消息 ID、ack、游标同步,任何内核里都存在,只是名字不同。
2.2 最小消息收发:一条文本消息从发送到送达的代码骨架
先把最简链路拉通。服务端收到 WebSocket 上行消息后,按类型分发,单聊直接投递给目标用户,群聊先查群成员再逐个投递。
// go 服务端:处理 websocket 上行消息 func handleMessage(c *Client, raw []byte) { var msg InMessage if err := json.Unmarshal(raw, &msg); err != nil { c.Send(errorResp("bad_message")) return } switch msg.Type { case "text", "image", "card", "robot": // 单聊:to 是用户ID;群聊:to 是群ID,先查群成员再投递 if msg.ToType == "user" { deliverToUser(msg.To, msg) } else if msg.ToType == "group" { members := groupService.MemberIDs(msg.To) for _, uid := range members { deliverToUser(uid, msg) } } default: c.Send(errorResp("unsupported_type")) } }客户端侧的发送更直接,关键是把消息结构化。
// 浏览器 / 小程序侧发送一条消息 const ws = new WebSocket("wss://im.example.com/ws?uid=1001&token=xxx"); function sendText(to, text, toType) { const payload = { id: genMsgId(), // 客户端生成,服务端去重 type: "text", // text / image / card / robot to: to, // toType=user 时是用户ID,group 时是群ID toType: toType, // user | group content: text, ts: Date.now() }; ws.send(JSON.stringify(payload)); }这里有两个容易忽略的约定。一个是 id:客户端生成,服务端按(sender, id)去重,网络抖动触发重发时复用同一个 id,不会产生重复消息。另一个是 toType:单聊和群聊共用一条通道,靠 toType 区分;如果只在 to 前面加前缀(比如 u_1001 / g_2001),后面做会话列表聚合时会很难拆。type 字段就是留给图片、名片和机器人扩展的占位,后面会逐步填进去。这里还没提 seq,是因为 seq 要由服务端在落库后分配,客户端只认 id。群聊投递循环里,离线用户不要同步阻塞写入,正确顺序是先把消息落 offline 表,再异步触发推送,否则一个 500 人群能把请求线程全部占住。
2.3 可靠性三件套:消息 ID、ack 与离线补拉
用户最敏感的问题是“消息是不是丢了”。可靠性靠三件事兜底:去重、确认、补拉。发送端发出消息后,如果在 3 秒内没收到服务端 ack,按指数退避重发,重试上限一般 5 次;服务端按发送者 id + 消息 id 去重后落库,再回 ack。这是第一层。
第二层是离线补拉。用户杀掉 App 再打开,中间的消息要从服务端拉回。用时间戳当游标是不行的:客户端时钟不准,服务端时钟也可能回拨,拉取结果会重复或空洞。正确做法是服务端给每条消息分配单调递增的 seq,客户端保存本地最大 seq,重连后从 seq+1 开始拉。
// 从游标拉取离线消息,seq 单调递增 func pullMessages(userID string, cursor int64, limit int) []*Message { rows := db.Query(` SELECT seq, msg_id, type, content FROM im_message WHERE receiver_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?`, userID, cursor, limit) // 返回后客户端把本地 seq 推进到最后一条的 seq }ack 链路里还要注意一点:群消息和离线推送是两条通道。群成员离线时,消息先进离线表,等用户上线再走补拉;如果离线通道也走 APNs / 厂商推送,那推送只做提醒,内容以 App 内拉取为准,不要信任推送 payload 里的完整消息。自己写的 IM 后台清老消息时,不要直接 delete from im_message where create_time < 30天,整表清会被写入锁拖垮;按 user_id 做分区表,或者拆冷热表,按月归档,这是被线上事故教育出来的血泪经验。
2.4 消息类型扩展:图片、名片怎么传,机器人路由挂在哪
把消息类型扩展成枚举之后,很多功能只是换了 content 的解析方式。图片消息不要在消息体里塞 base64,正确做法是客户端先传到对象存储,消息里只带 url 和缩略图 url,接收端先渲染缩略图,点击后再拉原图。名片消息的 content 是一个嵌套 JSON:{uid, nickname, avatar},接收端渲染成卡片即可,点卡片触发加好友流程。这两种类型不需要改协议,只需要改类型枚举。
机器人要单独说,因为它不是内容解析,是路由。我把机器人设计成一种特殊会话:用户发给机器人的消息,服务端识别到 toType=robot 后,不走正常投递,而是转发给机器人回调网关。
// 机器人消息路由:异步调用,避免拖垮长连接 func routeRobotMessage(msg *InMessage) { switch msg.Type { case "text": go callBot(msg.To, msg.SessionID, msg.Content) msg.Content = "" // 机器人的临时内容不落库 case "image", "card": // 机器人暂时不处理多媒体,回一个提示 go callBot(msg.To, msg.SessionID, "[暂不支持该消息类型]") } }机器人回调通常会串上下文,所以消息要带 session_id,一个会话内的多轮对话通过它串起来。回调接口的延时控制在 5 秒内,超时给用户回一句“机器人开小差了”。路由要放在业务处理之前,避免机器人消息在群聊里被广播给全员;机器人消息在群里 @ 全员时,要先经过群管理员配置的免打扰策略,否则就是一场消息风暴。回调网关我习惯独立部署,域名和进程都跟聊天主服务分开,防止某个第三方机器人接口卡死,把整个聊天链路拖挂。
3. 朋友圈、摇一摇、附近的人、收藏、扫码:社交外围的实现与边界
3.1 朋友圈:发件箱 / 收件箱模型与可见性控制
朋友圈的模型只有两个选择:写扩散和读扩散。写扩散是用户发帖后,把帖子写进所有好友的收件箱,读自己的朋友圈只要查一张表,延迟低,但每个粉丝都要写一条,大 V 发帖会瞬间放大几百上千倍。读扩散是帖子只存一份,查看时现查关注关系,节省写放大,但读路径复杂。“精仿微信”这个量级的项目,我倾向折中:普通用户写扩散,粉丝量大的账号读扩散。几千人使用的内部 IM 根本不需要读扩散,往 Redis 的收件箱列表里推一份就行。
表结构可以简化成三张:post 存帖子本体,feed 存每个用户收件箱里的帖子 ID,permission 存可见性配置。feed 表不用无限增长,单个用户的收件箱在 Redis 里裁剪到最近 200 条就够了,更早的帖子允许“加载更多”时再回源查。这里有个产品决策要提前定:好友上限是多少。如果好友上限 5000,写扩散的放大是 5000 倍;如果控制在 1000,压力完全不同。
可见性是最容易返工的点。发帖时会带 visible_list 和 unvisible_list,分别表示指定可见和指定不可见,存储上用 JSON 或 bit 位看团队习惯,但查询时必须统一逻辑:不给谁看的优先级高于给谁看。如果后面对接了“仅聊天”“某几个标签不可见”,标签的成员要按发帖时刻做快照,否则标签成员变化后,历史帖子的可见范围也跟着变,用户会投诉老朋友突然看不见自己的朋友圈。这个快照的成本不高,但能省掉一大类客诉。
3.2 摇一摇与附近的人:GeoHash 九宫格查询与限流
附近的人实现重点是索引,不是排序。直接对经纬度做全表排序,一千条数据还行,百万条就卡死。常见做法是把经纬度编码成 GeoHash 字符串,存一列并加前缀索引,查询时先按 hash 粗筛,再用 ST_Distance_Sphere 精确算距离。
-- 附近的人:按 geohash 粗筛,再按球面距离排序 SELECT uid, nickname, avatar, ST_Distance_Sphere(point(lng, lat), point(:myLng, :myLat)) AS distance FROM user_location WHERE geohash IN (:geohashNeighbors) -- 自己+周围8个格子,共9宫格 AND ST_Distance_Sphere(point(lng, lat), point(:myLng, :myLat)) <= :radius ORDER BY distance LIMIT 20;注意这里一定要在 WHERE 里限制距离,否则用户坐标在格子中心,却可能捞出哈希前缀相同但相距几千公里的数据。MySQL 的 ST_Distance_Sphere 返回单位是米,radius 按业务来,附近的人一般取 2000 米或 5000 米。距离展示上,我建议只返回“200m 以内 / 1km 以内 / 5km 以内”这种档位,不要暴露精确坐标,既保护隐私又能让客户端缓存更久。
摇一摇则完全不同,它没有索引问题,是请求放大的问题:同一秒内大量用户触发摇一摇,返回的都是随机匹配结果。我的做法是把每 3 秒内摇过的人放进一个 Redis SET,触发互摇时从集合里随机取一批 ID 返回;接口限流按每用户 5 秒一次,超了直接丢弃。摇一摇的结果是模糊匹配,延迟比准确更重要,这个接口如果慢,体验会非常糟糕。摇一摇的匹配对象头像昵称,不要现场查库拼装,直接走用户信息缓存,否则一秒内几百个匹配请求能把用户服务打穿。
3.3 收藏与扫码:被低估的两个“小模块”怎么设计
收藏表面简单,其实就是给消息做快照,但它和“消息删除”有联动。如果收藏表只存 source_msg_id,原消息被撤回或过期后,收藏页就显示成空白,这在用户眼里是翻车。正确做法是收藏时把消息内容快照进收藏表:owner_id、msg_type、content_snapshot、source_msg_id、created_at。撤回消息时可以同步撤回收藏,但不要因为原消息删除而让收藏页出现黑洞。content_snapshot 直接存 JSON,图片消息存 url 和缩略图 url,名片消息存 uid 和昵称头像,这样收藏列表页永远能独立渲染。
扫码是另一个被低估的模块。“精仿微信”的扫码至少有三条业务线:加好友、跳转网页、登录授权。设备扫到二维码后,统一走“识别 -> 解析 scheme -> 路由”的流程。加好友可以用 im://user?uid=1001 这类 scheme,二维码内容要带一次性签名,防止伪造二维码诱导添加。如果客户端是微信小程序,扫一扫用的是 wx.scanCode,真机预览时开发版会过期,需要在开发者工具里重新扫码打开,这一点排到联调再细说。登录授权的扫码本质是“PC 显示二维码 -> 手机扫码提交临时 code -> PC 轮询或长连接拿到 session”,临时 code 用一次即焚,别让它成为长期凭证。二维码过期时间一般 2 分钟,过期后 PC 端要自动刷新,否则用户扫了一个失效码,会认为是产品坏了。
4. 实时音视频通话:信令状态机、STUN/TURN 配置与三个必调参数
4.1 通话信令状态机:拨号、接听、挂断与超时兜底
音视频通话不是只靠 WebRTC 就能跑起来的,它首先是一套信令流程。信令解决“谁在什么时候该做什么”:A 发起呼叫、B 收到响铃、B 接听、A 确认、任一方挂断。这套状态最好显式建模,否则到联调阶段全是黑匣子。
| 当前状态 | 收到事件 | 新状态 | 说明 |
|---|---|---|---|
| idle | call | calling | 发起方拨号 |
| calling | ring | ringing | 被叫方收到来电 |
| ringing | accept | accepted | 接听,开始媒体协商 |
| ringing | reject | idle | 拒绝 |
| calling | timeout | idle | 45 秒无应答自动取消 |
| accepted | hangup | idle | 正常挂断 |
信令消息走已有的 WebSocket 通道传 JSON,不新建 TCP 连接。信令体里要带一个贯穿全流程的 call_id,联调时按 call_id 查日志,能一眼看清一次通话从开始到结束的状态流转。下面这个 TypeScript 片段是状态最小骨架,生产环境要加上“通话中网络断开 -> reconnecting”的分支。
export type CallState = "idle" | "calling" | "ringing" | "accepted" | "ended"; export function nextState(current: CallState, event: "call" | "ring" | "accept" | "reject" | "hangup" | "timeout"): CallState { switch (current) { case "idle": return event === "call" ? "calling" : current; case "calling": return event === "ring" ? "ringing" : current; case "ringing": return event === "accept" ? "accepted" : event === "reject" ? "idle" : current; case "accepted": return event === "hangup" ? "ended" : current; default: return current; } }信令里最常翻车的不是正常流程,而是超时和网络切换。A 拨号后 B 手机没网,B 永远不会收到 ring,A 一直停在 calling,不超时的话用户会以为功能坏了。所以 calling 状态必配 45 秒超时,ringing 状态 30 秒无应答自动回 timeout。网络切换是另一类坑:Wi-Fi 切流量后 IP 变了,ICE 候选需要重新协商,信令里要有专门的 renegotiate 事件,否则通话会卡在“黑屏但没挂断”的假连接状态。
4.2 STUN/TURN 配置:TURN 中继为什么必须自建
WebRTC 的音视频流走 UDP,NAT 穿透并不是总成功。公网环境下,STUN 打洞成功率一般在七成到八成五,剩下的失败场景(对称 NAT、企业防火墙)必须走 TURN 中继。只配 STUN 不配 TURN,意味着弱网下约两成用户通话直接“黑屏”。这是“精仿微信”这类项目里最容易偷懒、上线后最难看的一个点。生产环境我一般自建 coturn 作为 TURN 服务,最小配置如下。
# /etc/turnserver.conf 关键配置 listening-port=3478 realm=im.example.com lt-cred-mech user=imturn:CHANGE_ME客户端侧的 RTCPeerConnection 需要同时带上 STUN 和 TURN。TURN 的账号密码不要写死在 App 里,正确姿势是服务端提供一个信令接口动态签发临时凭证,有效期 15 分钟,过期自动失效。
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:turn.im.example.com:3478" }, { urls: "turn:turn.im.example.com:3478", username: "temp_user_abc", credential: "temp_pass_xyz" }] });提示:TURN 使用长期凭证时,用户名和密码在客户端日志里是明文可见的,动态签发的临时凭证比固定账号安全得多。
另外,私有化部署时 TURN 的 3478 端口要同时开放 UDP 和 TCP,TCP 是给那些禁 UDP 的弱网环境兜底的。自建完 TURN 后不要只在内网测,找一台完全公网的机器跑一通真机联调,确认媒体流能走中继。很多团队卡在这一步:内网全通、公网全黑,就是因为防火墙没放行 UDP 3478。
4.3 三个必调参数:分辨率、码率与 ICE 超时
音视频能不能用,很多时候不是协议问题,是参数问题。我每次集成必调三处,这三处占通话体验的八成。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 采集分辨率 | 720p | 移动端性价比最高,1080p 发热高、码率翻倍 |
| 视频码率 | 800~1500kbps | 按网络状况自适应,弱网降档到 300kbps |
| 音频码率 | 32~64kbps Opus | 语音清晰度够用,开回声消除和降噪 |
| ICE 超时 | disconnected 后 5 秒 | 超时主动提示,触发重新协商,别让用户干等 |
第一处是采集分辨率。移动端通话 720p 是性价比最高的档位,不要默认开 1080p,会直接拉高码率和发热。第二处是 ICE 超时。浏览器和原生端的 ICE 默认超时都比较长,弱网下用户会在黑屏里等十几秒。我的做法是 iceConnectionState 进入 disconnected 后 5 秒内没恢复,就主动提示“网络不稳定”,并触发重新协商。第三处是音频处理,采集轨道必须开 echoCancellation 和 noiseSuppression,不然弱网下的回声会把通话体验拖垮。调试时建议用两台真机打,别用 PC 模拟器测音视频,模拟器里很多音频底层行为跟真机不一样,测出来的结果不可信。
5. 避坑:IM 项目最容易翻车的 5 个点
5.1 消息乱序,聊天记录对不上
现象:同一会话里,后发的消息先显示,时间线错乱,偶尔还有重复消息。原因:消息落库后进入多线程投递,各线程完成时间不同;客户端收到后按到达时间插入,而不是按服务端序号。解决:服务端在落库时分配单调递增 seq,客户端维护本地最大 seq,新消息先进缓冲区按 seq 排序再渲染。重复消息用消息 id 去重,一看到重复就先查去重表,别想着“用户可能没发现”。双端同时登录时也一样,手机和 Web 各自维护同一个 seq 游标,服务端统一分配号段。
5.2 断网重连后消息凭空消失
现象:App 杀后台再打开,中间缺了十几条消息,朋友圈点赞也少了一部分。原因:客户端记录的最后一条消息用时间戳做游标,本地时钟和服务端时钟不一致时,拉取起点就错了。解决:服务端给每条消息分配全局递增 seq,重连后按“本地最大 seq + 1”拉取,拉完再推进游标。这个切换要在日志里打点,能看到重连补拉的条数和耗时。补拉接口要分页,limit 控制在 200 条以内,一次拉几万条会把客户端 UI 线程卡死。
5.3 附近的人总比别人少:GeoHash 边界坑
现象:两个人实际就在隔壁楼,但附近的人列表互相看不见。原因:GeoHash 字符串在网格边界处会突变,两个坐标相距不足百米,hash 前缀却完全不同。解决:查询时取“自己所在格子 + 周围 8 个格子”共 9 个 hash,而不是只查一个。SQL 里的 in 要写 9 个值,然后按 ST_Distance_Sphere 排序取最近 20 个。这个坑不踩一次很难意识到。用 PostgreSQL 的话换成 PostGIS 的 ST_DWithin,逻辑一样,只是函数名不同。
5.4 音视频接通了但没声音没画面
现象:呼叫状态显示已接通,但对方画面全黑、麦克风没声音,或者反过来自己听不到对方。原因:ICE candidate 没配对成功,或者本应走 TURN 中继的媒体流还在反复尝试直连;另一个常见原因是系统没授予摄像头和麦克风权限。这类问题排查起来像玄学,实际九成是 ICE 或权限。解决:先在端侧看 iceConnectionState 是 connected 还是 failed;如果 failed,关闭 Wi-Fi 用流量重试,能通说明需要 TURN 兜底。权限问题要在 getUserMedia 的 reject 回调里给出明确提示,不要让用户停在黑屏里。还有一个隐藏坑:TURN 临时凭证有效期 15 分钟,但一次通话可能超过 15 分钟,凭证过期会导致通话中途断流,所以临时凭证有效期要大于单次通话时长上限。
5.5 群成员一多,群消息延迟飙升
现象:群人数超过 500 后,每次发消息全员延迟明显,服务端 CPU 上涨,消息推送偶尔丢失。原因:写扩散模型下,一条群消息被复制 500 份写入离线表,再叠加 APNs / 厂商推送,放大效应直接拖垮数据库。解决:超过阈值人数的大群改为读扩散,成员进群后从群消息表按游标拉取;小群继续写扩散。另一个便宜好用的策略是按在线状态分桶:在线成员走长连接直接投递,离线成员批量进离线表,分批推送而不是同一秒内全部打给推送服务。机器人 @ 全员是群消息风暴最常见的炸药包,要加独立开关和频率限制,默认一天最多触发一次。
6. 上线前验证:并发压测、多端联调与协议留档的习惯
6.1 用 Python 异步压测模拟 1000 个并发连接
验证 IM 系统最先要看的是消息延迟,不是界面。用 asyncio + websockets 写一个最小压测脚本,每个连接发送一条消息后等待回声,统计耗时分布。
import asyncio, json, time, statistics import websockets async def one_client(uri, uid, results): async with websockets.connect(uri) as ws: t0 = time.perf_counter() await ws.send(json.dumps({ "id": uid, "type": "text", "to": "1000", "toType": "user", "content": "ping" })) await ws.recv() results.append((time.perf_counter() - t0) * 1000) async def main(): results = [] tasks = [one_client("wss://im.example.com/ws?uid=1001&token=x", i, results) for i in range(1000)] await asyncio.gather(*tasks) print("p50:", statistics.median(results), "ms") print("p95:", sorted(results)[int(len(results) * 0.95)], "ms") asyncio.run(main())压测的位置要在服务端外部,别在本机压本机,否则测的是回环性能。p95 超过 500ms 就需要查网关线程模型和 Redis 连接池了。
6.2 三端联调清单:消息、音视频、扫码一个都不能少
上线前我用三台设备各做一轮:手机、Web、PC 登录同一个账号,发文字、图片、名片,确认三端会话列表一致;再拨一通音视频电话,中途切一次 Wi-Fi 到流量,确认通话不掉线;最后冷启动 App,看离线消息补拉条数是否正确。如果客户端是微信小程序,记得开发版会过期,要在开发者工具里重新扫码打开。这一轮过了,再谈压测和容量规划。
6.3 我每次上线前必做的三件事:协议留档、打点、日志
第一件事是让服务端把所有上行消息的类型和字段格式打点,建一张消息类型分布表,每周看一次哪些类型占比异常。第二件事是留关键日志至少 30 天,群消息和机器人回调的日志单独放目录。第三件事是写一份协议文档,把所有消息类型、信令状态、seq 和 ack 的约定写清楚。我现在接手任何 IM 项目,第一反应是翻协议文档而不是翻代码,没有文档的项目,维护成本会翻倍。这些习惯帮我在过去不止一次避免上线当天才发现低级翻车。希望帮到你。
本文还有配套的精品资源,点击获取