mcpField
C
callkit-voip

callkit-voip

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

关于 callkit-voip

面向 iOS 开发者的 Swift 技能,介绍使用 CallKit 和 PushKit 构建 VoIP 通话并接入原生通话界面,涵盖来电、去电及音频会话管理。

主要功能

  1. 01处理来电与去电流程
  2. 02注册 VoIP 推送并更新令牌
  3. 03协调 CallKit 音频会话
  4. 04实现来电识别与拦截扩展
  5. 05检查常见错误与实现要点

适用场景

  1. 01为 iOS 应用接入原生来电界面
  2. 02处理 VoIP 推送到达后的来电报告
  3. 03管理通话应答、挂断及音频启动
  4. 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+。

目录

设置

项目配置
  1. 在 Signing & Capabilities 中启用 Voice over IP 后台模式
  2. 添加 Push Notifications 功能
  3. 对于通话目录扩展,添加 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 时间戳

参考资料

评论

支持 Markdown