10、iOS端实战:Swift集成WebRTC、Objective-C桥接、通话界面开发
这一章,我们终于要动手了。
前面讲了那么多理论、信令、网络穿透,现在轮到真刀真枪地写iOS代码。说实话,我第一次在iOS上集成WebRTC时,踩了不少坑。尤其是Swift和Objective-C混编那部分,文档少得可怜,社区里问问题都没人回。后来我干脆自己写了一套桥接层,用着用着就成了习惯。
好,咱们开始。
10.1 为什么要在iOS上折腾WebRTC?
你可能会问:直接用现成的SDK不香吗?
香,但不够灵活。很多商业SDK封装得太死,你想改个视频编码参数、调个音频路由,根本无从下手。自己集成WebRTC,虽然前期累一点,但后面想怎么玩都行。
另外,苹果对WebRTC的支持其实挺友好的。iOS 12以后,系统自带的WebRTC框架已经能用了。但说实话,我建议你还是用Google官方的WebRTC库,功能更全,更新也快。
10.2 Swift集成WebRTC:从零开始
先说说怎么把WebRTC库弄进项目里。
10.2.1 安装WebRTC框架
我个人习惯用CocoaPods。在Podfile里加上:
pod 'GoogleWebRTC'
然后 pod install,等它下载编译。第一次会比较慢,因为WebRTC库挺大的,大概200多MB。
如果你不想用CocoaPods,也可以手动下载framework拖进项目。但我建议你别这么干——依赖管理会变成噩梦。我曾经在一个项目里手动管理了三个版本的WebRTC,最后自己都分不清哪个是哪个了。
--verbose 看进度,免得以为卡死了。
10.2.2 初始化PeerConnection工厂
WebRTC的核心是 RTCPeerConnectionFactory。这东西得在应用启动时初始化一次,别到处new。
import WebRTC
class WebRTCManager {
static let shared = WebRTCManager()
private var factory: RTCPeerConnectionFactory?
func setup() {
// 初始化SSL,WebRTC依赖这个
RTCInitializeSSL()
// 创建工厂
let encoderFactory = RTCDefaultVideoEncoderFactory()
let decoderFactory = RTCDefaultVideoDecoderFactory()
factory = RTCPeerConnectionFactory(
encoderFactory: encoderFactory,
decoderFactory: decoderFactory
)
}
deinit {
RTCCleanupSSL()
}
}
嗯,这里要注意:RTCInitializeSSL() 和 RTCCleanupSSL() 必须成对出现。我见过有人只初始化不清理,结果内存泄漏查了半天。
10.2.3 创建PeerConnection
有了工厂,接下来就是创建连接。配置ICEServer是必须的,不然打不通。
func createPeerConnection() -> RTCPeerConnection? {
let config = RTCConfiguration()
config.iceServers = [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])]
config.sdpSemantics = .unifiedPlan
let constraints = RTCMediaConstraints(
mandatoryConstraints: nil,
optionalConstraints: ["DtlsSrtpKeyAgreement": "true"]
)
return factory?.peerConnection(
with: config,
constraints: constraints,
delegate: self
)
}
这里有个坑:sdpSemantics 一定要用 .unifiedPlan。以前还有个 .planB,但Google已经废弃了。如果你还用旧的,某些浏览器会连不上。
10.3 Objective-C桥接:Swift和OC的混编艺术
说实话,WebRTC的Objective-C接口写得很「老派」。很多回调都是用delegate,而不是block或闭包。在Swift里用起来特别别扭。
我的做法是:写一个Objective-C的桥接层,把那些delegate封装成block。这样Swift端调用起来就舒服多了。
10.3.1 创建桥接头文件
首先,在项目里新建一个Objective-C文件,Xcode会问你要不要创建桥接头文件。选「是」。
然后在桥接头文件里导入WebRTC:
#import <WebRTC/WebRTC.h>
10.3.2 封装RTCPeerConnectionDelegate
下面是我写的一个简化版桥接类。把delegate方法转成block属性:
// RTCConnectionBridge.h
#import <WebRTC/WebRTC.h>
typedef void (^OnIceCandidate)(RTCIceCandidate *candidate);
typedef void (^OnIceConnectionState)(RTCIceConnectionState state);
typedef void (^OnRemoteStream)(RTCMediaStream *stream);
@interface RTCConnectionBridge : NSObject <RTCPeerConnectionDelegate>
@property (nonatomic, copy) OnIceCandidate onIceCandidate;
@property (nonatomic, copy) OnIceConnectionState onIceConnectionState;
@property (nonatomic, copy) OnRemoteStream onRemoteStream;
@end
// RTCConnectionBridge.m
@implementation RTCConnectionBridge
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didGenerateIceCandidate:(RTCIceCandidate *)candidate {
if (self.onIceCandidate) {
self.onIceCandidate(candidate);
}
}
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didChangeIceConnectionState:(RTCIceConnectionState)newState {
if (self.onIceConnectionState) {
self.onIceConnectionState(newState);
}
}
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didAddStream:(RTCMediaStream *)stream {
if (self.onRemoteStream) {
self.onRemoteStream(stream);
}
}
// 其他delegate方法可以按需实现
@end
这样在Swift里用起来就清爽多了:
let bridge = RTCConnectionBridge()
bridge.onIceCandidate = { candidate in
// 发送candidate到远端
}
bridge.onIceConnectionState = { state in
// 更新UI
}
[weak self]。
10.4 通话界面开发:不只是UI
通话界面看起来简单,不就是几个按钮加一个视频画面吗?但实际做起来,细节多到让人崩溃。
10.4.1 布局思路
我一般把界面分成三层:
- 底层: 远端的视频画面(全屏)
- 中层: 本地的视频画面(小窗,可拖动)
- 顶层: 控制按钮(挂断、静音、切换摄像头等)
用代码布局的话,我推荐用SnapKit。纯手写frame太累了。
private func setupRemoteVideo() {
remoteView = RTCMTLVideoView(frame: .zero)
remoteView?.videoContentMode = .scaleAspectFill
view.addSubview(remoteView!)
remoteView?.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
private func setupLocalVideo() {
localView = RTCMTLVideoView(frame: .zero)
localView?.videoContentMode = .scaleAspectFill
view.addSubview(localView!)
localView?.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.right.equalToSuperview().offset(-16)
make.width.equalTo(100)
make.height.equalTo(160)
}
}
10.4.2 视频渲染的坑
WebRTC在iOS上渲染视频有两种方式:RTCEAGLVideoView(OpenGL)和 RTCMTLVideoView(Metal)。
我强烈建议用 RTCMTLVideoView。Metal性能更好,而且不会出现OpenGL那种「黑屏一下」的问题。不过要注意:Metal只在iOS 9以上支持,现在应该没问题了。
还有一个坑:如果你在模拟器上跑,视频渲染可能会出问题。模拟器不支持Metal,得用OpenGL。所以调试时最好用真机。
10.4.3 控制按钮的逻辑
挂断、静音、扬声器切换,这些按钮的逻辑其实挺绕的。我画了一张流程图,帮你理清思路:
你看,挂断的逻辑最复杂,因为要清理连接和资源。静音和切换摄像头相对简单,但也要注意状态同步。
10.4.4 音频路由的处理
通话时,用户可能想用扬声器,也可能想用听筒。iOS默认是听筒模式,你得手动切换。
func switchToSpeaker() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playAndRecord, mode: .voiceChat, options: .defaultToSpeaker)
try session.setActive(true)
} catch {
print("切换扬声器失败: \(error)")
}
}
func switchToEarpiece() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playAndRecord, mode: .voiceChat)
try session.setActive(true)
} catch {
print("切换听筒失败: \(error)")
}
}
这里有个细节:mode: .voiceChat 会自动启用一些语音处理优化,比如回声消除。如果你用 .default,对方可能会听到回声。
10.5 实战中的避坑指南
最后,分享几个我踩过的坑:
- 内存泄漏: WebRTC的对象生命周期管理很复杂。记得在
deinit里关闭所有连接,置空delegate。 - 后台处理: 应用进入后台时,视频渲染会暂停。如果你需要继续通话,得申请后台权限。
- 权限问题: 摄像头和麦克风权限要在Info.plist里配置好。不然一启动就crash。
- 日志调试: 开启WebRTC的日志:
RTCSetMinDebugLogLevel(RTCLoggingSeverity.verbose)。出了问题先看日志,别瞎猜。
好了,这一章的内容就到这里。代码量不小,但每一步都是实战中会用到的。你跟着敲一遍,基本就能应付大部分iOS端的WebRTC开发需求了。