callkit

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

CallKit

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

项目配置

  1. Enable the Voice over IP background mode in Signing & Capabilities
  2. Add the Push Notifications capability
  3. For call directory extensions, add a Call Directory Extension target
  1. 在「签名与功能」中启用Voice over IP后台模式
  2. 添加Push Notifications功能
  3. 如需使用通话目录扩展,新增一个Call Directory Extension target

Key Types

核心类型

TypeRole
CXProvider
Reports calls to the system, receives call actions
CXCallController
Requests call actions (start, end, hold, mute)
CXCallUpdate
Describes call metadata (caller name, video, handle)
CXProviderDelegate
Handles system call actions and audio session events
PKPushRegistry
Registers for and receives VoIP push notifications
类型作用
CXProvider
向系统上报通话信息,接收通话操作指令
CXCallController
请求执行通话操作(开始、结束、保持、静音)
CXCallUpdate
存储通话元数据(来电人姓名、是否支持视频、通话标识)
CXProviderDelegate
处理系统通话操作和音频会话事件
PKPushRegistry
注册并接收VoIP推送通知

Provider Configuration

Provider配置

Create a single
CXProvider
at app launch and keep it alive for the app lifetime. Configure it with a
CXProviderConfiguration
that describes your calling capabilities.
swift
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)
    }
}
在应用启动时创建单个
CXProvider
实例,并在应用生命周期内保持其存活。使用
CXProviderConfiguration
对其进行配置,声明你的通话功能支持的能力。
swift
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
CXProviderDelegate
to respond when the user answers:
swift
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()
    }
}
实现
CXProviderDelegate
来响应用户的接听操作:
swift
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
CXCallController
to request an outgoing call. The system routes the request through your
CXProviderDelegate
.
swift
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)")
        }
    }
}
使用
CXCallController
发起呼出通话请求,系统会将请求路由到你的
CXProviderDelegate
中处理。
swift
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
reportNewIncomingCall(with:update:completion:)
, iOS terminates your app and may stop delivering pushes entirely.
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推送但没有调用
reportNewIncomingCall(with:update:completion:)
,iOS会终止你的应用,甚至可能完全停止向你推送消息。
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
provider(_:didActivate:)
causes silence or immediate deactivation. CallKit manages session priority with the system.
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
}
provider(_:didActivate:)
回调触发前启动音频引擎会导致无声或会话被立即停用。CallKit会与系统协调管理会话优先级。
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
    CXProvider
    instance created at app launch and retained
  • CXProviderDelegate
    set before reporting any calls
  • Every VoIP push results in a
    reportNewIncomingCall
    call
  • action.fulfill()
    or
    action.fail()
    called for every provider delegate action
  • Audio engine started only after
    provider(_:didActivate:)
    callback
  • Audio engine stopped in
    provider(_:didDeactivate:)
    callback
  • Audio session category set to
    .playAndRecord
    with
    .voiceChat
    mode
  • VoIP push token sent to server on every
    didUpdate pushCredentials
    callback
  • PKPushRegistry
    created at every app launch (not lazily)
  • Call Directory phone numbers added in ascending E.164 order
  • CXCallUpdate
    populated with
    localizedCallerName
    and
    remoteHandle
  • Outgoing calls report
    startedConnectingAt
    and
    connectedAt
    timestamps
  • 功能配置中已启用VoIP后台模式
  • 应用启动时创建了单个
    CXProvider
    实例并持有引用
  • 上报任何通话前已设置
    CXProviderDelegate
  • 每个VoIP推送都会触发
    reportNewIncomingCall
    调用
  • 每个provider代理操作都调用了
    action.fulfill()
    action.fail()
  • 仅在
    provider(_:didActivate:)
    回调后才启动音频引擎
  • provider(_:didDeactivate:)
    回调中停止音频引擎
  • 音频会话类别设置为
    .playAndRecord
    ,模式为
    .voiceChat
  • 每次
    didUpdate pushCredentials
    回调触发时都将VoIP推送token发送到服务端
  • 每次应用启动时都创建
    PKPushRegistry
    实例(非懒加载)
  • 通话目录的电话号码按E.164格式升序添加
  • CXCallUpdate
    已填充
    localizedCallerName
    remoteHandle
    字段
  • 呼出通话已上报
    startedConnectingAt
    connectedAt
    时间戳

References

参考资料