iOS VoIP电话:CallKit与PushKit的应用

avatar
@阿里巴巴集团

作者:镇雷

苹果在WWDC2016推出了iOS10系统新功能CallKit framework,代替了原来的CoreTelephony.framework,可以调起系统的接听页进行音视频通话;iOS8中苹果新引入了PushKit的框架和一种新的push通知类型:VoIP push,提供区别于普通APNS push的能力,通过这种push方式收到消息时会直接将已经杀掉的APP激活,两个库配合使用形成了一套完整的VoIP解决方案。由于CallKit支持版本较高,而且限定了应用场景,目前集成的APP不是很多,官方文档和网上博客对相关功能介绍细节都很有限,这篇文章主要为了记录一下项目过程中遇到的问题。

==========

效果图如下,因为CallKit使用的是系统原生的控件, iOS10与iOS11的样式上有区别:

屏幕快照 2018-03-30 上午11.42.23.png

==========

闲鱼调用的逻辑图如下:

屏幕快照 2018-03-29 上午11.04.02.png

==========

下面是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。

IMG_5704.jpeg

IMG_5705.PNG

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