callkit
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCallKit
CallKit
Build VoIP calling features that integrate with the native iOS call UI using
CallKit and PushKit. Covers incoming/outgoing call flows, VoIP push
registration, audio session coordination, and call directory extensions.
Targets Swift 6.3 / iOS 26+.
使用CallKit和PushKit构建与iOS原生通话UI集成的VoIP通话功能。涵盖呼入/呼出通话流程、VoIP推送注册、音频会话协调以及通话目录扩展。适配Swift 6.3 / iOS 26+。
Contents
目录
Setup
安装设置
Project Configuration
项目配置
- Enable the Voice over IP background mode in Signing & Capabilities
- Add the Push Notifications capability
- For call directory extensions, add a Call Directory Extension target
- 在「签名与功能」中启用Voice over IP后台模式
- 添加Push Notifications功能
- 如需使用通话目录扩展,新增一个Call Directory Extension target
Key Types
核心类型
| Type | Role |
|---|---|
| Reports calls to the system, receives call actions |
| Requests call actions (start, end, hold, mute) |
| Describes call metadata (caller name, video, handle) |
| Handles system call actions and audio session events |
| Registers for and receives VoIP push notifications |
| 类型 | 作用 |
|---|---|
| 向系统上报通话信息,接收通话操作指令 |
| 请求执行通话操作(开始、结束、保持、静音) |
| 存储通话元数据(来电人姓名、是否支持视频、通话标识) |
| 处理系统通话操作和音频会话事件 |
| 注册并接收VoIP推送通知 |
Provider Configuration
Provider配置
Create a single at app launch and keep it alive for the app
lifetime. Configure it with a that describes your
calling capabilities.
CXProviderCXProviderConfigurationswift
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)
}
}在应用启动时创建单个实例,并在应用生命周期内保持其存活。使用对其进行配置,声明你的通话功能支持的能力。
CXProviderCXProviderConfigurationswift
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
呼入通话流程
When a VoIP push arrives, report the incoming call to CallKit immediately.
The system displays the native call UI. You must report the call before the
PushKit completion handler returns -- failure to do so causes the system to
terminate your app.
swift
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()
}
}
}
}收到VoIP推送时,需立即向CallKit上报呼入通话,系统会展示原生通话UI。你必须在PushKit的完成回调返回之前上报通话——未按要求执行会导致系统终止你的应用。
swift
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
处理接听操作
Implement to respond when the user answers:
CXProviderDelegateswift
extension CallManager: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
// End all calls, reset audio
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// Configure audio, connect to call server
configureAudioSession()
connectToCallServer(callUUID: action.callUUID)
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
disconnectFromCallServer(callUUID: action.callUUID)
action.fulfill()
}
}实现来响应用户的接听操作:
CXProviderDelegateswift
extension CallManager: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
// 结束所有通话,重置音频
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// 配置音频,连接到通话服务器
configureAudioSession()
connectToCallServer(callUUID: action.callUUID)
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
disconnectFromCallServer(callUUID: action.callUUID)
action.fulfill()
}
}Outgoing Call Flow
呼出通话流程
Use to request an outgoing call. The system routes the
request through your .
CXCallControllerCXProviderDelegateswift
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)")
}
}
}使用发起呼出通话请求,系统会将请求路由到你的中处理。
CXCallControllerCXProviderDelegateswift
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
呼出通话的代理方法
swift
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()
}
}swift
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 Registration
PushKit VoIP注册
Register for VoIP pushes at every app launch. Send the token to your server
whenever it changes.
swift
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)
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
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推送,每当token发生变化时都要将其发送到你的服务端。
swift
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)
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
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()
}
}
}Audio Session Coordination
音频会话协调
CallKit manages audio session activation/deactivation. Configure your audio
session when CallKit tells you to, not before.
swift
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)")
}
}
}CallKit会管理音频会话的激活/停用。请在CallKit触发回调时再配置音频会话,不要提前操作。
swift
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 Extension
通话目录扩展
Create a Call Directory extension to provide caller ID and call blocking.
swift
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
) {
// Phone numbers must be in ascending order (E.164 format as Int64)
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
)
}
}
}Reload the extension from the main app after data changes:
swift
CXCallDirectoryManager.sharedInstance.reloadExtension(
withIdentifier: "com.example.app.CallDirectory"
) { error in
if let error { print("Reload failed: \(error)") }
}创建通话目录扩展来提供来电显示和通话拦截功能。
swift
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
) {
// 电话号码必须按升序排列(E.164格式,类型为Int64)
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
)
}
}
}数据变更后从主应用重载扩展:
swift
CXCallDirectoryManager.sharedInstance.reloadExtension(
withIdentifier: "com.example.app.CallDirectory"
) { error in
if let error { print("Reload failed: \(error)") }
}Common Mistakes
常见错误
DON'T: Fail to report a call on VoIP push receipt
禁止:收到VoIP推送后未上报通话
If your PushKit delegate receives a VoIP push but does not call
, iOS terminates your app and
may stop delivering pushes entirely.
reportNewIncomingCall(with:update:completion:)swift
// WRONG -- no call reported
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
// Just process data, no call reported
processPayload(payload)
completion()
}
// CORRECT -- always report a call
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
let uuid = UUID()
provider.reportNewIncomingCall(
with: uuid, update: makeUpdate(from: payload)
) { _ in completion() }
}如果你的PushKit代理收到VoIP推送但没有调用,iOS会终止你的应用,甚至可能完全停止向你推送消息。
reportNewIncomingCall(with:update:completion:)swift
// 错误示例——未上报通话
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
// 仅处理数据,未上报通话
processPayload(payload)
completion()
}
// 正确示例——始终上报通话
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
let uuid = UUID()
provider.reportNewIncomingCall(
with: uuid, update: makeUpdate(from: payload)
) { _ in completion() }
}DON'T: Start audio before CallKit activates the session
禁止:在CallKit激活会话前启动音频
Starting your audio engine before causes silence
or immediate deactivation. CallKit manages session priority with the system.
provider(_:didActivate:)swift
// WRONG
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
startAudioEngine() // Too early -- session not active yet
action.fulfill()
}
// CORRECT
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
prepareAudioEngine() // Prepare, but do not start
action.fulfill()
}
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
startAudioEngine() // Now it's safe
}在回调触发前启动音频引擎会导致无声或会话被立即停用。CallKit会与系统协调管理会话优先级。
provider(_:didActivate:)swift
// 错误示例
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
startAudioEngine() // 过早操作——会话尚未激活
action.fulfill()
}
// 正确示例
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
prepareAudioEngine() // 仅做准备,不启动
action.fulfill()
}
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
startAudioEngine() // 此时启动才安全
}DON'T: Forget to call action.fulfill() or action.fail()
禁止:忘记调用action.fulfill()或action.fail()
Failing to fulfill or fail an action leaves the call in a limbo state and
triggers the timeout handler.
swift
// WRONG
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
// Forgot action.fulfill()
}
// CORRECT
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
action.fulfill()
}未完成或标记操作失败会导致通话进入挂起状态,并触发超时处理逻辑。
swift
// 错误示例
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
// 忘记调用action.fulfill()
}
// 正确示例
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
action.fulfill()
}DON'T: Ignore push token refresh
禁止:忽略推送token刷新
The VoIP push token can change at any time. If your server has a stale token,
pushes silently fail and incoming calls never arrive.
swift
// WRONG -- only send token once at first registration
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
// Token saved locally but never updated on server
}
// CORRECT -- always update server
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(token) // Always send to server
}VoIP推送token随时可能变更。如果你的服务端存储的是过期token,推送会静默失败,导致用户收不到来电。
swift
// 错误示例——仅在首次注册时发送token
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
// token仅存储在本地,未更新到服务端
}
// 正确示例——始终更新服务端
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(token) // 始终发送到服务端
}Review Checklist
检查清单
- VoIP background mode enabled in capabilities
- Single instance created at app launch and retained
CXProvider - set before reporting any calls
CXProviderDelegate - Every VoIP push results in a call
reportNewIncomingCall - or
action.fulfill()called for every provider delegate actionaction.fail() - Audio engine started only after callback
provider(_:didActivate:) - Audio engine stopped in callback
provider(_:didDeactivate:) - Audio session category set to with
.playAndRecordmode.voiceChat - VoIP push token sent to server on every callback
didUpdate pushCredentials - created at every app launch (not lazily)
PKPushRegistry - Call Directory phone numbers added in ascending E.164 order
- populated with
CXCallUpdateandlocalizedCallerNameremoteHandle - Outgoing calls report and
startedConnectingAttimestampsconnectedAt
- 功能配置中已启用VoIP后台模式
- 应用启动时创建了单个实例并持有引用
CXProvider - 上报任何通话前已设置
CXProviderDelegate - 每个VoIP推送都会触发调用
reportNewIncomingCall - 每个provider代理操作都调用了或
action.fulfill()action.fail() - 仅在回调后才启动音频引擎
provider(_:didActivate:) - 在回调中停止音频引擎
provider(_:didDeactivate:) - 音频会话类别设置为,模式为
.playAndRecord.voiceChat - 每次回调触发时都将VoIP推送token发送到服务端
didUpdate pushCredentials - 每次应用启动时都创建实例(非懒加载)
PKPushRegistry - 通话目录的电话号码按E.164格式升序添加
- 已填充
CXCallUpdate和localizedCallerName字段remoteHandle - 呼出通话已上报和
startedConnectingAt时间戳connectedAt
References
参考资料
- Extended patterns (hold, mute, group calls, delegate lifecycle): references/callkit-patterns.md
- CallKit framework
- CXProvider
- CXCallController
- CXCallAction
- CXCallUpdate
- CXProviderConfiguration
- CXProviderDelegate
- PKPushRegistry
- PKPushRegistryDelegate
- CXCallDirectoryProvider
- Making and receiving VoIP calls
- Responding to VoIP Notifications from PushKit
- 扩展模式(保持、静音、群组通话、代理生命周期):references/callkit-patterns.md
- CallKit framework
- CXProvider
- CXCallController
- CXCallAction
- CXCallUpdate
- CXProviderConfiguration
- CXProviderDelegate
- PKPushRegistry
- PKPushRegistryDelegate
- CXCallDirectoryProvider
- Making and receiving VoIP calls
- Responding to VoIP Notifications from PushKit