ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

精仿微信IM开发全解:消息、朋友圈与实时音视频的底层实现

精仿微信IM开发全解:消息、朋友圈与实时音视频的底层实现 简介这套基于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?uid1001tokenxxx); function sendText(to, text, toType) { const payload { id: genMsgId(), // 客户端生成服务端去重 type: text, // text / image / card / robot to: to, // toTypeuser 时是用户IDgroup 时是群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重连后从 seq1 开始拉。// 从游标拉取离线消息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}接收端渲染成卡片即可点卡片触发加好友流程。这两种类型不需要改协议只需要改类型枚举。机器人要单独说因为它不是内容解析是路由。我把机器人设计成一种特殊会话用户发给机器人的消息服务端识别到 toTyperobot 后不走正常投递而是转发给机器人回调网关。// 机器人消息路由异步调用避免拖垮长连接 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 存每个用户收件箱里的帖子 IDpermission 存可见性配置。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?uid1001 这类 scheme二维码内容要带一次性签名防止伪造二维码诱导添加。如果客户端是微信小程序扫一扫用的是 wx.scanCode真机预览时开发版会过期需要在开发者工具里重新扫码打开这一点排到联调再细说。登录授权的扫码本质是“PC 显示二维码 - 手机扫码提交临时 code - PC 轮询或长连接拿到 session”临时 code 用一次即焚别让它成为长期凭证。二维码过期时间一般 2 分钟过期后 PC 端要自动刷新否则用户扫了一个失效码会认为是产品坏了。4. 实时音视频通话信令状态机、STUN/TURN 配置与三个必调参数4.1 通话信令状态机拨号、接听、挂断与超时兜底音视频通话不是只靠 WebRTC 就能跑起来的它首先是一套信令流程。信令解决“谁在什么时候该做什么”A 发起呼叫、B 收到响铃、B 接听、A 确认、任一方挂断。这套状态最好显式建模否则到联调阶段全是黑匣子。当前状态收到事件新状态说明idlecallcalling发起方拨号callingringringing被叫方收到来电ringingacceptaccepted接听开始媒体协商ringingrejectidle拒绝callingtimeoutidle45 秒无应答自动取消acceptedhangupidle正常挂断信令消息走已有的 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 永远不会收到 ringA 一直停在 calling不超时的话用户会以为功能坏了。所以 calling 状态必配 45 秒超时ringing 状态 30 秒无应答自动回 timeout。网络切换是另一类坑Wi-Fi 切流量后 IP 变了ICE 候选需要重新协商信令里要有专门的 renegotiate 事件否则通话会卡在“黑屏但没挂断”的假连接状态。4.2 STUN/TURN 配置TURN 中继为什么必须自建WebRTC 的音视频流走 UDPNAT 穿透并不是总成功。公网环境下STUN 打洞成功率一般在七成到八成五剩下的失败场景对称 NAT、企业防火墙必须走 TURN 中继。只配 STUN 不配 TURN意味着弱网下约两成用户通话直接“黑屏”。这是“精仿微信”这类项目里最容易偷懒、上线后最难看的一个点。生产环境我一般自建 coturn 作为 TURN 服务最小配置如下。# /etc/turnserver.conf 关键配置 listening-port3478 realmim.example.com lt-cred-mech userimturn: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 和 TCPTCP 是给那些禁 UDP 的弱网环境兜底的。自建完 TURN 后不要只在内网测找一台完全公网的机器跑一通真机联调确认媒体流能走中继。很多团队卡在这一步内网全通、公网全黑就是因为防火墙没放行 UDP 3478。4.3 三个必调参数分辨率、码率与 ICE 超时音视频能不能用很多时候不是协议问题是参数问题。我每次集成必调三处这三处占通话体验的八成。参数推荐值说明采集分辨率720p移动端性价比最高1080p 发热高、码率翻倍视频码率8001500kbps按网络状况自适应弱网降档到 300kbps音频码率3264kbps 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?uid1001tokenx, 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 项目第一反应是翻协议文档而不是翻代码没有文档的项目维护成本会翻倍。这些习惯帮我在过去不止一次避免上线当天才发现低级翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表