简介:WebRTCDemo-iOS 是一份基于 Objective-C 编写的 iOS 端 WebRTC 示例工程,面向需要在 iPhone/iPad 上快速实现音视频通话、P2P 数据共享的开发者。工程包含 24 个文件,以 .m/.h 源文件为主,配合 plist 配置、storyboard/xib 界面布局及 xcodeproj 工程文件,整体仅 41KB,结构紧凑适合直接阅读和二次改造。已有 292 人学习下载。示例围绕 getUserMedia 音视频采集、RTCPeerConnection 连接管理、RTCSessionDescription 与 RTCIceCandidate 协商以及 Signaling 信令交换展开,并划分了 MyWebRTCPhone、RTCWKWebView、RTCMediaController、ViewController 等模块,覆盖权限申请、本地/远程视图展示、通话控制、网络信令发送等关键环节。通过梳理这套代码,可以了解 Objective-C 工程中如何集成 WebRTC 库,掌握从媒体捕获到 P2P 连接建立的完整链路,为在线教育、远程医疗、视频会议等实时通信应用提供可直接落地的参考。 我做iOS音视频开发这几年,接手过不少WebRTC相关的活儿,最典型的就是一对一视频客服、直播连麦、远程协作这类场景。前阵子在公司把一套可复用的iOS端WebRTC Demo整理出来了,正好赶上项目重构,踩了一堆坑也填了一堆坑,干脆写一篇完整点的实战记录,把从零搭WebRTC iOS端、信令交互、推流拉流、弱网优化到Xcode打包的整个链路都捋一遍。如果你正准备在iOS上集成WebRTC,或者被WebRTC的推流和拉流、卡顿优化这些问题折磨过,这篇文章能帮你少走不少弯路。

我默认你是已经了解WebRTC基本概念、但还没有完整跑通一条链路的iOS开发者。Demo整体基于WebRTC官方iOS Framework,信令走Socket.IO,视频用H.264,音频用OPUS,跑的是最标准的一对一P2P通话模型。看完这套东西,你不光能跑通Demo,还能知道音视频采集、编码推流、接收渲染、ICE连接、弱网拥塞控制这些环节在iOS上到底是怎么协作的。

1. 先想清楚:iOS上的WebRTC到底在做什么

1.1 把推流和拉流翻译成WebRTC的语言

很多人一上来就找“推流”“拉流”的API,其实WebRTC里根本没有这两个词。它只有对等连接(PeerConnection)和媒体轨道(Track):一端采集并发送媒体流,另一端接收并渲染,这就是常规意义上的推流和拉流。区别在于WebRTC是P2P架构,两端是对等的,每一端既是推流端又是拉流端,只是表现上谁发画面谁是“推流端”而已。

Demo里我用两个角色验证这种对等模型:一个端主动发起呼叫(offer),另一个端应答(answer)。发起端调用 addTrack 把本地摄像头轨道挂到PeerConnection上,接收端通过 onTrack 回调拿到远端轨道,再塞给渲染视图。这个过程本质上就是一次推拉流全链路:本地采集、编码、网络传输、接收解码、渲染。

1.2 Demo里最核心的四个模块

一套能跑的iOS端WebRTC Demo,至少包含四块东西:

  • 信令模块 :负责交换offer/answer SDP和ICE候选,我用的Socket.IO,也可以用WebSocket或者自己写一个长连接服务。
  • 媒体模块 :音视频采集、轨道管理、编解码参数设置,这是WebRTC SDK封装好的能力,但需要理解。
  • 网络模块 :STUN/TURN配置、ICE候选收集、连接状态监听、弱网事件回调。
  • UI与渲染模块 :本地预览、远端渲染、通话控制按钮、连接状态展示。

这四块在工程上是可拆分的。我当时的做法是建了一个 RTCClient 单例统一管理PeerConnection生命周期,信令层单独抽一个 SignalingService ,UI层只跟 RTCClient 打交道,这样后期换信令方案、调整音视频参数都不会动到UI代码。

2. 环境准备与工程搭建

2.1 WebRTC SDK的两种引入方式

iOS端接入WebRTC目前主流两种方式:

  • 用Google维护的预编译Framework,通过CocoaPods引入 WebRTC pod。
  • 自己用 webrtc源码下载 编译,工程里维护一套自编译Framework。

我强烈建议非深度定制需求的团队用第一种。自己编译WebRTC源码不是不能做,但极其耗时,需要下载Google的depot_tools,在Linux或macOS上跑Ninja构建,一次完整编译少说两三小时,而且版本升级要重编。当时我们团队评估过自己编只是为了想改编解码器内部参数,后来发现SDK暴露的配置项基本够用,就老实回到了预编译方案。

Podfile里面核心就一行:

pod 'WebRTC', '~> 128.0'

版本号尽量保持跟信令服务端、Web管理端一致,避免SDP字段版本不兼容。iOS最低版本建议设到13.0以上,低版本在音视频硬编硬解上会有各种奇葩问题。

2.2 信令服务器的最小实现

WebRTC本身不负责信令,offer/answer SDP和ICE候选必须自己搭通道传递。Demo里我写了一个极简的Node.js Socket.IO信令服务,只做三件事:房间管理、消息转发、用户进出通知。

核心就是一个转发逻辑:

io.on('connection', (socket) => {
  socket.on('join', (roomId) => {
    socket.join(roomId);
    socket.to(roomId).emit('peer-joined', { id: socket.id });
  });
  
  socket.on('signal', (data) => {
    socket.to(data.roomId).emit('signal', {
      from: socket.id,
      type: data.type,
      sdp: data.sdp,
      candidate: data.candidate
    });
  });
});

信令消息我统一用 type + sdp/candidate 的JSON结构。有人喜欢分 offer 、 answer 、 ice-candidate 多个事件名,我建议合并成一个 signal 事件,客户端用一个switch分支解析,扩展性更好。

2.3 权限和网络配置

iOS端采集音视频必须处理权限。 Info.plist 里要加 NSCameraUsageDescription 和 NSMicrophoneUsageDescription ,否则系统直接闪退。我在Demo里做了个权限预检,在初始化RTCClient之前先用 AVCaptureDevice 的授权状态判断,这样用户体验好很多。

然后就是ATS的问题。如果你的信令服务器走的是HTTP明文(开发环境很常见),在 Info.plist 里加:

<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>

这个只建议Debug环境开,Release版最好还是给合法的HTTPS域名做例外。

3. 核心代码逐段拆解

3.1 音频会话配置

iOS上WebRTC跑起来,第一道坎就是AVAudioSession。手机上同时有通话、铃声、后台通知等多路音频,AVAudioSession就是系统音频资源的总调度器。WebRTC需要录音权限和播放权限,必须在初始化时设置好Category和Mode。

我实测最稳的配置是:

let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
try audioSession.setActive(true)

.playAndRecord 表示同时播放和录音, .voiceChat 给系统一个“这是通话场景”的提示,系统会自动做回声消除、降噪处理。 defaultToSpeaker 这个选项一定要加,否则iPhone默认把通话声音输出到听筒,用户会以为没有声音。

注意,AVAudioSession的配置时机要在PeerConnection创建之前完成,而且如果app本身有音乐播放功能,需要在通话开始前把其他音频会话中断掉。微信语音通话时音乐自动暂停,就是通过AVAudioSession的Interruption通知实现的。

3.2 PeerConnection初始化和媒体轨道

初始化PeerConnection是整套代码的核心步骤,参数集中在 RTCConfiguration 里:

let config = RTCConfiguration()
config.iceServers = [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])]
config.sdpSemantics = .unifiedPlan
config.continualGatheringPolicy = .gatherContinually
config.iceCandidatePoolSize = 5

let videoSource = factory.videoSource()
let capturer = RTCCameraVideoCapturer(delegate: videoSource)

let videoTrack = factory.videoTrack(with: videoSource, trackId: "local_video_track")
let audioConstraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
let audioSource = factory.audioSource(with: audioConstraints)
let audioTrack = factory.audioTrack(with: audioSource, trackId: "local_audio_track")

let peerConnection = factory.peerConnection(with: config, constraints: constraints, delegate: self)

有几个关键点值得展开:

  • sdpSemantics 必须用 .unifiedPlan ,Plan B已经废弃了,新老端互通时SDP格式不一致会直接协商失败。
  • continualGatheringPolicy 设成 .gatherContinually 可以让ICE候选持续收集。默认的 .gatherOnce 在某些网络切换场景下,候选收集不完整,连接慢甚至失败。
  • iceCandidatePoolSize 预分配ICE候选池,取值一般5左右,设太大会增加网络请求和内存消耗,太小则快速连接的效果不明显。
  • 摄像头采集用 RTCCameraVideoCapturer ,可以从 RTCCameraVideoCapturer.captureDevices() 里拿前置/后置摄像头,Demo里默认前置。

选择摄像头这个细节很容易被忽略。 captureDevices() 返回的摄像头列表顺序不一定是“前置、后置”,我用 AVCaptureDevice.Position 手动过滤前后置,代码更可靠:

let devices = RTCCameraVideoCapturer.captureDevices()
let frontCamera = devices.first(where: { $0.position == .front })
let backCamera = devices.first(where: { $0.position == .back })

摄像头切换时,要给capturer重新设置设备、格式和帧率。这里有个坑:如果旧格式还挂在新设备上直接切换,会黑屏一两秒。我的做法是先stopCapture再startCapture,中间把渲染视图的清屏逻辑处理好。

3.3 offer/answer协商与候选交换

协商流程是WebRTC连接建立的核心。发起端createOffer,设置本地描述后通过信令发给对端;对端createAnswer,也设置本地描述返回。两端再把各自收集到的ICE候选通过信令发给对方。

发起端关键代码:

peerConnection.offer(for: constraints) { [weak self] sdp, error in
  guard let sdp = sdp else { return }
  self?.peerConnection.setLocalDescription(sdp) { error in
    self?.signalingService.sendSignal(type: "offer", sdp: sdp)
  }
}

对端收到offer后:

peerConnection.setRemoteDescription(offerSdp) { error in
  peerConnection.answer(for: constraints) { [weak self] sdp, error in
    peerConnection.setLocalDescription(sdp) { error in
      self?.signalingService.sendSignal(type: "answer", sdp: sdp)
    }
  }
}

这个流程不能乱序:必须先setRemoteDescription再createAnswer,否则SDK不知道要用什么参数应答。我在联调时遇到过对端设置remoteDescription失败的情况,排查下来是SDP字符串传输过程中被信令服务加工过,类型、编解码行被截断。所以信令服务转发SDP时尽量原文透传,不要做任何格式整理。

ICE候选的交换则是 onIceCandidate 回调触发:

func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
  signalingService.sendSignal(type: "candidate", candidate: candidate)
}

对端收到candidate后调用 add(_ candidate: RTCIceCandidate) 。

有个实践中容易忽略的细节:候选到达的时机和SDP不一致时,WebRTC会自动处理乱序,但如果你在信令层对消息做了队列或重排,反而会引入额外的延迟。我实测下来,信令层不做重排、不乱丢,直接透传,让PeerConnection内部去应对,是最稳的。

3.4 连接状态观测

连接状态变化是排查问题的重要入口。我在Demo里实现了 RTCPeerConnectionDelegate 的 didChange 状态回调,对 iceConnectionState 做了完整的日志输出:

func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCIceConnectionState) {
  switch stateChanged {
  case .connected:
    // 通话建立成功
  case .disconnected:
    // 网络异常或者远端切后台
  case .failed:
    // ICE失败,需要重建或提示用户
  case .closed:
    // 连接已关闭
  default:
    break
  }
}

调试时这个回调比看日志更直观。 .disconnected 状态我提醒一下:它不是立刻判死的,WebRTC会先进入disconnected,如果网络能恢复会自动回到connected。不要在disconnected时立刻销毁PeerConnection,要启动一个超时保护,比如5-10秒内没恢复就走失败流程。

4. 弱网卡顿优化

4.1 卡顿的根源

WebRTC在弱网下的表现,核心取决于拥塞控制和丢包恢复机制。网上搜“webrtc弱网卡顿怎么优化”能搜出一堆方案,但很多是Web端的,iOS端的优化思路其实大同小异,无非是三板斧:拥塞控制、前向纠错、丢包重传。

拥塞控制由SDK内置的GCC(Google Congestion Control)算法负责,它根据网络延迟和丢包率动态调整发送码率。这玩意儿大多数时候是靠谱的,但实时性偏保守,带宽明明有富余了也不愿意立刻涨上去。

丢包恢复依赖两个机制:NACK(丢包重传)和FEC(前向纠错)。NACK是发现丢包后请对端重传,适合丢包率不高的场景;FEC是发送冗余数据包,接收端即使丢了一部分也能恢复,但会占用额外带宽。弱网时两者需要平衡,FEC占比太高,网络拥塞会加剧。

4.2 可配置项与实操建议

iOS端能直接调的主要是 RTCRtpEncodingParameters :

let rtpParameters = RTCRtpEncodingParameters()
rtpParameters.isActive = true
rtpParameters.maxBitrateBps = 800_000
rtpParameters.minBitrateBps = 100_000
rtpParameters.maxFramerate = 24
rtpParameters.networkPriority = .high

这是我在一个网络较差场景下的配置:最大码率800kbps,最小100kbps,帧率上限24fps。实测在这种受限带宽下,画面仍然能保持基本流畅,虽然清晰度会下降,但不会卡成幻灯片。

帧率和码率的平衡我的经验是:弱网场景优先保帧率,码率适当降低。用户对卡顿的感知远强于对模糊的感知。视频从30fps掉到24fps基本感知不到,但码率从1.5Mbps降到800kbps会有明显的画质下降,不过屏幕尺寸小,远距离看勉强可以接受。

如果业务场景允许,开通SVC(可伸缩视频编码)也是有效手段。SVC让发送端同时编码多路不同分辨率/帧率的视频,网络波动时接收端自适应选择。但SVC在iOS端兼容性还在完善,非重度需求不建议首版就上。

5. 常见问题与排查技巧实录

我整理一下在调试这个Demo及之前项目中频繁踩的坑,按出现频率排序:

问题现象 根因 解决方案
本地预览黑屏 摄像头权限未授权 / capturer未启动 检查AVCaptureDevice授权状态,确认capturer.startCapture被调用
远端画面黑屏 远端Track未关联到渲染视图 / 视频参数不匹配 在onTrack回调里立即设置renderer;检查协商后SDP的video方向是否为sendrecv
能连上但没声音 AVAudioSession配置错误 确认category是playAndRecord,mode是voiceChat,并设置defaultToSpeaker
始终连接不上 STUN失效 / 信令消息乱序 换谷歌公共STUN;抓包看信令时序
画面卡顿严重 码率上限设置过大或过小 根据网络实测调整maxBitrateBps
切换摄像头黑屏 stopCapture和startCapture衔接问题 先stop再start,并且重置渲染视图
断开网络后无法恢复 disconnected状态等待时间太短 加入5-10秒超时保护,期间不销毁PeerConnection

逐个说几个典型的排查细节。

第一个是“播放远端视频但画面全黑”。 我第一次做的时候,在 onTrack 回调里打印远端Track对象,一切正常,但画面就是黑的。后来发现是渲染视图的问题:我用的是普通的 RTCEAGLVideoView ,接收远端Track之后没有立刻设置renderer。正确做法是拿到远端track后立即创建渲染视图并添加约束,否则track已经开始解码渲染,视图还没有挂上去,自然黑屏。还有种情况是 RTCEAGLVideoView 的frame大小为零,因为AutoLayout还没有布局完成。我现在的做法是在挂载视图前强制 layoutIfNeeded() 。

第二个是“信令通了但媒体一直connecting”。 这种问题多半出在ICE候选没有成功交换,尤其是网络环境比较复杂,比如公司内网、多个NAT层级。STUN拿到的server reflexive候选无法打通,就需要TURN中继服务器。很多人测试时懒得搭TURN,结果换了个网络环境就连接失败了。我给Demo加了一个可配置的TURN服务器,生产环境必须把TURN安排上。

TURN服务器地址在 RTCIceServer 里与STUN并列配置,实际上一个 RTCIceServer 可以同时包含多个URL和一个用户名/密码。生产环境建议用开源的coturn自建,或者云厂商的TURN服务,不要裸奔STUN。

第三个是“后台切回来,音视频全断”。 iOS对后台任务限制很严,App退到后台后网络请求会被挂起,摄像头的采集也会被系统中断。常规做法是在生命周期回调里处理:进入后台时先暂停视频发送,保持音频(语音通话场景);回到前台时恢复采集,并重新协商。但iOS的 beginBackgroundTask 只能保证有限时间,长时间后台运行做不到。如果产品有“退后台继续通话”的需求,要么做VoIP Push + CallKit方案,要么在后台模式下播放静音音频占住资源。这个没有银弹,得看业务取舍。

第四个是Release模式表现和Debug不一致。 这个问题很阴间。同样一台手机,Debug版清晰流畅,Release版卡顿明显。最后定位到是编译优化选项和日志打印的影响。WebRTC SDK默认带一套阈值判断逻辑,Debug模式下因为日志输出阻塞了线程,反而给网络恢复留了时间,Release模式下逻辑执行太快,拥塞窗口刹不住。解决方案是开启WebRTC自带的 LoggingSeverity 控制日志级别,同时给SDK加一个运行时参数配置,不要用Debug/Release宏硬编码业务参数。

6. Xcode打包发布实战记录

6.1 Framework合并与位码兼容

WebRTC官方预编译Framework是同时支持模拟器和真机的动态Framework,但是打包上传App Store时有一个经典问题:SDK默认包含x86_64模拟器架构,如果直接上传,App Store会警告包含不受支持的架构。

解决办法是在Xcode的Build Phase里添加脚本,在编译时剔除模拟器架构:

if [ "${CONFIGURATION}" = "Release" ]; then
  FRAMEWORK_PATH="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework"
  ARCHS="arm64 arm64e"
  lipo -remove x86_64 "$FRAMEWORK_PATH/WebRTC" -output "$FRAMEWORK_PATH/WebRTC"
fi

这个脚本我在项目里用了很久,实测对App Store上架没有副作用。建议只在Release下执行,Debug模式保留模拟器架构,这样开发期还能在模拟器上调试UI。

再说说Bitcode。WebRTC官方SDK早期版本兼容Bitcode,但新版本已经默认关闭支持。如果你在工程里开启了Bitcode,链接WebRTC时会出现 bitcode bundle could not be generated 错误。直接在Build Settings里把 Enable Bitcode 设成NO即可,现在App Store接受非Bitcode包。

6.2 包体和启动速度的实测认识

WebRTC Framework本身不小,我在Xcode打包Release包后实测,IPA体积大概会增加60-80MB。如果你对包体敏感,可以考虑:

  • 只保留arm64真机架构,Release包会小一些。
  • 在WebRTC Framework里用 strip 工具去掉调试符号表。
  • 按需裁剪不需要的功能,例如纯音频通话场景可以去掉视频编解码相关库。

启动速度方面,WebRTC的PeerConnection初始化很耗时,我实测iPhone 12上首次创建PeeringConnection到连接建立,平均需要1.5-2.5秒。其中摄像头启动、音视频采集管线创建占了大头。优化方向是提前预热:在进入通话页面之前,就把音频会话、PeerConnection、音频采集模块先初始化好。但预热又会增加冷启动时间,这里需要产品取舍。我的做法是:App启动时先做音频会话预配置(轻量、影响小),进入通话页前才开始创建真正的PeerConnection和采集模块。

另外Xcode打包还要注意签名问题。WebRTC的BundleID是 org.webrtc.RTC ,默认是签名状态。如果你用fastlane自动化签名,要注意嵌入Framework的签名步骤不能省略,否则真机安装会闪退。报错一般是 dyld: Library not loaded: @rpath/WebRTC.framework ,这种就是没有对Framework签名或者签名失效。

7. 最后再说点实操里总结出来的经验

整个Demo做完,我最大的感受是:WebRTC iOS端真正难的不是API调用,而是对链路整体运行机制的理解。你懂了SDP协商、ICE候选、拥塞控制这几个核心概念,代码怎么写都顺;不懂这些,照着文档抄代码,出了问题只能干瞪眼。

有几个习惯是我后来一直保持的:真机调试时用Xcode的Network Link Conditioner模拟弱网,每次改动后都做一次“弱网卡顿”测试,把网络从正常切到高丢包再切回来,观察画面恢复速度和音质变化。这个习惯帮我提前抓到了不少隐藏问题,比如FEC参数配置过重导致正常网络下带宽浪费。

另一个习惯是抓包验证。iOS端的WebRTC报文是SRTP加密的,Wireshark默认看不到媒体内容,但信令部分的SDP是明文的,用Charles或Whistle代理信令,能看到offer/answer的完整交互过程,排查协商问题效率极高。

如果你正准备把WebRTC Demo往自己的业务场景落地,我的建议是先跑通一对一连麦,再扩展多路和直播场景。一对一通了,多路就是PeerConnection的叠加和轨道管理逻辑,没有本质难度;但是直播场景会牵扯出混流、合流、观众低延迟播放等一系列新问题,那是另一个量级的工作量。先把地基打牢,后续扩展才稳。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐