简介:这是一份面向iOS开发者的WebRTC集成示例工程,使用Objective-C编写,目标是在iPhone或iPad上实现端到端音视频通话与P2P数据共享。工程规模虽小但结构完整,涵盖Xcode工程配置、视图控制器、本地媒体捕获与渲染、RTCPeerConnection会话管理以及基于WebSocket的自定义信令协商等核心模块,适合想快速掌握WebRTC落地方式的初中级iOS开发者。压缩包共24个文件,以.m/.h源码文件为主,并包含plist属性配置、storyboard/xib界面布局、pbxproj工程配置与xcworkspace工作区等辅助内容,整体仅41KB,轻量易读且目录脉络清晰。目前已有293人学习下载。通过研读示例,可以清晰理解getUserMedia权限申请、本地与远端视频流绑定、SDP描述交换、ICE候选收集等关键流程,同时学习AVFoundation与WebRTC库的配合用法,为开发在线教育、远程医疗、视频会议等实时通信场景打下基础。
1. 一篇用 Objective-C 写的 iOS 音视频 Demo:值得花两小时跑通吗
第一次看到 WebRTCDemo-iOS 这个仓库名,我以为是又一份用 HTML 在浏览器里演示 WebRTC 的代码。点进去才发现,整份源码是用 Objective-C 写的原生 iOS 工程,它做了一件我原本以为要折腾很久的事:让两台 iPhone 通过 P2P 通道互相看到实时画面。这个 Demo 解决的是原生端接入 WebRTC 时的完整链路问题——采集、信令、ICE 打洞、媒体协商、渲染,每一步都有可运行的代码。适合想自己写视频客服、远程协助或一对一音视频功能原型的 iOS 开发者,也适合刚接触 WebRTC 但不想只看理论的人:你跑起来之后,信令这块黑匣子就基本透明了。
2. 项目骨架拆解:目录结构与信令设计
先说技术选型。这份源码用 Objective-C 写,依赖管理走 CocoaPods,媒体引擎是 WebRTC 官方预编译框架,信令部分用的是 WebSocket JSON 消息,没有引入额外的 IM SDK。整体思路很朴素:WebRTC 只负责媒体传输,信令自己定协议,后端可以随便接。我拆过的工程里,这种组织方式最适合拿来当二次开发的基础,因为它没有把逻辑绑死在某个厂商的云服务上。
2.1 目录结构与模块职责
拿到源码第一步是摸清谁干什么。这份源码的目录结构大致是下面这个样子,我把每个文件的职责补在了注释里,方便按图索骥:
WebRTCDemo-iOS/ ├── AppDelegate.m # 应用生命周期,初始化 SSL 与日志 ├── ViewController.m # 呼叫面板,输入对端标识、发起呼叫 ├── RTCManager.h / RTCManager.m # 封装 RTCPeerConnection 生命周期、媒体约束、本地流 ├── SignalClient.h / SignalClient.m # WebSocket 信令客户端,负责消息收发与重连 ├── VideoCallViewController.m # 通话页,创建本地渲染视图并把远端轨道绑定上去 ├── WebRTCConfig.h / WebRTCConfig.m # ICE 服务器地址、媒体约束等硬编码配置 └── Base.lproj/Main.storyboard # 页面跳转与控件布局模块职责划分得很清楚:RTCManager 是整个项目的核心,SignalClient 只是消息搬运工,二者通过 delegate 回调通信。为什么这么拆——把媒体栈和信令栈分开,后面换信令协议只动 SignalClient,换音视频处理只动 RTCManager,二次开发时这个边界非常重要。
2.2 信令流程:从呼叫到挂断的状态流转
WebRTC 直连只是媒体通道,双方要交换的 SDP 和 ICE candidate 必须先经过信令通道。该 Demo 的信令设计是 WebSocket + JSON 简单消息,消息类型可以整理成一张表:
| 消息类型 | 发送方 | 载荷 | 作用 |
|---|---|---|---|
| join | 发起方/加入方 | roomId, uid | 进入同一房间 |
| offer | 发起方 | sdp | 发起媒体协商 |
| answer | 应答方 | sdp | 回复协商结果 |
| candidate | 双方 | sdpMid, sdpMLineIndex, candidate | 交换 ICE 候选 |
| leave | 任一方 | roomId, uid | 通知对方结束 |
完整的状态流转是 idle -> calling -> ringing -> connected -> closed。这里建议你把本地业务状态和 RTCSignalingState 分开理解:前者是 UI 状态,比如“正在呼叫”“对方振铃”;后者是 peerConnection 内部状态。排查问题时先把这两个状态分别画出来,能少走很多弯路。我见过不少开发者把两者混在一起,结果 UI 已经显示通话中,底层其实还在 connecting。
2.3 配置参数:ICE 服务器、媒体约束与硬编码项
接下来是配置。所有 ICE 和媒体相关的参数都集中在 WebRTCConfig.m 里,这份源码的做法是在里面写一个默认配置类:
#import "WebRTCConfig.h" @implementation WebRTCConfig + (RTCConfiguration *)defaultConfiguration { RTCConfiguration *config = [[RTCConfiguration alloc] init]; // 这里放的 STUN 是占位示例,真实调试请替换成你自己的服务地址 RTCIceServer *stunServer = [[RTCIceServer alloc] initWithURLStrings:@[@"stun:stun.example.com:19302"]]; config.iceServers = @[stunServer]; config.sdpSemantics = RTCSdpSemanticsUnifiedPlan; config.iceCandidatePoolSize = 5; config.continualGatheringPolicy = RTCContinualGatheringPolicyGatherContinually; return config; } + (RTCMediaConstraints *)defaultMediaConstraints { // 新版 WebRTC 不再需要 DtlsSrtpKeyAgreement,传 nil 即可 return [[RTCMediaConstraints alloc] initWithMandatoryConstraints:nil optionalConstraints:nil]; } @end参数说明:iceServers 列表里可以放多个 STUN/TURN,生产环境通常至少一个 STUN 加一个 TURN 兜底;sdpSemantics 用 UnifiedPlan,旧工程的 Plan B 已经废弃,不要照抄旧帖子;iceCandidatePoolSize 影响候选收集速度,值太小在弱网下表现差;continualGatheringPolicy 设为持续收集,网络变化时更容易拿到可用 candidate。如果只是跑通 Demo,STUN 换成任意公网 STUN 服务就能用,但 TURN 必须自己搭或找云厂商,只有 STUN 时对称型 NAT 后面大概率打洞失败。
如果你不想走 CocoaPods 集成,也可以直接把 WebRTC.framework 拖进工程,但这份 Demo 用 pod 管理,还是建议保持原样,踩坑更少。
3. 核心链路拆解:从采集到远端画面的完整流程
这一章是整个项目最值得读的部分。WebRTC 在 iOS 上的链路可以拆成四段:视频上行、音频上行、协商连接、渲染下行。每段都有独立的系统组件和回调,搞懂这四段,后面改代码心里就有地图了。
3.1 视频上行链路:摄像头采集与轨道发布
视频采集由 RTCCameraVideoCapturer 负责,它是对系统 AVCaptureSession 的封装,你只需要指定设备、分辨率和帧率。常见做法是在 RTCManager 初始化时创建 videoSource,再把 capturer 的 delegate 指向它:
// 1. 创建 videoSource,capturer 把帧交给它 RTCVideoSource *videoSource = [self.factory videoSource]; RTCCameraVideoCapturer *capturer = [[RTCCameraVideoCapturer alloc] initWithDelegate:videoSource]; // 2. 选择前置摄像头并启动采集 AVCaptureDevice *camera = [RTCCameraVideoCapturer captureDevices].firstObject; AVCaptureDeviceFormat *format = [RTCCameraVideoCapturer supportedFormatsForDevice:camera].firstObject; [capturer startCaptureWithDevice:camera format:format fps:30]; // 3. 从 videoSource 生成轨道 RTCVideoTrack *localTrack = [self.factory videoTrackWithSource:videoSource trackId:@"video0"]; // 4. 把轨道发布到 peerConnection [self.peerConnection addTrack:localTrack streamIds:@[@"stream0"]];逻辑说明:前两步建立了从摄像头到 videoSource 的数据管道,第三步把 videoSource 包装成轨道,第四步才真正把轨道“发布”出去。旋转和镜像由框架内部处理,客户端不用手动改方向。如果只创建轨道不 addTrack,对端永远不会收到画面——这个顺序错一点都不行。
参数说明:fps 建议 30,网络差时可以降到 15;format 选第一个通常分辨率不高,二次开发时建议遍历 supportedFormats 挑一个 1280x720 的。capturer 和 videoSource 都需要被强引用,否则 ARC 释放后画面直接黑屏。
3.2 音频链路:AVAudioSession 与回声消除
音频链路比视频链路容易翻车,因为先要过 AVAudioSession 这一道系统关卡。iOS 的音频会话是全局的,不设置或 category 不对,WebRTC 拿不到麦克风,或者声音不从扬声器出。我一般会在呼叫开始时这样配置:
AVAudioSession *audioSession = [AVAudioSession sharedInstance]; [audioSession setCategory:AVAudioSessionCategoryPlayAndRecord withOptions:AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetooth error:nil]; [audioSession setActive:YES error:nil];逻辑说明:PlayAndRecord 是通话场景的标配 category,它同时打开播放和录音路径;DefaultToSpeaker 让声音外放,AllowBluetooth 允许蓝牙耳机接入。回声消除、降噪、自动增益是 WebRTC 音频引擎内置的,客户端不需要自己实现,但系统来电打断后要重新 setActive,否则通话会变成单向无声。
参数说明:如果做的是在线教育或会议类应用,最好把 AllowBluetooth 去掉,避免用户戴上蓝牙耳机后远端听不清楚;如果只想快速验证,DefaultToSpeaker 可以不加,但声音可能从听筒出,体验很怪。
3.3 协商与连接链路:Offer/Answer 与 ICE 候选交换
这是 WebRTC 最核心的部分,也是最容易出玄学问题的地方。整条协商链路分三个阶段:发起方创建 offer,应答方处理 offer 并回 answer,双方交换 ICE candidate。先看发起方:
- (void)makeOffer { RTCMediaConstraints *constraints = [WebRTCConfig defaultMediaConstraints]; [self.peerConnection offerForConstraints:constraints completionHandler:^(RTCSessionDescription *sdp, NSError *error) { if (error) { NSLog(@"创建 Offer 失败: %@", error); return; } self.localSdp = sdp; // 本地先设置,再发给对端 [self.peerConnection setLocalDescription:sdp completionHandler:^(NSError *err) { if (err) NSLog(@"setLocalDescription 失败: %@", err); }]; [self.signalClient sendOffer:sdp]; }]; }逻辑说明:offer 必须在 setLocalDescription 之后再发,因为本地需要先生成 ICE 候选;候选是异步产生的,后面通过 delegate 回调继续补充。setLocalDescription 的作用是让 peerConnection 知道自己处于协商状态,之后收集到的 candidate 才会指向正确的媒体通道。
应答方那边是一个对称流程,但多一步判断:
- (void)handleRemoteDescription:(RTCSessionDescription *)remoteSdp { [self.peerConnection setRemoteDescription:remoteSdp completionHandler:^(NSError *error) { if (error) { NSLog(@"setRemoteDescription 失败: %@", error); return; } // 收到 offer 才需要回 answer,answer 本身不需要再被应答 if (remoteSdp.type == RTCSdpTypeOffer) { [self makeAnswer]; } }]; } - (void)makeAnswer { RTCMediaConstraints *constraints = [WebRTCConfig defaultMediaConstraints]; [self.peerConnection answerForConstraints:constraints completionHandler:^(RTCSessionDescription *sdp, NSError *error) { if (error) return; [self.peerConnection setLocalDescription:sdp completionHandler:nil]; [self.signalClient sendAnswer:sdp]; }]; }逻辑说明:handleRemoteDescription 里的判断很关键,因为 answer 不会触发二次协商。setRemoteDescription 是媒体协商的分水岭,只有它成功之后,收到的 ICE candidate 才能被 addIceCandidate 生效。网络里 candidate 稍早于 answer 到达是常事,所以 SignalClient 收到 candidate 时如果 remoteDescription 还没设置,要先把候选缓存起来。
ICE 候选处理是双方都要实现的 delegate 方法:
// RTCPeerConnectionDelegate - (void)peerConnection:(RTCPeerConnection *)peerConnection didGenerateIceCandidate:(RTCIceCandidate *)candidate { [self.signalClient sendCandidate:candidate]; } // SignalClient 收到远端 candidate 后的处理 - (void)receivedCandidate:(RTCIceCandidate *)candidate { if (self.peerConnection.remoteDescription) { [self.peerConnection addIceCandidate:candidate]; } else { // 缓存,等 remoteDescription 设置后再补加 [self.pendingCandidates addObject:candidate]; } }物理意义说明:SDP 里有多个媒体描述,每个 m-line 有对应的 mid;candidate 必须通过 sdpMid 和 sdpMLineIndex 告诉 peerConnection 自己属于哪一条媒体通道,加错位置会导致画面或声音缺失。很多呼叫超时问题出在这段缓存逻辑上——信令通不等于媒体通,看到 connecting 卡住,先怀疑候选没对上。
3.4 渲染链路:远端轨道如何画到屏幕上
最后一段是渲染。远端流到达后,通过 didAddStream 回调把轨道绑定到视图:
- (void)didAddStream:(RTCMediaStream *)stream { // 取第一条视频轨,绑定到渲染控件 RTCVideoTrack *remoteVideoTrack = stream.videoTracks.firstObject; RTCMTLVideoView *remoteView = self.remoteVideoView; [remoteVideoTrack addRenderer:remoteView]; }逻辑说明:RTCMTLVideoView 实现了 RTCVideoRenderer 协议,addRenderer 之后 WebRTC 会把解码后的帧直接交给 Metal 渲染。必须在主线程设置视图,否则会出现时序问题。如果黑屏,先检查这个回调到底有没有触发——回调没走说明远端流根本没到,问题在协商链路不在渲染。
参数说明:一个 track 可以同时 addRenderer 到多个视图,比如本地预览加画中画;挂断时记得 removeRenderer,否则视图会一直保留最后一帧。这个习惯建议从第一天就养成,内存泄漏是慢慢积累的。
4. 运行与常见问题排查:从拉取到两台真机看到画面
4.1 环境准备与五步运行流程
跑这个 Demo 不需要自己编译 WebRTC,依赖拉下来直接就能跑。流程很短:
# 1. 安装依赖 cd WebRTCDemo-iOS pod install # 2. 打开工作区 open WebRTCDemo.xcworkspacepod install 完成后一定要打开 .xcworkspace 而不是 .xcodeproj,否则 WebRTC 框架不会被链接,编译直接报找不到头文件。在 Xcode 里还需要做两件事:把 Bundle Identifier 改成你自己的,以及选择真机——模拟器没有摄像头,跑不出 WebRTC 画面。两台真机的 Bundle ID 可以一样,不影响联调,但签名证书要能覆盖两台设备。
信令地址在 WebRTCConfig.h 里改,两端填同一个房间号即可。建议两台设备连同一 WiFi,先排除跨网段防火墙干扰;等画面通了再换 4G/5G 测 STUN 打洞,一步一步来,不要一开始就上最复杂的环境。
4.2 编译链接:framework 架构冲突
现象:构建报错 “Building for iOS Simulator, but the linked framework was built for iOS”,或者一堆 Undefined symbol。原因:GoogleWebRTC 的预编译框架只带 arm64 真机架构,不含模拟器 slice;如果工程开启了 Bitcode,链接阶段也会报错。解决:用真机调试;打包时关闭 Enable Bitcode;如果一定要在模拟器里跑,需要走源码编译 WebRTC 或换支持模拟器的框架版本,但不建议在这份 Demo 上折腾,收益太低。
4.3 真机黑屏:权限描述与渲染时序
现象:画面和音频都建立了、日志没有报错,但远端画面一直是黑的。原因有两个,第一个是 Info.plist 缺少 NSCameraUsageDescription,导致 App 第一次请求摄像头时被系统直接拒绝;第二个是 didAddStream 回调里没有在主线程 addRenderer,或者 addRenderer 时视图还不存在于 window 上。解决:检查 Info.plist 的权限描述;把 addRenderer 包进 dispatch_async(dispatch_get_main_queue(), …) ;同时在 RTCManager 里对远端 track 做一次强引用,避免回调完成后被释放。这个坑我踩过,后来习惯是拿到 track 先加渲染,再弹通话页。
4.4 没有声音或扬声器不出声:AVAudioSession 的坑
现象:画面正常但没有声音;插着耳机听得到,拔掉耳机又没了。原因:没有把 AVAudioSession 切到 PlayAndRecord,或者被系统打断后没有重新激活;还有一个容易忽略的情况是 iPhone 侧边的静音拨片,以及别的 App 正占用音频会话。解决:在通话页 viewWillAppear 里设置 category 并 setActive:YES,viewWillDisappear 时恢复 SoloAmbient;排查时不要凭耳朵判断,用AVAudioSession.sharedInstance.currentRoute打印当前输出设备。用 AirPods 联调时记得保留 AllowBluetooth 选项,否则连上了也不出声。
4.5 一直 connecting 或呼叫超时:ICE 候选时序错位
现象:offer 和 answer 都交换完了,但 peerConnection 一直处于 connecting,最后超时关闭。原因:最常见的是 candidate 先于 remoteDescription 到达,客户端没缓存直接丢弃,导致可用候选缺失;其次是只有 STUN 没有 TURN,某一端在对称型 NAT 后面打洞失败。解决:按 3.3 节的方式加 pendingCandidates 缓存,在 setRemoteDescription 回调里统一补 addIceCandidate;生产环境必须配 TURN 兜底。把信令和媒体两条链路分开看,很多超时问题就变清晰了——信令通不等于媒体通。
4.6 归档与上架:Bitcode 和模拟器残留
现象:Archive 成功后上传报 “Unsupported architecture” 或 “Bitcode is not supported”。原因:GoogleWebRTC 官方预编译包不含 Bitcode,同时 framework 里残留了模拟器 slice。解决:工程 Build Settings 里把 Enable Bitcode 设为 NO;用lipo -info检查 framework 架构,残留了 x86_64 的话提前剥掉。这条属于上架血泪经验,早看到能省一天。
5. 二次开发思路:从 Demo 变成你的音视频功能模块
5.1 把分散的回调收拢成 RTCManager 单例
原 Demo 把创建 peerConnection、处理信令、创建 answer 都写在 ViewController 里,二次开发第一步是把这个收尾工作做掉——把逻辑收进单例,只暴露业务方法:
typedef NS_ENUM(NSInteger, RTCConnectionState) { RTCConnectionStateIdle, RTCConnectionStateConnecting, RTCConnectionStateConnected, RTCConnectionStateFailed }; @interface RTCManager : NSObject @property (nonatomic, copy) void (^stateChanged)(RTCConnectionState state); @property (nonatomic, copy) void (^remoteTrackAdded)(RTCVideoTrack *track); - (void)joinRoom:(NSString *)roomId; - (void)hangup; @end这样 ViewController 只关心 UI,换信令协议、加统计上报都不需要动页面。这个抽象是我看了这份源码之后马上会做的事。
5.2 从一对一扩展到多人房间
扩展成多人时,把 peerConnection 从“一个”变成“每个远端一个”。常见做法是用一个 NSMutableDictionary 以 uid 为 key 管理多个连接;信令 join 消息带上 roomId 和 uid,收到其他人上线消息就 createPeerConnection,收到 leave 就关闭对应连接。注意多人场景下本地视频轨可以复用同一个 capturer,但 audio track 需要加到每一个 peerConnection 上,否则别人听不到你。
5.3 改完怎么验证:两台真机联调的习惯
我从这份 Demo 上学到的习惯是:改完代码先不打日志,先跑通场景再看状态。联调时盯着 RTCSignalingState 和 ICEConnectionState 两个回调,任何一步卡住都先确认信令消息有没有发出去。从那以后我每次跑 WebRTC 相关改动,都强制自己走一遍“采集 → 信令 → 协商 → 渲染”四个节点再动下一步。希望帮到你。
本文还有配套的精品资源,点击获取