iOS 使用 PushKit & CallKit 搭建通话

3,619 阅读4分钟

通话流程

Background Mode

Target > Signing & Capabilities > Background Modes

PushKit

推送权限

请求到推送权限之后,需要调用 register

 UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) {[weak self] granted, error in
     if granted {
         self?.updateNotificationToken()
     }
     UIApplication.shared.registerForRemoteNotifications()
}

停止推送

如果不需要继续接收推送,需要调用unregister

UIApplication.shared.unregisterForRemoteNotifications()

设置 PKPushRegistry

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool

设置 PKPushRegistry

var voipRegistry = PKPushRegistry(queue: nil)

func setupPush() {
    voipRegistry.delegate = self
    voipRegistry.desiredPushTypes = [PKPushType.voIP]
    UIApplication.shared.registerForRemoteNotifications()
}

PKPushRegistryDelegate

推送的token会发生变化(过期、更新),在下面的方法可以监听并做相应的处理

// token更新的时候调用 在这里提交/缓存新的token
public func pushRegistry(_ registry: PKPushRegistry,
                         didUpdate pushCredentials: PKPushCredentials,
                         for type: PKPushType) 
                        
// token失效的时候调用 在这里清空缓存的token
public func pushRegistry(_ registry: PKPushRegistry,
                         didInvalidatePushTokenFor
                         type: PKPushType)

收到通话推送时的核心操作,只要是VOIP推送必须上报,不上报必Crash。

上报详情看 CallKit 的 上报IncomingCall章节

public func pushRegistry(_ registry: PKPushRegistry,
                         didReceiveIncomingPushWith payload: PKPushPayload,
                         for type: PKPushType,
                         completion: @escaping () -> Void) {
    if type == .voIP {
        reportInComingCall()
    }
}

CallKit

主要用到的类如下

  • CXProviderConfiguration 配置callkit
  • CXProvider 接收 callkit 操作的类
  • CXCallController 系统通话页面
  • CXAction 通话页面的操作,可修改callkit
  • CXTransaction 提交操作的载体

初始化

  • 开发者监听用户对CallKit 的操作,需要通过 Provider
  • CXProviderConfiguration用于初始化Provider,并决定CallKit 开放的功能

创建 Config

func createConfig() -> CXProviderConfiguration {
    let providerConfiguration = CXProviderConfiguration(localizedName: "Call")
    // true=callKit显示Video按钮;false=callKit显示FaceTime按钮
    providerConfiguration.supportsVideo = false
    // group 同时设置1 callKit 可以禁用 addCall按钮
    providerConfiguration.maximumCallGroups = 1
    providerConfiguration.maximumCallsPerCallGroup = 1
    // callKit App按钮的图标
    providerConfiguration.iconTemplateImageData = data
    // 铃声
    providerConfiguration.ringtoneSound = sound
    // 展示在系统的最近通话
    providerConfiguration.includesCallsInRecents = true
    // 类型
    providerConfiguration.supportedHandleTypes = [.generic]
    return providerConfiguration
}

创建 Provider

func setupProvider() {
    let config = createConfig()
    provider = CXProvider(configuration: config)
    provider.setDelegate(self, queue: nil)
}

上报 IncomingCall

只要是 voip推送(IncomingCall) 必须上报,不上报必Crash。

上报之后会拉起 CallKit 的推送UI

func reportInComingCall(callerID: String, completion: (() -> Void)?) {
    // 判断一下是否满足自己的通话条件
    if isIllegalCall(callerID) {
        // 不满足也要上报假通话 不然crash
        reportFakeCall(completion: completion)
        return
    }
    // 上报真实的通话
    reportCall(callerID: callerID, completion: completion)
}

上报假通话

func reportFakeCall(completion: (() -> Void)?) {
    let update = CXCallUpdate()
    update.supportsHolding = false
    update.supportsGrouping = false
    update.supportsUngrouping = false
    update.supportsDTMF = false
    update.hasVideo = false
    // 虚假UUID 32位 有格式要求
    let fakeUUID = UUID(uuidString: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE")!
    provider.reportNewIncomingCall(with: fakeUUID, update: update) { [weak self] _ in
        completion?()
        // 直接挂断虚假通话
        self?.provider.reportCall(with: uuid, endedAt: nil, reason: .unanswered)
    }
}

上报真实通话

func reportCall(callerID: String, completion: (() -> Void)?) {
    let update = CXCallUpdate()
    let callerUUID = UUID()
    update.remoteHandle = CXHandle(type: .generic, value: callerID)
    update.localizedCallerName = callerID
    provider.reportNewIncomingCall(with: callerUUID, update: update) { [weak self] error in
        if let error {
            print(error)
        }
        completion?()
    }
}

监听用户操作

用户操作CallKit页面上的按钮都会通过`CXProviderDelegate`回调

用户点击了接听

func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
    action.fulfill()
}

用户点击了挂断

func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
    action.fulfill()
}

用户点击静音

在这里提交操作和同步自定义页面UI

action.fulfill() 必须告知 callkit 这个操作完成了,不然状态会刷回去

func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
    print(action.isMuted)
    action.fulfill()
}

输出设备状态变化

在这里同步自定义页面的音频设备UI

func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {}

提交操作到CallKit

常见的 Action

  • 接听:`CXAnswerCallAction`

  • 挂断:`CXEndCallAction`

  • 静音:`CXSetMutedCallAction`

  • 挂起:`CXSetHeldCallAction`

  • 群组:`CXSetGroupCallAction`

  • 双频多音:`CXPlayDTMFCallAction`

提交步骤都

  • 创建一个 `CXAction`
  • 根据 aciton 创建 `CXTransaction`
  • `CXCallController` 提交 transaction

提交成功之后会触发 `CXProviderDelegate` ,注意处理状态同步的时候不要相互调用

func setupMute(isMute: Bool) {
    guard let uuid = activeCall.uuid else {
        return
    }
    let action = CXSetMutedCallAction(call: uuid, muted: isMute)
    let transaction = CXTransaction(action: action)
    callController.request(transaction) { error in
        if let error {
            Log.e(error)
            return
        }
    }
}

func hangup(uuid: UUID) {
    let action = CXEndCallAction(call: uuid)
    let transaction = CXTransaction(action: action)
    callController.request(transaction) { error in
        if let error {    
            Log.e(error)
        }
    }
}

Speaker处理

配置好 AudioSession,CallKit UI会自动更新

func setupAudioSession(speakerOn: Bool) {
    let audioSession = AVAudioSession.sharedInstance()
    do {
        try audioSession.setCategory(.playAndRecord,
                                     mode: .voiceChat,
                                     options: speakerOn ? .defaultToSpeaker : .allowBluetooth)
        try audioSession.overrideOutputAudioPort(speakerOn ? .speaker : .none)
        try audioSession.setActive(true)
    } catch let error {
        Log.e(error)
    }
}

AVAudioSessionCategory

AVAudioSessionCategory播放录音打断其他App受静音键影响
Ambient❌️❌️
SoloAmbient❌️
Playback❌️❌️
Record❌️❌️
PlayAndRecord❌️
AudioProcessing❌️❌️❌️
MultiRoute❌️❌️

AVAudioSessionMode

AVAudioSessionModeAVAudioSessionCategory默认AVAudioSessionCategoryOption场景
DefaultAll-
VoiceChatPlayAndRecordAllowBluetooth
DefaultToSpeaker
VoIP
VideoChatPlayAndRecordAllowBluetooth
DefaultToSpeaker
视频聊天
GameChatPlayAndRecordAllowBluetooth
DefaultToSpeaker
游戏
VideoRecordingPlayAndRecord
Record
AVCaptureSession API摄像头采集视频
MeasurementPlayAndRecord
Record
Playback
-最小系统
MoviePlaybackPlayback-视频播放

AVAudioSessionCategoryOption

AVAudioSessionCategoryOption可用的AVAudioSessionCategory作用
MixWithOthersPlayback
PlayAndRecord
MultiRoute
不会打断其他应用程序的音频播放
DuckOthers
PlayAndRecord
Record
会话时降低其他程序的音频播放声音
AllowBluetoothAll允许蓝牙设备
DefaultToSpeaker
All默认选择内置扬声器
InterruptSpokenAudioAndMixWithOthersAll使用时打断其他应用,结束时自动恢复其他应用的播放
AllowBluetoothA2DPAll允许A2DP蓝牙设备
AllowAirPlayAll允许AirPlay