苹果在 WWDC2016 推出了 iOS10 系统新功能 CallKit framework, 代替了原来的 CoreTelephony.framework, 可以调起系统的接听页进行音视频通话; iOS8 中苹果新引入了 PushKit 的框架和一种新的 push 通知类型: VoIP push, 提供区别于普通 APNS push 的能力, 通过这种 push 方式收到消息时会直接将已经杀掉的 APP 激活, 两个库配合使用形成了一套完整的 VoIP 解决方案. 由于 CallKit 支持版本较高, 而且限定了应用场景, 目前集成的 APP 不是很多, 官方文档和网上博客对相关功能介绍细节都很有限, 这篇文章主要为了记录一下项目过程中遇到的问题.
==========
效果图如下, 因为 CallKit 使用的是系统原生的控件, iOS10 与 iOS11 的样式上有区别:
==========
闲鱼调用的逻辑图如下:
==========
下面是 CallKit 和 PushKit 这两个库的简单介绍:
CallKit 主要有: CXProvider,CXCallController,CXProviderConfiguration 这三个类, 使用时需要新建一个 CallKit 管理类并实现 CXProviderDelegate 协议. 实现步骤如下:
1, 设置 CXProviderConfiguration
- static CXProviderConfiguration* configInternal = nil;
- configInternal = [[CXProviderConfiguration alloc] initWithLocalizedName:@"闲鱼"];
- configInternal.supportsVideo = true;
- configInternal.maximumCallsPerCallGroup = 1;
- configInternal.maximumCallGroups = 1;
- configInternal.supportedHandleTypes = [[NSSet alloc] initWithObjects:[NSNumber numberWithInt:CXHandleTypeGeneric],[NSNumber numberWithInt:CXHandleTypePhoneNumber], nil];
- UIImage* iconMaskImage = [UIImage imageNamed:@"IconMask"];
- configInternal.iconTemplateImageData = UIImagePNGRepresentation(iconMaskImage);
2, 初始化 CXProvider 与 CXCallController
- self.provider = [[CXProvider alloc] initWithConfiguration: configInternal];
- [provider setDelegate:self queue:dispatch_get_main_queue()];
- self.callController = [[CXCallController alloc] initWithQueue:dispatch_get_main_queue()];
3, 实现通话流程或按钮的回调方法(每个回调结束的时候要执行[action fulfill]; 否则会提示通话失败)
- - (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action;
- - (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action;
- - (void)provider:(CXProvider *)provider performEndCallAction:(CXEndCallAction *)action;
- - (void)provider:(CXProvider *)provider performSetHeldCallAction:(CXSetHeldCallAction *)action;
- - (void)provider:(CXProvider *)provider performSetMutedCallAction:(CXSetMutedCallAction *)action;
- - (void)provider:(CXProvider *)provider performSetGroupCallAction:(CXSetGroupCallAction *)action;
- - (void)provider:(CXProvider *)provider performPlayDTMFCallAction:(CXPlayDTMFCallAction *)action;
- ......
4, 实现呼起电话和结束电话的方法
- - (void)reportIncomingCallWithTitle:(NSString *)title Sid:(NSString *)sid{
- CXCallUpdate* update = [[CXCallUpdate alloc] init];
- update.supportsDTMF = false;
- update.supportsHolding = false;
- update.supportsGrouping = false;
- update.supportsUngrouping = false;
- update.hasVideo = false;
- update.remoteHandle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:sid];
- update.localizedCallerName = title;
- NSUUID *uuid = [NSUUID UUID];
- // 弹出电话页面
- [self.provider reportNewIncomingCallWithUUID:uuid update:update completion:^(NSError * _Nullable error) {
- }];
- }
- - (void)endCallAction {
- CXEndCallAction* endCallAction = [[CXEndCallAction alloc] initWithCallUUID:self.currentCall];
- CXTransaction* transaction = [[CXTransaction alloc] init];
- [transaction addAction:endCallAction];
- // 关闭电话页面
- [_callController requestTransaction:transaction completion:^(NSError * _Nullable error) {
- }];
- }
PushKit 主要有 3 步操作:
1, 通过 PKPushRegistry 注册 VoIP 服务(一般在 APP 启动代码里添加)
- - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
- PKPushRegistry *pushRegistry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()];
- pushRegistry.delegate = self;
- pushRegistry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP];
- return YES;
- }
2, 实现 PKPushRegistryDelegate 获取 token 方法
- - (void)pushRegistry:(PKPushRegistry *)registry didUpdatePushCredentials:(PKPushCredentials *)credentials forType:(NSString *)type {
- NSString *str = [NSString stringWithFormat:@"%@",credentials.token];
- NSString *tokenStr = [[[str stringByReplacingOccurrencesOfString:@"<" withString:@""]
- stringByReplacingOccurrencesOfString:@">" withString:@""] stringByReplacingOccurrencesOfString:@" "withString:@""];
- // 上传 token 处理
- }
3, 实现 PKPushRegistryDelegate 接收 VoIP 消息方法
- - (void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(NSString *)type {
- NSDictionary *alert = [payload.dictionaryPayload[@"aps"] objectForKey:@"alert"];
- // 调用 CallKit 处理
- }
- ==========
在做 VoIP 方案时可能会遇到的问题:
Q: 锁屏时收不到 VoIP 消息的问题
A: 开发时遇到一个非锁屏下能正常收到 VoIP push, 但锁屏时经常收不到的问题, 经排查, 是锁屏下收到 VoIP 时 APP 发生了 crash,crash 日志里显示的原因是 Termination Reason: Namespace SPRINGBOARD,Code 0x8badf00d, 这个错误是因为 watchdog 超时引起, 程序启动时, 超过了 5-6 秒 APP 会被系统杀掉, 而系统在锁屏的状态下启动要比激活状态慢很多, 很容易触发 watchdog 的 crash. 解决的方法就是优化 APP 启动时的代码, 把可以延后的操作尽量延后执行, 同时我对设备的 cpu 也做的了判断, armv7 的低端设备启动慢容易超时不使用 VoIP, 保留 APNS 发送.
Q:APP 启动时收不到 VoIP token 问题
A: 要接收 VoIP token 除了要引入 PushKit 库, 注册并实现代理外, 还要在工程的 Capabilities 中打开 3 个 backmode:Background fetch,Remote nofications,Voice over IP, 以及 Push Notifications(在工程里打开设置, 和手机里设置的接收通知权限没有关系, 即使用户将设置里的 APNS 关闭也能收到 VoIP 消息).
Q: 获取点击通话记录事件问题
A: 收到的 VoIP 电话, 会出现在系统通话记录里, 点击通话记录, 会执行回调
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray * __nullable))restorationHandler
外部链接唤起都会执行这个方法, 需要再根据 userActivity.activityType 的值 (INStartAudioCallIntent 或 INStartVideoCallIntent, 取决于你在唤起 CallKit 时 CXCallUpdate 设置的 hasVideo 值) 来判断是点击通话记录行为.
在通话记录详情里, 有个人社交资料, 这里的值是通过 CXCallUpdate 的 remoteHandle 带过去的, 这个值一般用一个唯一而又不敏感的值 (避免使用电话号码) 用于回拨, 我们使用的是 IM 会话的 sessionId.
Q: 无声问题
A: 主要是在接通的时候在 performAnswerCallAction 方法里将 AVAudioSession 设置 setCategory 为 PlayAndRecord.(双方都需要将 AVAudioSession 设置为 PlayAndRecord)结束之后关闭音频, 去初始化.
Q:facetime 按钮隐藏问题
A: 因为对方很可能没有登录或是安卓手机, facetime 大部分情况下是无法接通的, 但接听页中的这个按钮是无法隐藏的, 不过可以替换为自己的视频按钮, 通过将 CXProviderConfiguration 的 supportsVideo 设为 true,facetime 按钮位置就会显示为视频, 点击后跳转进入 APP, 并会触发外部跳转链接方法
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray * __nullable))restorationHandler
userActivity.activityType 的值是 INStartVideoCallIntent(也就是说如果你在 CXCallUpdate 设置的 hasVideo 值为 true 的时候, 将无法区分这个回调是点击接听页视频跳转进来触发的还是点击通话记录跳转进来触发的, 所以建议 hasVideo 设置为 false), 我们再通过这个回调打开闲鱼音视频通话的视频开关.
Q: 埋点问题
A: 锁屏接听页上有 6 个按钮, 分别为: 静音, 拨号键盘, 免提, 添加通话, 视频, 闲鱼(自定义按钮, 点击跳转进入 APP), 再给各个按钮设置埋点的时候遇到这个问题: CallKit 只提供了静音和添加通话的回调方法, 点击视频按钮可以在外部跳转链接方法获取到, 其他按钮都没有相应的回调, 免提键只能通过监听 AVAudioSessionPortOverride 值的变化来获取, 拨号键盘和跳转进入 APP 的自定义按钮无法获取点击事件.
Q: 兼容老版本问题
A: 因为 PushKit 是从 iOS8 开始支持, CallKit 是从 iOS10 开始支持, 这两个库的调用都需要做版本保护, 我们希望的是 iOS10 以前的版本都保留 APNS 来通知, iOS8 和 9 的设备即使收到 VoIP 消息也无法唤起 CallKit 功能, 于是我们和消息中心的同学定的规则是: 有要发送 push 的请求时先查询到用户表里有没有 VoIP token, 没有 token 时仍然发送 APNS 消息, 客户端会判断系统版本, 如果是 iOS10 之前的我们客户端就不上传 VoIP token.
Q:VoIP 证书问题
A: 申请的方法同 APNS 证书, 在苹果开发中心申请, VoIP 证书没有像 APNS 证书那样区分开发证书与发布证书, 两种场景通用一个证书, 生成消息服务端使用的 p12 证书的流程也和 APNS 一样, 需要注意的申请 VoIP 证书的 bundleID 需要提前配置好 APNS 证书.
Q: 免提键闪烁, 失效问题
A: 免提键默认关闭, 会监听 APP 里 AVSession 的 AVAudioSessionPortOverride 值, 我们原来有一个逻辑是连接中是扬声器模式, 连接成功后切换为听筒模式, 会导致用户在接听过程中接听页上的按钮闪烁, 用户在连接中做的免提操作失效问题, 所以要保持整个通话流程里 APP 里不要改变扬声器的设置.
Q: 自定义按钮上的 icon 设置问题
A: 自定义按钮用的 iconMask 是图片的剪影, 原有的 icon 图片放上去显示是一个白色的方块, 需要把图片背景抠除, 保存为有 alpha 通道的 png 图片
Q: 审核问题
A: 最近 App Store 审核变的更加严格, 提交审核时除了提供两个可以正常通话的测试账号外最好再提供一个相关功能的演示视频, 并且演示视频里要有 APP 被杀掉, 然后再收到 VoIP 通知打开的操作.
========= 扩展
苹果在推出 CallKit 的时候就将这两个库绑定介绍, 实际上是两个可以独立调用的库, 除了基本的视频通话功能, CallKit 和 PushKit 分别有其他的扩展应用:
CallKit 可以用作通讯录扩展功能, 用来屏蔽骚扰电话, 比如在 IM 里拉黑了某个用户, 可以同时将他的手机号码屏蔽, 实现方法如下:
1, 创建一个 target, 选择 Call Directory Extension
2, 主程序中获取授权状态和保存需要拦截的号码
- CXCallDirectoryManager *manager = [CXCallDirectoryManager sharedInstance];
- // 获取权限状态
- [manager getEnabledStatusForExtensionWithIdentifier:@"XXX" completionHandler:^(CXCallDirectoryEnabledStatus enabledStatus, NSError * _Nullable error) {
- if (!error) {
- if (enabledStatus == CXCallDirectoryEnabledStatusDisabled ) {
- }
- }
- }];
- NSUserDefaults * userDefaults = [[NSUserDefaults alloc]initWithSuiteName:@"XXX"];
- // 黑名单号码要升序排列
- NSArray *sortedArray = [phoneNumberList sortedArrayUsingComparator:^NSComparisonResult(id _Nonnull obj1, id _Nonnull obj2) {
- return [obj1 compare:obj2];
- }];
- [userDefaults setObject:sortedArray forKey:@"blackPhoneNum"];
- [userDefaults synchronize];
- CXCallDirectoryManager *manager = [CXCallDirectoryManager sharedInstance];
- [manager reloadExtensionWithIdentifier:@"XXX" completionHandler:^(NSError * _Nullable error) {
3,Extension 的代码 CallDirectoryHandler.m 的方法实现
- - (BOOL)addBlockingPhoneNumbersToContext:(CXCallDirectoryExtensionContext *)context {
- NSUserDefaults * userDefaults = [[NSUserDefaults alloc]initWithSuiteName:@"XXX"];
- NSArray * array = [userDefaults objectForKey:@"blackPhoneNum"];
- [array enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
- NSString * phoneStr = obj;
- int64_t phoneInt = [phoneStr integerValue];
- CXCallDirectoryPhoneNumber number = phoneInt ;
- [context addBlockingEntryWithNextSequentialPhoneNumber:number];
- }];
- return YES;
- }
- - (BOOL)addIdentificationPhoneNumbersToContext:(CXCallDirectoryExtensionContext *)context {
- NSUserDefaults * userDefaults = [[NSUserDefaults alloc]initWithSuiteName:@"XXX"];
- NSArray * array = [userDefaults objectForKey:@"blackPhoneNum"];
- [array enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
- NSString * phoneStr = obj;
- int64_t phoneInt = [phoneStr integerValue];
- CXCallDirectoryPhoneNumber number = phoneInt ;
- NSString *label = @"黑名单";
- [context addIdentificationEntryWithNextSequentialPhoneNumber:number label:label];
- }];
- return YES;
- }
需要注意两点:
设置的拦截号码数组中必须为升序排列;
拦截的国内手机号码前必须加上 86;
不满足的话, 在设置中开启 '来电阻止与身份识别'的时候会报应用程序扩展时出现错误.
而 PushKit 的因为权限很大, 可以通过 PushKit 在后台打开应用做很多事, 而且系统也没有给用户提供任何开关来关闭它(所以苹果对 PushKit 的审核是比较严格的, 需要谨慎使用, 保护用户数据), 通过后台打开 APP, 可以实现后台提前加载某些比较大的资源或 crash 之后再后台将数据重置等功能, 具体做法欢迎共同探讨.
=========
参考:
- https://developer.apple.com/reference/callkit
- https://developer.apple.com/documentation/pushkit?language=objc
- https://developer.apple.com/library/prerelease/content/samplecode/Speakerbox/Introduction/Intro.html
来源: https://juejin.im/post/5ae194adf265da0b9d77eb87