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,最后自己都分不清哪个是哪个了。

小提示: 如果pod下载太慢,可以试试把repo源换成国内的镜像。或者直接用 --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
}
注意: block会捕获self,小心循环引用。建议在Swift端用 [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 控制按钮的逻辑

挂断、静音、扬声器切换,这些按钮的逻辑其实挺绕的。我画了一张流程图,帮你理清思路:

通话控制逻辑流程 用户点击按钮 按钮类型? 挂断 关闭连接 释放资源 返回主界面 切换音频Track状态 更新按钮图标 切换摄像头 重新创建视频Track 更新本地画面

你看,挂断的逻辑最复杂,因为要清理连接和资源。静音和切换摄像头相对简单,但也要注意状态同步。

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,对方可能会听到回声。

核心要点: 音频路由切换后,一定要重新激活session。不然设置不会生效。我因为这个bug被测试妹子追着骂了一下午。

10.5 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 内存泄漏: WebRTC的对象生命周期管理很复杂。记得在 deinit 里关闭所有连接,置空delegate。
  • 后台处理: 应用进入后台时,视频渲染会暂停。如果你需要继续通话,得申请后台权限。
  • 权限问题: 摄像头和麦克风权限要在Info.plist里配置好。不然一启动就crash。
  • 日志调试: 开启WebRTC的日志:RTCSetMinDebugLogLevel(RTCLoggingSeverity.verbose)。出了问题先看日志,别瞎猜。

好了,这一章的内容就到这里。代码量不小,但每一步都是实战中会用到的。你跟着敲一遍,基本就能应付大部分iOS端的WebRTC开发需求了。


公众号:蓝海资料掘金营,微信deep3321