callkit について
iOS 開発者向けの CallKit 実装ガイドです。VoIP の通話フロー、PushKit 登録、オーディオ制御、着信拒否・識別拡張を扱い、報告要件や統合時の注意点も説明します。
主な機能
- 01着信・発信フロー
- 02VoIP プッシュの登録と処理
- 03オーディオセッションの調整
- 04着信識別拡張の管理
- 05統合時の確認項目
活用シーン
- 01iOS アプリにネイティブの着信 UI を追加
- 02PushKit の VoIP 通知とトークン更新を処理
- 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 ネイティブの通話 UI と連携する VoIP 通話機能を構築します。着信/発信のフロー、VoIP プッシュ登録、オーディオセッションの連携、通話ディレクトリ拡張機能を扱います。
内容
- セットアップ
- プロバイダーの設定
- 着信フロー
- 発信フロー
- PushKit VoIP 登録
- オーディオセッションの連携
- 通話ディレクトリ拡張機能とマネージャー
- よくある間違い
- 確認チェックリスト
- 参考資料
セットアップ
プロジェクトの設定
- Signing & Capabilities でバックグラウンドモードの Voice over IP を有効にします
- Push Notifications 機能を追加します
- 通話ディレクトリ拡張機能を使用する場合は、Call Directory Extension ターゲットを追加します
主な型
| 型 | 役割 |
|---|---|
CXProvider | システムに通話を通知し、通話アクションを受け取ります |
CXCallController | 通話アクション(開始、終了、保留、ミュート)を要求します |
CXCallUpdate | 通話メタデータ(発信者名、ビデオ、ハンドル)を記述します |
CXProviderDelegate | システムの通話アクションとオーディオセッションイベントを処理します |
PKPushRegistry | VoIP プッシュを登録し、受信します |
PKVoIPPushMetadata | VoIP プッシュを報告する必要があるかどうかを示す、iOS 26.4 以降のメタデータです |
プロバイダーの設定
アプリの起動時に CXProvider を 1 つ作成し、アプリの実行中は保持します。通話機能を記述する 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 に報告します。システムはネイティブの通話 UI を表示します。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) {
// 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()
}
}
発信フロー
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 プッシュを登録し、トークンの変更をサーバーに送信します。iOS 13 SDK 以降でビルドされたアプリでは、報告が必要な VoIP 通話プッシュをすべて、PushKit の完了ハンドラーが呼び出される前に CallKit を使って報告する必要があります。このフレームワークを使ってビルドされたアプリでは、LiveCommunicationKit を使用します。iOS 26.4 以降では、PKVoIPPushMetadata.mustReport が判定基準となります。true の場合は完了前に報告し、false の場合は CallKit または LiveCommunicationKit による報告は不要です。完了前に必要な報告を行わないとアプリが終了されることがあり、失敗が繰り返されると VoIP プッシュの配信が停止される可能性があります。
| 経路 | 報告の判定 | 完了のタイミング |
|---|---|---|
iOS 26.4 以降 mustReport == true | CallKit または LiveCommunicationKit で報告 | 報告コールバックの後 |
iOS 26.4 以降 mustReport == false | CallKit/LiveCommunicationKit による報告は不要 | ローカル処理の後 |
| 古いデリゲート | iOS 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 プッシュではなくアプリとサーバー間の接続を通じて送信してください。
オーディオセッションの連携
オーディオの有効化タイミングは 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 検索ではなく、事前読み込みした発信者 ID の表示/ブロックに Call Directory を使用します。
拡張機能は beginRequest(with:) でソート済みの一括データを読み込みます。メインアプリは
CXCallDirectoryManager を使用して有効状態を確認し、無効の場合は「着信拒否設定と発信者ID」設定を開き、
データの変更後に再読み込みします。
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(...) を確認し、無効の場合は
「着信拒否設定と発信者ID」のために openSettings(...) を使用し、データの変更後に
reloadExtension(...) を呼び出します。APNs 認証キーのローテーションと通常のリモート通知の設定は
push-notifications に振り分けてください。
よくある間違い
| 間違い | 修正方法 |
|---|---|
| 必須の VoIP プッシュをデータのみとして扱っている | バージョン固有の報告ルールを適用し、完了前に報告してから PushKit の完了ハンドラーを呼び出します。 |
| メディア/サーバーの準備が整う前に応答アクションを完了している | 接続中は保留し、準備が整った時点で完了するか、失敗させて .failed を報告します。 |
provider(_:didActivate:) より前にメディアを開始している | 必要に応じて事前に準備しますが、開始するのはアクティブ化後のみとし、非アクティブ化/リセット時に停止します。 |
アクションの処理経路で fulfill() または fail() が呼び出されない | 成功、キャンセル、タイムアウト、ネットワークエラーの各経路で、終了アクションを 1 つ実行するようにします。 |
| トークンの更新を無視している | didUpdate pushCredentials で受け取るすべてのトークンをサーバーに送信します。 |
| Call Directory が通話ごとにネットワーク通信を行っている | ソート済みのエントリーを事前読み込みして、拡張機能を再読み込みします。 |
レビューチェックリスト
- ケイパビリティで VoIP バックグラウンドモードが有効になっている
- アプリの起動時に単一の
CXProviderインスタンスを作成し、保持している - 通話を報告する前に
CXProviderDelegateを設定している - iOS 26.4 以降の PushKit 経路では、
mustReportが true の場合に報告し、false の場合は省略できる - iOS 13 SDK 以降では、PushKit の VoIP 通話プッシュを完了前に CallKit に報告している
- VoIP APNs リクエストの
apns-expirationが0または数秒以内になっている - 最初のプッシュ後の切断や詳細の更新には、アプリサーバーへの接続を使用している
- プロバイダーデリゲートのすべてのアクションで
action.fulfill()またはaction.fail()を呼び出している -
CXAnswerCallActionを完了するのは、通話サーバー/メディアへの接続が準備できた後のみ - オーディオエンジンを開始するのは
provider(_:didActivate:)コールバック後のみ -
provider(_:didDeactivate:)コールバック内でオーディオエンジンを停止している - オーディオセッションのカテゴリを
.playAndRecord、モードを.voiceChatに設定している -
didUpdate pushCredentialsコールバックのたびに VoIP プッシュトークンをサーバーに送信している -
PKPushRegistryをアプリ起動のたびに作成している(遅延作成ではない) - Call Directory のデータは事前読み込みしており、着信ごとに取得していない
-
CXCallDirectoryPhoneNumberが国番号と数字として説明されている -
CXCallDirectoryManagerに状態確認、再読み込み、設定を開く API が記載されている -
CXCallUpdateにlocalizedCallerNameとremoteHandleを設定している - 発信通話で
startedConnectingAtとconnectedAtのタイムスタンプを報告している - iOS 26 の通話翻訳では、ミュート中も上り音声をアクティブに保っている
- 暗号化されたメタデータのフィルタリングについて、通知サービス拡張のエンタイトルメントに言及している
参考資料
- 拡張パターン(保留、ミュート、グループ通話、デリゲートのライフサイクル):references/callkit-patterns.md
- CallKit フレームワーク
- CXProvider
- CXCallController
- CXCallAction
- CXCallUpdate
- CXProviderConfiguration
- CXProviderDelegate
- PKPushRegistry
- PKPushRegistryDelegate
- PKVoIPPushMetadata
- CXCallDirectoryProvider
- CXCallDirectoryPhoneNumber
- CXCallDirectoryManager
- CXSetTranslatingCallAction
- reportNewIncomingVoIPPushPayload(_:completion:)
- VoIP 通話の発信と受信
- PushKit からの VoIP 通知への応答