iOS CallKit与PushKit的集成

很多VoIP的开发者发现,升级到Xcode9以后,原来的Voice over IP的选项消失了,需要自行去info.plist中添加App provides Voice over IP services。在某些时候,对传统VoIP架构的支持将被删除,于是所有的VoIP应用将不得不转移到新的基于PushKit的VoIP架构

这里我就来简单介绍一下如何集成CallKit与PushKit。

要集成,首先就要导入framework,图中的三个framework都要导入,第一个framework是从通讯录中直接拨打App电话所需要的。



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



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


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


下面就可以测试推送啦。。。
我们先来看看在哪里接推送,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拨打电话接入成功了!


接电话


首先来讲一下如果接电话,来到ProviderDelegate中,添加方法:

func reportIncomingCall(uuid: UUID, handle: String, hasVideo: Bool = false, completion: ((Error?) -> Void)?) {
    //准备向系统报告一个 call update 事件,它包含了所有的来电相关的元数据。
    let update = self.callUpdate(handle: handle, hasVideo: hasVideo)
    //调用 CXProvider 的reportIcomingCall(with:update:completion:)方法通知系统有来电。
    provider.reportNewIncomingCall(with: uuid, update: update) { error in
        if error == nil {
            //completion 回调会在系统处理来电时调用。如果没有任何错误,你就创建一个 Call 实例,将它添加到 CallManager 的通话列表。
            let call = Call(uuid: uuid, handle: handle)
            self.callManager.add(call: call)
        }
        //调用 completion,如果它不为空的话。
        completion?(error)
    }
}

这个方法需要在所有接电话的地方手动调用,需要根据自己的业务逻辑来判断。还有就是不要忘了iOS的版本兼容哦。。

在ProviderDelegate中实现系统接电话的代理:
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
    //从 callManager 中获得一个引用,UUID 指定为要接听的动画的 UUID。
    guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
        action.fail()
        return
    }
    //设置通话要用的 audio session 是 App 的责任。系统会以一个较高的优先级来激活这个 session。
    configureAudioSession()
    //通过调用 answer,你会表明这个通话现在激活
    call.answer()
    //在这里添加自己App接电话的逻辑

    //在处理一个 CXAction 时,重要的一点是,要么你拒绝它(fail),要么满足它(fullfill)。如果处理过程中没有发生错误,你可以调用 fullfill() 表示成功。
    action.fulfill()
}


回到AppDelegate中,找到之前写的PushKit收到推送的代理方法,在里面添加:

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("Callkit& pushRegistry didReceiveIncomingPush")
    //别忘了在这里加上你们自己接电话的逻辑,比如连接聊天服务器啥的,不然这个电话打不通的
    if let uuidString = payload.dictionaryPayload["UUID"] as? String,
        let handle = payload.dictionaryPayload["handle"] as? String,
        let hasVideo = payload.dictionaryPayload["hasVideo"] as? Bool,
        let uuid = UUID(uuidString: uuidString)
    {
        if #available(iOS 10.0, *) {
            ProviderDelegate.shared.reportIncomingCall(uuid: uuid, handle: handle, hasVideo: hasVideo, completion: { (error) in
                if let e = error {
                    log.info("CallKit& displayIncomingCall Error \(e)")
                }
            })
        } else {
            // Fallback on earlier versions
        }
    }
}

至此,CallKit接电话的逻辑完成了,你只需要在合适的地方调用reportIncomingCall就可以调出系统的通话页面了。


挂电话


来到CallKitManager中,添加方法:

func end(call: Call) {
    //先创建一个 CXEndCallAction。将通话的 UUID 传递给构造函数,以便在后面可以识别通话。
    let endCallAction = CXEndCallAction(call: call.uuid)
    //然后将 action 封装成 CXTransaction,以便发送给系统。
    let transaction = CXTransaction(action: endCallAction)
    requestTransaction(transaction)
}

来到ProviderDelegate中,实现系统代理:
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
    //从 callManager 获得一个 call 对象。
    guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
        action.fail()
        return
    }
    //当 call 即将结束时,停止这次通话的音频处理。
    stopAudio()
    //调用 end() 方法修改本次通话的状态,以允许其他类和新的状态交互。
    call.end()
    //在这里添加自己App挂断电话的逻辑
    //将 action 标记为 fulfill。
    action.fulfill()
    //当你不再需要这个通话时,可以让 callManager 回收它。
    callManager.remove(call: call)
}

添加完之后,只需要在你自己App挂断电话的地方调用:
if #available(iOS 10.0, *) {
    if let call = CallKitManager.shared.calls.first { //因为我们这里不支持群通话,所以一次只有一个call
        CallKitManager.shared.end(call: call)
    }
}

就可以了。。这里的CallKitManager.shared.calls保存了所有CallKit的通话。是咱们自己写的工具类哦,忘了的话自己翻翻上篇文章。

至此,CallKit挂电话的逻辑结束。。


通话暂时挂起

来到CallKitManager中,添加方法:
func setHeld(call: Call, onHold: Bool) {
    //这个 CXSetHeldCallAction 包含了通话的 UUID 以及保持状态
    let setHeldCallAction = CXSetHeldCallAction(call: call.uuid, onHold: onHold)
    let transaction = CXTransaction()
    transaction.addAction(setHeldCallAction)

    requestTransaction(transaction)
}

来到ProviderDelegate中,实现系统代理:
func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
    guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
        action.fail()
        return
    }
    //获得 CXCall 对象之后,我们要根据 action 的 isOnHold 属性来设置它的 state。
    call.state = action.isOnHold ? .held:.active
    //根据状态的不同,分别进行启动或停止音频会话。
    if call.state == .held {
        stopAudio()
    } else {
        startAudio()
    }
    //在这里添加你们自己的通话挂起逻辑
    action.fulfill()
}

添加完之后,只需要在你自己App通话暂时挂起的地方调用:
if #available(iOS 10.0, *) {
    if let call = CallKitManager.shared.calls.first {
        CallKitManager.shared.setHeld(call: call, onHold: true)
    }
}

就可以了。。

至此,CallKit通话暂时挂起的逻辑结束。。


麦克风静音


来到CallKitManager中,添加方法:

func setMute(call: Call, muted: Bool) {
    //CXSetMutedCallAction设置麦克风静音
    let setMuteCallAction = CXSetMutedCallAction(call: call.uuid, muted: muted)
    let transaction = CXTransaction()
    transaction.addAction(setMuteCallAction)
    requestTransaction(transaction)
}

来到ProviderDelegate中,实现系统代理:
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
    guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
        action.fail()
        return
    }
    //获得 CXCall 对象之后,我们要根据 action 的 ismuted 属性来设置它的 state。
    call.state = action.isMuted ? .muted : .active
    //在这里添加你们自己的麦克风静音逻辑
    action.fulfill()
}

添加完之后,只需要在你自己App麦克风静音的地方调用:
if #available(iOS 10.0, *) {
    if let call = CallKitManager.shared.calls.first {
        CallKitManager.shared.setMute(call: call, muted: true)
    }
}

就可以了。。

至此,CallKit麦克风静音的逻辑结束。。

到这里,在App内互动的CallKit的基本功能都已经集成完毕,其实到后面大家就能看出来,文章中所有的功能实现,都是先在CallKitManager写用户需要调用的方法,在ProviderDelegate里面实现系统的代理方法,并且加上自己的通话逻辑。


关于系统扬声器与听筒的切换


这里不讲如何切换扬声器与听筒,只讲如何监听切换,保持App内通话页面免提的状态跟系统通话页面的一致。

在自己的通话页面上添加通知监听:
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVAudioSessionRouteChange, object: nil, queue: OperationQueue.main) {[weak self] (noti) in
    guard let w = self else { return }
    if #available(iOS 10.0, *) {
        let route = AVAudioSession.sharedInstance().currentRoute
        for desc in route.outputs {
            if desc.portType == "Speaker" {
                // "免提功能已开启"
            } else {
                // "对方已接通,请使用听筒接听"
            }
        }
    }
}


从系统通话记录中拨打App电话


在文章的开头,我们已经导入了Intents.framework,下面开始对他进行操作。

首先创建两个协议,目的是为了加几个属性:
protocol StartCallConvertible {
    var startCallHandle: String? { get }
    var video: Bool? { get }
}

extension StartCallConvertible {
    var video: Bool? {
        return nil
    }
}

@available(iOS 10.0, *)
    protocol SupportedStartCallIntent {
    var contacts: [INPerson]? { get }
}

@available(iOS 10.0, *)
extension INStartAudioCallIntent: SupportedStartCallIntent {}
@available(iOS 10.0, *)
extension INStartVideoCallIntent: SupportedStartCallIntent {}

然后对NSUserActivity进行扩展,实现StartCallConvertible协议:
extension NSUserActivity: StartCallConvertible {

    var startCallHandle: String? {
        if #available(iOS 10.0, *) {
            guard let interaction = interaction,
                    let startCallIntent = interaction.intent as? SupportedStartCallIntent,
                    let contact = startCallIntent.contacts?.first
            else {
                return nil
            }
            return contact.personHandle?.value
        }
        return nil
    }

    var video: Bool? {
        if #available(iOS 10.0, *) {
            guard let interaction = interaction,
                let startCallIntent = interaction.intent as? SupportedStartCallIntent
            else {
                return nil
            }

            return startCallIntent is INStartVideoCallIntent
        }
        return nil
    }
}

回到AppDelegate中,实现代理方法:
override func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
    if #available(iOS 10.0, *) {
        guard let handle = userActivity.startCallHandle else {
            log.info("Callkit& Could not determine start call handle from user activity: \(userActivity)")
            return false
        }

        guard let video = userActivity.video else {
            log.info("Callkit& Could not determine video from user activity: \(userActivity)")
            return false
        }
        //如果当前有电话,需要根据自己App的业务逻辑判断

        //如果没有电话,就打电话,调用自己的打电话方法。

        return true
    }
    return false
}

至此,从通话记录中调到自己App打电话的功能就实现啦。

猜你喜欢

转载自blog.csdn.net/youshaoduo/article/details/79192440