iOS VoIP应用程序(这一篇就够了)

一、涉及

1、CallKit

1.1、概述 (对于CallKit我们并不陌生)

  • 为您的应用程序的IP电话(VoIP)服务显示系统呼叫UI,并将您的呼叫服务与其他应用程序和系统协调。

1.2、功能场景

  • 通过它能实现IP电话(VoIP)应用程序集成到iPhone的用户界面
  • CallKit功能可以让第三方社交APP的语音通话与运营商提供的电话功能在体验上相一致
  • 收到通讯请求时弹出系统的通话界面进行交互,它可以让应用程序调用系统的通话和通话记录界面
CallKit.jpeg

1.3、支持

2、PushKit

2.1、概述

  • 响应与应用程序的复杂性、文件提供商和VoIP服务相关的推送通知

2.2、功能场景

  • 仅当发生 VoIP 推送时才会唤醒设备,从而节省能源。

  • 与标准推送通知(用户必须在您的应用执行操作之前做出响应)不同,VoIP 推送会直接发送到您的应用进行处理。

  • VoIP 推送被视为高优先级通知,并且会立即传送

  • VoIP 推送可以包含比标准推送通知提供的数据更多的数据

  • 如果您的应用程序在收到 VoIP 推送时未运行,则会自动重新启动

  • 即使您的应用程序在后台运行,您的应用程序也有运行时间来处理推送

2.3、支持

  • iOS 8.0+
  • iPadOS 8.0+
  • macOS 10.15+
  • Mac Catalyst 13.0+
  • watchOS 6.0+

3、Voice Over IP (VoIP)

3.1、概述

  • 互联网协议语音 (VoIP) 应用程序允许用户使用互联网连接而不是设备的蜂窝服务拨打和接听电话
  • IP电话(VoIP)是Apple提供给开发者的网络电话功能接口
  • 会导致高能耗,庆幸应用不活跃时VoIP应用程序可以完全空闲以节省资源

3.2、功能场景

  • 网络电话:完全基于Internet传输实现的语音通话方式,一般是PC和PC之间进行通话
  • 与公众电话网互联的IP电话:通过宽带或专用的IP网络,实现语音传输。终端可以是PC或者专用的IP话机
  • 传统电信运营商的VoIP业务:通过电信运营商的骨干IP网络传输语音。提供的业务仍然是传统的电话业务,使用传统的话机终端。通过使用IP电话卡,或者在拨打的电话号码之前加上IP拨号前缀,这就使用了电信运营商提供的VoIP业务

3.3、支持

  • 你猜(可以连接互联网的设备)

二、使用

1、唤醒应用,PushKit的配置步骤

1.1、注册VoIP Push Notification通知证书

配置证书.jpeg

ps:

\color{red} {需要注意}:普通的推送分开发环境和生产环境,VoIP证书不进行区分,生产环境和开发环境是通用的。之后选择一个AppID并且上传前面生成的cerSigningRequest文件来完成VoIP证书的创建。

创建完成后,在证书列表可以看到多了个 VoIP服务证书,可以加载此证书进行VoIP推送。

info.plist

1.2、工程配置

a、配置push

配置push.png

b、配置后台获取模块、打开VoIP

配置后台获取.png

打开VoIP.png

c、配置依赖系统库

配置依赖系统库.png

1.3、代码配置

a、导入需要的头文件

#import <PushKit/PushKit.h>
#import <UserNotifications/UserNotifications.h>
#import <AudioToolbox/AudioToolbox.h></pre>

b、注册本地通知设置代理

 PKPushRegistry *pushRegistry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()];
    pushRegistry.delegate = self;
    pushRegistry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP];
    //ios10注册本地通知
    if ([[UIDevice currentDevice].systemVersion floatValue] >= 10.0) {
        //iOS 10
    UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
    [center requestAuthorizationWithOptions:(UNAuthorizationOptionBadge | UNAuthorizationOptionSound | UNAuthorizationOptionAlert) completionHandler:^(BOOL granted, NSError * _Nullable error) {
        if (!error) {
            NSLog(@"request authorization succeeded!");
        }
    }];
    [center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
        NSLog(@"%@",settings);
    }];
    }

c、代理实现

如果您的应用程序在\color{red} {收到推送时}(pushRegistry:didReceiveIncomingPushWithPayload:forType:)\color{red} {未运行,您的应用程序将自动启动},您有\color{red} {30s的时间}处理这个事件

#pragma mark -pushkitDelegate

- (void)pushRegistry:(PKPushRegistry *)registry didUpdatePushCredentials:(PKPushCredentials *)credentials forType:(NSString *)type{
    if([credentials.token length] == 0) {
        NSLog(@"voip token NULL");
        return;
    }
    //应用启动获取token,并上传服务器
    token = [[[[credentials.token description] stringByReplacingOccurrencesOfString:@"<"withString:@""]
              stringByReplacingOccurrencesOfString:@">" withString:@""]
             stringByReplacingOccurrencesOfString:@" " withString:@""];
    //token上传服务器
    //[self uploadToken];
}

- (void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(NSString *)type{
    if ([[UIDevice currentDevice].systemVersion floatValue] >= 10.0) {
        UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
        UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
        content.body =[NSString localizedUserNotificationStringForKey:[NSString
                                                                       stringWithFormat:@"%@%@", CallerName,
                                                                       @"邀请你进行通话。。。。"] arguments:nil];;
        UNNotificationSound *customSound = [UNNotificationSound soundNamed:@"voip_call.caf"];
        content.sound = customSound;
        UNTimeIntervalNotificationTrigger* trigger = [UNTimeIntervalNotificationTrigger
                                                      triggerWithTimeInterval:1 repeats:NO];
        request = [UNNotificationRequest requestWithIdentifier:@"Voip_Push"
                                                       content:content trigger:trigger];
        [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
            NSLog(@"结束");
        }];
    }else {

        callNotification = [[UILocalNotification alloc] init];
        callNotification.alertBody = [NSString
                                      stringWithFormat:@"%@%@", CallerName,
                                      @"邀请你进行通话。。。。"];

        callNotification.soundName = @"voip_call.caf";
        [[UIApplication sharedApplication]
         presentLocalNotificationNow:callNotification];

    }
    /**
     初始化计时器  每一秒振动一次
     @param playkSystemSound 振动方法
     @return
     */
    if(_vibrationTimer){
        [_vibrationTimer invalidate];
        _vibrationTimer = nil;
    }else{
        _vibrationTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(playkSystemSound) userInfo:nil repeats:YES];
    }
    }

}
//振动
- (void)playkSystemSound{
        AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
}

2、CallKit如何控制

2.1、关于CallKit

下图比较形象的表达了应用程序与CallKit以及系统的关系:

CallKit

CXProvider

  • 主要负责通话流程的控制,向系统注册通话和更新通话的连接状态等。
  • 代表电话提供商对象,VoIP应用程序应该创建一个全局的实例,供使用,与系统双向通信

CXCallController

  • 主要负责执行对通话的操作
  • 呼叫控制器,当用户在应用程序内部进行的通讯操作时,可以使用这个类来通知系统,与系统双向通信

2.2、呼入、来电处理(CXProvider

a、通过CXProviderConfiguration初始化一个全局的CXProvider实例,并指定代理&可选队列

b、接收到VOIP通知后弹出通话界面,需要使用CXProvider传递CXCallUpdate给系统来进行控制。如下图:

CXProvider

c、使用全局的CXProvider实例传递CXCallUpdate调起通话界面的简单的代码:

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(@"吊起界面");
}];

d、之后系统会将一些用户操作通过CXAction传递会APP,如下:

CXAction

e、 在CXProviderDelegate中处理这些系统回调事件

//当接收到呼叫重置时 调用的函数,这个函数必须被实现,其不需做任何逻辑,只用来重置状态
- (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;
//有事务被提交时调用 
//如果返回YES 则表示事务被捕获处理 后面的回调都不会调用 如果返回NO 则表示事务不被捕获,会回调后面的函数
- (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;
//DTMF功能回调
- (void)provider:(CXProvider *)provider performPlayDTMFCallAction:(CXPlayDTMFCallAction *)action;

f、锁屏和应用程序在后台的效果分别如下所示:

image
image
image

通过CXProviderDelegate相关函数来处理系统通话界面的某些操作回调给应用程序。

2.3、呼出,拨号处理(CXCallController

a、APP中进行的操作如果需要通知系统,需要使用CXCallController通过CXTransaction传递。

CXCallController

b、创建一个新的呼叫控制器CXCallController,可用指定队列创建。获取活动调用观察者CXCallObserver指定观察者的代理CXCallObserverDelegate

c、呼出

  • 实例化一个CXStrartCallAction(发起呼出呼叫的行为封装),由唯一标识呼叫的UUID和指定接收方的对象CXHandle组成
  • 再使用CXStrartCallAction实例创建CTTransaction实例
  • 通过CXCallController的requestTransaction....将行为通知给系统
let uuid = UUID()
let handle = CXHandle(type: .emailAddress, value: "jappleseed@apple.com") 
let startCallAction = CXStartCallAction(call: uuid)
startCallAction.destination = handle 
let transaction = CXTransaction(action: startCallAction)
callController.request(transaction) { error in
    if let error = error {
        print("Error requesting transaction: \(error)")
    } else {
        print("Requested transaction successfully")
    }
}

d、接收者接听电话后,系统调用CXProvider的代理方法:
接听者在这个方法中\color{red} {配置音视频会话(第3部分有举例)}

//点击开始按钮的回调
- (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action;

e、APP中的其他操作也可以举一反三:例如App内的通讯需要添加到系统的历史通话列表:

f、通过CXCallObserverDelegate回调来处理系统事件

extension ViewController: CXCallObserverDelegate {
    func callObserver(_ callObserver: CXCallObserver,callChanged call: CXCall) {

        if call.isOutgoing {

            print("电话播出")

            if call.hasConnected {
                print("电话接通")
                operation(state: CurrentState.HasConnected)
            }
            if call.hasEnded {
                print("电话挂断")
                operation(state: CurrentState.HasEnded)
            }
            if call.isOnHold {
                print("无人接听挂断")
                operation(state: CurrentState.IsOnHold)
            }
        } else {
            print("other error")
        }
    }
}

2.4、来电拦截与号码识别

上面介绍了CallKit的结合PushKit&VoIP可实现的通讯功能,有通讯功能就需要进行联系人识别与黑名单。

社交网络应用程序有一个Call Directory应用程序扩展程序,可以下载并添加用户所有朋友的电话号码。

因此,当用户接到Jane的来电时,系统会显示类似“(应用程序名称)来电显示:Jane Appleseed”而不是“未知来电者”。

CallKit框架中还有一部分内容可以结合\color{red} {Call Directory Extension)}来实现好吗拦截与识别。

Call Directory Extension:

a、创建应用扩展target

选择Call Directory Extension:

Extension

主程序会自动生成Call Directory应用程序扩展的主要对象(继承自CXCallDirectoryProvider的Handler)。

b、应用程序通知扩展程序更新号码库(切换app账号等场景)

CXCallDirectoryManager的api就比较简单了,凭场景组合使用:

CXCallDirectoryManager
/// 单例
@property (readonly, class) CXCallDirectoryManager *sharedInstance;
/// 利用扩展程序bundle id(在extension的info.plist中)来查询是否开启来电识别 
func getEnabledStatusForExtension(withIdentifier: String, completionHandler: (CXCallDirectoryManager.EnabledStatus, Error?) -> Void)
/// 通知系统更新(主app与extension的数据通信、参考group共享数据的编程方式来实现)
func reloadExtension(withIdentifier: String, completionHandler: ((Error?) -> Void)?)
/// 打开设置页面
func openSettings(completionHandler: ((Error?) -> Void)?)
settings

c、扩展程序中的增删电话操作

系统默认生成的应用程序扩展的主要对象中已经替我们优雅的生成了必要的代码,我们仅需取group(主程序给扩展程序使用的数据)中取出来设置就可以

override func beginRequest(with context: CXCallDirectoryExtensionContext) {
        context.delegate = self
    // Check whether this is an "incremental" data request. If so, only provide the set of phone number blocking
    // and identification entries which have been added or removed since the last time this extension's data was loaded.
    // But the extension must still be prepared to provide the full set of data at any time, so add all blocking
    // and identification phone numbers if the request is not incremental.
    if context.isIncremental {
        addOrRemoveIncrementalBlockingPhoneNumbers(to: context)
        addOrRemoveIncrementalIdentificationPhoneNumbers(to: context)
    } else {
        addAllBlockingPhoneNumbers(to: context)
        addAllIdentificationPhoneNumbers(to: context)
    }
    context.completeRequest()
}

d、CXCallDirectoryExtensionContext是操作上下文,不需要初始化,会自系统的方法传递出来

//是否支持增量更新
@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; 

3、结合音视频对话SDK建立通话(以声网 Agora SDK使用举例)

3.1、基本流程

image

3.2、代码使用

a、使用 App ID创建AgoraRtcEngineKit

private lazy var rtcEngine: AgoraRtcEngineKit = AgoraRtcEngineKit.sharedEngine(withAppId: <#Your AppId#>, delegate: self)

b、设置好ChannelProfile和本地预览视图

override func viewDidLoad() {
    super.viewDidLoad()
    rtcEngine.setChannelProfile(.communication)
    let canvas = AgoraRtcVideoCanvas()
    canvas.uid = 0
    canvas.view = localVideoView
    canvas.renderMode = .hidden
    rtcEngine.setupLocalVideo(canvas)
}

c、在AgoraRtcEngineDelegate的远端用户加入频道事件中设置远端视图:

extension ViewController: AgoraRtcEngineDelegate {
    func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
        let canvas = AgoraRtcVideoCanvas()
        canvas.uid = uid
        canvas.view = remoteVideoView
        canvas.renderMode = .hidden
        engine.setupRemoteVideo(canvas)
        remoteUid = uid
        remoteVideoView.isHidden = false
   }
}

d、实现通话开始、静音、结束的方法:

extension ViewController {
    func startSession(_ session: String) {
        rtcEngine.startPreview()
        rtcEngine.joinChannel(byToken: nil, channelId: session, info: nil, uid: 0, joinSuccess: nil)
    }
    func muteAudio(_ mute: Bool) {
        rtcEngine.muteLocalAudioStream(mute)
    }
    func stopSession() {
        remoteVideoView.isHidden = true
        rtcEngine.leaveChannel(nil)
        rtcEngine.stopPreview()
    }
}

一个简单的视频通话应用搭建就完成了。双方只要调用startSession(_:)方法加入同一个频道,就可以进行视频通话。

三、参考

吃水不忘挖井人 :

CallKit

PushKit

Voice Over IP (VoIP) Best Practices

iOS VoIP实践

iOS使用VOIP与CallKit实现体验优质的网络通讯功能

Swift3.0拨打电话,获取通话状态(接通,挂断...)

iOS CallKit框架 详解

应用扩展编程指南

如何结合CallKit和AgoraSDK实现视频VoIP通话应用

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容