1. 程式人生 > >如何結合 CallKit 和 Agora SDK 實現視訊 VoIP 通話應用

如何結合 CallKit 和 Agora SDK 實現視訊 VoIP 通話應用

作者簡介:龔宇華,聲網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 最重要的類有兩個,CXProvider 和 CXCallController。這兩個類是 CallKit 框架的核心。

CXProvider

CXProvider 主要負責通話流程的控制,向系統註冊通話和更新通話的連線狀態等。重要的 api 有下面這些:

 1open class CXProvider : NSObject {
2    /// 初始化方法
3    public
init(configuration: CXProviderConfiguration)
4    /// 設定回撥物件
5    open func setDelegate(_ delegate: CXProviderDelegate?, queue: DispatchQueue?)
6    /// 向系統註冊一個來電。如果註冊成功,系統就會根據 CXCallUpdate 中的資訊彈出來電畫面
7    open func reportNewIncomingCall(with UUID: UUID, update: CXCallUpdate, completion: @escaping (Error?) -> Swift.Void)
8    /// 更新一個通話的資訊
9    open func reportCall(with UUID: UUID, updated update: CXCallUpdate)
10    /// 告訴系統通話開始連線
11    open func reportOutgoingCall(with UUID: UUID, startedConnectingAt dateStartedConnecting: Date?)
12    /// 告訴系統通話連線成功
13    open func reportOutgoingCall(with UUID: UUID, connectedAt dateConnected: Date?)
14    /// 告訴系統通話結束
15    open func reportCall(with UUID: UUID, endedAt dateEnded: Date?, reason endedReason: CXCallEndedReason)
16}

可以看到,CXProvider 使用 UUID 來標識一個通話,使用 CXCallUpdate 類來設定通話的屬性。開發者可以使用正確格式的字串為每個通話建立對應的 UUID;也可以直接使用系統建立的 UUID

使用者在系統介面上對通話進行的操作都通過 CXProviderDelegate 中的回撥方法通知應用。

CXCallController

CXCallController 主要負責執行對通話的操作。

1open class CXCallController : NSObject {
2    /// 初始化方法
3    public convenience init()
4    /// 獲取 callObserver,通過 callObserver 可以得到系統所有進行中的通話的 uuid 和通話狀態
5    open var callObserver: CXCallObserver { get }
6    /// 執行對一個通話的操作
7    open func request(_ transaction: CXTransaction, completion: @escaping (Error?) -> Swift.Void)
8}

其中 CXTransaction 是一個操作的封裝,包含了動作 CXAction 和通話 UUID。發起通話、接聽通話、結束通話通話、靜音通話等動作都有對應的 CXAction 子類。

和 Agora SDK 結合

下面我們看下怎麼在一個使用 Agora SDK 的視訊通話應用中整合 CallKit。

實現視訊通話

首先快速實現一個視訊通話的功能。

使用 AppId 建立 AgoraRtcEngineKit 例項:

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

設定 ChannelProfile 和本地預覽檢視:

1override func viewDidLoad() {
2    super.viewDidLoad()
3    rtcEngine.setChannelProfile(.communication)
4    let canvas = AgoraRtcVideoCanvas()
5    canvas.uid = 0
6    canvas.view = localVideoView
7    canvas.renderMode = .hidden
8    rtcEngine.setupLocalVideo(canvas)
9}

在 AgoraRtcEngineDelegate 的遠端使用者加入頻道事件中設定遠端檢視:

 1extension ViewController: AgoraRtcEngineDelegate {
2    func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
3        let canvas = AgoraRtcVideoCanvas()
4        canvas.uid = uid
5        canvas.view = remoteVideoView
6        canvas.renderMode = .hidden
7        engine.setupRemoteVideo(canvas)
8        remoteUid = uid
9        remoteVideoView.isHidden = false
10    }
11}

實現通話開始、靜音、結束的方法:

 1extension ViewController {
2    func startSession(_ session: String) {
3        rtcEngine.startPreview()
4        rtcEngine.joinChannel(byToken: nil, channelId: session, info: nil, uid: 0, joinSuccess: nil)
5    }
6    func muteAudio(_ mute: Bool) {
7        rtcEngine.muteLocalAudioStream(mute)
8    }
9    func stopSession() {
10        remoteVideoView.isHidden = true
11        rtcEngine.leaveChannel(nil)
12        rtcEngine.stopPreview()
13    }
14}

至此,一個簡單的視訊通話應用搭建就完成了。雙方只要呼叫 startSession(_:) 方法加入同一個頻道,就可以進行視訊通話。

來電顯示

我們首先建立一個專門的類 CallCenter 來統一管理 CXProvider 和 CXCallController

 1class CallCenter: NSObject {
2    fileprivate let controller = CXCallController()
3    private let provider = CXProvider(configuration: CallCenter.providerConfiguration)
4    private static var providerConfiguration: CXProviderConfiguration {
5        let appName = "AgoraRTCWithCallKit"
6        let providerConfiguration = CXProviderConfiguration(localizedName: appName)
7        providerConfiguration.supportsVideo = true
8        providerConfiguration.maximumCallsPerCallGroup = 1
9        providerConfiguration.maximumCallGroups = 1
10        providerConfiguration.supportedHandleTypes = [.phoneNumber]
11        if let iconMaskImage = UIImage(named: <#Icon file name#>) {
12            providerConfiguration.iconTemplateImageData = UIImagePNGRepresentation(iconMaskImage)
13        }
14        providerConfiguration.ringtoneSound = <#Ringtone file name#>
15        return providerConfiguration
16    }
17}

其中 providerConfiguration 設定了 CallKit 向系統註冊通話時需要的一些基本屬性。比如 localizedName 告訴系統向用戶顯示應用的名稱。iconTemplateImage 給系統提供一張圖片,以在鎖屏的通話介面中顯示。ringtoneSound 是自定義來電響鈴檔案。

接著,我們建立一個接收到呼叫後把呼叫通過 CallKit 註冊給系統的方法。

 1func showIncomingCall(of session: String) {
2    let callUpdate = CXCallUpdate()
3    callUpdate.remoteHandle = CXHandle(type: .phoneNumber, value: session)
4    callUpdate.localizedCallerName = session
5    callUpdate.hasVideo = true
6    callUpdate.supportsDTMF = false
7    let uuid = pairedUUID(of: session)
8    provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in
9        if let error = error {
10            print("reportNewIncomingCall error: \(error.localizedDescription)")
11        }
12    })
13}

簡單起見,我們用對方的手機號碼字串做為通話 session 標示,並構造一個簡單的 session 和 UUID 匹配查詢系統。最後在呼叫了 CXProvider 的 reportNewIncomingCall(with:update:completion:) 方法後,系統就會根據 CXCallUpdate 中的資訊,彈出和運營商電話類似的介面提醒使用者。使用者可以接聽或者拒接,也可以點選第六個按鈕開啟 app。

接聽/結束通話通話

使用者在系統介面上點選“接受”或“拒絕”按鈕後,CallKit 會通過 CXProviderDelegate 的相關回調通知 app。

 1func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
2    guard let session = pairedSession(of:action.callUUID) else {
3        action.fail()
4        return
5    }
6    delegate?.callCenter(self, answerCall: session)
7    action.fulfill()
8}
9func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
10    guard let session = pairedSession(of:action.callUUID) else {
11        action.fail()
12        return
13    }
14    delegate?.callCenter(self, declineCall: session)
15    action.fulfill()
16}

通過回撥傳入的 CXAction 物件,我們可以知道使用者的操作型別以及通話對應的 UUID。最後通過我們自己定義的 CallCenterDelegate 回撥通知到 app 的 ViewController 中。

發起通話/靜音/結束通話

使用 CXStartCallAction 構造一個 CXTransaction,我們就可以用 CXCallController 的 request(_:completion:) 方法向系統註冊一個發起的通話。

 1func startOutgoingCall(of session: String) {
2    let handle = CXHandle(type: .phoneNumber, value: session)
3    let uuid = pairedUUID(of: session)
4    let startCallAction = CXStartCallAction(call: uuid, handle: handle)
5    startCallAction.isVideo = true
6    let transaction = CXTransaction(action: startCallAction)
7    controller.request(transaction) { (error) in
8        if let error = error {
9            print("startOutgoingSession failed: \(error.localizedDescription)")
10        }
11    }
12}

同樣的,我們可以用 CXSetMutedCallAction 和 CXEndCallAction 來靜音/結束通話。

 1func muteAudio(of session: String, muted: Bool) {
2    let muteCallAction = CXSetMutedCallAction(call: pairedUUID(of: session), muted: muted)
3    let transaction = CXTransaction(action: muteCallAction)
4    controller.request(transaction) { (error) in
5        if let error = error {
6            print("muteSession \(muted) failed: \(error.localizedDescription)")
7        }
8    }
9}
10func endCall(of session: String) {
11    let endCallAction = CXEndCallAction(call: pairedUUID(of: session))
12    let transaction = CXTransaction(action: endCallAction)
13    controller.request(transaction) { error in
14        if let error = error {
15            print("endSession failed: \(error.localizedDescription)")
16        }
17    }
18}

模擬來電和呼叫

真實的 VoIP 應用需要使用信令系統或者 iOS 的 PushKit 推送,來實現通話呼叫。為了簡單起見,我們在 Demo 上添加了兩個按鈕,直接模擬收到了新的通話呼叫和撥出新的通話。

1private lazy var callCenter = CallCenter(delegate: self)
2@IBAction func doCallOutPressed(_ sender: UIButton) {
3    callCenter.startOutgoingCall(of: session)
4}
5@IBAction func doCallInPressed(_ sender: UIButton) {
6    callCenter.showIncomingCall(of: session)
7}

接著通過實現 CallCenterDelegate 回撥,呼叫我們前面已經預先實現了的使用 Agora SDK 進行視訊通話功能,一個完整的 CallKit 視訊應用就完成了。

 1extension ViewController: CallCenterDelegate {
2    func callCenter(_ callCenter: CallCenter, startCall session: String) {
3        startSession(session)
4    }
5    func callCenter(_ callCenter: CallCenter, answerCall session: String) {
6        startSession(session)
7        callCenter.setCallConnected(of: session)
8    }
9    func callCenter(_ callCenter: CallCenter, declineCall session: String) {
10        print("call declined")
11    }
12    func callCenter(_ callCenter: CallCenter, muteCall muted: Bool, session: String) {
13        muteAudio(muted)
14    }
15    func callCenter(_ callCenter: CallCenter, endCall session: String) {
16        stopSession()
17    }
18}


通話過程中在音訊外放的狀態下鎖屏,會顯示類似運營商電話的通話介面。不過可惜的是,目前 CallKit 還不支援像 FaceTime 那樣的在鎖屏下顯示視訊的功能。

通訊錄/系統通話記錄

使用了 CallKit 的 VoIP 通話會出現在使用者系統的通話記錄中,使用者可以像運營商電話一樣直接點選通話記錄發起新的 VoIP 呼叫。同時使用者通訊錄中也會有對應的選項讓使用者直接使用支援 CallKit 的應用發起呼叫。

實現這個功能並不複雜。無論使用者是點選通訊錄中按鈕,還是點選通話記錄,系統都會啟動開啟對應 app,並觸發 UIApplicationDelegate 的 application(_:continue:restorationHandler:) 回撥。我們可以在這個回撥方法中獲取到被使用者點選的電話號碼,並開始 VoIP 通話。

 1func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
2    guard let interaction = userActivity.interaction else {
3        return false
4    }
5    var phoneNumber: String?
6    if let callIntent = interaction.intent as? INStartVideoCallIntent {
7        phoneNumber = callIntent.contacts?.first?.personHandle?.value
8    } else if let callIntent = interaction.intent as? INStartAudioCallIntent {
9        phoneNumber = callIntent.contacts?.first?.personHandle?.value
10    }
11    let callVC = window?.rootViewController as? ViewController
12    callVC?.applyContinueUserActivity(toCall:phoneNumber)
13    return true
14}
15extension ViewController {
16    func applyContinueUserActivity(toCall phoneNumber: String?) {
17        guard let phoneNumber = phoneNumber, !phoneNumber.isEmpty else {
18            return
19        }
20        phoneNumberTextField.text = phoneNumber
21        callCenter.startOutgoingCall(of: session)
22    }
23}

一些注意點

  1. 必需在專案的後臺模式設定中啟用 VoIP 模式,才可以正常使用 CallKit 的相關功能。這個模式需要在 Info.plist 檔案的 UIBackgroundModes 欄位下新增 voip 項來開啟。 如果沒有開啟後臺 VoIP 模式,呼叫 reportNewIncomingCall(with:update:completion:) 等方法不會有效果。

  2. 當發起通話時,在使用 CXStartCallAction 向系統註冊通話後,系統會啟動應用的 AudioSession,並將其優先順序提高到運營商通話的級別。如果應用在這個過程中自己對 AudioSession 進行設定操作,很可能會導致 AudioSession 啟動失敗。所以應用需要等系統啟動 AudioSession 完成,在收到 CXProviderDelegate 的 provider(_:didActive:) 回撥後,再進行 AudioSession 相關的設定。我們在 Demo 中是通過 Agora SDK 的 disableAudio() 和 enableAudio() 等介面來處理這部分邏輯的。

  3. 整合 CallKit 後,VoIP 來電也會和運營商電話一樣受到使用者系統 “勿擾” 等設定的影響。

  4. 在聽筒模式下按鎖屏鍵,系統會按照結束通話處理。這個行為也和運營商電話一致。

進入 Github 檢視完整程式碼:https://github.com/AgoraIO/Agora-RTC-With-CallKit