如何結合 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 地址

相關文章
相關標籤/搜索