资讯动态

iOS WebRTC示例解析:从信令到渲染跑通音视频通话链路

发布时间:2026/10/18 3:55:16 来源:尧图企业网站定制
简介面向iOS开发者的WebRTC集成示例工程基于Objective-C实现同类应用间的实时音视频通话适合希望掌握WebRTC核心流程与原生设备适配的移动端工程师入门参考。压缩包共24个文件包含7个m源码、5个h头文件、3个plist配置以及pbxproj工程文件、storyboard/xib界面描述、xcworkspacedata工作区信息等整体仅41KB目录紧凑便于逐文件对照源码结构。已有293人学习使用。示例围绕getUserMedia、RTCPeerConnection、RTCSessionDescription与RTCIceCandidate等关键对象展开完整演示了音视频权限申请、SDP与ICE候选信息交换、本地/远端视频绑定和通话控制等环节同时涉及AVFoundation采集、信令服务器交互、错误处理与单元测试配置并可借此理解依赖库集成方式。项目将UI层、媒体处理、网络信令与WebRTC封装分离适合作为视频会议、在线教育、远程医疗等实时通信场景的起步模板。1. 项目定位一个把WebRTC通话链路跑通的iOS示例如果你接到“在App里加一个视频通话”的需求直接上手WebRTC会被一堆概念先绕晕SDP、ICE、STUN/TURN、信令……这时候手头有一个能跑的iOS Demo就特别救命。这个WebRTCDemo-iOS就是干这个的基于Objective-C实现把本地摄像头采集、音视频轨道、PeerConnection建立、SDP与ICE交换、远端画面渲染这一整条最小链路都跑通了。它不追求功能花哨而是给你一条能照着执行的代码路径跑起来能看到自己和对方的画面再改成自己的业务就够了。适合两类人一是想快速验证WebRTC在iOS真机上效果的移动端开发二是手里有老OC工程、需要把音视频通话塞进现有架构的二次开发者。因为模拟器没法调相机所以这篇会按真机联调链路来拆从源码结构到避坑照着走一遍就能跑出画面。这个Demo里最值得说的其实是它的封装方式信令、PeerConnection、UI三层拆得比较干净。拆完你会发现要替换掉信令服务器或者加一个多人通话并不是推倒重来而是替换中间层的几个模块。下面开始。2. 技术选型与整体架构从WebRTC框架到信令链路2.1 为什么是Objective-C加WebRTC框架WebRTCDemo-iOS选择Objective-C是很现实的工程决策。WebRTC的iOS库本身是C内核加OC接口用OC调用最直接不需要做Swift和C的桥接更重要的是现在还有大量老App是纯OC写的直接嵌入这个Demo样式不会引入混编成本。如果你在Swift项目里也可以把这套代码作为参考把类转换过去但架构上不用改。媒体通道用的WebRTC框架信令用的WebSocket通过CocoaPods管理。注意信令服务器不参与媒体传输它只负责在客户端之间转发消息所以音频和视频是端到端直连的。这个设计让Demo在弱网时会有一定的丢包和卡顿但也能看出WebRTC的码率自适应效果生产环境可以再接一个TURN服务器解决跨网络打洞失败的问题。Podfile里面只依赖了两个库看起来非常轻platform :ios, 12.0 target WebRTCDemo do use_frameworks! pod WebRTC pod SocketRocket enduse_frameworks!是CocoaPods的默认配置会把Pods里的依赖编译成动态库。WebRTC框架比较大包含编码器、音视频处理和网络传输层SocketRocket则是WebSocket客户端负责和信令服务器通信。如果你想把信令换成自己公司的IM通道只需要把SocketRocket那一行去掉替换成你的长连接SDK。2.2 工程目录结构源码包里有什么拿到源码后你最先看到的是这样一个目录布局WebRTCDemo-iOS/ ├── WebRTCDemo/ │ ├── AppDelegate.h/m │ ├── ViewControllers/ │ │ ├── HomeViewController.m // 输入房间号发起通话 │ │ └── CallViewController.m // 通话主界面渲染本地/远端画面 │ ├── RTC/ │ │ ├── RTCClient.h/m // 封装PeerConnection与信令收发 │ │ ├── RTCSignaling.h/m // 信令消息模型与WebSocket管理 │ │ └── RTCExtensions.h/m // 音视频session配置、设备切换 │ ├── Models/ │ │ └── SignalMessage.h/m // 消息类型定义join/offer/answer/ice │ └── SupportingFiles/ │ └── Info.plist ├── WebRTCDemo.xcworkspace ├── Podfile └── signaling-server/ └── ws-server.js // 本地信令服务器Node.js这个结构把UI、信令、RTC逻辑分开了。RTCClient是核心它握有RTCPeerConnectionFactory和RTCPeerConnection通过代理模式把状态回调给CallViewController。你可以把RTCClient替换成自己的业务层UI不用大动。目录里的ws-server.js是一个简单的WebSocket服务用Node.js写负责同一个房间内消息的转发。Demo没有把信令服务器写进iOS工程而是在仓库里单独放了一个server目录这符合前后端分离的习惯你也可以换成一个现成的IM推送服务。2.3 信令与媒体协商流程一次通话的时序可以简化成三步加入房间、交换媒体描述、交换网络候选。下面是一份信令消息的JSON示例{ type: offer, roomId: 10086, connectionId: client-001, sdp: v0 ... }参数说明里type是消息类型roomId用于在服务器上区分房间connectionId标识来源客户端sdp是媒体描述。Demo中的信令服务器只做转发把一个客户端的offer/answer/ice原样广播到同房间的另一个客户端不处理业务逻辑。所以你可以把ws-server.js换成任何能双向转发JSON的通道比如自己的IM长连接。ICE候选的消息同样是一段JSON{ type: ice, roomId: 10086, connectionId: client-001, payload: { candidate: candidate:1 1 UDP 2122252543 192.168.1.10 54321 typ host } }payload里的candidate是标准的ICE候选行包含传输协议、IP和端口。收到这种消息后调用PeerConnection的addIceCandidate方法代码长这样- (void)handleIce:(SignalMessage *)message { NSDictionary *payload message.payload; NSString *candidateString payload[candidate]; NSString *mid payload[sdpMid]; NSNumber *line payload[sdpMLineIndex]; RTCIceCandidate *candidate [[RTCIceCandidate alloc] initWithSDP:candidateString sdpMLineIndex:line.integerValue sdpMid:mid]; [self.peerConnection addIceCandidate:candidate completionHandler:^(NSError *error) { if (error) NSLog(add ICE candidate error: %, error); }]; }注意addIceCandidate必须等远端描述set完之后再调用否则会报错。WebRTC内部会用sdpMid和sdpMLineIndex把候选关联到具体的媒体轨道所以这两项不能填错。3. 核心模块代码拆解从采集到渲染的完整链路3.1 本地采集与音视频轨道创建先看音频与视频的初始化。这一段决定了能不能让对方听到你、看到你- (void)setupLocalMedia { RTCAudioSessionConfiguration *audioConfig [[RTCAudioSessionConfiguration alloc] init]; audioConfig.category AVAudioSessionCategoryPlayAndRecord; audioConfig.categoryOptions AVAudioSessionCategoryOptionDefaultToSpeaker; audioConfig.mode AVAudioSessionModeVideoChat; [RTCAudioSessionConfiguration setSharedConfiguration:audioConfig]; RTCAudioSource *audioSource [self.factory audioSourceWithConstraints:nil]; self.localAudioTrack [self.factory audioTrackWithSource:audioSource trackId:local_audio_track]; }逻辑说明这里先把音频session设置为播放和录音并默认走扬声器避免对方听不到自己说话。音频源的约束为空表示使用默认的Opus编码参数。trackId虽然是自定义的但两端是通过它来识别媒体轨道的需要保持唯一且稳定。视频部分要稍微多一点- (void)setupLocalVideo { RTCCameraVideoCapturer *capturer [[RTCCameraVideoCapturer alloc] init]; self.localVideoCapturer capturer; AVCaptureDevice *device [RTCCameraVideoCapturer captureDevices].firstObject; // 默认前置 AVCaptureDeviceFormat *format [RTCCameraVideoCapturer supportedFormatsForDevice:device].lastObject; [capturer startCaptureWithDevice:device format:format fps:30]; RTCVideoSource *videoSource [self.factory videoSource]; // 注意需要把capturer桥接到videoSource常见做法是给videoSource加一个适配器 self.localVideoTrack [self.factory videoTrackWithSource:videoSource trackId:local_video_track]; }逻辑说明RTCCameraVideoCapturer负责从摄像头采集帧然后交给RTCVideoSource再生成视频轨道。这里选择第一个设备前置摄像头并用最后一个格式一般是最高的分辨率。fps设为30实际项目要考虑性能和电量的平衡尤其老机型可以降到24。参数说明captureDevices返回的是AVCaptureDevice数组顺序一般是前置、后置supportedFormatsForDevice返回该设备支持的采集格式数组越靠后分辨率越高但码率也更高。Demo直接用了lastObject二次开发时建议根据需要的宽高比动态选择。3.2 信令消息模型与事件回调信令消息用不可变模型封装比较好避免在传递过程中被意外修改typedef NS_ENUM(NSInteger, SignalType) { SignalTypeJoin 0, SignalTypeOffer, SignalTypeAnswer, SignalTypeIce, SignalTypeLeave }; interface SignalMessage : NSObject property (nonatomic, assign) SignalType type; property (nonatomic, copy) NSString *roomId; property (nonatomic, copy) NSString *senderId; property (nonatomic, copy) NSDictionary *payload; end网上很多Demo用裸字典传消息一旦key拼错就只能靠猜。用枚举加payload字典的结构既保留了解析的灵活性又避免了类型灾难。信号接收方拿到SignalMessage后按type走不同分支比如SignalTypeOffer就触发setRemoteDescription逻辑SignalTypeIce就调用addIceCandidate。为了让UI层不感知底层协议RTCClient对外暴露一组代理方法protocol RTCClientDelegate NSObject - (void)rtcClient:(RTCClient *)client didReceiveRemoteTrack:(RTCVideoTrack *)track; - (void)rtcClient:(RTCClient *)client didChangeState:(RTCClientState)state; - (void)rtcClient:(RTCClient *)client didReceiveMessage:(SignalMessage *)message; end这样CallViewController只需要实现代理收到远端track时把它挂到渲染视图上收到状态变化时更新界面按钮。信令细节被锁在RTCClient内部这种委托模式在二次开发时很省事比如你要加一个“对方挂断”的消息只用在代理里加一个回调UI侧单独响应。3.3 PeerConnection建立与Offer/Answer流程PeerConnection是WebRTC的核心先把配置和服务创建出来- (void)createPeerConnection { RTCConfiguration *config [[RTCConfiguration alloc] init]; config.iceServers [ [[RTCIceServer alloc] initWithURLStrings:[stun:stun.example.com:19302]] ]; config.sdpSemantics RTCSdpSemanticsUnifiedPlan; RTCMediaConstraints *constraints [[RTCMediaConstraints alloc] initWithMandatoryConstraints:nil optionalConstraints:{DtlsSrtpKeyAgreement: true}]; self.peerConnection [self.factory peerConnectionWithConfiguration:config constraints:constraints delegate:self]; if (self.localAudioTrack) { [self.peerConnection addTrack:self.localAudioTrack streamIds:[local_stream]]; } if (self.localVideoTrack) { [self.peerConnection addTrack:self.localVideoTrack streamIds:[local_stream]]; } }逻辑说明STUN地址用了一个公网占位地址作用是协助两端获取公网候选生产环境建议替换成自己搭的STUN或者TURN。sdpSemantics用UnifiedPlan这是当前主流的媒体协商方案iOS端WebRTC的默认值也是它。DtlsSrtpKeyAgreement设为true是为了兼容老版Chrome但现在可以去掉属于历史兼容项。处理远程Offer时核心步骤是设置远端描述然后生成Answer- (void)handleOffer:(SignalMessage *)message { RTCSessionDescription *remoteSdp [[RTCSessionDescription alloc] initWithType:RTCSdpTypeOffer sdp:message.payload[sdp]]; __weak typeof(self) weakSelf self; [self.peerConnection setRemoteDescription:remoteSdp completionHandler:^(NSError *error) { if (error) { NSLog(set remote desc error: %, error); return; } RTCMediaConstraints *answerConstraints [[RTCMediaConstraints alloc] initWithMandatoryConstraints:nil optionalConstraints:{OfferToReceiveAudio: true, OfferToReceiveVideo: true}]; [weakSelf.peerConnection answerForConstraints:answerConstraints completionHandler:^(RTCSessionDescription *answerSdp, NSError *ansError) { [weakSelf.peerConnection setLocalDescription:answerSdp completionHandler:^(NSError *setError) { [weakSelf sendSignal:SignalTypeAnswer payload:{sdp: answerSdp.sdp}]; }]; }]; }]; }逻辑说明直接影响通话成败的是顺序先setRemoteDescription再createAnswer最后setLocalDescription。一旦顺序错乱WebRTC库会直接报错。这里在setLocalDescription完成后才把answer发出去是为了确保本地状态已经切换对端收到answer时能对应上之前的offer。如果只createAnswer但没setLocalDescription对端拿到answer也无法串起来。还有一个细节如果你不想把编码器、传输参数写死在Demo里可以在createOffer之前调整一下SDP比如替换fps行或增加带宽上限。后面二次开发部分我会再展开。3.4 远端视频渲染与会话层当对端媒体协商完成后代理回调会拿到远端视频轨道- (void)rtcClient:(RTCClient *)client didReceiveRemoteTrack:(RTCVideoTrack *)remoteTrack { RTCMTLVideoView *remoteView [[RTCMTLVideoView alloc] initWithFrame:self.view.bounds]; [remoteTrack addRenderer:remoteView]; [self.view addSubview:remoteView]; }RTCMTLVideoView是使用Metal的渲染器性能比旧版RTCEAGLVideoView更好也支持自动布局。注意在添加渲染器之前先确保视图尺寸是最终布局否则画面会被拉伸。本地视频和远端视频的渲染策略是分开的本地视频一般放在小窗用capturer自身带渲染预览或者也通过RTCVideoTrack来渲染。同时会话层管理着音频session的激活和释放。典型做法是在通话页面出现时激活session结束通话时release- (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; RTCAudioSession *session [RTCAudioSession sharedInstance]; [session lockForConfiguration]; [session setCategory:AVAudioSessionCategoryPlayAndRecord withOptions:AVAudioSessionCategoryOptionDefaultToSpeaker error:nil]; [session unlockForConfiguration]; } - (void)closeCall { [self.peerConnection close]; self.peerConnection nil; RTCAudioSession *session [RTCAudioSession sharedInstance]; [session lockForConfiguration]; [session setActive:NO error:nil]; [session unlockForConfiguration]; }这里的坑是前后台切换会导致session被系统打断所以Demo在viewWillAppear统一做恢复这是一个比较稳妥的习惯。4. 真机运行与双端联调从pod install到看到画面4.1 环境准备跑这个Demo你需要准备一台iPhone只跑单端是看不到效果的一台Mac以及一个能运行Node.js的环境。CocoaPods是依赖管理的关键如果没有安装先执行sudo gem install cocoapods然后进入项目根目录pod install。注意一定要打开生成的.xcworkspace而不是原来的.xcodeproj否则Pods里的WebRTC库找不到。打开workspace后修改Bundle Identifier并选择自己的开发者账号签名。这一步不做的话真机装不上。WebRTC.framework比较大编译时间会偏长首次启动会有几分钟的等待不是死机。如果你的Mac内存小于16G建议编译时关掉Xcode的Indexing功能否则风扇会起飞。4.2 启动信令服务器信令服务器在signaling-server目录下打开一个终端执行cd signaling-server npm install # 安装ws依赖 node ws-server.js默认监听3000端口。如果你改了端口记得同步改iOS端RTCSignaling.m里的websocketURL字符串。服务器日志会打印每个连接进来的客户端和接收到的消息类型。联调时最好保持这个终端在前台方便观察有没有因为防火墙导致的连接失败。注意iOS App如果连接到本机服务器要确保手机和Mac在同一个局域网内。直接用localhost真机无法访问需要改成Mac的局域网IP。这是新手最常见的翻车点。你可以用ifconfig查一下Mac当前IP然后替换掉常量static NSString * const kWebSocketURL ws://192.168.1.10:3000;改完重新编译服务器端应该会打出一条新的连接日志。4.3 双机联调与验证在两台iPhone上装同一个App打开HomeViewController输入同一个房间号先到的会显示“等待对方加入”第二台加入后自动触发呼叫。这时候你能看到本地画面远端画面在对方加入和ICE候选交换完成后出现。验证链路是否正常有几个观察点连接状态从RTCPeerConnectionStateNew一路变成Connected说明候选交换成功。控制台输出“set remote desc”或“addIceCandidate”时没有error。远端画面出现后用手遮住摄像头观察采集端码率和帧率变化确认不是纯本地回显。如果出现“本地有画面但对方看到黑屏”的情况多半是摄像头权限问题或采集格式不对。这类问题放在下一章的避坑里一起说因为都是真机调试里最常踩的坑。5. 避坑记录真机调试中遇到的常见问题5.1 本地采集不出画面现象打开通话界面后本地预览一直是黑屏但App没崩溃也没有权限弹窗。原因多半是摄像头权限没有在Info.plist里声明。WebRTC库在启动采集时会检查NSCameraUsageDescription如果缺失就直接fail而且是在后台静默失败界面上看不出任何报错。解决在Info.plist里加上摄像头和麦克风权限描述keyNSCameraUsageDescription/key string用于视频通话时的画面采集/string keyNSMicrophoneUsageDescription/key string用于视频通话时的音频输入/string这个描述一定要写清楚否则审核也可能被拒。加了权限描述后还黑屏再检查是不是模拟器运行模拟器没有摄像头RTCCameraVideoCapturer返回空设备建议直接放弃在模拟器上调试视频。5.2 远端画面出来了但听不到声音现象视频很清楚但说话没声音扬声器也没反应。原因音频session没有配置成播放和录音。默认AVAudioSession是soloAmbient只会放音不录音或者录音但不用扬声器。很多Demo在App启动后才设置audio session这时候WebRTC内部已经拿不到正确的配置了。解决在setupLocalMedia里按照3.1的代码把sharedConfiguration设置好并确保在创建RTCAudioTrack之前完成。如果已经在通话中再设置一次也可以但最好在首个音频track创建前就固定避免出现“第一次没声音挂断重来就好”的玄学问题。5.3 切换前后摄像头后画面卡死现象点击切换摄像头本地画面瞬间冻住远端画面也卡住没有崩溃。原因RTCCameraVideoCapturer的startCaptureWithDevice是异步的切换过程中旧采集还在工作两个采集源同时访问冲突容易造成画面冻结。另外切换时没有重建视频轨道导致WebRTC层一直停留在旧设备状态。解决切换前停掉capturer释放session配置然后重新用新设备startCapture- (void)switchCamera { if (!self.localVideoCapturer) return; AVCaptureDevice *currentDevice self.localVideoCapturer.device; AVCaptureDevice *nextDevice nil; NSArrayAVCaptureDevice * *devices [RTCCameraVideoCapturer captureDevices]; for (AVCaptureDevice *device in devices) { if (device.position ! currentDevice.position) { nextDevice device; break; } } AVCaptureDeviceFormat *format [RTCCameraVideoCapturer supportedFormatsForDevice:nextDevice].lastObject; [self.localVideoCapturer startCaptureWithDevice:nextDevice format:format fps:30]; }这里依赖RTCCameraVideoCapturer内部对切换的兼容处理不需要重新创建VideoTrack。如果切换后黑屏检查设备是否有权限以及format是否为空。5.4 一直报ICE失败连接无法建立现象双方都在同一个房间但peerConnection始终在connecting几秒后变成failed虽然没有超时但无法通话。原因STUN请求被网络屏蔽或者本机没有公网IPv4地址比如某些Wi-Fi环境下。最直接的排查方法是看控制台有没有收到ICE candidate的STUN-success响应。如果一直是ICE/STUN failed说明STUN服务器不可达。解决换一个STUN地址或者在config里加TURN服务器。如果只是在局域网调试可以跳过STUN直接连但WebRTC不支持无ICE直接通信所以最稳妥是搭一个内网TURN这里的坑是TURN的认证需要同步到客户端。5.5 通话结束后再次拨号无法接通现象第一次通话正常挂断后马上重拨对方能收到消息但没有画面控制台出现-1错误。原因挂断时只关闭了peerConnection但RTCClient的单例还持有旧的factory和audio session配置第二次复用时会因为状态残留导致媒体协商失败。这是很多Demo的通病清理不彻底。解决在closeCall里把factory之外的所有状态都重置包括localTrack、remoteTrack、capturer并重新执行setupLocalMedia。也可以参考下面的清理顺序- (void)reset { [self.peerConnection close]; self.peerConnection nil; [self.localVideoCapturer stopCapture]; self.localVideoCapturer nil; self.localAudioTrack nil; self.localVideoTrack nil; [RTCAudioSession sharedInstance] setActive:NO error:nil]; }现在你至少能稳定跑通一两次通话了。接下来我们把Demo往自己的业务上靠。6. 二次开发技巧把Demo改造成自己的业务组件6.1 替换信令通道Demo用的WebSocket信令只适合开发验证。真实业务里你的信令通道可能已经选好了比如IM的长连接或者自研推送。替换的思路很简单只要让RTCClient不再直接依赖RTCSignaling而是依赖一个抽象协议就可以把信令通道整个换掉。定义一个RTCSignalingProtocol提供sendMessage和connect方法SocketRocket实现只是其中一个实现。这样换通道时不影响PeerConnection逻辑。我个人习惯在开工前先画一张信令时序图把join/offer/answer/ice/leave一条条理清楚。这个Demo的ws-server.js里已经包含了这些类型直接照搬到自己项目里就行比凭空设计快很多。6.2 增加多人通话或数据通道如果要从一对一改成多人最简单的做法不是连多个PeerConnection而是用一个多人房间管理器为每个远端连接建立独立的RTCPeerConnection并维护一张peerId到connection的映射表。UI渲染也要做相应改动。另一个实用技巧是给PeerConnection创建一个RTCDataChannel用来传输业务消息比如文本消息、屏幕控制指令。数据通道不经过媒体流延迟低很适合聊天室类的功能。6.3 代码硬化与验证方式把Demo往工程里搬的时候建议做这几件事把RTCClient改成可复用模块增加错误回调并分类把音频session的配置从view级别挪到application级别避免页面切换时的状态抖动再加一个环境开关区分STUN/TURN的调试和正式配置。我自己习惯在每次改动后跑一遍端到端验证双机进入同一房间检查拉流首帧时间、音频延迟以及挂断重连是否正常。别只在模拟器上敷衍一遍真机不同网络环境下的表现差异很大。遇到问题先看控制台的ICE状态迁移而不是盲改UI。从那以后我每次拿到一个WebRTC项目都强制先看信令时序和状态机再动手改代码翻车率低了很多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑