如何結合 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}
一些注意點
必需在專案的後臺模式設定中啟用 VoIP 模式,才可以正常使用 CallKit 的相關功能。這個模式需要在
Info.plist
檔案的UIBackgroundModes
欄位下新增voip
項來開啟。 如果沒有開啟後臺 VoIP 模式,呼叫reportNewIncomingCall(with:update:completion:)
等方法不會有效果。當發起通話時,在使用
CXStartCallAction
向系統註冊通話後,系統會啟動應用的 AudioSession,並將其優先順序提高到運營商通話的級別。如果應用在這個過程中自己對 AudioSession 進行設定操作,很可能會導致 AudioSession 啟動失敗。所以應用需要等系統啟動 AudioSession 完成,在收到CXProviderDelegate
的provider(_:didActive:)
回撥後,再進行 AudioSession 相關的設定。我們在 Demo 中是通過 Agora SDK 的disableAudio()
和enableAudio()
等介面來處理這部分邏輯的。整合 CallKit 後,VoIP 來電也會和運營商電話一樣受到使用者系統 “勿擾” 等設定的影響。
在聽筒模式下按鎖屏鍵,系統會按照結束通話處理。這個行為也和運營商電話一致。
進入 Github 檢視完整程式碼:https://github.com/AgoraIO/Agora-RTC-With-CallKit