mcpField
C
callkit

callkit

github.com/dpearson2699开发1.2k3.1k 次安装13 周前更新
代码仓库

关于 callkit

面向 iOS 开发者的 CallKit 实现指南,涵盖 VoIP 通话生命周期、PushKit 注册、音频协调和来电目录扩展。重点说明系统通话报告时机及常见集成问题。

主要功能

  1. 01来电与去电流程
  2. 02VoIP 推送注册与处理
  3. 03音频会话协调
  4. 04来电目录扩展管理
  5. 05集成检查清单

适用场景

  1. 01在 iOS 应用中实现系统来电界面
  2. 02处理 PushKit VoIP 推送与令牌更新
  3. 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

Project Configuration
  1. 在 Signing & Capabilities 中启用 Voice over IP 后台模式
  2. 添加 Push Notifications 功能
  3. 对于通话目录扩展,添加一个 Call Directory Extension 目标
Key Types
TypeRole
CXProvider向系统报告通话,并接收通话操作
CXCallController请求通话操作(开始、结束、保持、静音)
CXCallUpdate描述通话元数据(来电者姓名、视频、句柄)
CXProviderDelegate处理系统通话操作和音频会话事件
PKPushRegistry注册并接收 VoIP 推送通知
PKVoIPPushMetadataiOS 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 推送停止投递。

PathReport decisionCompletion timing
iOS 26.4+ mustReport == true使用 CallKit 或 LiveCommunicationKit 报告报告回调之后
iOS 26.4+ mustReport == false无需通过 CallKit/LiveCommunicationKit 报告本地处理之后
Older delegateiOS 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 通话翻译在静音期间保持上行音频处于活动状态
  • 加密元数据筛选说明通知服务扩展权限

参考资料

评论

支持 Markdown