简介:面向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": "v=0 ..." }参数说明里,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里加上摄像头和麦克风权限描述:
<key>NSCameraUsageDescription</key> <string>用于视频通话时的画面采集</string> <key>NSMicrophoneUsageDescription</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; NSArray<AVCaptureDevice *> *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项目,都强制先看信令时序和状态机,再动手改代码,翻车率低了很多。希望帮到你。
本文还有配套的精品资源,点击获取