ARTICLE DETAIL

资讯详情

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

双端原生+PHP后台语音视频交友系统架构解析

双端原生+PHP后台语音视频交友系统架构解析 简介这是一套一对一语音视频直播社交应用的双端原生工程并配套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 基于 TCPTCP 的拥塞控制会导致延迟线性上升通话质量断崖式下降。正确做法是信令走 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}:statestring当前状态值calling / talking / ended无user_state:{user_id}string用户当前空闲状态匹配和呼叫都依赖这个字段无user_locationgeo全部在线用户的位置配合在线状态清理状态机切换必须集中在服务端不能在客户端自行更改。原因很直白客户端的状态不可信用户可以改包欺骗服务器。服务端每收到一次信令先检查当前状态是否合法再执行迁移最后广播通知对端。一个典型的错误是把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 在线但状态为 busyB 收到 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 做日志检索时也会顺畅得多。本文还有配套的精品资源点击获取
返回列表