首页 / 资讯中心 / 文章详情

原生双端1v1音视频源码:音视频链路、信令与后台Token实战

原生双端1v1音视频源码:音视频链路、信令与后台Token实战 ★ FEATURED ARTICLE
简介面向一对一音视频社交场景的双端原生源码包适合需要快速搭建语音房、陪聊、兴趣交友类应用的开发者或团队。系统覆盖AndroidJava与iOSObjective-C双端客户端及ThinkPHP后台核心功能包括秒级匹配语音匹配、视频匹配、随机速配、独立资料卡、私聊发送图片、语音与短视频、礼物打赏、动态发布并支持用户自主开关语音/视频接听权限运营侧还可启用邀请好友获奖励、活动激励等常用机制。后台提供清晰数据库结构与完整建表SQL基础部署文档能辅助理解工程划分便于二次开发时快速定位模块整体上手门槛不高。压缩包共4个文件整体3KB以Python脚本、txt/inscode说明文档和gitignore配置为主能够辅助环境检查、项目说明与版本管理导入后可快速核对运行环境与目录结构进而复用内嵌的核心通信能力。已有69人学习下载后可直接对接主流IM或音视频SDK扩展业务。1. 原生双端一对一音视频社交源码别盯着界面先看这三件事手里拿着一套「原生双端一对一音视频社交APP源码Android/iOSThinkPHP后台」的时候大部分人先被 App 界面吸引礼物面板、美颜滤镜、匹配动画看着确实像那么回事。但真正决定这套源码能不能用于上线、值不值得你投入时间改的从来不是 UI而是三条底层链路音视频通道走的是什么方案、通话信令的状态机做得够不够稳、ThinkPHP 后台能不能安全地签发音视频 Token。这三件事只要有一件不扎实Demo 跑得再顺一上真机、一进弱网、一并发量上来就露馅。这篇文章按一线做 1v1 社交 App 的常见路径拆开讲架构怎么选、双端怎么改、后台怎么接、哪些坑必须绕开。适合手里已有源码准备二次开发的产品经理和客户端/服务端开发也适合正准备评估这类源码值不值得买的技术负责人。2. 先拆架构音频链路、信令通道、ThinkPHP 后台各自管什么2.1 原生双端为什么是 1v1 通话的默认答案市面上这类源码分两派一派是 Flutter/React Native 跨平台写一套搞定双端另一派是 Android 和 iOS 各写一套原生工程。标题里明确写了「原生双端」这本身就是一个重要的选型信号。1v1 音视频社交对客户端的要求非常苛刻通话页要长时间保持屏幕常亮、退到后台不能断声音、来电时要弹出系统级通知、音频焦点和听筒/扬声器切换必须跟系统深度绑定。这些能力跨平台框架能做但每做一个都要写平台通道第三方音频 SDK 的适配也经常要打补丁。原生工程的优势在于音频会话管理直接调系统 API来电响铃可以用系统通知抢焦点没有中间层损耗。很多团队踩过跨平台方案的坑之后换回原生核心原因就是在真机上通话体验调不动。原生双端还有一个实际好处排查问题时能分端定位。某一天用户反馈「Android 听不到声音但 iOS 正常」原生双端源码里你能直接去查音频会话的配置代码如果是一套跨平台代码你要先排查框架层再排查平台层绕一大圈。做社交产品双端表现不一致是常态原生架构至少让排查路径短一半。源码评估时先确认 Android 端是不是 Kotlin/Java、iOS 端是不是 Swift/Objective-C再确认音视频模块是不是独立封装——这两点占了双端源码一半的价值。2.2 音视频链路三选一自建媒体、SaaS RTC、纯 P2P音视频链路是一对一社交 App 最贵的部分也是源码评估时最先要确认的部分。常见的实现方案有三条路我按实际碰到的项目经验做了一个对比方案硬件/带宽成本开发量抗弱网能力适合谁自建媒体服务器SFU/TURN高随并发线性增长极大需专门的 RTC 团队自己调难度高大厂或并发规模明确的场景SaaS RTC SDK按分钟计费起步门槛低小集成 SDK 即可厂商已经做了全网优化大多数源码和二开团队纯 P2P STUN/TURN中TURN 流量仍要花钱中信令和 NAT 穿透自己写弱网和复杂 NAT 下不稳只做小范围试点的原型从我接触到的「Android/iOS ThinkPHP 后台」这类源码看绝大多数走的是 SaaS RTC SDK 这条路客户端接入厂商 SDK后台向厂商服务端申请 token媒体流全部走厂商的加速网络。这也是我比较推荐中小团队选的路线。自建媒体服务器不是不行但 1v1 场景下你要自己做带宽调度、弱网对抗、服务器扩容成本远远超过按分钟付给厂商的费用而纯 P2P 方案在对称型 NAT 环境下大概率打洞失败线上翻车风险太高。SaaS 方案的核心是把「媒体面」外包把「信令面」留给自己。判断一套源码是否成熟就看它有没有把这两层分开媒体面由 SDK 回调驱动信令面由自己的长连接驱动两层通过通话房间号channel关联而不是混在一起。混在一起的源码通话中一旦网络切换信令和媒体同时断恢复逻辑根本写不清楚。2.3 后台的边界账号、信令、记录唯独不碰媒体流ThinkPHP 后台在这套架构里扮演三个角色账号与鉴权、信令转发、通话数据沉淀。账号体系负责登录注册、用户资料、上下线状态信令转发负责把「呼叫、响铃、接听、挂断、占线」这些指令从主叫发给被叫数据沉淀负责把每次通话的时长、结果、原因写进数据库用于计费和风控。后台最不应该做的是碰媒体流。有些源码为了省 SaaS 费用想用后台直接转发音视频数据这在 1v1 场景下等于自己造了一个 RTC 网关带宽和延迟都扛不住。正确做法是后台只通过 API 向 RTC 厂商申请 token客户端拿 token 直接跟厂商的媒体网络建立连接。信令路径和媒体路径天然分离通话质量才有保障。我评估这类源码时有个习惯动作先找后台代码里有没有「向 RTC 服务商请求 token」的封装再看数据库表里有没有 call_record 这类话单表。两个都有说明作者是按线上标准写的只有前者没有后者说明只能算 Demo 级别接完还得自己补计费与风控。下一章就从双端工程结构开始把跑通第一通电话的路径过一遍。3. 双端源码落地跑通第一通 1v1 电话要动的代码3.1 先认清 Android/iOS 双端的工程骨架拿到源码第一件事不是看代码是看目录。一套结构清晰的双端源码Android 和 iOS 的模块划分应该一一对应否则后续改需求时要两边分别猜体力消耗巨大。我按常见工程结构列一张对照表你可以拿它跟手里的源码比对模块AndroidKotlin/JavaiOSSwift/OC职责网络层api/Network/封装 HTTP 请求统一加鉴权头长连接socket/SocketManager维护 WebSocket收发信令音视频rtc/RTCManager封装 SaaS RTC SDK 的加入/离开/订阅通话页面call/Call/来电/去电界面按钮事件用户与资料profile/Profile/资料编辑、头像上传通用组件common/Common/图片加载、列表、弹窗核对的要点是「通话页面」和「音视频模块」是否分离。分离的源码里通话页只负责展示状态和接收用户点击真正的进房、订阅、挂断逻辑都封装在 rtc/ 或 RTCManager 里。这样有个直接好处你要换一家 RTC 厂商时只需要重写 rtc 模块通话页和后台都不用动。如果源码里 RTC 逻辑直接写在 Activity/ViewController 里后续升级 SDK 会被迫翻整个页面这种源码改造成本很高。3.2 必改配置清单从 Demo 指向你自己的后台源码默认连的是作者自己的后台地址和 RTC AppId不改配置直接跑要么请求失败要么通话建立不了。必改项集中在一个配置入口不要散落得到处都是。Android 端我一般先找一个 AppConfig 类的常量定义类似这样object AppConfig { // 修改点1你自己的 ThinkPHP 后台地址 const val API_BASE_URL https://api.your-domain.com/v1 // 修改点2信令 WebSocket 地址注意是 wss 不是 ws const val SOCKET_URL wss://api.your-domain.com/ws // 修改点3SaaS RTC 服务商分配的 AppId const val RTC_APP_ID your_rtc_app_id // 修改点4呼叫超时时间毫秒 const val CALL_TIMEOUT 30000L }这四个参数背后各有一个坑。API 地址要和后台路由前缀严格一致ThinkPHP 常见是/v1或/api改错了所有请求都 404。WebSocket 地址必须用wss://线上环境用ws://会被部分运营商和 WebView 策略拦截。RTC_APP_ID 不换的话会出现多人共用同一个 AppId 导致的频道串线。超时时间建议 2535 秒太短用户还没来得及点接听太长占线反馈太慢。iOS 端对应位置通常在Config.swift或AppEnvironment.swift参数名几乎一样按后台实际配置同步改即可。3.3 Android 端呼出拿 token、进频道、订阅远端流跑通第一通电话核心链路是点击拨号 → 请求后台签发 RTC token → 调用 RTC SDK 加入频道 → 订阅对端视频流。以 Android 端为例核心代码是这样一段逻辑fun startCall(callId: String, toUid: String) { // 1. 请求 ThinkPHP 后台签发该频道的 RTC token api.fetchRtcToken(callId, currentUid).enqueue { resp - if (!resp.success) { callView.showError(resp.message) returnenqueue } // 2. 拿到 token 后加入 RTC 频道 rtcManager.joinChannel( token resp.data.token, // 后台签发的动态 token channel callId, // 频道名 呼叫 ID不用业务房间号 uid currentUid, // 用户 ID服务端和客户端必须同型 callback { state - when (state) { // 3. 进房成功后发布本地流并等待订阅远端 RtcState.JOINED - rtcManager.publishLocalStream() RtcState.REMOTE_JOINED - { rtcManager.subscribeRemoteStream(state.remoteUid) callView.switchToTalking() } } } ) } }这段代码里有三个决定成败的参数。第一channel直接复用callId我建议不要用「主叫 UID 被叫 UID」拼频道名因为一次通话里用户可能重拨、换人接听频道名必须一次通话一个 ID才能在话单里精确追溯。第二uid的取值类型要和后台签发 token 时传的完全一致Android 的Int和后台 PHP 字符串拼接一不小心就会因为类型不一致导致鉴权失败这个后面避坑章还会展开。第三REMOTE_JOINED是订阅对端流的时机必须先订阅再切页面状态否则画面会出现几秒黑屏。很多源码在这一点上处理得草率导致每次通话接通后先黑一下。3.4 iOS 端接听音频会话与回调处理是重点iOS 端跑通通话的关键不在 RTC SDK 本身而在音频会话配置。iOS 系统对音频的管控比 Android 严格不提前配置好AVAudioSession会出现来电不响铃、接通后没有声音、切到后台声音消失三种诡异问题。接听链路的代码一般长这样func acceptCall(callId: String, fromUid: String) { // 1. 配置音频会话通话场景必须用 PlayAndRecord let session AVAudioSession.sharedInstance() try? session.setCategory(.playAndRecord, mode: .voiceChat) try? session.setActive(true) // 2. 请求后台签发 token 并加入频道 api.fetchRtcToken(callId: callId, uid: currentUid) { result in guard let token result.token else { return } rtcManager.joinChannel(token: token, channel: callId) { event in switch event { case .joined: rtcManager.publishLocalStream() case .remotePublished(let uid): rtcManager.subscribeRemoteStream(uid: uid) self.callView.switchToTalking() } } } }音频会话的category只能选.playAndRecord选成.playback只有声音播放没有录音权限对方就听不到你说话mode选.voiceChat是为了让系统启用通话降噪和回声消除。还有一个小习惯setActive(true)要放在 joinChannel 之前否则 SDK 内部激活音频时会和系统抢占焦点轻则延迟重则直接进不了通话状态。iOS 端的 RTC 回调大多在后台线程更新 UI 必须回到主线程这也是新手最容易忽略的崩溃点。4. ThinkPHP 后台对接登录鉴权、Token 签发与话单落库4.1 后台接口清单与统一鉴权ThinkPHP 后台是这套源码的「大脑」接口设计是否规范直接决定客户端二改效率。我列出 1v1 音视频社交最核心的一组接口你可以拿这个清单检查手里的源码接口方法与路径用途是否需要登录登录POST /v1/auth/login手机号/验证码登录返回用户 token否刷新资料PUT /v1/user/profile更新头像、昵称是活跃用户列表GET /v1/match/list抽取可呼叫的在线用户是申请 RTC TokenPOST /v1/call/token生成音视频频道 token是上报通话状态POST /v1/call/report同步开始/结束时间与原因是通话记录GET /v1/call/history拉取历史话单是这里的核心原则是除了登录接口其余所有接口都必须走统一鉴权。鉴权推荐放在中间件里而不是每个控制器各写一遍。ThinkPHP 6/8 里中间件是一个独立的类逻辑统一改一处全部生效。4.2 登录接口与 Token 中间件登录不需要玩家自己设密码1v1 社交产品绝大多数走手机号验证码。登录成功后后台签发一个业务 token客户端之后每次请求都带在 Header 里。业务 token 我用 ThinkPHP 中间件统一校验这是源码里最常见的标准做法// app/middleware/AuthCheck.php namespace app\middleware; use think\Request; use think\facade\Cache; class AuthCheck { public function handle(Request $request, \Closure $next) { $token $request-header(Authorization, ); $token str_replace(Bearer , , $token); // 1. 从缓存查 token拿不到说明未登录或已过期 $user Cache::get(login_token_{$token}); if (!$user) { return json([code 401, message 未登录或登录过期]); } // 2. 把用户信息挂到请求对象上控制器里直接取 $request-user $user; return $next($request); } }这段中间件的核心是「缓存即会话」。用 Cache 而不是数据库查 user是因为 1v1 场景下每次进入匹配列表、每次呼叫都要校验登录态走数据库会把查询压力放大好几倍。token 过期时间我建议设 7 天客户端每次进 App 时如果发现 401就静默刷新登录态。Authorization头统一用Bearer前缀是行业惯例客户端封装网络层时也按这个格式拼避免后台和客户端各写各的导致鉴权失败。4.3 服务端签发 RTC Token安全边界在这里申请 RTC Token 是后台最重要的接口它的安全性决定音视频服务会不会被别人盗刷。很多源码的翻车点在这里把 RTC 服务商的 AppSecret 放在客户端客户端自己签 token。这样等于把保险箱钥匙贴在了保险箱上。正确做法是客户端只传频道 ID 和用户 ID后台拿着 AppSecret 向 RTC 服务商申请再把 token 返回给客户端// 控制器app/controller/Call.php public function token() { $callId request()-post(call_id, ); $uid request()-post(uid, ); if (empty($callId) || empty($uid)) { return json([code 400, message 参数缺失]); } // 1. 组装 RTC 服务商需要的参数 $params [ channel $callId, // 频道名一次通话一个频道 uid intval($uid), // 用户ID必须转成服务商要求的类型 role subscriber, // 角色决定权限1v1场景可不做强区分 expireAt time() 3600, // token 有效期 1 小时 ]; // 2. 调用 RTC 服务商的服务端 API具体路径以你的服务商文档为准 $rtcToken Http::post($rtcServer . /token, [ appId $this-config[rtc_app_id], sign $this-sign($params, $this-config[rtc_app_secret]), params $params, ]); return json([code 0, data [token $rtcToken[token]]]); }两个参数必须盯紧。channel和客户端 joinChannel 传的频道名要完全一致建议统一用 call_id而不是用房间号或商品 ID避免客户端和后台拼接规则不一致。uid在 PHP 里是字符串传到 RTC 服务商前要先转成目标类型否则会出现「token 里是字符串、客户端进房传的是整数」这种鉴权失败。token 有效期不要设太长1 小时足够完成一次通话过期后如需续通话要让客户端重新申请。4.4 通话记录落库别只信客户端的时长上报通话结束后客户端会上报时长和状态但这些数据可以被篡改计费依据不能只靠它。标准做法是后台把信令节点的关键动作逐一落库最后用「被叫接听时间」与「最后一次挂断时间」计算官方时长。表结构一般长这样字段类型说明idint主键call_idvarchar(64)一次通话唯一标识客户端生成from_uidint主叫用户 IDto_uidint被叫用户 IDstatustinyint0 待接听 / 1 通话中 / 2 已结束start_atdatetime被叫点击接听的时间end_atdatetime任何一方挂断的时间durationint服务端计算的时长秒reasontinyint结束原因1 主叫挂断 2 被叫挂断 3 超时 4 网络断我建议在call/token接口里先创建一条 record状态为 0等到后台收到信令层上报的「接听」事件时更新为 1 并写 start_at收到「挂断」时算好 duration 再更新状态。这样即使客户端不上报后台也有 start_at 和 end_at 的完整记录。同步留意两个客户端各算各的时长经常有 5 秒级误差服务端统一计算后对账问题会少很多。5. 避坑记录从源码到线上踩过的 5 个真实坑5.1 切后台锁屏后无声断流双端表现还不一样现象通话中按 Home 键或锁屏Android 端声音断断续续甚至直接断流iOS 端有时正常有时对方听不到自己。原因Activity/ViewController 退到后台时将 RTC 模块一并释放了或者音频焦点被系统回收。Android 端的生命周期里onPause只应该暂停本地视频采集不应该销毁 RTC 引擎iOS 端退后台后AVAudioSession需要继续保持 active。解决Android 用前台服务保活通话进程并在onPause中只关闭摄像头不拆流iOS 在AppDelegate中实现beginBackgroundTask并在通话期间不主动setActive(false)。验证方法很简单接通后锁屏等 30 秒再解锁看通话是否正常。5.2 被叫响铃了主叫没动静信令状态对不上现象A 呼叫 BB 手机响铃了A 手机上却一直停留在「呼叫中」转圈几秒后超时。原因呼叫信令从 A 到后台再到 B 的链路里B 的回执信令丢失或者后台没有把「响铃」事件广播回 A。很多源码里只做了单向信令下发没有做 ack 确认。解决所有信令加callId收到消息必须回 ack后台在转发信令时记录日志A 超过 2 秒没收到 ack 就自动重发客户端对重复信令做幂等处理避免 B 连续收到两次呼叫。排查时打开后台的信令日志看 ACK 在哪一跳断了通常是转发逻辑少写了一个 target。5.3 RTC Token 校验失败每次原因都不重样现象Token 校验失败的表现五花八门——进房时提示token expired、加入频道后马上被踢出、Android 能进 iOS 进不去。原因绝大多数是 token 里的 channel 或 uid 与客户端实际传的不一致少数是服务器时钟偏差导致 token 有效期计算失败。解决先统一 channel 拼接规则后台和客户端全部用 callId任何一端都不得自己加前缀uid 类型统一为整数或统一为字符串配套检查 PHPintval()的位置服务器配置 NTP 自动同步时间避免因为 5 分钟时钟偏差导致刚签发的 token 被判过期。加一个调试接口把 token 解析后的 payload 返回给客户端一眼就能看出哪里不匹配。5.4 只开了 STUN 没开 TURN部分网络永远连不通现象WiFi 环境下通话正常切到某个运营商的 4G/5G 网络后就一直「连接中」换一张卡又正常。原因当前网络处于对称型 NAT 内P2P 打洞失败而此时没有 TURN relay 服务器兜底。解决在 RTC 服务商控制台同时开启 STUN 和 TURN或直接使用厂商的 relay 模式不要为了省流量只留 STUN上线前用三类网络做冒烟测试家宽 WiFi、运营商大网 NAT、公司内网每一路都要实际跑通一分钟通话。这个坑在 Demo 阶段很难暴露因为大家测试时都在同一个办公室网络一旦用户分布到各种运营商网络就原形毕露。5.5 拿源码不换 AppId 直接跑上线即翻车现象本地测试一切正常打包上线后用户频繁掉线、进房失败后台报错提示 AppId 不存在或被封禁。原因源码自带的 AppId 是作者申请的测试凭证有并发限制且回调域名白名单指向作者自己的服务器别人的请求自然被拒。解决按模块做一次「AppId 全量替换」Android 的 Manifest 和配置文件、iOS 的 plist 和 Info 配置、ThinkPHP 后台的config里rtc_app_id以及 RTC 服务商控制台的回调域名白名单。替换完用抓包工具确认请求里的 AppId 已经全部变成新值再打包测试。6. 进阶一对一通话状态机与上线前的验证三板斧6.1 五个状态和合法迁移表音视频通话的本质是状态机不是按钮事件。「拨号按钮」只是触发状态迁移的输入真正决定通话体验的是每个状态下能做什么、不能做什么。我按 1v1 场景把状态压缩成五个状态含义合法进入方式合法迁出方式IDLE空闲任何结束态CALLING / RINGING被叫CALLING主叫呼叫中IDLE 去电RINGING对端响铃/ IDLE超时或取消RINGING被叫响铃中CALLINGTALKING接听/ IDLE拒绝或超时TALKING通话中RINGING 接听IDLE任一挂断、断网超过阈值CLOSING正在关闭TALKING 发起挂断IDLE清理完成熟悉这张表你会少踩很多坑例如 CALLING 状态下收到重发信令不能重复进房TALKING 状态下收到新呼叫只能返回「占线」而不能打断。源码里如果没有这张表只是用 boolean 变量标记「在通话中」挂断重连后十有八九状态对不上。6.2 信令消息的统一格式与幂等所有信令消息建议统一成一种 JSON 结构字段固定扩展靠 payload{ cmd: call.invite, callId: c_20240601_123456_8877, fromUid: 10001, toUid: 10002, ts: 1717228800, payload: { rtcChannel: c_20240601_123456_8877 } }命令字cmd用「模块.动作」命名方便后台日志按前缀过滤。callId是全局唯一一次通话自始至终不变所有信令都带它客户端收到重复call.invite时直接忽略第二次。ts用于对端判断消息是否过期超过 10 秒的消息不再处理。这套格式的好处是后台只做转发不改内容客户端只要按cmd分发到对应 handler排查问题时 grep 日志即可。6.3 上线前怎么验证状态机不散架最后一公里我会用「三台设备 五组动作」验证状态机的真实健壮性。设备一台 Android、一台 iOS、再加任一一台手机做干扰源。动作主叫挂断、被叫拒接、双方同时挂断、通话中拔掉一台设备的网线开飞行模式等待 60 秒、通话中直接杀进程再重进。每组动作跑完回头检查后台 call_record 的 status 和 duration凡是出现「状态一直停在通话中」「时长 0 秒但 status1」的记录都是状态机泄漏的现场。补一个开发习惯每次状态变更都在客户端打一条带 callId 的日志。线上用户报障时把双端日志按时间戳对齐五分钟内就能定位是哪一端的状态乱了。这套方法比加一堆监控指标更直接。我做过的 1v1 通话项目里线上 90% 的动线故障靠这三板斧都能复现。希望这些踩坑经验帮到你少走我走过的弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站