iOS CallKit与PushKit的集成(一)

作者: 捡书 | 来源:发表于2018-01-26 16:20 被阅读88次

    很多VoIP的开发者发现,升级到Xcode9以后,原来的Voice over IP的选项消失了,需要自行去info.plist中添加App provides Voice over IP services。


    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电话所需要的。


    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文件给服务器。


    1870246-f767b26f3aceb124.png

    导出证书这里就不做过多赘述,只要知道一点,VoIP的PushKit证书只有Product环境的,但是测试环境也能使。


    1870246-5d199f5d045e84c1.png

    导出p12文件,注意导出的文件大小应该有6kb,如果只有一半说明你没把公钥导进去。


    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的集成(二)

    相关文章

      网友评论

      • 心语风尚:怎么在自己app没有启动情况下监听 电话的接听与挂断

      本文标题:iOS CallKit与PushKit的集成(一)

      本文链接:https://www.haomeiwen.com/subject/xsqwaxtx.html