callkit

callkit

熱門

使用 CallKit 與 PushKit 實作 VoIP 通話功能。適用於建置來電與去電流程、註冊 VoIP 推播通知、設定 CXProvider 與 CXCallController、處理通話操作、協調音訊工作階段,以及建立用於來電辨識與通話封鎖的 Call Directory 擴充功能。

967星標
49分支
更新於 2026/7/31
SKILL.md
唯讀
名稱
callkit
描述

使用 CallKit 與 PushKit 實作 VoIP 通話功能。適用於建置來電與去電流程、註冊 VoIP 推播通知、設定 CXProvider 與 CXCallController、處理通話操作、協調音訊工作階段,以及建立用於來電辨識與通話封鎖的 Call Directory 擴充功能。

CallKit

使用 CallKit 與 PushKit 建立可整合原生 iOS 通話 UI 的 VoIP 通話功能。涵蓋來電/去電流程、VoIP 推播註冊、音訊工作階段協調以及 Call Directory 擴充功能。

目錄

環境設定

專案設定

  1. 在 Signing & Capabilities 中啟用 Voice over IP 背景模式
  2. 新增 Push Notifications 功能 (Capability)
  3. 若需建立 Call Directory 擴充功能,請新增 Call Directory Extension Target

核心型別

型別 角色
CXProvider 向系統回報通話,並接收通話操作
CXCallController 請求通話操作(開始、結束、保留、靜音)
CXCallUpdate 描述通話中繼資料(來電者姓名、視訊、控制代碼/Handle)
CXProviderDelegate 處理系統通話操作與音訊工作階段事件
PKPushRegistry 註冊並接收 VoIP 推播通知
PKVoIPPushMetadata iOS 26.4+ 的中繼資料,用於判斷是否必須回報 VoIP 推播

Provider 設定

在 App 啟動時建立單一 CXProvider 實例,並在 App 整個生命週期中維持其存活。使用 CXProviderConfiguration 進行設定,以描述您的通話功能支援。

import CallKit

/// CXProvider 會將所有 Delegate 呼叫分派至傳入 `setDelegate(_:queue:)` 的佇列。
/// 由於 `let` 屬性只初始化一次且永不變更,因此即使標示 @unchecked Sendable,
/// 此型別在跨並行領域 (Concurrency Domains) 共享時依然是安全無虞的。
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 回報來電。系統將顯示原生的通話 UI。您必須在 PushKit completion handler 傳回之前回報必要的通話 —— 若未能做到這一點,系統將會強制終止您的 App。

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) {
        // 結束所有通話,重置音訊
    }

    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        // 準備音訊,並僅在通話真正準備就緒後才調用 fulfill
        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()
    }
}

去電流程

使用 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 方法

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 註冊

每次 App 啟動時都要註冊 VoIP 推播,並將變更後的 Token 傳送至您的伺服器。對於使用 iOS 13 SDK 或更高版本建置的 App,所有要求回報的 VoIP 通話推播,都必須在 PushKit completion 呼叫前透過 CallKit 回報(若使用 LiveCommunicationKit 框架建置的 App 則透過該框架回報)。在 iOS 26.4+ 中,以 PKVoIPPushMetadata.mustReport 為判定基準:true 代表必須在 completion 前完成回報;false 代表無需透過 CallKit 或 LiveCommunicationKit 回報。若未能及時回報必要的推播,可能會導致 App 被系統強制終止,多次失敗甚至會使系統停止推送 VoIP 推播。

路徑 回報判斷 Completion 時機
iOS 26.4+ mustReport == true 透過 CallKit 或 LiveCommunicationKit 回報 於回報 Callback 完成後
iOS 26.4+ mustReport == false 無需 CallKit/LiveCommunicationKit 回報 於本地處理完成後
舊版 Delegate iOS 13 SDK+ 將 VoIP 通話推播視為必須回報 於回報 Callback 完成後
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)
    }

    @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)
    }

    // 保留舊版 callback 以支援 iOS 26.0-26.3 及更舊的部署目標。
    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 {
                // 通話已被「勿擾模式」或黑名單過濾
            }
            completion()
        }
    }
}

伺服器端的 VoIP 推播應設定較短的存活時間:將 apns-expiration 設為 0 或僅數秒。在初始推播喚醒 App 後,後續的掛斷與通話細節變更應透過 App 與伺服器之間的連線傳送,而非繼續發送 VoIP 推播。

音訊工作階段協調

CallKit 掌控音訊啟動的邊界:僅在 provider(_:didActivate:) 中啟動媒體串流,並在 provider(_:didDeactivate:) 以及重置流程中停止或拆除音訊設備。

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 擴充功能與管理器

請使用 Call Directory 來預載來電者識別與封鎖資料,而非在每次來電時才透過 API 查詢。擴充功能會在 beginRequest(with:) 中載入已排序的大量資料;主 App 則透過 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
    ) {
        // 國家代碼 + 數字,依升冪排序
        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
            )
        }
    }
}

<!-- truncated for translation batch; full body continues in source -->