iOS CallKit與PushKit的整合(一)

weixin_34148456發表於2018-01-26

宣告一下,現在國區的App Store應中國特色社會主義的要求,禁止上架有callkit功能的APP,已有的也要整改,刪除callkit功能。

很多VoIP的開發者發現,升級到Xcode9以後,原來的Voice over IP的選項消失了,需要自行去info.plist中新增App provides Voice over IP services。


4058077-de2e70951d923ce2.png
20180122153315445.png

隱藏了這個選項其實是為了強制大家使用CallKit+PushKit來做VoIP的應用程式。

我們的經驗是基於PushKit的VoIP應用程式比那些使用傳統VoIP架構的應用程式更可靠,更省電。

具體來說,我們鼓勵VoIP應用程式充分利用iOS 10 SDK中的新框架CallKit,從根本上改善了VoIP應用程式的使用者體驗。

另外,請注意,macOS 10.12 Sierra不支援Xcode 7。

在某些時候,對傳統VoIP架構的支援將被刪除,於是所有的VoIP應用將不得不轉移到新的基於PushKit的VoIP架構。

這裡我就來簡單介紹一下如何整合CallKit與PushKit

要整合,首先就要匯入framework,圖中的三個framework都要匯入,第一個framework是從通訊錄中直接撥打App電話所需要的。


4058077-67e6cc0c59bcf157.png
A8E35734-9BDD-4921-A7E8-64E37AD3C407.png

PushKit

這個是iOS8後才支援的框架,如果你的專案現在還在支援iOS7,那麼你可以以辭職為籌碼去跟產品經理鬥智鬥勇了。

整合PushKit很簡單,跟註冊普通的APNS推送一個樣,先去註冊:

//import PushKit  這個加在檔案頭部。大家都是老司機了,缺標頭檔案自己加。
let voipRegistry = PKPushRegistry(queue: DispatchQueue.main)
voipRegistry.delegate = self
voipRegistry.desiredPushTypes = [PKPushType.voIP]

然後註冊成功沒呢?看這個代理方法:

func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
        if pushCredentials.token.count > 0 {
            var token = NSString(format: "%@", pushCredentials.token as CVarArg) as String
            print("pushRegistry credentialsToken \(token)")
        }
    }

大家注意了,這裡的token跟APNS的deviceToken雖然長度和格式一樣,但是內容是不同的。這是因為蘋果需要區分這是PushKit的推送還是APNS的推送。

註冊好token後,就可以上傳給自己的伺服器了。然後需要自己的伺服器發推送。
這裡就牽扯到證書的問題了,首先要知道的是,VoIP的PushKit推送證書跟APNS的是兩個不同的證書,需要自己去生成,然後匯出p12檔案給伺服器。


4058077-c676a2eae0598956.png
1870246-f767b26f3aceb124.png

匯出證書這裡就不做過多贅述,只要知道一點,VoIP的PushKit證書只有Product環境的,但是測試環境也能使。


4058077-719b111251b76fc6.png
1870246-5d199f5d045e84c1.png

匯出p12檔案,注意匯出的檔案大小應該有6kb,如果只有一半說明你沒把公鑰導進去。


4058077-ebe88ef9a20909ab.png
1870246-e5d4fe2e73dfc69e.png

下面就可以測試推送啦。。。
我們先來看看在哪裡接推送,Appdelegate裡面有這個方法:

func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) {
        guard type == .voIP else {
            log.info("Callkit& pushRegistry didReceiveIncomingPush But Not VoIP")
            return
        }
        log.info("pushRegistry didReceiveIncomingPush")
    }

這個方法裡的PKPushPayload裡有個dictionaryPayload,是個字典,作用跟APNS裡的info一個樣。。。要學會舉一反三吶。。

至此,一套PushKit的推送流程就搭建好了。。如果伺服器沒搞好,但是想測試的話,可以用這個:
https://github.com/noodlewerk/NWPusher
一個很牛逼的Push測試軟體。用的HTTP2,只要證書選對,token填對,就能發啦。。

CallKit

重點來了。。
對於CallKit首先要明確一點。在你使用的時候,不要把他看成一個很複雜的框架,他就是系統的打電話頁面,跟你自己寫的打電話頁面一樣一樣的;只要是頁面,就可以呼叫顯示和消失,可以對上面的按鈕進行操作。

工欲善其事必先利其器,我們首先來建立幾個工具類:
第一個,Call類,用來管理CallKit的電話,注意是管理CallKit的電話,跟你自己的電話邏輯不衝突!!

enum CallState { //狀態都能看得懂吧。。看不懂的自己打個電話想想流程。
    case connecting
    case active
    case held
    case ended
    case muted
}

enum ConnectedState {
    case pending
    case complete
}

class Call {
  
    let uuid: UUID //來電的唯一識別符號
    let outgoing: Bool //是撥打的還是接聽的
    let handle: String //後面很多地方用得到,名字都是handle哈,可以理解為電話號碼,其實就是自己App裡被呼叫方的賬號(至少我們是這樣的)。。

    var state: CallState = .ended {
        didSet {
            stateChanged?()
        }
    }
  
    var connectedState: ConnectedState = .pending {
        didSet {
             connectedStateChanged?()
        }
    }
  
    var stateChanged: (() -> Void)?
    var connectedStateChanged: (() -> Void)?
  
    init(uuid: UUID, outgoing: Bool = false, handle: String) {
        self.uuid = uuid
        self.outgoing = outgoing
        self.handle = handle
    }
  
    func start(completion: ((_ success: Bool) -> Void)?) {
        completion?(true)

        DispatchQueue.main.asyncAfter(wallDeadline: DispatchWallTime.now() + 3) {
            self.state = .connecting
            self.connectedState = .pending
      
            DispatchQueue.main.asyncAfter(wallDeadline: DispatchWallTime.now() + 1.5) {
                self.state = .active
                self.connectedState = .complete
            }
        }
    }
  
    func answer() {
        state = .active
    }
  
    func end() {
        state = .ended
    }
}

然後建立一個Audio類,用來管理音訊,鈴聲的播放。

func configureAudioSession() { //這裡必須這麼做。。不然會出現沒鈴聲的情況。原因嘛。。我也不知道。。
    log.info("Callkit& Configuring audio session")
    let session = AVAudioSession.sharedInstance()
    do {
        try session.setCategory(AVAudioSessionCategoryPlayAndRecord)
        try session.setMode(AVAudioSessionModeVoiceChat)
    } catch (let error) {
        log.info("Callkit& Error while configuring audio session: \(error)")
    }
}

func startAudio() {
    log.info("Callkit& Starting audio")
    //開始播放鈴聲
}

func stopAudio() {
    log.info("Callkit& Stopping audio")
    //停止播放鈴聲
}

工具類都做好了,下面開始整合CallKit~~~~~~~~~~~
首先,建立一個CallKitManager的類,只要是使用者發起的動作,都跟這個類有關係。

@available(iOS 10.0, *)
class CallKitManager {
    
    static let shared = CallKitManager()
  
    var callsChangedHandler: (() -> Void)?

    private let callController = CXCallController()
    private(set) var calls = [Call]()
    
    private init(){}

    func callWithUUID(uuid: UUID) -> Call? {
        guard let index = calls.index(where: { $0.uuid == uuid }) else {
          return nil
        }
        return calls[index]
    }

    func add(call: Call) {
        calls.append(call)
        call.stateChanged = { [weak self] in
          guard let strongSelf = self else { return }
          strongSelf.callsChangedHandler?()
        }
        callsChangedHandler?()
    }

    func remove(call: Call) {
        guard let index = calls.index(where: { $0 === call }) else { return }
        calls.remove(at: index)
        callsChangedHandler?()
    }

    func removeAllCalls() {
        calls.removeAll()
        callsChangedHandler?()
    }
}

想必大家都發現了,現在CallKitManager裡面只有callController跟CallKit有關係,不急,我們一點一點的把這個類豐富起來。這麼做是為了加深理解,並不是簡單的複製程式碼,到時候出了問題知道在哪進行改動。

現在CallKitManager裡面的函式,其實是用了我們自己寫的Call類,對CallKit做一個邏輯的管理,大家發現了,這裡就跟佇列一個樣,add、remove、removeAll、callWithUUID(根據uuid去找到這個call物件)。

然後我們來看一下callController這個CXCallController物件,CallKitManager裡面目前唯一與CallKit有關係就是他。CXCallController可以讓系統收到App的一些Request,使用者的action,App內部的事件。

我們現在來豐富CallKitManager,先從打電話開始:
新增下列程式碼:

func startCall(handle: String, videoEnabled: Bool) {
        //一個 CXHandle 物件表示了一次操作,同時指定了操作的型別和值。App支援對電話號碼進行操作,因此我們在操作中指定了電話號碼。
        let handle = CXHandle(type: .phoneNumber, value: handle)
        //一個 CXStartCallAction 用一個 UUID 和一個操作作為輸入。
        let startCallAction = CXStartCallAction(call: UUID(), handle: handle)
        //你可以通過 action 的 isVideo 屬性指定通話是音訊還是視訊。
        startCallAction.isVideo = videoEnabled
        let transaction = CXTransaction(action: startCallAction)
        requestTransaction(transaction)
    }

//呼叫 callController 的 request(_:completion:) 。系統會請求 CXProvider 執行這個 CXTransaction,這會導致你實現的委託方法被呼叫。
    private func requestTransaction(_ transaction: CXTransaction) {
        callController.request(transaction) { error in
            if let error = error {
                log.info("Callkit& Error requesting transaction: \(error)")
            } else {
                log.info("Callkit& Requested transaction successfully")
            }
        }
    }

是不是迫不及待的想呼叫一下這個函式了?但是呼叫後發現,並沒有什麼事情發生。。
其實就是這樣。。因為你只向系統傳送了要打電話的請求,但是系統也要告訴你你現在可不可以打,這樣才叫與系統通訊嘛。。不能只是單方面的要求,還需要對方的應答。這裡其實就跟伺服器請求一個樣,發要求,等回應,收到回應後進行下一步操作。

那麼這裡,我們就需要來接收系統的回應了。。怎麼接收到呢?
我們新建一個類,名字叫ProviderDelegate,繼承自誰不重要,重要的是需要遵循CXProviderDelegate這個代理。

@available(iOS 10.0, *)
class ProviderDelegate: NSObject, CXProviderDelegate {
    static let shared = ProviderDelegate()
    //ProviderDelegate 需要和 CXProvider 和 CXCallController 打交道,因此保持兩個對二者的引用。
    private let callManager: CallKitManager //還記得他裡面有個callController嘛。。
    private let provider: CXProvider
    
    override init() {
        self.callManager = CallKitManager.shared
        //用一個 CXProviderConfiguration 初始化 CXProvider,前者在後面會定義成一個靜態屬性。CXProviderConfiguration 用於定義通話的行為和能力。
        provider = CXProvider(configuration: type(of: self).providerConfiguration)
        super.init()
        //為了能夠響應來自於 CXProvider 的事件,你需要設定它的委託。
        provider.setDelegate(self, queue: nil)
    }
    
    //通過設定CXProviderConfiguration來支援視訊通話、電話號碼處理,並將通話群組的數字限制為 1 個,其實光看屬性名大家也能看得懂吧。
    static var providerConfiguration: CXProviderConfiguration {
        let providerConfiguration = CXProviderConfiguration(localizedName: "Mata Chat")//這裡填你App的名字哦。。
        providerConfiguration.supportsVideo = false
        providerConfiguration.maximumCallsPerCallGroup = 1
        providerConfiguration.maximumCallGroups = 1
        providerConfiguration.supportedHandleTypes = [.phoneNumber]
        return providerConfiguration
    }
    
    //這個方法牛逼了,它是用來更新系統電話屬性的。。
    func callUpdate(handle: String, hasVideo: Bool) -> CXCallUpdate {
        let update = CXCallUpdate()
        update.localizedCallerName = "ParadiseDuo"//這裡是系統通話記錄裡顯示的聯絡人名稱哦。需要顯示什麼按照你們的業務邏輯來。
        update.supportsGrouping = false
        update.supportsHolding = false
        update.remoteHandle = CXHandle(type: .phoneNumber, value: handle) //填了聯絡人的名字,怎麼能不填他的handle('電話號碼')呢,具體填什麼,根據你們的業務邏輯來
        update.hasVideo = hasVideo
        return update
    }

    //CXProviderDelegate 唯一一個必須實現的代理方法!!當 CXProvider 被 reset 時,這個方法被呼叫,這樣你的 App 就可以清空所有去電,會到乾淨的狀態。在這個方法中,你會停止所有的撥出音訊會話,然後拋棄所有啟用的通話。
    func providerDidReset(_ provider: CXProvider) {
        stopAudio()
        for call in callManager.calls {
            call.end()
        }
        callManager.removeAllCalls()
        //這裡新增你們結束通話電話或拋棄所有啟用的通話的程式碼。。
    }
}

上面的ProviderDelegate準備工作做好後,繼續我們打電話的邏輯,在ProviderDelegate新增代理方法:

func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        //向系統通訊錄更新通話記錄
        let update = self.callUpdate(handle: action.handle.value, hasVideo: action.isVideo)
        provider.reportCall(with: action.callUUID, updated: update)
        
        let call = Call(uuid: action.callUUID, outgoing: true, handle: action.handle.value)
        //當我們用 UUID 建立出 Call 物件之後,我們就應該去配置 App 的音訊會話。和呼入通話一樣,你的唯一任務就是配置。真正的處理在後面進行,也就是在 provider(_:didActivate) 委託方法被呼叫時
        configureAudioSession()
        //delegate 會監聽通話的生命週期。它首先會會報告的就是撥出通話開始連線。當通話最終連上時,delegate 也會被通知。
        call.connectedStateChanged = { [weak self] in
            guard let w = self else {
                return
            }
            if call.connectedState == .pending {
                w.provider.reportOutgoingCall(with: call.uuid, startedConnectingAt: nil)
            } else if call.connectedState == .complete {
                w.provider.reportOutgoingCall(with: call.uuid, connectedAt: nil)
            }
        }
        //呼叫 call.start() 方法會導致 call 的生命週期變化。如果連線成功,則標記 action 為 fullfill。
        call.start { [weak self] (success) in
            guard let w = self else {
                return
            }
            if success {
               //這裡填寫你們App內打電話的邏輯。。
  
                w.callManager.add(call: call)
                //所有的Action只有呼叫了fulfill()之後才算執行完畢。
                action.fulfill()
            } else {
                action.fail()
            }
        }
    }

//當系統啟用 CXProvider 的 audio session時,委託會被呼叫。這給你一個機會開始處理通話的音訊。
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        startAudio() //一定要記得播放鈴聲吶。。
    }

至此,通過CallKit撥打電話的邏輯就完成了。你只要在自己App需要打電話的地方,呼叫
CallKitManager.shared.startCall(handle: userName, videoEnabled: false)就行啦。。但是有一點需要注意,CallKit只有iOS 10以上支援,所以iOS 10以下的手機還是要支援你們原來打電話的邏輯,像這樣:

if #available(iOS 10.0, *) {
       CallKitManager.shared.startCall(handle:userName, videoEnabled: false)
} else {
      //原來打電話的邏輯
}

然後當你興沖沖的去用CallKit打電話的時候,卻發現彈出的是自己的通話頁面。。。T_T
但是此時你檢視系統的通話記錄,應該會發現通話記錄裡面新增了一條從自己App打出去的記錄。這樣就說明CallKit撥打電話接入成功了!

因為內容較多,分成了三篇文章,下一篇講如何接電話,繼續完善這篇文章的程式碼。
iOS CallKit與PushKit的整合(二)

相關文章