iOS使用VOIP与CallKit实现体验优质的网络通讯功能
VOIP是Apple提供给开发者的网络电话功能接口。简单来说,其可以让你的应用程序在完全杀死的情况下被服务端唤醒。CallKit是iOS10引入的新框架,使用它可以让你的应用程序调用系统的通话和通话记录界面。试想一下,用户可以在锁屏,应用被杀死,应用在后台等情况下收到通讯请求并且弹出系统的通话界面进行交互是多么酷的一件事。
一、创建VOIP推送证书
VOIP说是一种网络电话服务,其实质是一种特殊的长连接,使用它每个网络电话类APP不需要自己单独进行保活维护,在进行通话请求时,只需要发送一条VOIP推送,VOIP推送会将应用程序拉起,之后由应用程序处理通讯逻辑。VOIP也是Push的一种,只是其是一种特殊的Push,普通的Push当应用被杀死后可以收到,但是用户点击Push消息前应用程序是不会被激活的,VOIP则不然,可以直接激活应用。
VOIP推送证书的创建方式与普通推送证书的创建方式基本一致,首先需要生成certSigningRequest文件,打开钥匙串应用:
在证书助理栏选择从证书颁发机构申请证书:
填写相关资料后,将生成的文件保存:
在Apple开发者中心创建新的证书,证书类型选择生产环境的VOIP服务证书:
需要注意,普通的推送分开发环境和生产环境,VOIP证书不进行区分,生产环境和开发环境是通用的。之后选择一个AppID并且上传前面生成的certSigningRequest文件来完成VOIP证书的创建。
创建完成后,在证书列表可以看到多了一个VOIP服务证书,可以加载此证书进行VOIP推送。
二、PushKit详析
我们知道,客户端若想要接收普通的Push消息,是需要注册Token,通过Token来进行个推的。VOIP推送也是一样的,只是这类推送需要使用PushKit框架。
首先需要用到PKPushRegistey类,这个类进行推送的相关配置和Token的申请:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @interface PKPushRegistry : NSObject
@property (readwrite,weak,nullable) id<PKPushRegistryDelegate> delegate;
@property (readwrite,copy,nullable) NSSet<PKPushType> *desiredPushTypes;
- (nullable NSData *)pushTokenForType:(PKPushType)type;
- (instancetype)initWithQueue:(nullable dispatch_queue_t)queue NS_DESIGNATED_INITIALIZER; - (instancetype)init NS_UNAVAILABLE; @end
|
PKPushRegistryDelegate相关函数意义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
- (void)pushRegistry:(PKPushRegistry *)registry didUpdatePushCredentials:(PKPushCredentials *)pushCredentials forType:(PKPushType)type;
- (void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(PKPushType)type NS_DEPRECATED_IOS(8_0, 11_0);
- (void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(PKPushType)type withCompletionHandler:(void(^)(void))completion NS_AVAILABLE_IOS(11_0);
- (void)pushRegistry:(PKPushRegistry *)registry didInvalidatePushTokenForType:(PKPushType)type;
|
如果配置成功,在收到VOIP推送时,无论应用程序是否活跃,都会执行代理函数,我们便可以在其中进行逻辑处理。
三、关于CallKit框架
CallKit框架是iOS10后系统提供的一套网络电话UI和交互相关接口,应用程序可以调用系统的电话界面来进行逻辑传递。下图比较形象的表达了应用程序与CallKit的关系:
以收到网络电话为例,如果应用程序在前台,客户端可以直接处理通讯逻辑,如果应用程序不在前台,服务端可以发送一条VOIP推送唤醒APP,之后APP通知CallKit框架来唤起系统的通讯界面。CXProvider类主要负责系统服务于APP之间的交互。例如可以通过它来更新通话界面,显示通话的来自方,当用户点击通话界面的某些按钮后,也通过它来通知APP做逻辑处理。
需要注意,上图在CallKit和System之间有两个双向的白色箭头,这描述了CallKit和系统交互的四个方向。
首先,App想要和系统交互,例如接收到VOIP通知后弹出通话界面,需要使用CXProvider通过CXCallUpdate来进行控制。如下图:
之后系统会将一些用户操作通过CSAction传递会APP,如下:
APP中进行的操作如果需要通知系统,需要使用CXCallController通过CXTransaction传递。例如App内的通讯需要添加到系统的历史通话列表。如下:
1.先来看CXProvider类
CXProvider类用来对系统通话界面进行一些配置操作,并处理回调逻辑,解析如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| - (instancetype)initWithConfiguration:(CXProviderConfiguration *)configuration NS_DESIGNATED_INITIALIZER; - (instancetype)init NS_UNAVAILABLE;
- (void)setDelegate:(nullable id<CXProviderDelegate>)delegate queue:(nullable dispatch_queue_t)queue;
- (void)reportNewIncomingCallWithUUID:(NSUUID *)UUID update:(CXCallUpdate *)update completion:(void (^)(NSError *_Nullable error))completion;
- (void)reportCallWithUUID:(NSUUID *)UUID endedAtDate:(nullable NSDate *)dateEnded reason:(CXCallEndedReason)endedReason;
- (void)reportCallWithUUID:(NSUUID *)UUID updated:(CXCallUpdate *)update;
- (void)reportOutgoingCallWithUUID:(NSUUID *)UUID startedConnectingAtDate:(nullable NSDate *)dateStartedConnecting;
- (void)reportOutgoingCallWithUUID:(NSUUID *)UUID connectedAtDate:(nullable NSDate *)dateConnected;
@property (nonatomic, readwrite, copy) CXProviderConfiguration *configuration;
- (void)invalidate;
@property (nonatomic, readonly, copy) NSArray<CXTransaction *> *pendingTransactions; - (NSArray<__kindof CXCallAction *> *)pendingCallActionsOfClass:(Class)callActionClass withCallUUID:(NSUUID *)callUUID;
|
2.在看CXProviderConfiguration类
这个类用来进行Provider的配置,例如设置通讯服务名称,铃声,图标,是否支持组等。解析如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @property (nonatomic, readonly, copy) NSString *localizedName;
@property (nonatomic, strong, nullable) NSString *ringtoneSound;
@property (nonatomic, copy, nullable) NSData *iconTemplateImageData;
@property (nonatomic) NSUInteger maximumCallGroups;
@property (nonatomic) NSUInteger maximumCallsPerCallGroup;
@property (nonatomic) BOOL includesCallsInRecents;
@property (nonatomic) BOOL supportsVideo;
@property (nonatomic, copy) NSSet<NSNumber *> *supportedHandleTypes;
|
当App接收到来电VOIP通知时,可以使用CXCallUpdate来更新状态唤出通话界面。
3.CXCallUpdate类
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @property (nonatomic, copy, nullable) CXHandle *remoteHandle;
@property (nonatomic, copy, nullable) NSString *localizedCallerName;
@property (nonatomic) BOOL supportsHolding;
@property (nonatomic) BOOL supportsGrouping;
@property (nonatomic) BOOL supportsUngrouping;
@property (nonatomic) BOOL supportsDTMF;
@property (nonatomic) BOOL hasVideo;
|
CXHandle中来定义操作的类型,解析如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
|
@property (nonatomic, readonly) CXHandleType type;
@property (nonatomic, readonly, copy) NSString *value;
- (instancetype)initWithType:(CXHandleType)type value:(NSString *)value NS_DESIGNATED_INITIALIZER;
|
下面给出了简单的当被叫收到VOIP后调起通话界面的代码:
1 2 3 4 5 6 7 8 9 10 11
| CXCallUpdate * callUpdate = [[CXCallUpdate alloc]init]; callUpdate.supportsGrouping = YES; callUpdate.supportsDTMF = YES; callUpdate.hasVideo = YES; callUpdate.supportsHolding = YES; [callUpdate setLocalizedCallerName:nickName]; CXHandle * handle = [[CXHandle alloc]initWithType:CXHandleTypePhoneNumber value:from]; callUpdate.remoteHandle = handle; [[self shareInstance].callProvider reportNewIncomingCallWithUUID:[self shareInstance].uuid update:callUpdate completion:^(NSError * _Nullable error) { LOG(@"吊起界面"); }];
|
锁屏和应用程序在后台的效果分别如下所示:
4.CXProviderDelegate相关函数解析
CXProviderDelegate中的相关函数用来处理系统通话界面的某些操作回调给应用程序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| - (void)providerDidReset:(CXProvider *)provider;
- (void)providerDidBegin:(CXProvider *)provider;
- (void)provider:(CXProvider *)provider didActivateAudioSession:(AVAudioSession *)audioSession;
- (void)provider:(CXProvider *)provider didDeactivateAudioSession:(AVAudioSession *)audioSession;
- (void)provider:(CXProvider *)provider timedOutPerformingAction:(CXAction *)action;
- (BOOL)provider:(CXProvider *)provider executeTransaction:(CXTransaction *)transaction;
- (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;
|
需要注意,上面的最后几个回调中CXStartCallAction都会提供一个fullfill的函数,当处理完成回调逻辑后,开发者需要手动调用此函数来通知系统。同样,其中还有一个fail和timeout函数,调用它要通知系统此行为执行失败和超时。
5.CXCallController解析
当用户在应用程序内部进行的通讯操作时,可以使用这个类来通知系统。
1 2 3 4 5 6 7 8 9 10
| - (instancetype)init; - (instancetype)initWithQueue:(dispatch_queue_t)queue;
@property (nonatomic, readonly, strong) CXCallObserver *callObserver;
- (void)requestTransaction:(CXTransaction *)transaction completion:(void (^)(NSError *_Nullable error))completion;
- (void)requestTransactionWithActions:(NSArray<CXAction *> *)actions completion:(void (^)(NSError *_Nullable error))completion API_AVAILABLE(ios(11.0)); - (void)requestTransactionWithAction:(CXAction *)action completion:(void (^)(NSError *_Nullable error))completion API_AVAILABLE(ios(11.0));
|
6.CXTransaction类
CXTransaction是封装了行为的事务。
1 2 3 4 5 6 7 8 9 10 11
| @property (nonatomic, readonly, copy) NSUUID *UUID;
@property (nonatomic, readonly, assign, getter=isComplete) BOOL complete;
@property (nonatomic, readonly, copy) NSArray<__kindof CXAction *> *actions;
- (instancetype)initWithActions:(NSArray<CXAction *> *)actions; - (instancetype)initWithAction:(CXAction *)action;
- (void)addAction:(CXAction *)action;
|
四、进行来电拦截与号码识别
上面我们介绍了使用CallKit框架来实现的通讯功能,有通讯功能就难免需要进行联系人识别与黑名单。CallKit框架中还有一部分内容可以结合Call Directory Extension来实现号码拦截与识别。
首先创建一个扩展Target,选择Call Directory Extension:
创建好Target工程后,其实需要的核心代码Xcode已经帮我们都生成。
第一步,需要在主APP中进行号码服务的验证和更新,
1 2 3 4 5 6 7 8 9 10 11
| _manager = [[CXCallDirectoryManager alloc]init]; [_manager getEnabledStatusForExtensionWithIdentifier:@"jaki.CallKitTest.Ex" completionHandler:^(CXCallDirectoryEnabledStatus enabledStatus, NSError * _Nullable error) { if (enabledStatus==CXCallDirectoryEnabledStatusEnabled) { NSLog(@"允许"); }else{ NSLog(@"请开启"); } }]; [_manager reloadExtensionWithIdentifier:@"jaki.CallKitTest.Ex" completionHandler:^(NSError * _Nullable error) { NSLog(@"刷新配置"); }];
|
通常情况下,当用户在主APP中进行添加联系人,登录,切换账户等操作后,需要通知扩展程序进行号码库的更新,当然,一般在号码库更新时需要从主APP传递数据给扩展,我们可以通过Group来实现,这里不再展开。
工程运行后,会在用户的“设置->电话->来电组织与身份识别”项目中看到扩展程序:
当用户打开此服务或者调用上面的reloadExtension时,会从执行扩展程序的相关方法来重新加载号码库。需要注意,reloadExtension函数中的id参数为扩展项目的bundleID,不是主项目的。
在扩展工程的info.plist文件中,默认配置好了处理来电的操作类,如果要自定义,需要开发者手动修改:
默认的CallDirectoryHandler类为来电拦截与身份识别的操作类,其集成自CXCallDirectoryProvider类,当收到加载号码库的请求时,会执行下面的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| - (void)beginRequestWithExtensionContext:(CXCallDirectoryExtensionContext *)context { context.delegate = self; if (context.isIncremental) { [self addOrRemoveIncrementalBlockingPhoneNumbersToContext:context];
[self addOrRemoveIncrementalIdentificationPhoneNumbersToContext:context]; } else { [self addAllBlockingPhoneNumbersToContext:context];
[self addAllIdentificationPhoneNumbersToContext:context]; } [context completeRequestWithCompletionHandler:nil]; }
|
上面是Xcode默认提供的实现,十分优雅,在iOS11后,号码库的更新支持增量,所以这里进行的区分。
CXCallDirectoryExtensionContext是一个操作上下文,通过它可以像号码库中添加删除数据。解析如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @property (nonatomic, readonly, getter=isIncremental) BOOL incremental API_AVAILABLE(ios(11.0));
- (void)addBlockingEntryWithNextSequentialPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber;
- (void)removeBlockingEntryWithPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber API_AVAILABLE(ios(11.0));
- (void)removeAllBlockingEntries API_AVAILABLE(ios(11.0));
- (void)addIdentificationEntryWithNextSequentialPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber label:(NSString *)label;
- (void)removeIdentificationEntryWithPhoneNumber:(CXCallDirectoryPhoneNumber)phoneNumber API_AVAILABLE(ios(11.0));
- (void)removeAllIdentificationEntries API_AVAILABLE(ios(11.0));
- (void)completeRequestWithCompletionHandler:(nullable void (^)(BOOL expired))completion;
|
添加了黑名单后,用户将收不到此号码的电话,同样,设置了身份识别后,当用户播出前,会显示设置的身份信息(需要注意,大陆号码需要前面带86),如下: