如何结合 CallKit 和 Agora SDK 实现视频 VoIP 通话应用

做者简介:龚宇华,声网 Agora.io 首席 iOS 研发工程师,负责 iOS 端移动应用产品设计和技术架构。

简介

CallKit 是苹果在 iOS10 中推出的,专为 VoIP 通话场景设计的系统框架,在 iOS 上为 VoIP 通话提供了系统级的支持。git

在 iOS10 之前,VoIP 场景的体验存在不少局限。好比没有专门的来电呼叫通知方式,App 在后台接收到来电呼叫后,只能使用通常的系统通知方式提示用户。若是用户关掉了通知权限,就会错过来电。VoIP 通话自己也很容易被打断。好比用户在通话过程当中打开了另外一个使用音频设备的应用,或者接到了一个运营商电话,VoIP 通话就会被打断。github

为了改善 VoIP 通话的用户体验问题,CallKit 框架在系统层面把 VoIP 通话提升到了和运营商通话同样的级别。当 App 收到来电呼叫后,能够经过 CallKit 把 VoIP 通话注册给系统,让系统使用和运营商电话同样的界面提示用户。在通话过程当中,app 的音视频权限也变成和运营商电话同样,不会被其余应用打断。VoIP 通话过程当中接到运营商电话时,在界面上由用户本身选择是否挂起 /挂断当前的 VoIP 通话。canvas

另外,使用了 CallKit 框架的 VoIP 通话也会和运营商电话同样出如今系统的电话记录中。用户能够直接在通信录和电话记录中发起新的 VoIP 呼叫。api

所以,一个有 VoIP 通话场景的应用应该尽快集成 CallKit,以大幅提升用户体验和使用便捷性。session

下面咱们就来看下 CallKit 的使用方法,而且把它集成到一个使用 Agora SDK 的视频通话应用中。架构

CallKit 基本类介绍

CallKit 最重要的类有两个,CXProviderCXCallController。这两个类是 CallKit 框架的核心。app

CXProvider

CXProvider 主要负责通话流程的控制,向系统注册通话和更新通话的链接状态等。重要的 api 有下面这些:框架

open class CXProvider : NSObject {
    
    /// 初始化方法
    public init(configuration: CXProviderConfiguration)

    /// 设置回调对象
    open func setDelegate(_ delegate: CXProviderDelegate?, queue: DispatchQueue?)

    /// 向系统注册一个来电。若是注册成功,系统就会根据 CXCallUpdate 中的信息弹出来电画面
    open func reportNewIncomingCall(with UUID: UUID, update: CXCallUpdate, completion: @escaping (Error?) -> Swift.Void)

    /// 更新一个通话的信息
    open func reportCall(with UUID: UUID, updated update: CXCallUpdate)

    /// 告诉系统通话开始链接
    open func reportOutgoingCall(with UUID: UUID, startedConnectingAt dateStartedConnecting: Date?)

    /// 告诉系统通话链接成功
    open func reportOutgoingCall(with UUID: UUID, connectedAt dateConnected: Date?)

    /// 告诉系统通话结束
    open func reportCall(with UUID: UUID, endedAt dateEnded: Date?, reason endedReason: CXCallEndedReason)
}

能够看到,CXProvider 使用 UUID 来标识一个通话,使用 CXCallUpdate 类来设置通话的属性。开发者可使用正确格式的字符串为每一个通话建立对应的 UUID;也能够直接使用系统建立的 UUIDide

用户在系统界面上对通话进行的操做都经过 CXProviderDelegate 中的回调方法通知应用。ui

CXCallController

CXCallController 主要负责执行对通话的操做。

open class CXCallController : NSObject {

    /// 初始化方法
    public convenience init()
    
    /// 获取 callObserver,经过 callObserver 能够获得系统全部进行中的通话的 uuid 和通话状态
    open var callObserver: CXCallObserver { get }

    /// 执行对一个通话的操做
    open func request(_ transaction: CXTransaction, completion: @escaping (Error?) -> Swift.Void)
}

其中 CXTransaction 是一个操做的封装,包含了动做 CXAction 和通话 UUID。发起通话、接听通话、挂断通话、静音通话等动做都有对应的 CXAction 子类。

和 Agora SDK 结合

下面咱们看下怎么在一个使用 Agora SDK 的视频通话应用中集成 CallKit。Demo 的完整代码见 Github 地址

实现视频通话

首先快速实现一个视频通话的功能。

使用 AppId 建立 AgoraRtcEngineKit 实例:

private lazy var rtcEngine: AgoraRtcEngineKit = AgoraRtcEngineKit.sharedEngine(withAppId: <#Your AppId#>, delegate: self)

设置 ChannelProfile 和本地预览视图:

override func viewDidLoad() {
    super.viewDidLoad()
        
    rtcEngine.setChannelProfile(.communication)
    
    let canvas = AgoraRtcVideoCanvas()
    canvas.uid = 0
    canvas.view = localVideoView
    canvas.renderMode = .hidden
    rtcEngine.setupLocalVideo(canvas)
}

AgoraRtcEngineDelegate 的远端用户加入频道事件中设置远端视图:

extension ViewController: AgoraRtcEngineDelegate {
    func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
        let canvas = AgoraRtcVideoCanvas()
        canvas.uid = uid
        canvas.view = remoteVideoView
        canvas.renderMode = .hidden
        engine.setupRemoteVideo(canvas)
        
        remoteUid = uid
        remoteVideoView.isHidden = false
    }
}

实现通话开始、静音、结束的方法:

extension ViewController {
    func startSession(_ session: String) {
        rtcEngine.startPreview()
        rtcEngine.joinChannel(byToken: nil, channelId: session, info: nil, uid: 0, joinSuccess: nil)
    }
    
    func muteAudio(_ mute: Bool) {
        rtcEngine.muteLocalAudioStream(mute)
    }
    
    func stopSession() {
        remoteVideoView.isHidden = true
        
        rtcEngine.leaveChannel(nil)
        rtcEngine.stopPreview()
    }
}

至此,一个简单的视频通话应用搭建就完成了。双方只要调用 startSession(_:) 方法加入同一个频道,就能够进行视频通话。

来电显示

咱们首先建立一个专门的类 CallCenter 来统一管理 CXProviderCXCallController

class CallCenter: NSObject {
        
    fileprivate let controller = CXCallController()
    private let provider = CXProvider(configuration: CallCenter.providerConfiguration)
    
    private static var providerConfiguration: CXProviderConfiguration {
        let appName = "AgoraRTCWithCallKit"
        let providerConfiguration = CXProviderConfiguration(localizedName: appName)
        providerConfiguration.supportsVideo = true
        providerConfiguration.maximumCallsPerCallGroup = 1
        providerConfiguration.maximumCallGroups = 1
        providerConfiguration.supportedHandleTypes = [.phoneNumber]
        
        if let iconMaskImage = UIImage(named: <#Icon file name#>) {
            providerConfiguration.iconTemplateImageData = UIImagePNGRepresentation(iconMaskImage)
        }
        providerConfiguration.ringtoneSound = <#Ringtone file name#>
        
        return providerConfiguration
    }
}

其中 providerConfiguration 设置了 CallKit 向系统注册通话时须要的一些基本属性。好比 localizedName 告诉系统向用户显示应用的名称。iconTemplateImage 给系统提供一张图片,以在锁屏的通话界面中显示。ringtoneSound 是自定义来电响铃文件。

接着,咱们建立一个接收到呼叫后把呼叫经过 CallKit 注册给系统的方法。

func showIncomingCall(of session: String) {
    let callUpdate = CXCallUpdate()
    callUpdate.remoteHandle = CXHandle(type: .phoneNumber, value: session)
    callUpdate.localizedCallerName = session
    callUpdate.hasVideo = true
    callUpdate.supportsDTMF = false
    
    let uuid = pairedUUID(of: session)
    
    provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in
        if let error = error {
            print("reportNewIncomingCall error: \(error.localizedDescription)")
        }
    })
}

简单起见,咱们用对方的手机号码字符串作为通话 session 标示,并构造一个简单的 session 和 UUID 匹配查询系统。最后在调用了 CXProviderreportNewIncomingCall(with:update:completion:) 方法后,系统就会根据 CXCallUpdate 中的信息,弹出和运营商电话相似的界面提醒用户。用户能够接听或者拒接,也能够点击第六个按钮打开 app。

接听 /挂断通话

用户在系统界面上点击“接受”或“拒绝”按钮后,CallKit 会经过 CXProviderDelegate 的相关回调通知 app。

func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
    guard let session = pairedSession(of:action.callUUID) else {
        action.fail()
        return
    }
    
    delegate?.callCenter(self, answerCall: session)
    action.fulfill()
}

func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
    guard let session = pairedSession(of:action.callUUID) else {
        action.fail()
        return
    }
    
    delegate?.callCenter(self, declineCall: session)
    action.fulfill()
}

经过回调传入的 CXAction 对象,咱们能够知道用户的操做类型以及通话对应的 UUID。最后经过咱们本身定义的 CallCenterDelegate 回调通知到 app 的 ViewController 中。

发起通话 /静音 /结束通话

使用 CXStartCallAction 构造一个 CXTransaction,咱们就能够用 CXCallControllerrequest(_:completion:) 方法向系统注册一个发起的通话。

func startOutgoingCall(of session: String) {
    let handle = CXHandle(type: .phoneNumber, value: session)
    let uuid = pairedUUID(of: session)
    let startCallAction = CXStartCallAction(call: uuid, handle: handle)
    startCallAction.isVideo = true
    
    let transaction = CXTransaction(action: startCallAction)
    controller.request(transaction) { (error) in
        if let error = error {
            print("startOutgoingSession failed: \(error.localizedDescription)")
        }
    }
}

一样的,咱们能够用 CXSetMutedCallActionCXEndCallAction 来静音 /结束通话。

func muteAudio(of session: String, muted: Bool) {
    let muteCallAction = CXSetMutedCallAction(call: pairedUUID(of: session), muted: muted)
    let transaction = CXTransaction(action: muteCallAction)
    controller.request(transaction) { (error) in
        if let error = error {
            print("muteSession \(muted) failed: \(error.localizedDescription)")
        }
    }
}

func endCall(of session: String) {
    let endCallAction = CXEndCallAction(call: pairedUUID(of: session))
    let transaction = CXTransaction(action: endCallAction)
    controller.request(transaction) { error in
        if let error = error {
            print("endSession failed: \(error.localizedDescription)")
        }
    }
}

模拟来电和呼叫

真实的 VoIP 应用须要使用信令系统或者 iOS 的 PushKit 推送,来实现通话呼叫。为了简单起见,咱们在 Demo 上添加了两个按钮,直接模拟收到了新的通话呼叫和呼出新的通话。

private lazy var callCenter = CallCenter(delegate: self)

@IBAction func doCallOutPressed(_ sender: UIButton) {
    callCenter.startOutgoingCall(of: session)
}

@IBAction func doCallInPressed(_ sender: UIButton) {
    callCenter.showIncomingCall(of: session)
}

接着经过实现 CallCenterDelegate 回调,调用咱们前面已经预先实现了的使用 Agora SDK 进行视频通话功能,一个完整的 CallKit 视频应用就完成了。

extension ViewController: CallCenterDelegate {
    func callCenter(_ callCenter: CallCenter, startCall session: String) {
        startSession(session)
    }
    
    func callCenter(_ callCenter: CallCenter, answerCall session: String) {
        startSession(session)
        callCenter.setCallConnected(of: session)
    }
    
    func callCenter(_ callCenter: CallCenter, declineCall session: String) {
        print("call declined")
    }
    
    func callCenter(_ callCenter: CallCenter, muteCall muted: Bool, session: String) {
        muteAudio(muted)
    }
    
    func callCenter(_ callCenter: CallCenter, endCall session: String) {
        stopSession()
    }
}

通话中的界面

通话过程当中在音频外放的状态下锁屏,会显示相似运营商电话的通话界面。不过惋惜的是,目前 CallKit 还不支持像 FaceTime 那样的在锁屏下显示视频的功能。

通信录 /系统通话记录

使用了 CallKit 的 VoIP 通话会出如今用户系统的通话记录中,用户能够像运营商电话同样直接点击通话记录发起新的 VoIP 呼叫。同时用户通信录中也会有对应的选项让用户直接使用支持 CallKit 的应用发起呼叫。

通话记录

实现这个功能并不复杂。不管用户是点击通讯录中按钮,仍是点击通话记录,系统都会启动打开对应 app,并触发 UIApplicationDelegateapplication(_:continue:restorationHandler:) 回调。咱们能够在这个回调方法中获取到被用户点击的电话号码,并开始 VoIP 通话。

func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
    guard let interaction = userActivity.interaction else {
        return false
    }
    
    var phoneNumber: String?
    if let callIntent = interaction.intent as? INStartVideoCallIntent {
        phoneNumber = callIntent.contacts?.first?.personHandle?.value
    } else if let callIntent = interaction.intent as? INStartAudioCallIntent {
        phoneNumber = callIntent.contacts?.first?.personHandle?.value
    }
    
    let callVC = window?.rootViewController as? ViewController
    callVC?.applyContinueUserActivity(toCall:phoneNumber)
    
    return true
}

extension ViewController {
    func applyContinueUserActivity(toCall phoneNumber: String?) {
        guard let phoneNumber = phoneNumber, !phoneNumber.isEmpty else {
            return
        }
        phoneNumberTextField.text = phoneNumber
        callCenter.startOutgoingCall(of: session)
    }
}

一些注意点

  1. 必需在项目的后台模式设置中启用 VoIP 模式,才能够正常使用 CallKit 的相关功能。这个模式须要在 Info.plist 文件的 UIBackgroundModes 字段下添加 voip 项来开启。 若是没有开启后台 VoIP 模式,调用 reportNewIncomingCall(with:update:completion:) 等方法不会有效果。
  2. 当发起通话时,在使用 CXStartCallAction 向系统注册通话后,系统会启动应用的 AudioSession,并将其优先级提升到运营商通话的级别。若是应用在这个过程当中本身对 AudioSession 进行设置操做,极可能会致使 AudioSession 启动失败。因此应用须要等系统启动 AudioSession 完成,在收到 CXProviderDelegateprovider(_:didActive:) 回调后,再进行 AudioSession 相关的设置。咱们在 Demo 中是经过 Agora SDK 的 disableAudio()enableAudio() 等接口来处理这部分逻辑的。
  3. 集成 CallKit 后,VoIP 来电也会和运营商电话同样受到用户系统 “勿扰” 等设置的影响。
  4. 在听筒模式下按锁屏键,系统会按照挂断处理。这个行为也和运营商电话一致。

最后再次附上完整 Demo Github 地址

相关文章
相关标签/搜索