こんにちは!kubellのiOS開発グループ機能開発チームの田川です。 この記事はkubell Advent Calendar 2025の11日目の記事です。
本日、 Chatwork内の通話機能であるChatwork Liveのシステム移行が行われました。 これまで「Agora」のSDKを使用していた部分が「ZoomVideoSDK」に置き換わっています。
今回はChatwork Liveでも使われているCallKitについて解説します。
CallKitの基礎
公式ドキュメント CallKitとはAppleが提供する通話機能用のフレームワークです。CallKitとVoIPプッシュ通知を組み合わせることによって、OSレベルで統合された通話機能をユーザーに提供することができます。 iOS/iPadOS 10.0以上で使用可能です。 1つ注意点としてCallKitは中国本土で使用することができないため、コード上で中国本土での利用を不可能にする必要があります。 CallKitに似たフレームワークであるLiveCommunicationKitは中国でも使用できるようです。
発信処理の実装
まずは発信時の実装について解説します。CallKitを扱うclassとしてCallManagerクラスを作成しています。
CallManagerクラスではCXCallController(アクションをシステムにリクエストするためのクラス)とCXProvider(通話機能を提供するためのクラス)のインスタンスを保持しています。
また、発信時の情報を一時的に保持するためのuserInfoも保持しています。
1. CXCallControllerの初期化
CXCallControllerは発信や終了といったアクションをシステムにリクエストするためのクラスです。
初期化時にCXProviderConfigurationを渡して設定を行います。
CXProviderConfigurationでは通話がビデオをサポートするか、通話の呼び出し音、同時通話数の最大値などを設定できます。
また、CXProviderのDelegateを設定しておきます。
import CallKit class CallManager: NSObject { let callController = CXCallController() private lazy var callProvider: CXProvider = { let configuration = CXProviderConfiguration() configuration.ringtoneSound = "call_sound.mp3" configuration.supportsVideo = true configuration.maximumCallsPerCallGroup = 1 configuration.maximumCallGroups = 1 configuration.supportedHandleTypes = [.generic] let provider = CXProvider(configuration: configuration) return provider }() // 発信時の情報を保持 private var callInfo: CallInfo? override init() { super.init() callProvider.setDelegate(self, queue: nil) } }
2. システムへの発信リクエスト
発信を開始するには、CXStartCallActionを作成し、CXTransactionにラップしてOSにリクエストします。
startCallAction.isVideoでビデオ通話かどうかをOSに伝えます。このプロパティの値で通話アプリに表示される通話履歴のアイコンやデフォルトの音声出力などが変わります。(isVideoがtrueならデフォルトがスピーカー出力になります。)
callController.requestはcompletionHandlerとasync/awaitの両方の形式をサポートしています。async/awaitを使用した方が簡潔に書けるので嬉しいですね。
extension CallManager { /// 発信を開始する /// - Parameters: /// - handle: 発信先の識別子 /// - hasVideo: ビデオ通話かどうか func startCall(to handle: String, hasVideo: Bool = false, callInfo: CallInfo) async throws { let uuid = callInfo.uuid let callHandle = CXHandle(type: .generic, value: handle) // 発信アクションを作成 let startCallAction = CXStartCallAction(call: uuid, handle: callHandle) startCallAction.isVideo = hasVideo // トランザクションを作成してリクエスト let transaction = CXTransaction(action: startCallAction) try await callController.request(transaction) self.callInfo = callInfo } }
3. CXProviderDelegateでの発信アクション処理
CXCallControllerへの発信リクエストが成功すると、CXProviderDelegateのprovider(_ provider: CXProvider, perform action: CXStartCallAction)メソッドが呼ばれます。接続が完了した場合はreportOutgoingCall()とaction.fulfill()を、失敗した場合はaction.fail()を呼び出す必要があります。
以下がCXProviderDelegateの実装例です。providerDidReset(_ provider: CXProvider)のみ実装が必須で他はオプショナルです。
ただし、supportsDTMF = trueかつfunc provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction)のdelegateメソッドを実装していない状態だと通話中にDTMF機能を使うとクラッシュしてしまうようなパターンもあるので、注意が必要です。
通話が開始された後にCXCallUpdateで通話の状態を更新しています。
extension CallManager: CXProviderDelegate { func providerDidReset(_ provider: CXProvider) { // Providerがリセットされた際の処理 } /// 発信アクションが実行された時に呼ばれる func provider(_ provider: CXProvider, perform action: CXStartCallAction) { guard let callInfo else { action.fail() return } // 通話の接続状態を「接続中」に更新 provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) Task { do { // 実際の通話処理を開始(SDKのセッション開始, Webサーバーへのリクエストなど、音声の接続はまだ行わない) try await startCallSession(callInfo: callInfo) // 通話が接続されたことをシステムに通知 provider.reportOutgoingCall(with: action.callUUID, connectedAt: nil) // 通話情報を更新 let update = CXCallUpdate() update.supportsDTMF = false update.supportsHolding = true update.supportsGrouping = false update.supportsUngrouping = false update.hasVideo = true provider.reportCall(with: callInfo.uuid, updated: update) action.fulfill() } catch { action.fail() } } } }
4. 音声の接続処理
CallKitを使用して通話を開始すると、OSがよしなにAVAudioSessionの設定を行うので、OSによる処理が完了するまでSDKによる音声の接続を待つ必要があります。
AVAudioSessionの設定が完了したらfunc provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession)のdelegateメソッドで通知されます。
extension CallManager { func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { // SDKの音声接続処理 connectAudio() } }
着信処理の実装
次に着信時の実装について解説します。CallKitの着信処理は、PushKitフレームワークによるVoIPプッシュ通知を起点とします。今回はVoIPプッシュ通知の設定方法などには触れません。 発信処理と同様にCallManagerクラスに実装を追加していきます。
1. VoIPプッシュ通知の受信
まずはAppDelegateでPKPushRegistryDelegateを実装し、VoIPプッシュ通知を受信できるようにします。
pushRegistry(_:didReceiveIncomingPushWith:for:)メソッドでVoIPプッシュ通知を受け取り、CallManagerの着信通知メソッドを呼び出します。
このメソッドもcompletionHandler形式とasync/await形式の両方が用意されています。
VoIPプッシュ通知を受信したら、アプリは即座にCallKitを使用した着信画面を立ち上げる必要があります。
import PushKit extension AppDelegate: PKPushRegistryDelegate { func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { let token = pushCredentials // サーバーに送信 sendTokenToServer(credentials) } // VoIPプッシュ通知を受け取った時 func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) async { guard type == .voIP else { return } // ペイロードから通話情報を取得 let callInfo = CallInfo(payload: payload.dictionaryPayload) do { // CallManagerに着信を通知 try await callManager.reportIncomingCall(callInfo: callInfo) } catch { // 着信通知に失敗した場合のエラーハンドリング } } }
2. 着信をシステムに通知
AppDelegateから呼び出されるCallManagerの着信通知メソッドを実装します。
CXProviderのreportNewIncomingCallメソッドを呼び出してシステムに着信があったことを通知します。
この際、通話を一意に識別するためのUUIDと、通話情報を格納したCXCallUpdateを渡します。
extension CallManager { /// 着信をシステムに通知する /// - Parameters: /// - handle: 発信者の識別子 /// - hasVideo: ビデオ通話かどうか /// - callInfo: 着信時の情報 func reportIncomingCall(callInfo: CallInfo) async throws { let uuid = UUID() // 通話情報を作成 let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .generic, value: callInfo.name) update.supportsDTMF = false update.supportsHolding = true update.supportsGrouping = false update.supportsUngrouping = false update.hasVideo = callInfo.hasVideo // システムに着信を通知 try await callProvider.reportNewIncomingCall(with: uuid, update: update) self.callInfo = callInfo } }
3. CXProviderDelegateでの着信応答アクション処理
システムへの着信通知が成功すると、OSが着信画面を表示します。ユーザーが応答ボタンをタップすると、CXProviderDelegateのprovider(_ provider: CXProvider, perform action: CXAnswerCallAction)が、拒否ボタンをタップするとfunc provider(_ provider: CXProvider, perform action: CXEndCallAction)が呼ばれます。
以下が着信応答時のCXProviderDelegateの実装例です。発信処理と同様に、処理が完了した際に必ずaction.fulfill()またはaction.fail()を呼び出す必要があります。
extension CallManager: CXProviderDelegate { //(略) /// 着信で応答が押された時に呼ばれる func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { guard let callInfo else { action.fail() return } Task { do { // 実際の通話処理を開始(SDKのセッション開始, Webサーバーへのリクエストなど、音声の接続はまだ行わない) try await startCallSession(callInfo: callInfo) // 通話情報を更新 let update = CXCallUpdate() update.supportsDTMF = false update.supportsHolding = true update.supportsGrouping = false update.supportsUngrouping = false update.hasVideo = true provider.reportCall(with: callInfo.uuid, updated: update) action.fulfill() } catch { action.fail() } } } // 通話が終了した時(着信で拒否を押した時など) func provider(_ provider: CXProvider, perform action: CXEndCallAction) { // 通話終了時の処理 callInfo = nil action.fulfill() } }
4. 音声の接続処理
発信処理と同様に、OSによるAVAudioSessionの設定が完了するまでSDKによる音声の接続を待つ必要があります。
AVAudioSessionの設定が完了したらfunc provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession)のdelegateメソッドで通知されます。
extension CallManager { func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { // SDKの音声接続処理 connectAudio() } }
まとめ
今回はCallKitの主要なクラスと、VoIPプッシュ通知を起点とした着信処理の基本的な実装方法について解説しました。 実際のプロダクト開発では、保留・ミュートといった通話中の操作、複数の通話のハンドリングやDTMFのサポートなど、考慮すべき点が多く存在します。 CallKitにはこれらに関する機能も用意されているので、気になった方はぜひ調べてみてください。
最後にkubellではエンジニアを募集中です。気になった方はぜひ下のリンクからご応募ください。 www.kubell.com