iOS VoIP電話:CallKit與PushKit的應用

閒魚技術發表於2018-04-26

作者:鎮雷

蘋果在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

相關文章