使用 CallKit 與 PushKit 實作 VoIP 通話功能。適用於建置來電與去電流程、註冊 VoIP 推播通知、設定 CXProvider 與 CXCallController、處理通話操作、協調音訊工作階段,以及建立用於來電辨識與通話封鎖的 Call Directory 擴充功能。
CallKit
使用 CallKit 與 PushKit 建立可整合原生 iOS 通話 UI 的 VoIP 通話功能。涵蓋來電/去電流程、VoIP 推播註冊、音訊工作階段協調以及 Call Directory 擴充功能。
目錄
環境設定
專案設定
- 在 Signing & Capabilities 中啟用 Voice over IP 背景模式
- 新增 Push Notifications 功能 (Capability)
- 若需建立 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 -->






