关于 callkit
面向 iOS 开发者的 CallKit 实现指南,涵盖 VoIP 通话生命周期、PushKit 注册、音频协调和来电目录扩展。重点说明系统通话报告时机及常见集成问题。
主要功能
- 01来电与去电流程
- 02VoIP 推送注册与处理
- 03音频会话协调
- 04来电目录扩展管理
- 05集成检查清单
适用场景
- 01在 iOS 应用中实现系统来电界面
- 02处理 PushKit VoIP 推送与令牌更新
- 03协调通话音频启动和停止
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill callkit安装后在 Claude Code 中输入/callkit确认已加载。
附带文件evals/evals.jsonreferences/callkit-patterns.md
详细介绍
CallKit
使用 CallKit 和 PushKit 构建与 iOS 原生通话界面集成的 VoIP 通话功能。涵盖来电/去电流程、VoIP 推送注册、音频会话协调和通话目录扩展。
Contents
- Setup
- Provider Configuration
- Incoming Call Flow
- Outgoing Call Flow
- PushKit VoIP Registration
- Audio Session Coordination
- Call Directory Extension and Manager
- Common Mistakes
- Review Checklist
- References
Setup
Project Configuration
- 在 Signing & Capabilities 中启用 Voice over IP 后台模式
- 添加 Push Notifications 功能
- 对于通话目录扩展,添加一个 Call Directory Extension 目标
Key Types
| Type | Role |
|---|---|
CXProvider | 向系统报告通话,并接收通话操作 |
CXCallController | 请求通话操作(开始、结束、保持、静音) |
CXCallUpdate | 描述通话元数据(来电者姓名、视频、句柄) |
CXProviderDelegate | 处理系统通话操作和音频会话事件 |
PKPushRegistry | 注册并接收 VoIP 推送通知 |
PKVoIPPushMetadata | iOS 26.4+ 元数据,用于说明是否必须报告 VoIP 推送 |
Provider Configuration
在应用启动时创建一个 CXProvider,并在应用的整个生命周期内保持其运行。使用描述通话功能的 CXProviderConfiguration 对其进行配置。
import CallKit
/// CXProvider dispatches all delegate calls to the queue passed to `setDelegate(_:queue:)`.
/// The `let` properties are initialized once and never mutated, making this type
/// safe to share across concurrency domains despite @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)
}
}
Incoming Call Flow
收到必须报告的 VoIP 通话推送时,应立即向 CallKit 报告来电。系统会显示原生通话界面。必须在 PushKit 完成处理程序返回之前报告必须报告的通话,否则系统会终止应用。
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()
}
}
}
}
Handling the Answer Action
实现 CXProviderDelegate,以响应用户接听操作:
extension CallManager: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
// End all calls, reset audio
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// Prepare audio, then fulfill only after the call is actually ready
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()
}
}
Outgoing Call Flow
使用 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)")
}
}
}
Delegate Methods for Outgoing Calls
extension CallManager {
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
configureAudioSession()
// Begin connecting to server
provider.reportOutgoingCall(
with: action.callUUID,
startedConnectingAt: Date()
)
connectToServer(callUUID: action.callUUID) {
provider.reportOutgoingCall(
with: action.callUUID,
connectedAt: Date()
)
}
action.fulfill()
}
}
PushKit VoIP Registration
在每次应用启动时注册 VoIP 推送,并将令牌变更发送到服务器。对于使用 iOS 13 SDK+ 构建的应用,每个必须报告的 VoIP 通话推送都必须在 PushKit 完成处理之前通过 CallKit 报告;基于该框架构建的应用则使用 LiveCommunicationKit。在 iOS 26.4+ 中,PKVoIPPushMetadata.mustReport 决定是否需要报告:true 表示必须在完成处理之前报告;false 表示无需通过 CallKit 或 LiveCommunicationKit 报告。未能在完成处理前报告必须报告的通话可能会导致应用终止;反复失败可能会使 VoIP 推送停止投递。
| Path | Report decision | Completion timing |
|---|---|---|
iOS 26.4+ mustReport == true | 使用 CallKit 或 LiveCommunicationKit 报告 | 报告回调之后 |
iOS 26.4+ mustReport == false | 无需通过 CallKit/LiveCommunicationKit 报告 | 本地处理之后 |
| Older delegate | 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()
// Send token to your server
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)
}
// Keep the older callback for iOS 26.0-26.3 and older deployment targets.
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 {
// Call was filtered by DND or block list
}
completion()
}
}
}
服务器端 VoIP 推送应使用较短的有效期:将 apns-expiration 设为 0 或仅几秒。初始推送唤醒应用后,应通过应用与服务器的连接发送挂断和通话详情变更,而不是继续发送 VoIP 推送。
Audio Session Coordination
CallKit 负责音频激活时机:仅在 provider(_:didActivate:) 中启动媒体,并在 provider(_:didDeactivate:) 和重置流程中停止媒体或拆除相关资源。
extension CallManager {
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
// Audio session is now active -- start audio engine / WebRTC
startAudioEngine()
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
// Audio session deactivated -- stop audio engine
stopAudioEngine()
}
func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
} catch {
print("Audio session configuration failed: \(error)")
}
}
}
通话目录扩展和管理器
使用通话目录预加载来电显示信息/屏蔽号码,而不是逐通电话通过 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
) {
// Country code + digits, sorted in ascending order
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
)
}
}
}
主应用管理器:状态、设置、重新加载
let manager = CXCallDirectoryManager.sharedInstance
manager.getEnabledStatusForExtension(withIdentifier: extensionID) { status, _ in
guard status == .enabled else {
manager.openSettings { _ in } // Call Blocking & Identification
return
}
manager.reloadExtension(withIdentifier: extensionID) { _ in }
}
在假定扩展已启用前,先检查 getEnabledStatusForExtension(...);未启用时,
使用 openSettings(...) 打开“通话阻止与身份识别”设置,并在数据变更后调用
reloadExtension(...)。将 APNs 身份验证密钥轮换和常规远程通知设置交由 push-notifications 处理。
常见错误
| 错误 | 修正方法 |
|---|---|
| 将必需的 VoIP 推送当作仅携带数据处理 | 应用对应版本的上报规则,在完成前上报,然后调用 PushKit 完成处理程序。 |
| 在媒体/服务器准备就绪前就完成应答操作 | 连接期间保持挂起;就绪时完成,否则失败并上报 .failed。 |
在 provider(_:didActivate:) 之前启动媒体 | 如有需要可提前准备,但只能在激活后启动,并在停用/重置时停止。 |
某条操作路径从不调用 fulfill() 或 fail() | 确保成功、取消、超时和网络错误路径都有一个终止操作。 |
| 忽略令牌刷新 | 将每个 didUpdate pushCredentials 令牌发送到服务器。 |
| 通话目录逐通电话执行网络请求 | 预加载已排序的条目,并重新加载扩展。 |
审核清单
- 已在功能中启用 VoIP 后台模式
- 在应用启动时创建并保留单个
CXProvider实例 - 在上报任何通话前设置
CXProviderDelegate - iOS 26.4+ 的 PushKit 路径会在
mustReport为 true 时上报,为 false 时可以跳过 - 使用 iOS 13 SDK+ 时,PushKit VoIP 通话推送会在完成前上报给 CallKit
- VoIP APNs 请求使用的
apns-expiration为0或仅几秒 - 初始推送后,通过应用服务器连接处理挂断和详细信息更新
- 每个 provider 委托操作都会调用
action.fulfill()或action.fail() - 仅在通话服务器/媒体连接就绪后才完成
CXAnswerCallAction - 仅在
provider(_:didActivate:)回调后启动音频引擎 - 在
provider(_:didDeactivate:)回调中停止音频引擎 - 音频会话类别设为
.playAndRecord,模式设为.voiceChat - 每次
didUpdate pushCredentials回调时都将 VoIP 推送令牌发送到服务器 - 每次应用启动时都创建
PKPushRegistry(而不是延迟创建) - 通话目录数据为预加载,而非在每次来电时获取
- 文档说明
CXCallDirectoryPhoneNumber的格式为国家/地区呼叫代码加数字 -
CXCallDirectoryManager列出状态检查、重新加载和打开设置的 API - 使用
localizedCallerName和remoteHandle填充CXCallUpdate - 外拨通话会上报
startedConnectingAt和connectedAt时间戳 - iOS 26 通话翻译在静音期间保持上行音频处于活动状态
- 加密元数据筛选说明通知服务扩展权限
参考资料
- 扩展模式(保持、静音、群组通话、委托生命周期):references/callkit-patterns.md
- CallKit 框架
- CXProvider
- CXCallController
- CXCallAction
- CXCallUpdate
- CXProviderConfiguration
- CXProviderDelegate
- PKPushRegistry
- PKPushRegistryDelegate
- PKVoIPPushMetadata
- CXCallDirectoryProvider
- CXCallDirectoryPhoneNumber
- CXCallDirectoryManager
- CXSetTranslatingCallAction
- reportNewIncomingVoIPPushPayload(_:completion:)
- 拨打和接听 VoIP 通话
- 响应来自 PushKit 的 VoIP 通知