Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Sansan音声通話の裏側 【前編:CallKit/PushKit 】

はじめに

こんにちは、尾林です。
Sansan事業部プロダクト開発部でSansan iOSアプリの開発を担当しています。

10月6日、Sansanの iOS/Android アプリで社内通話機能がリリースされました。
社内通話機能とは Sansan をご利用いただいている企業様の同僚間でのIP通話が可能になる機能のことです。

昨今ではLINEやWhatsAppを筆頭に社会インフラとなっているIP電話ですが、弊社としてこの通話機能を搭載する試みは初となり、開発を担当した私としても非常に多くの学びを得ることができました。
本記事ではこの学びを共有すべく、社内通話機能を開発する中で得た技術的知見をiOSの視点から紹介します。
また本稿は前編/後編に分かれた2記事構成とさせていただいており、前編となる今回の記事では通話に関するUI/UX分野の知見を中心にお伝えしたいと思います。

IP通話の実現に必要な要素

IP通話を実現する上で必要な要素として、大きく以下の2つが挙げられます。

  1. スムーズな通話体験を実現するためのUI/UXの提供
  2. 発信者と着信者の音声情報を繋ぐバックエンド機構

1. スムーズな通話体験を実現するためのUI/UXの提供

スムーズな通話体験を実現するためには、通話をする上で必要なインタラクション*1や着信〜応答までのシームレスなUXの提供が必要になります。
これらを実現する手段として、Sansan iOSアプリではAppleが提供している CallKitPushKit を採用しました。
CallKitとはVoIP*2を用いた通話をするために必要なUI機能をアプリから利用できるフレームワークのことで、2016年に公開されたiOS10から我々開発者に対して提供が開始されました。
このCallKitをVoIP専用のPush通知機能を提供するPushKitと組み合わせて実装することで、従来と比較してよりスムーズな通話体験を生み出すことが可能になりました。
iOS9以前では、端末がロックされている状態で着信に応答する場合は「端末のロックを解除 → Push通知をタップする」等の動作が必要でしたが、iOS10からオフィシャルなフレームワークが提供されたことでこれらの動作が不要になり、着信〜応答に関するUXが飛躍的に向上しました。

2. 発信者と着信者の音声情報を繋ぐバックエンド機構

CallKitやPushKitはあくまで「通話に関するUI/UXを提供する」フレームワークであり、実際の音声情報をやり取りするための手段は提供されていません。これを実現するためのバックエンド機構として、SansanではTwilio社が提供する Twilio Voice API サービスを採用しました。 www.twilio.com Twilio Voice APIではクライアントからの発信リクエストをWebhookとして受け取り、アプリケーションサーバで生成したTwiML*3を返すことでクライアント間の疎通を実現することができます。
この Twilio Voice API を利用することで、IP通話における核となる「ネットワーク上での音声データ転送処理」を肩代わりしてもらっています。
このAPIを利用するためのSDK*4がiOS/Androidアプリに対して提供されており、これをSansanアプリに組み込む形で音声情報の疎通を実現しています。

音声通話システムの概観

上記2つの要素を検討した末、Sansanアプリ上で通話機能を実現するための構成は以下のようになりました。

f:id:obayashi1020:20200912150028j:plain
音声通話システムの概観

「はじめに」でも述べましたが、本稿は前編/後編の2記事構成となっており、前編となる今回はCallKit/PushKitフレームワークの基本的な使い方やTipsを紹介します。
Twilio SDKの組み込みについては後編の記事投稿をお待ちいただければと思います。

各フレームワークの基本的な使い方

前編となる本稿では、Appleが提供しているCallKitとPushKitの基本的な使い方を中心に紹介します。
まずは着信を受けるための準備としてPushKitの導入が必要になるので、先にPushKitの説明から入ります。

PushKit

PushKitはVoIPサービスを用いたPush通知を受けるためのフレームワークです。 従って最初にVoIPサービス利用を適用する設定が必要になります。
適用に必要な作業は以下の4つです。

  1. 証明書署名要求ファイル(CSR)の作成
  2. 1を元にVoIP Services Certificate(.cer)の作成
  3. Provisioning Profileの作成 or 紐付け
  4. アプリのInfo.plistにてVoIPサービスを有効化

1と3については通常のアプリ開発での作業と同様ですので、この場では2と4の手順について説明します。

VoIP Services Certificate(.cer)の作成

まずはVoIPを用いた通話をするために、VoIP証明書を用意する必要があります。
他の証明書を作成する際と同様に、Apple Developer Programにその口が用意されているのでそこから作成していきます。

1. Apple Developer Programから、新規Certificatesを作成

f:id:obayashi1020:20200912155636p:plain
Certificatesの追加

2. VoIP Services Certificateを選択し、Continueをクリック

f:id:obayashi1020:20200905132109p:plain
VoIP証明書を選択

3. 開発するアプリのApp IDを選択し、Continueをクリック

4. ローカルで作成したCSRファイルを選択し、Continueをクリック

5. 指定したApp IDにVoIP ServicesのCertificateがあることを確認 *5

VoIPサービスを有効化

続いてアプリに対してVoIPサービスの利用を有効化する手順を紹介します。

1. XcodeからアプリのInfo.plistを表示

2. [Required background modes] に以下の2つを設定

  • App plays audio or streams audio/video using AirPlay
  • App provides Voice over IP services
    f:id:obayashi1020:20200905131219p:plain
    VoIPの有効化

以上の手順でVoIPサービスを利用するための準備が整いました。

PushKitの実装

これらを設定後、AppDelegateに以下の実装を入れることで、VoIPを用いた着信処理を実現することができます。

import PushKit

class AppDelegate: UIResponder, UIApplicationDelegate {
    private let voipRegistry = PKPushRegistry(queue: .main)

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
                           ︙
        // PushRegistryオブジェクトの初期化
        // voipRegisterのpropertyに更新が入ると、オブジェクトがPushKitサーバーに都度登録される
        voipRegistry = PKPushRegistry(queue: nil)
        voipRegistry.delegate = self
        voipRegistry.desiredPushTypes = [PKPushTypeVoIP]
                           ︙
    }
}

extension AppDelegate: PKPushRegistryDelegate {
    
    func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
        // プッシュ通知の受け取り情報をシステムが更新した際に呼ばれる(アプリ初回起動時等)
    }

    func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
        // 設定アプリ等でプッシュ通知設定を無効にした際に呼ばれる
    }

    func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
        // VoIPによるPush通知が届いた際に呼ばれる
    }
}

ここまでの準備ができた状態で端末が着信を受けるとfunc pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) が発火するようになります。
このイベントの発火を起点にして着信UIを表示したり、着信時のロジックを実装していくことになります。
以上でアプリ側で着信を受けるための準備はできました。以降ではUI表示を請け負うCallKitの使い方を紹介していきます。

CallKit

CallKitはiOSのVoIP機能に対してアプリから直接アクセスすることのできるフレームワークです。
これによって標準電話アプリと同等のUI/UXを提供することができるようになります。
CallKitを利用するためには CXProviderCXCallController の2つのクラスを用いた実装が必須になるので、まずはここから説明をしていきます。

CXProviderの準備

通話を開始する際のインタフェースをCXProviderクラスに定義する必要があります。
CXProviderConfigurationを介して通話画面のインタフェースや通話に関する設定値を定義していきます。

let configuration = CXProviderConfiguration(localizedName: "Sansan")
configuration.supportsVideo = false // ビデオ通話をサポートするか
configuration.maximumCallGroups = 1  //通話相手の最大人数
configuration.iconTemplateImageData = UIImage(named:"app_icon").pngData() // CallKitの通話UIに表示されるアプリへの導線ボタン画像

var provider = CXProvider(configuration: configuration)
provider.setDelegate(self, queue: nil)

iconTemplateImageDataを設定すると、CallKitの通話UIに表示されるアプリへの導線ボタンに画像を表示することができます。 しかし、CallKitが提供するUIに統一性を持たせる目的で、この画像に色味を持たせることは出来ません。 PNG画像が持つα値を元にモノトーンで表示されるようです。

f:id:obayashi1020:20200912151952p:plain
iconTemplateImageDataを設定後の通話UI

CXCallControllerの準備

CXCallControllerインスタンスを生成することで、システムに対して発信や終了等のリクエストを送ることができます。 初期化時に必須のパラメータは特にありません。

var controller = CXCallController()

 


 
以上でCallKitを利用して発着信するための準備はできました。
ここから発信や通話終了処理、着信時の応答処理について説明します。

CallKitでの発信

発信を表すCXStartCallActionを元にしたCXTransactionを生成し、それを引数にしたrequestメソッドをCXCallControllerから呼び出します。
発信リクエストが成功したら、provider.reportCallを呼び出すことでその旨をシステムに通知します。

func startCall() {
    callUUID = UUID()
    guard let callUUID = callUUID else { return }

    let callUpdate = CXCallUpdate()
    let callHandle = CXHandle(type: .generic, value: "発信者の名前") // valueの値が「発信者の名前」として着信UIに表示される
    let startCallAction = CXStartCallAction(call: callUUID, handle: callHandle)
    let transaction = CXTransaction(action: startCallAction)

    // controller: CXCallControllerインスタンス
    controller.request(transaction) { error in
        if error != nil { return }
        self.provider.reportCall(with: callUUID, updated: callUpdate)
    }
}

発信リクエストの成功がシステムに通知されると、CXProviderDelegateメソッドの一つであるfunc provider(_ provider: CXProvider, perform action: CXStartCallAction)が呼ばれます。
(後述のインタラクションにおいてもCXProviderDelegateがいくつか出てきますが、これに準拠したメソッドを実装することで各インタラクションでの挙動を表現することができます。)
ここまできて初めて発信リクエストの流れが完了するので、このDelegateメソッドにCallKitの発信UI表示処理やTwilioに対する発信リクエスト処理を実装します。

func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
    // CallKitの発信UIを表示
    provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date())
    
    // Twilioに対する発信リクエスト
}

CallKitでの着信と応答

端末が着信を受けると、PushKit経由でfunc pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType)が呼ばれますので、ここを起点に着信UI表示処理を実装していきます。

func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
    // VoIPによるPush通知が届いた際に呼ばれる
    incoming()
}

func incoming() { 
    let callHandle = CXHandle(type: .generic, value: "発信者の名前")
    let callUpdate = CXCallUpdate()

    // 着信UIの表示
    provider.reportNewIncomingCall(with: uuid, update: callUpdate) { _ in }
}

CXProviderreportNewIncomingCallを呼び出すと、着信UIが表示されます。着信UIに表示される「応答」ボタンをタップすると、CXProviderDelegateメソッドのfunc provider(_ provider: CXProvider, perform action: CXAnswerCallAction)が呼ばれます。
このメソッドに応答後の処理(独自通話画面への遷移処理やTwilioへの応答リクエスト)を実装していきます。

func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
    // (アプリ独自の通話画面が必要であれば)通話画面への遷移処理
    // Twilioの応答リクエスト
    if Twilioへの応答リクエストが成功したら {
        action.fulfill()
    } else {
        action.fail()
    }
}

CallKitでの通話終了

CallKit上で通話終了の処理を実現する方法は前述した発信リクエストに似ており、通話終了リクエストを管理するCXEndCallActionというクラスがあります。これをベースに生成したCXTransactionを引数にして、CXCallControllerから終了リクエストを投げます。

func endCall(by uuid: UUID) {
    let endCallAction = CXEndCallAction(call: uuid)
    let transaction = CXTransaction(action: endCallAction)
    // controller: CXCallControllerインスタンス
    controller.request(transaction) { _ in }
}

通話終了リクエストが投げられると、上記と同様にCXProviderDelegateメソッドのfunc provider(_ provider: CXProvider, perform action: CXEndCallAction)が呼ばれます。
このメソッドの中でTwilioへの終了リクエストを実装していきます。

func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
    // Twilioの通話終了リクエスト
    if Twilioへの通話終了リクエストが成功したら {
        action.fulfill()
    } else {
        action.fail()
    }
}

[注意] CallKitを導入したアプリの中国配信

ここまで紹介してきたように、iOSにおける良質な通話体験には欠かせないCallKit/PushKitですが、一部のアプリに対して注意すべきことがあります。
それは「CallKitを導入したアプリは中国に対して配信不可能である」ということです。
2018年5月頃から、中国向けに配信しているiOSアプリがCallKitフレームワークを搭載していると、Appleの審査でリジェクトされるようになりました。
詳細な理由については不明ですが、リジェクトされる理由には「中国政府の規制により、中国本土ではCallKitの機能は提供できない」旨の記載がされるようです。 「CallKit China」で検索すると、さまざまな記事がヒットしますので興味のある方はご覧になってみてください。

最後に

本稿ではSansanアプリに搭載された音声通話機能の裏側の前編として、「システムの概観」と「通話体験のUI/UX提供手段としてのCallKit/PushKitフレームワークの基本的な使い方」について紹介させていただきました。
次回となる後編では実際にTwilio SDKの組み込み方法の紹介を通して、音声データの疎通部分についての記事を投稿する予定です。


buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

*1:通話中のボリューム調整やスピーカーやミュートのON/OFF等が挙げられます。

*2:「Voice over Internet Protocol」の略で、インターネット上で音声通話を実現する技術のことを表します。

*3:発信リクエストを受け取った際に、Twilioに対して動作を指示する独自マークアップ言語

*4:QuickstartがGitHubに公開されているので、無料で気軽に試すことも可能です。github.com

*5:作成した.cerファイルからTwilioに登録する情報を書き出す手順が必要になりますが、これは後編で紹介します。

© Sansan, Inc.