iOS VoIP应用程序(这一篇就够了)
一、涉及
1、CallKit
1.1、概述 (对于CallKit我们并不陌生)
- 为您的应用程序的IP电话(VoIP)服务显示系统呼叫UI,并将您的呼叫服务与其他应用程序和系统协调。
1.2、功能场景
- 通过它能实现IP电话(VoIP)应用程序集成到iPhone的用户界面
- CallKit功能可以让第三方社交APP的语音通话与运营商提供的电话功能在体验上相一致
- 收到通讯请求时弹出系统的通话界面进行交互,它可以让应用程序调用系统的通话和通话记录界面
1.3、支持
- iOS 10.0+
- iPadOS 10.0+
- macOS 10.15+
- Mac Catalyst 13.0+
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通知证书
配置证书.jpegps:
:普通的推送分开发环境和生产环境,VoIP证书不进行区分,生产环境和开发环境是通用的。之后选择一个AppID并且上传前面生成的cerSigningRequest文件来完成VoIP证书的创建。
创建完成后,在证书列表可以看到多了个 VoIP服务证书,可以加载此证书进行VoIP推送。
info.plist1.2、工程配置
a、配置push
配置push.pngb、配置后台获取模块、打开VoIP
配置后台获取.png打开VoIP.png
c、配置依赖系统库
配置依赖系统库.png1.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、代理实现
如果您的应用程序在(pushRegistry:didReceiveIncomingPushWithPayload:forType:),您有处理这个事件
#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- 主要负责通话流程的控制,向系统注册通话和更新通话的连接状态等。
- 代表电话提供商对象,VoIP应用程序应该创建一个全局的实例,供使用,与系统双向通信
- 主要负责执行对通话的操作
- 呼叫控制器,当用户在应用程序内部进行的通讯操作时,可以使用这个类来通知系统,与系统双向通信
2.2、呼入、来电处理(CXProvider)
a、通过CXProviderConfiguration初始化一个全局的CXProvider实例,并指定代理&可选队列
b、接收到VOIP通知后弹出通话界面,需要使用CXProvider传递CXCallUpdate给系统来进行控制。如下图:
CXProviderc、使用全局的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,如下:
CXActione、 在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传递。
CXCallControllerb、创建一个新的呼叫控制器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的代理方法:
接听者在这个方法中
//点击开始按钮的回调
- (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框架中还有一部分内容可以结合来实现好吗拦截与识别。
Call Directory Extension:
- CXCallDirectoryProvider:主机应用程序的Call Directory应用程序扩展的主要对象。
- CXCallDirectoryExtensionContext:用于向Call Directory应用程序扩展添加标识和阻止条目的程序化界面。
- CXCallDirectoryExtensionContextDelegate:当请求失败时,Call Directory扩展上下文对象调用的方法集合。
- CXCallDirectoryManager:管理Call Directory应用程序扩展的对象的程序化界面。
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、基本流程
image3.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(_:)方法加入同一个频道,就可以进行视频通话。
三、参考
吃水不忘挖井人 :
Voice Over IP (VoIP) Best Practices