iOS上的VoIP应用如果只是自己做一套通话界面,用户体验会大打折扣:来电时App在后台根本没有机会弹界面,用户在锁屏状态下也看不到任何来电提醒。苹果提供的CallKit框架正是为了解决这个问题,它允许第三方通话应用接入系统电话级别的UI,包括全屏来电界面、锁屏接听滑块、系统通讯录匹配以及最近通话记录。配合PushKit的VoIP推送机制,App即使被用户完全杀死,也能在收到来电时被系统唤醒并弹出来电界面。这套方案是苹果官方钦定的VoIP标准做法,本文将从原理到实践完整梳理整个开发流程。

一、PushKit与CallKit的协作机制
很多刚接触VoIP开发的同学容易把这两个框架的职责搞混。简单来说,PushKit负责“唤醒”,CallKit负责“展示”。PushKit提供了一个特殊的推送通道叫VoIP推送,它与普通APNs推送最大的区别在于:普通推送只是展示一条横幅,而VoIP推送会直接唤醒App进程执行代码,哪怕App已经被杀死。App被唤醒后会触发didReceiveIncomingPushWith回调,开发者必须在这个回调里同步调用CallKit的reportNewIncomingCall方法上报来电,系统才会弹出来电界面。
这里有一条苹果的硬性规定:从iOS 13开始,如果收到VoIP推送后没有调用reportNewIncomingCall,系统会直接杀掉App进程,并且这个行为会被记录,多次触发后VoIP推送权限可能被收回。这是苹果为了防止开发者滥用VoIP推送做消息通道而设的关卡,也是审核被拒的高频原因。所以一定要理解:VoIP推送只服务于真实来电,不能拿来当普通IM通知用。
另一个关键点是CallKit接管了音频会话的配置。一旦接入CallKit,你就不能再随意调用AVAudioSession的setActive方法,音频会话的激活与失活由系统在通话生命周期内统一调度,开发者只需要在CXProviderDelegate的音频激活回调里开始播放或采集音频即可,否则会出现声音忽大忽小、路由异常等问题。
二、推送证书配置与令牌注册
VoIP推送使用独立的证书体系。你需要在开发者后台为App ID开启Push Notifications能力,然后单独创建一个VoIP Services Certificate,注意它和普通的APNs证书不是同一个东西,导出p8或者p12后配置到自己的推送服务端。服务端下发VoIP推送时,payload的组装也有讲究,下面是一个典型的报文结构:
{
"aps": {
"alert": {
"title": "张三",
"body": "来电"
},
"sound": "default"
},
"callId": "9f8e7d6c-1234-5678-abcd-ef0123456789",
"callerName": "张三",
"callerNumber": "+8613800138000"
}
客户端注册部分,PushKit的入口是PKPushRegistry,注册后系统会在回调里下发VoIP专用令牌,你需要把它上报到自己的信令服务器。代码如下:
// 在AppDelegate中初始化PushKit
PKPushRegistry *registry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()];
registry.delegate = self;
registry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP];
#pragma mark - PKPushRegistryDelegate
- (void)pushRegistry:(PKPushRegistry *)registry didUpdatePushCredentials:(PKPushCredentials *)credentials forType:(PKPushType)type {
// 将VoIP令牌转成十六进制字符串,上报给信令服务器
unsigned char token[credentials.token.length];
[credentials.token getBytes:token length:credentials.token.length];
NSMutableString *tokenString = [NSMutableString string];
for (int i = 0; i < credentials.token.length; i++) {
[tokenString appendFormat:@"%02x", token[i]];
}
NSLog(@"VoIP Token: %@", tokenString);
}
- (void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWith:(PKPushPayload *)payload forType:(PKPushType)type withCompletionHandler:(void (^)(void))completion {
// 必须在此回调内同步上报来电,否则iOS 13+会终止App
NSString *callId = payload.dictionaryPayload[@"callId"];
NSString *callerName = payload.dictionaryPayload[@"callerName"];
NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:callId];
[self reportIncomingCallWithUUID:uuid callerName:callerName];
completion();
}
需要注意令牌的有效性:卸载重装、系统升级都可能使令牌变化,所以每次启动App都要重新注册并上报最新令牌。另外调试阶段可以用一款叫VoipPush的测试工具手动下发推送,验证证书配置是否正确,避免服务端还没就绪时干等。
三、CallKit来电上报与系统UI集成
CallKit的核心角色有三个:CXProvider负责与系统UI交互,CXCallController负责发起和管理通话动作,CXCallUpdate则描述来电的元信息。初始化时需要仔细配置CXProviderConfiguration,它决定了来电界面长什么样,比如铃声文件名、是否支持视频、号码显示格式等。
- (void)setupCallKit {
CXProviderConfiguration *config = [[CXProviderConfiguration alloc] initWithLocalizedName:@"我的网络电话"];
config.supportsVideo = NO;
config.maximumCallGroups = 1;
config.maximumCallsPerCallGroup = 1;
config.supportedHandleTypes = [NSSet setWithObject:@(CXHandleTypePhoneNumber)];
// 铃声文件需要放在bundle根目录,传文件名不带后缀
config.ringtoneSound = @"voip_ringtone.caf";
// iOS 11+ 支持每次通话单独设置铃声
_provider = [[CXProvider alloc] initWithConfiguration:config];
[_provider setDelegate:self queue:dispatch_get_main_queue()];
_callController = [[CXCallController alloc] initWithQueue:dispatch_get_main_queue()];
}
- (void)reportIncomingCallWithUUID:(NSUUID *)uuid callerName:(NSString *)callerName {
CXCallUpdate *update = [[CXCallUpdate alloc] init];
update.remoteHandle = [[CXHandle alloc] initWithType:CXHandleTypePhoneNumber value:@"+8613800138000"];
update.localizedCallerName = callerName;
update.hasVideo = NO;
update.supportsGrouping = NO;
update.supportsUngrouping = NO;
update.supportsHolding = NO;
[_provider reportNewIncomingCallWithUUID:uuid update:update completion:^(NSError *error) {
if (error) {
NSLog(@"上报来电失败: %@", error);
}
}];
}
reportNewIncomingCall执行成功后,系统会立即弹出来电界面,不管App在前台还是后台,锁屏状态下同样有效。界面上显示的名称来自localizedCallerName,如果这个号码存在于系统通讯录,CallKit会自动匹配通讯录头像和姓名,这个体验和原生电话完全一致,是自绘界面无法做到的。
用户在系统界面上点接听或挂断,事件会通过CXProviderDelegate回调到App。接听时需要先等待音频会话激活,这是新手最容易忽略的时序问题:
#pragma mark - CXProviderDelegate
- (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action {
// 不要在这里直接激活音频,先记录通话上下文
[self.currentCall answerWithUuid:action.callUUID];
[action fulfill];
}
- (void)providerDidActivateAudioSession:(CXProvider *)provider {
// 系统激活音频会话后,才能安全地开始音频采集与播放
AVAudioSession *session = [AVAudioSession sharedInstance];
[session setCategory:AVAudioSessionCategoryPlayAndRecord
mode:AVAudioSessionModeVoiceChat
options:AVAudioSessionCategoryOptionAllowBluetooth
error:nil];
[self.audioEngine start];
}
- (void)provider:(CXProvider *)provider performEndCallAction:(CXEndCallAction *)action {
[self.currentCall hangup];
[action fulfill];
}
- (void)providerDidDeactivateAudioSession:(CXProvider *)provider {
// 通话结束后系统会失活音频会话,可在这里做资源清理
[self.audioEngine stop];
}
四、通话记录写入与常见坑
只要通过CallKit上报过来电,系统就会自动在“最近通话”里生成记录,用户甚至可以直接从通话记录回拨。回拨时系统会启动App并触发CXHandle指定的URL Scheme或者通过CXStartCallAction回调进入App,你需要在CXProviderDelegate的performStartCallAction里处理外呼逻辑。通话记录的时长、 missed标记都由系统根据你上报的通话状态自动维护,开发者不需要也无法手动写数据库。
实际开发中有几个高频坑值得单独说明。第一,来电铃声必须是bundle内的资源且格式为caf或aiff,如果配置了不存在的文件名,来电会静默使用默认铃声,不会报错,排查起来很费劲。第二,App被杀后来电时进程会被重新拉起,但留给你的启动时间有限,不要在didFinishLaunchingWithOptions里做耗时初始化,否则上报来电会超时失败。第三,completion()必须调用且必须在reportNewIncomingCall之后调用,顺序反了或者漏掉都会导致系统判定推送处理异常。第四,提审时苹果会重点核查是否真的实现了通话功能,如果你的App只是用VoIP推送做通知而没有真实通话,基本会被4.2或2.1条款拒绝,准备好演示账号和测试通话环境能大幅加快审核。
整体来看,PushKit加CallKit的组合并不复杂,难点在于理解系统对时序的严格要求以及音频会话的生命周期管理。建议先用最简单的demo跑通“推送到来电界面弹出到接听回调”这条主链路,再逐步叠加音频引擎、信令层,出现问题时按链路逐段排查,效率会高很多。
PushKitCallKitiOS VoIP开发修改时间:2026-09-13 22:33:18