做者簡介:龔宇華,聲網 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 最重要的類有兩個,CXProvider
和 CXCallController
。這兩個類是 CallKit 框架的核心。app
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
。ide
用戶在系統界面上對通話進行的操做都經過 CXProviderDelegate
中的回調方法通知應用。ui
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 的視頻通話應用中集成 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
來統一管理 CXProvider
和 CXCallController
。
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 匹配查詢系統。最後在調用了 CXProvider
的 reportNewIncomingCall(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
,咱們就能夠用 CXCallController
的 request(_: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)") } } }
一樣的,咱們能夠用 CXSetMutedCallAction
和 CXEndCallAction
來靜音 /結束通話。
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,並觸發 UIApplicationDelegate
的 application(_: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) } }
Info.plist
文件的 UIBackgroundModes
字段下添加 voip
項來開啓。 若是沒有開啓後臺 VoIP 模式,調用 reportNewIncomingCall(with:update:completion:)
等方法不會有效果。CXStartCallAction
向系統註冊通話後,系統會啓動應用的 AudioSession,並將其優先級提升到運營商通話的級別。若是應用在這個過程當中本身對 AudioSession 進行設置操做,極可能會致使 AudioSession 啓動失敗。因此應用須要等系統啓動 AudioSession 完成,在收到 CXProviderDelegate
的 provider(_:didActive:)
回調後,再進行 AudioSession 相關的設置。咱們在 Demo 中是經過 Agora SDK 的 disableAudio()
和 enableAudio()
等接口來處理這部分邏輯的。最後再次附上完整 Demo Github 地址