关于 callkit-voip
面向 iOS 开发者的 Swift 技能,介绍使用 CallKit 和 PushKit 构建 VoIP 通话并接入原生通话界面,涵盖来电、去电及音频会话管理。
主要功能
- 01处理来电与去电流程
- 02注册 VoIP 推送并更新令牌
- 03协调 CallKit 音频会话
- 04实现来电识别与拦截扩展
- 05检查常见错误与实现要点
适用场景
- 01为 iOS 应用接入原生来电界面
- 02处理 VoIP 推送到达后的来电报告
- 03管理通话应答、挂断及音频启动
- 04实现号码识别和来电拦截
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill callkit-voip安装后在 Claude Code 中输入/callkit-voip确认已加载。
附带文件references/callkit-patterns.md
详细介绍
CallKit + PushKit VoIP
使用 CallKit 和 PushKit 构建与 iOS 原生通话界面集成的 VoIP 通话功能。涵盖来电和去电流程、VoIP 推送注册、音频会话协调以及通话目录扩展。目标版本为 Swift 6.2 / iOS 26+。
目录
设置
项目配置
- 在 Signing & Capabilities 中启用 Voice over IP 后台模式
- 添加 Push Notifications 功能
- 对于通话目录扩展,添加 Call Directory Extension 目标
关键类型
| 类型 | 作用 |
|---|---|
CXProvider | 向系统报告通话,接收通话操作 |
CXCallController | 请求通话操作(开始、结束、保持、静音) |
CXCallUpdate | 描述通话元数据(来电者姓名、视频、句柄) |
CXProviderDelegate | 处理系统通话操作和音频会话事件 |
PKPushRegistry | 注册并接收 VoIP 推送通知 |
Provider 配置
在应用启动时创建一个 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)
}
}
来电流程
收到 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()
}
}
}
}
处理接听操作
实现 CXProviderDelegate,以响应用户接听操作:
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()
}
}
去电流程
使用 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()
// 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 注册
每次应用启动时都要注册 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)
}
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()
}
}
}
音频会话协调
CallKit 管理音频会话的激活和停用。只有在 CallKit 通知你时才配置音频会话,不要提前配置。
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)")
}
}
}
通话目录扩展
创建通话目录扩展,以提供来电显示和来电拦截功能。
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
)
}
}
}
数据变更后,从主应用重新加载扩展:
CXCallDirectoryManager.sharedInstance.reloadExtension(
withIdentifier: "com.example.app.CallDirectory"
) { error in
if let error { print("Reload failed: \(error)") }
}
常见错误
不要:收到 VoIP 推送时未报告来电
如果 PushKit 代理收到 VoIP 推送,却没有调用 reportNewIncomingCall(with:update:completion:),iOS 会终止你的应用,还可能完全停止向其投递推送。
// 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() }
}
不要:在 CallKit 激活会话前启动音频
在 provider(_:didActivate:) 调用前启动音频引擎会导致无声或立即停用。CallKit 会与系统协调会话优先级。
// 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
}
不要:忘记调用 action.fulfill() 或 action.fail()
未完成或未失败的操作会使通话陷入未定状态,并触发超时处理程序。
// WRONG
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
// Forgot action.fulfill()
}
// CORRECT
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
action.fulfill()
}
不要:忽略推送令牌刷新
VoIP 推送令牌可能随时变化。如果你的服务端持有过期令牌,推送就会悄然失败,导致无法收到来电。
// 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
}
审查清单
- 在 capabilities 中启用 VoIP 后台模式
- 在应用启动时创建并保留单个
CXProvider实例 - 在报告任何通话前设置
CXProviderDelegate - 每个 VoIP 推送都会调用
reportNewIncomingCall - 对每个 provider 委托操作都调用
action.fulfill()或action.fail() - 仅在
provider(_:didActivate:)回调后启动音频引擎 - 在
provider(_:didDeactivate:)回调中停止音频引擎 - 将音频会话类别设置为
.playAndRecord,模式设为.voiceChat - 在每次
didUpdate pushCredentials回调中将 VoIP 推送令牌发送到服务器 - 每次应用启动时创建
PKPushRegistry(不要延迟创建) - 按升序 E.164 顺序添加呼叫目录电话号码
- 使用
localizedCallerName和remoteHandle填充CXCallUpdate - 为拨出电话报告
startedConnectingAt和connectedAt时间戳
参考资料
- 扩展模式(保持、静音、群组通话、委托生命周期):
references/callkit-patterns.md - CallKit 框架
- CXProvider
- CXCallController
- CXCallAction
- CXCallUpdate
- CXProviderConfiguration
- CXProviderDelegate
- PKPushRegistry
- PKPushRegistryDelegate
- CXCallDirectoryProvider
- 拨打和接听 VoIP 电话
- 响应来自 PushKit 的 VoIP 通知