使用 CallKit 和 PushKit 实现 VoIP 通话功能。适用于构建呼入/呼出通话流程、注册 VoIP 推送通知、配置 CXProvider 和 CXCallController、处理通话动作、协调音频会话,以及创建用于来电识别和通话拦截的 Call Directory 扩展。
CallKit
结合 CallKit 与 PushKit 构建集成 iOS 原生通话界面的 VoIP 通话功能。涵盖呼入/呼出通话流程、VoIP 推送注册、音频会话协调以及 Call Directory 通话目录扩展。
目录
基础配置
项目配置
- 在 Signing & Capabilities 中开启 Voice over IP 后台模式
- 添加 Push Notifications(推送通知)能力
- 如需支持通话目录扩展,需添加 Call Directory Extension Target
核心类型
| 类型 | 作用 |
|---|---|
CXProvider |
向系统上报通话,接收系统分发的通话动作 |
CXCallController |
请求发起通话动作(发起、挂断、保持、静音) |
CXCallUpdate |
描述通话元数据(来电者姓名、视频状态、句柄/标识) |
CXProviderDelegate |
处理系统通话动作及音频会话事件 |
PKPushRegistry |
注册并接收 VoIP 推送通知 |
PKVoIPPushMetadata |
iOS 26.4+ 引入的元数据,用于标识是否必须向系统上报该 VoIP 推送 |
Provider Configuration
在应用启动时创建单例 CXProvider,并在应用的整个生命周期内保持其存活。使用 CXProviderConfiguration 对其进行配置,声明应用支持的通话能力。
import CallKit
/// CXProvider 会将所有代理回调分发到传给 `setDelegate(_:queue:)` 的队列中。
/// 这里的 `let` 属性仅在初始化时赋值且不可变更,因此尽管声明了 @unchecked Sendable,
/// 该类型在跨并发域共享时依然是安全的。
final class CallManager: NSObject, @unchecked Sendable {
static let shared = CallManager()
let provider: CXProvider
let callController = CXCallController()
private override init() {
let config = CXProviderConfiguration()
config.localizedName = "My VoIP App"
config.supportsVideo = true
config.maximumCallsPerCallGroup = 1
config.maximumCallGroups = 2
config.supportedHandleTypes = [.phoneNumber, .emailAddress]
config.includesCallsInRecents = true
provider = CXProvider(configuration: config)
super.init()
provider.setDelegate(self, queue: nil)
}
}
呼入通话流程
当收到要求上报的 VoIP 通话推送时,必须立即向 CallKit 上报呼入通话,系统随后会展示原生通话界面。你必须在 PushKit completion handler 回调返回之前完成上报——若未及时上报,系统将直接终止你的应用。
func reportIncomingCall(
uuid: UUID,
handle: String,
hasVideo: Bool
) async throws {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
update.hasVideo = hasVideo
update.localizedCallerName = "Jane Doe"
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
provider.reportNewIncomingCall(
with: uuid,
update: update
) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
处理接听动作
实现 CXProviderDelegate 以响应用户的接听操作:
extension CallManager: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
// 挂断所有通话,重置音频
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// 准备音频,仅在通话确实准备就绪后再标记 fulfill
configureAudioSession()
connectToCallServer(callUUID: action.callUUID) { success in
if success {
action.fulfill()
} else {
provider.reportCall(
with: action.callUUID,
endedAt: Date(),
reason: .failed
)
action.fail()
}
}
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
disconnectFromCallServer(callUUID: action.callUUID)
action.fulfill()
}
}
呼出通话流程
使用 CXCallController 请求发起呼出通话。系统会将该请求路由至你的 CXProviderDelegate 进行处理。
func startOutgoingCall(handle: String, hasVideo: Bool) {
let uuid = UUID()
let handle = CXHandle(type: .phoneNumber, value: handle)
let startAction = CXStartCallAction(call: uuid, handle: handle)
startAction.isVideo = hasVideo
let transaction = CXTransaction(action: startAction)
callController.request(transaction) { error in
if let error {
print("Failed to start call: \(error)")
}
}
}
呼出通话的代理方法
extension CallManager {
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
configureAudioSession()
// 开始连接服务器
provider.reportOutgoingCall(
with: action.callUUID,
startedConnectingAt: Date()
)
connectToServer(callUUID: action.callUUID) {
provider.reportOutgoingCall(
with: action.callUUID,
connectedAt: Date()
)
}
action.fulfill()
}
}
PushKit VoIP 注册
每次应用启动时都要注册 VoIP 推送,并在 Token 发生变化时同步更新到服务端。对于基于 iOS 13 SDK 及以上构建的应用,所有要求上报的 VoIP 通话推送都必须在 PushKit 回调完成之前通过 CallKit(或 LiveCommunicationKit)完成上报。在 iOS 26.4+ 上,通过 PKVoIPPushMetadata.mustReport 进行判断:true 表示必须在回调完成前上报;false 则无需上报 CallKit 或 LiveCommunicationKit。若未能在回调完成前完成必须的上报,应用可能会被强行终止;多次失败甚至会导致系统暂停向该应用投递 VoIP 推送。
| 场景/路径 | 上报决策 | 回调完成时机 |
|---|---|---|
iOS 26.4+ mustReport == true |
使用 CallKit 或 LiveCommunicationKit 上报 | 完成上报回调后 |
iOS 26.4+ mustReport == false |
无需上报 CallKit/LiveCommunicationKit | 完成本地处理后 |
| 较旧代理回调 | iOS 13 SDK+ 默认将 VoIP 通话推送视为必须上报 | 完成上报回调后 |
import PushKit
final class PushManager: NSObject, PKPushRegistryDelegate {
let registry: PKPushRegistry
override init() {
registry = PKPushRegistry(queue: .main)
super.init()
registry.delegate = self
registry.desiredPushTypes = [.voIP]
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
let token = pushCredentials.token
.map { String(format: "%02x", $0) }
.joined()
// 发送 Token 到你的服务器
sendTokenToServer(token)
}
@available(iOS 26.4, *)
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingVoIPPushWith payload: PKPushPayload,
metadata: PKVoIPPushMetadata,
withCompletionHandler completion: @escaping @Sendable () -> Void
) {
guard metadata.mustReport else {
completion()
return
}
handleIncomingVoIPPush(payload, completion: completion)
}
// 保留旧版回调,以兼容 iOS 26.0-26.3 及更低部署目标
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
handleIncomingVoIPPush(payload, completion: completion)
}
private func handleIncomingVoIPPush(
_ payload: PKPushPayload,
completion: @escaping () -> Void
) {
let callUUID = UUID()
let handle = payload.dictionaryPayload["handle"] as? String ?? "Unknown"
Task {
do {
try await CallManager.shared.reportIncomingCall(
uuid: callUUID,
handle: handle,
hasVideo: false
)
} catch {
// 通话已被“勿扰模式”或黑名单过滤
}
completion()
}
}
}
服务端发送 VoIP 推送时应设置较短的生存时间(TTL):将 apns-expiration 设置为 0 或仅保留数秒。在初始推送唤醒应用后,后续的挂断通知与通话详情变更应直接通过应用与服务器之间的长连接传输,而非继续发送 VoIP 推送。
音频会话协调
CallKit 掌控着音频激活的边界:仅在 provider(_:didActivate:) 回调中启动媒体播放/录制,并在 provider(_:didDeactivate:) 或重置流程中停止或销毁音频引擎。
extension CallManager {
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
// 音频会话现已激活 —— 启动音频引擎 / WebRTC
startAudioEngine()
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
// 音频会话已停用 —— 停止音频引擎
stopAudioEngine()
}
func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
} catch {
print("Audio session configuration failed: \(error)")
}
}
}
Call Directory 扩展与管理器
Call Directory 适用于预加载来电识别/拦截数据,而非每次来电时通过 API 实时查询。扩展会在 beginRequest(with:) 中加载已排序的批量数据;主应用则使用 CXCallDirectoryManager 检查启用状态、在未启用时引导打开“通话阻止与身份识别”设置,并在数据更新后重新加载。请将 CXCallDirectoryPhoneNumber 存储为“国家代码 + 纯数字”且按升序排列的形式(例如 18005551234),切勿使用格式化字符串。
import CallKit
final class CallDirectoryHandler: CXCallDirectoryProvider {
override func beginRequest(
with context: CXCallDirectoryExtensionContext
) {
if context.isIncremental {
addOrRemoveIncrementalEntries(to: context)
} else {
addAllEntries(to: context)
}
context.completeRequest()
}
private func addAllEntries(
to context: CXCallDirectoryExtensionContext
) {
// 国家代码 + 数字,按升序排列
let blockedNumbers: [CXCallDirectoryPhoneNumber] = [
18005551234, 18005555678
]
for number in blockedNumbers {
context.addBlockingEntry(
withNextSequentialPhoneNumber: number
)
}
let identifiedNumbers: [(CXCallDirectoryPhoneNumber, String)] = [
(18005551111, "Local Pizza"),
(18005552222, "Dentist Office")
]
for (number, label) in identifiedNumbers {
context.addIdentificationEntry(
withNextSequentialPhoneNumber: number,
label: label
)
}
}
}
``
<!-- truncated for translation batch; full body continues in source -->






