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

简介

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

在 iOS10 以前,VoIP 场景的体验存在很多局限。比如没有专门的来电呼叫通知方式,App 在后台接收到来电呼叫后,只能使用一般的系统通知方式提示用户。如果用户关掉了通知权限,就会错过来电。VoIP 通话本身也很容易被打断。比如用户在通话过程中打开了另一个使用音频设备的应用,或者接到了一个运营商电话,VoIP 通话就会被打断。

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

另外,使用了 CallKit 框架的 VoIP 通话也会和运营商电话一样出现在系统的电话记录中。用户可以直接在通讯录和电话记录中发起新的 VoIP 呼叫。

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

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

CallKit 基本类介绍

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

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;也可以直接使用系统创建的 UUID

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

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 地址


RTE开发者社区
647 声望966 粉丝

RTE 开发者社区是聚焦实时互动领域的中立开发者社区。不止于纯粹的技术交流,我们相信开发者具备更加丰盈的个体价值。行业发展变革、开发者职涯发展、技术创业创新资源,我们将陪跑开发者,共享、共建、共成长。