简介:这是一套一对一语音视频直播社交应用的双端原生工程,并配套PHP后台源码,面向有一定开发经验的中高级移动端开发者,适用于构建带匹配机制、实时音视频通话和即时通信能力的交友平台。压缩包共收录2003个文件,体积约618MB,其中安卓端以Java与XML源码为主,苹果端由Objective-C的h/m文件构成,cpp文件承担底层音频流处理,PHP后台配合SQL数据库共同完成服务端逻辑。已有867人学习,有一定参考价值。资源未附带教程,需要自行研究,但源码功能完整,覆盖速度匹配、视频语音匹配、即时通信、动态发布、私聊礼物、音视频通话等核心业务,并实现用户端自定义关闭语音或视频接听、邀请分享奖励机制,适合作为社交应用二次开发或原生实时通信产品架构的参考蓝本。
1. 一套“双端原生 + PHP 后台”的源码,拆开看是三层系统
源码交易平台上经常出现这种标题,长相是一个 zip 包,但拆开看是三层独立的系统:iOS 端、Android 端和 PHP 后台。这类源码真正的价值不在 UI 界面和那几十个页面文件,而在三件事:双端怎么把音频视频能力拉起来、PHP 怎么写实时信令、以及匹配和计费这些业务怎么与通话过程咬合在一起。适合两种人:一是想快速上线一个语音交友产品、但不想被第三方 RTC SaaS 绑死的小团队;二是想研究 WebRTC 与即时通信链路完整闭环的服务端或客户端开发。标题里的每一段都值得单独过一遍,下面按架构、业务、端上接入和上线验证四段展开。
2. 先把架构立住:PHP 做业务面,信令与媒体面走两条独立通道
2.1 双端原生不是情怀,是通话类 App 的硬性约束
很多项目为了省成本会用跨端框架做聊天室,但聊天和通话对系统底层能力的依赖完全不同。跨端方案在摄像头采集、音视频编解码、音频焦点抢占、前后台切换这些场景里总是隔着一层抽象,一旦遇到机型兼容问题,排查成本会远超省下的开发成本。
原生端的优势在通话链路里是硬性的:iOS 端可以直接管理 AVAudioSession 的播放和录音通路,Android 端可以精确控制音频焦点和前台服务。语音视频通话对延迟和后台保活的要求极高,这些能力只有原生 API 才给得完整。
这套源码里的双端原生通常指的是 Kotlin/Java 的 Android 工程和 Swift/Objective-C 的 iOS 工程,两者各自独立调取系统摄像头和麦克风权限,再通过 WebRTC 或第三方 RTC SDK 完成音视频传输。业务界面可以做得简单,但底层通信能力必须完整。
2.2 PHP 后台管什么、不该管什么
这个标题里最容易被低估的是 PHP 后台。很多开发者第一反应是"PHP 不是做网页的吗,怎么撑得起实时通话",这恰恰是这套源码的关键设计点:PHP 后台负责的是业务面,不是信令面和媒体面。
业务面包括用户注册登录、个人资料、匹配算法、余额计费、聊天记录、礼物打赏、举报拉黑。这类操作并发量不算极端,PHP-FPM 短请求完全可以承载,数据库用 MySQL,缓存用 Redis,结构非常清晰。
信令面则是另一条通道。呼叫、接听、挂断、忙线、网络切换这些实时控制指令,走的是 WebSocket 长连接,不能用 HTTP 短轮询代替——轮询延迟在 1 到 3 秒之间,用户等接听时每一秒都很敏感。
媒体面更是独立:音频视频数据流量大、实时性要求高,通常走 WebRTC 的 UDP 通道,完全不经过 PHP。PHP 后台在整个通话过程中只做两件事:下发放通信号和记录通话结果。
| 技术点 | 推荐方案 | 说明 |
|---|---|---|
| 业务接口 | PHP + MySQL | 注册登录、资料、余额、举报等低频请求 |
| 实时信令 | PHP + Swoole WebSocket | 呼叫、接听、挂断、忙线等高频短消息 |
| 在线状态 | Redis | 用户上下线、空闲/忙碌状态,全局实时可见 |
| 媒体传输 | WebRTC + TURN | 音视频直接点对点或经中继转发,PHP 不参与 |
| 异步任务 | PHP CLI + Redis 队列 | 通话结束后的计费、录制转码、消息推送 |
需要注意,如果这套源码的信令是用轮询做的,那不管界面多完善都不建议直接上线,轮询在整个通话生命周期里会造成大量无效请求,服务器成本翻倍,体验还差。
2.3 一次呼叫的完整信令链路
一次一对一通话从用户 A 发起呼叫到接通,信令链路大概是这样的:
A 的客户端通过 WebSocket 向服务器发送invite消息,携带被叫方 ID、通话类型(语音或视频)、呼叫来源(随机匹配或好友列表)。PHP 的 Swoole WebSocket 服务收到后,先查 Redis 里 B 的在线状态和当前状态标识。如果 B 在线且状态为空闲,服务器把invite推给 B,同时把 A 的状态更新为"呼叫中"。
B 客户端弹出来电界面,用户点击接听后发送accept消息。服务器收到后给 A 推送accept,同时生成一个会话 ID 和房间号码,双方客户端拿到房间号后各自开始连接 WebRTC 媒体通道。
这个过程中 PHP 做的工作本质上是消息路由和状态管理。下面是一个用 Swoole 实现的最小信令服务端框架:
<?php // ws_server.php use Swoole\WebSocket\Server; $server = new Server("0.0.0.0", 9502); // 维护 fd 到用户 ID 的映射 $fdToUser = []; $userToFd = []; $server->on('message', function (Server $server, $frame) use (&$fdToUser, &$userToFd) { $data = json_decode($frame->data, true); $action = $data['action'] ?? ''; $userId = $data['user_id'] ?? ''; switch ($action) { case 'login': // 用户建立连接后先登录,绑定 fd 与 user_id $fdToUser[$frame->fd] = $userId; $userToFd[$userId] = $frame->fd; // 更新 Redis 在线状态 redis()->hSet('online_users', $userId, time()); break; case 'invite': $targetUserId = $data['target_user_id']; $targetFd = $userToFd[$targetUserId] ?? null; if ($targetFd) { $server->push($targetFd, json_encode([ 'action' => 'invite', 'from_user_id' => $userId, 'call_type' => $data['call_type'], 'room_id' => uniqid('room_'), ])); } else { $server->push($frame->fd, json_encode([ 'action' => 'invite_failed', 'reason' => 'target_offline', ])); } break; case 'accept': $targetFd = $userToFd[$data['target_user_id']] ?? null; if ($targetFd) { $server->push($targetFd, json_encode([ 'action' => 'accept', 'from_user_id' => $userId, 'room_id' => $data['room_id'], ])); } break; case 'hangup': $targetFd = $userToFd[$data['target_user_id']] ?? null; if ($targetFd) { $server->push($targetFd, json_encode([ 'action' => 'hangup', 'from_user_id' => $userId, ])); } break; } });这个例子做了大幅简化,真实项目里还有心跳超时、断线重连、状态互斥这些逻辑。但核心路径已经看得出来:PHP 在信令链路里扮演的是路由器,每一个 action 对应一次状态迁移,客户端之间不直接互发消息,统一经服务器中转。
login消息必须在连接建立后立刻发送,服务器需要把 WebSocket 的 fd 和业务用户 ID 绑定,后续所有信令才能找到投递目标。invite里的room_id由服务器生成,保证全局唯一,避免两端各自生成房间号导致拼接不一致。hangup需要双方都能发起,否则一方 App 崩溃或杀进程时对端会永远停留在通话界面。
2.4 两条通道必须分离的核心原因
媒体面如果也走 WebSocket,后果是灾难性的。音视频数据量巨大,WebSocket 基于 TCP,TCP 的拥塞控制会导致延迟线性上升,通话质量断崖式下降。
正确做法是信令走 WebSocket 保证可靠到达,媒体走 WebRTC 的 UDP 通道保证低延迟。WebRTC 内部实现了自适应码率、丢包重传、抖动缓冲,这些机制在弱网环境下的表现远比人为控制 TCP 连接稳定。
PHP 后台在这个架构里只需要关心信令和业务,不需要理解媒体流的编码格式,这大大降低了服务端复杂度。这也是这套标题告诉我们的最核心的架构思想:业务的归业务,实时的归实时,媒体的归媒体。
3. 用 PHP + Redis 把匹配、房间、计费做成可维护的业务代码
3.1 匹配逻辑:Redis GEO 就近查询 + 标签过滤 + 状态过滤
匹配是这个 App 的核心功能之一。常见做法是:用户在客户端点"开始匹配",服务端返回一个当前可通话的异性用户。匹配策略通常由三个维度组成:地理位置相近、兴趣标签重合度高、当前在线且状态为空闲。
用 MySQL 直接做复杂匹配查询不是不行,但在线用户数量大时性能不好。Redis 的 GEO 结构非常适合做位置邻近查询,配合 hash 存用户标签,再用一个有序集合存空闲用户池,可以实现毫秒级匹配响应。
以下是匹配接口的简化实现思路:
<?php /** * 匹配一个可通话的用户 * @param int $userId 当前用户 ID * @param float $lng 经度 * @param float $lat 纬度 * @return int|null 匹配到的用户 ID */ function matchUser(int $userId, float $lng, float $lat): ?int { $redis = redis(); // 1. 在当前用户周围 5km 内查找在线用户 $nearby = $redis->geoRadius('user_location', $lng, $lat, 5, 'km', ['COUNT' => 50]); // 2. 排除自己 $nearby = array_diff($nearby, [$userId]); // 3. 过滤必须是空闲状态且性别符合偏好的用户 foreach ($nearby as $candidateId) { // 状态检查:正在通话中不匹配 $state = $redis->hGet('user_state', (string)$candidateId); if ($state !== 'idle') { continue; } // 性别检查:这里省略具体偏好逻辑 $gender = $redis->hGet('user_gender', (string)$candidateId); if (isBlocked($userId, $candidateId)) { continue; } return (int)$candidateId; } return null; }geoRadius是 Redis GEO 的核心命令,底层基于跳表实现,5 公里范围查询耗时常驻在 1 毫秒以内。user_location这个 key 存储所有在线用户的位置,客户端每次更新位置时覆盖写入。
这里有个容易被忽略的细节:匹配时必须同时检查双方状态,不能只查对方。如果男方在匹配的同时被别人呼入,状态可能已经变成"呼叫中"或"通话中",匹配接口查到的结果就失效了。所以实际项目中匹配成功推送后,要用 Redis 的 WATCH/MULTI 或 Lua 脚本做原子状态切换,防止两个人同时被匹配给不同的用户。
3.2 房间状态机与 Redis key 设计
一对一会话的常规状态迁移路径是:idle(空闲)-> calling(呼叫中)-> talking(通话中)-> idle(结束),另外还有cancel(取消)和timeout(超时)两个旁路分支。
每个状态在 Redis 里都应该有对应的 key 来支撑查询。常见的设计是每个通话会话分配一个房间 ID,房间信息用 hash 存储,状态变更时同步更新 Redis 并记录时间戳。
| Redis key | 类型 | 作用 | 过期时间 |
|---|---|---|---|
room:{room_id} | hash | 存储主叫 ID、被叫 ID、创建时间、通话类型 | 按场景而定,常规通话结束后保留 1 小时 |
room:{room_id}:state | string | 当前状态值:calling / talking / ended | 无 |
user_state:{user_id} | string | 用户当前空闲状态,匹配和呼叫都依赖这个字段 | 无 |
user_location | geo | 全部在线用户的位置 | 配合在线状态清理 |
状态机切换必须集中在服务端,不能在客户端自行更改。原因很直白:客户端的状态不可信,用户可以改包欺骗服务器。服务端每收到一次信令,先检查当前状态是否合法,再执行迁移,最后广播通知对端。
一个典型的错误是把room:{room_id}:state设置成跟随连接断开自动过期,这会导致通话结束后的计费逻辑丢掉上下文。计费是在通话结束后异步执行的,如果状态 key 在结束前被清理,这笔通话就无法对账。建议通话结束时显式写入ended,保留 1 小时后再由定时任务清理。
3.3 通话结束上报与余额扣减:必须由服务端主导
即时通信类 App 的一对一语音视频功能通常会按分钟计费,剩余时长不足时强制中断。计费数据不能依赖客户端上报时长,客户端可能因为进程被杀、网络异常导致上报失败,也可能被人为篡改。
常规做法是:媒体服务器或服务端信令网关在通话结束时记录开始时间和结束时间,计算出实际通话秒数,再通过 Redis 队列将计费任务投递给 PHP 异步消费进程。以下是一个稳妥的计费说明:
<?php /** * 通话结束后的计费逻辑 * @param int $userId 被扣费的用户 ID * @param int $seconds 通话秒数,由服务端记录 */ function billForCall(int $userId, int $seconds): void { $redis = redis(); // 1. 计算费用:每分钟 10 点币,不足一分钟按一分钟算 $minutes = (int)ceil($seconds / 60); $cost = $minutes * 10; // 2. 扣减余额,使用 Lua 脚本保证原子性 $script = <<<'LUA' local balance = redis.call('hget', KEYS[1], ARGV[1]) if not balance or tonumber(balance) < tonumber(ARGV[2]) then return -1 end redis.call('hincrby', KEYS[1], ARGV[1], -tonumber(ARGV[2])) return tonumber(balance) - tonumber(ARGV[2]) LUA; $result = $redis->eval($script, ['user_balance', $userId], $userId, $cost); if ($result < 0) { // 余额不足,记录欠费并触发强制挂断逻辑 $redis->hSet('user_overdue', (string)$userId, time()); } // 3. 写入通话记录,这里放入队列异步落库 $redis->lpush('cdr_queue', json_encode([ 'user_id' => $userId, 'seconds' => $seconds, 'cost' => $cost, 'created_at' => time(), ])); }Lua 脚本保证了"检查余额"和"扣减余额"两步是原子操作,避免并发下出现余额被扣成负数的情况。cdr_queue列表累积通话详单,由后台 PHP CLI 进程消费写入 MySQL,报表和对账都从这里出数据。
实际生产环境还要考虑通话结束到扣费成功这 1 秒窗口内用户又发起新通话的情况,常规做法是扣费前检查用户的状态位,发现还在通话中就拒绝新呼叫。这套逻辑必须在 PHP 后台与信令服务的协作边界上做好约定,否则会出现连锁 bug。
4. 原生端接入:iOS 与 Android 的权限、采集和 WebRTC 协商
4.1 iOS 端:AVAudioSession 是被坑最多的环节
iOS 端接入音频视频通话,最麻烦的不是 UI,而是AVAudioSession的配置。很多源码在模拟器上跑得通,真机一通话就没声音,根源几乎都是 AVAudioSession 的 category 配置不对。
以下是一段常用的 AVAudioSession 配置代码,放到呼叫接通的回调里调用:
import AVFoundation func configureAudioSession() { let session = AVAudioSession.sharedInstance() do { // playAndRecord:同时支持播放和录音 // defaultToSpeaker:默认外放,否则通话声音会从听筒出 try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth]) try session.setActive(true) } catch { print("AVAudioSession config failed: \(error)") } }mode: .voiceChat告诉系统当前场景是语音通话,系统会做对应的回声消除和降噪处理。allowBluetooth是为了支持蓝牙耳机,缺失这个选项会导致配对蓝牙耳机后声音还是从听筒出来。
视频通话还需要申请摄像头权限,在Info.plist里配置NSCameraUsageDescription和NSMicrophoneUsageDescription,缺失键名会导致 App 直接崩溃。很多源码包会漏掉这两个键,上架审核时也经常会因为这个被拒。
4.2 Android 端:权限申请与音频焦点
Android 端的核心问题是运行时权限和音频焦点抢占。Android 6.0 以上需要在运行时动态申请RECORD_AUDIO和CAMERA权限,另外还要处理收到来电时其他 App 播放音乐的情况。
音频焦点的正确处理方式是:呼叫开始时请求音频焦点,通话结束时放弃音频焦点。如果不做这一步,用户通话时其他应用的声音会同时播放,体验非常混乱。
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange -> when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> { // 其他应用占用了音频焦点,通常需要暂停通话音频 // 实际项目中这里需要和 WebRTC 的音频轨道联动 } AudioManager.AUDIOFOCUS_GAIN -> { // 重新获得焦点,恢复音量 } } } private fun requestAudioFocus() { val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager val result = audioManager.requestAudioFocus( audioFocusListener, AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT ) if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { // 焦点获取失败,提示用户关闭其他音频应用 } }Android 端做后台通话保活时还有一个细节:必须在通话期间启动前台服务,否则系统会在 App 退到后台数秒后杀掉进程。前台服务需要配套通知栏常驻提示,这个通知的文案要提前想好,涉及通话中、来电振铃、通话结束三种状态。
4.3 自建 TURN 还是接第三方 RTC SDK:成本与可控性的权衡
很多拿到这套源码的人会纠结一个问题:媒体面是自建 WebRTC 服务器还是集成第三方 RTC SDK。
自建方案的优势是数据链路完全自主,不依赖外部厂商。但 WebRTC 的点对点连接在很多网络环境下打洞失败率高,尤其是对称型 NAT 和运营商级 NAT 后面,这时必须依赖 TURN 服务器做中继转发。一台自建 TURN 服务器的带宽成本不低,按 1 对 1 语音每小时约 30MB 流量、视频约 300MB 流量估算,1000 个日活用户每个通话 10 分钟,每月的带宽开销很容易超出小团队的预算。
第三方 RTC SDK 的优劣势同样明显:接入简单、弱网优化做得好、不需要自己运维媒体集群,但通话数据经过第三方,且按分钟计费,长期来看是一笔持续支出。常见的做法是:MVP 阶段或预算有限时用第三方 SDK,用户规模上来之后再逐步迁移到自建方案,或采用"信令自建 + 媒体走第三方"的混合方案。
| 对比维度 | 自建 WebRTC + TURN | 第三方 RTC SDK |
|---|---|---|
| 接入成本 | 高,需要处理 ICE/STUN/TURN 全套流程 | 低,几行代码即可拉起通话 |
| 音视频质量 | 依赖自身运维水平,弱网优化需要大量调参 | 厂商成熟,弱网表现稳定 |
| 带宽成本 | 按服务器带宽付费,规模上来后可控 | 按分钟付费,单价较贵 |
| 数据隐私 | 数据完全自有 | 媒体流经第三方服务器 |
| 适用阶段 | 用户规模稳定后 | 产品验证阶段或小团队起步 |
无论选哪条路,信令层都必须保留自己的 PHP + WebSocket 实现,因为呼叫逻辑、状态管理和匹配业务都在这一层,第三方 SDK 只负责媒体传输。架构上保持信令与媒体解耦,未来替换媒体方案时不需要动业务代码。
5. 上线前必须做的三轮验证:信令压测、状态一致性、日志关联
5.1 先打爆 WebSocket,再谈业务功能
很多源码包里的 Swoole 信令服务没有做过压力测试,真实并发场景下会出现消息堆积、fd 泄漏、内存增长。上线前先做一轮信令压测,用最简单的工具模拟大量客户端同时登录、互发邀请和接听。
压测重点看三个指标:WebSocket 连接数上限、消息延迟分布、内存曲线是否平稳。Swoole 自带taskworker和协程能力,但如果代码里写了同步阻塞操作,压测时耗时就会线性上升。
压测时发现消息延迟从 10ms 涨到 500ms,通常不是 WebSocket 本身的问题,而是某个处理函数里执行了 MySQL 查询或 Redis 慢命令。信令通道里的逻辑要精简到只做路由和状态校验,任何可能需要几十毫秒的操作都改为异步投递到队列。
5.2 状态一致性检查清单
双端 + 后台的状态同步是最容易出 bug 的区域。以下是一份按通话阶段划分的检查清单,逐项跑一遍比上线上再排查省力得多:
| 检查环节 | 验证内容 | 预期表现 |
|---|---|---|
| 呼叫发起 | A 发起呼叫后 A 的状态 | 变为 calling,不能再被匹配 |
| 呼叫发起 | B 在线但状态为 busy | B 收到 busy 提示,A 收到呼叫失败 |
| 呼叫超时 | B 不接听超过 30 秒 | 双方收到 timeout 消息,状态回 idle |
| 通话中挂断 | A 主动挂断 | B 收到 hangup,状态回 idle |
| 通话中掉线 | A 的 WebSocket 断开 | B 收到对端离线消息,通话自动结束 |
| 余额不足 | A 余额少于 1 分钟费用 | 通话强制中断,双方状态回 idle |
| 并发匹配 | 两个用户同时匹配到同一目标 | 只有一方成功,另一方返回匹配失败 |
掉线场景是最容易忽略的。移动网络切换 Wi-Fi 与 4G 会导致 TCP 连接短暂断开,WebSocket 会触发 close 事件,服务端必须在 close 回调里做状态重置,并把对端拉回空闲态。这个逻辑如果缺失,就会出现一方已经退出但另一方界面还停留在通话中的卡死现象。
5.3 一个贯穿全链路的排查技巧:把 session_id 打进每一条日志
排查双端 + 后台问题时,最痛苦的是无法把一次通话的客户端日志与服务器日志对应上。我的习惯做法是:每次呼叫生成一个session_id,从 invite 消息开始,后续所有信令、状态变更、计费记录都携带这个 ID,并在客户端以同样的 ID 输出日志。
这样一来,线上查问题时只需要让用户报个时间点,从 Nginx 访问日志或信令服务器日志里捞到session_id,就能把一次通话的全过程串起来:谁发起了呼叫、信令走了多久、媒体有没有建立、为什么挂断、扣费是否正确。没有这个 ID 串联,排障就是大海捞针,尤其在跨端问题并发出现的时候。
这个习惯应该在编码阶段就落实,不要在出问题之后才补。日志里把session_id放在第一列,配合user_id和时间戳,用标准格式输出到统一的日志文件,后续接 ELK 或 Loki 做日志检索时也会顺畅得多。
本文还有配套的精品资源,点击获取