WWDC Quick Look 💓 By SwiftGGTeam
ライブコミュニケーション体験を作成する

ライブコミュニケーション体験を作成する

元の動画を見る

ハイライト

AppleはLiveCommunicationKitフレームワークを導入します。これにより、サードパーティのリアルタイムコミュニケーションアプリは、システム電話と同等の全画面着信UI、Dynamic Islandでのマルチタスク表示、PhoneアプリのRecents記録、Siriからの起動能力を得られます。従来のCXProvider APIを置き換える仕組みです。

重要ポイント

CXProviderからLiveCommunicationKitへ

これまで、iOSでVoIP通話を実装する開発者は、主にCallKitのCXProvider APIに頼っていました。この方法で基本的なシステム着信画面は作れましたが、柔軟性には限界があり、グループ通話、通話の結合、ビデオ通話といった現代的な通信場面を直感的に扱いにくい面がありました。

LiveCommunicationKitは、これらの問題を解決するための新しいフレームワークです。「通話」を、より一般的な「会話(conversation)」という概念として抽象化します。1対1の音声、ビデオ、グループ会話、結合されたセッションなど、さまざまな形態をすべてシステムレベルのUIへ接続できます。

00:53

会話のライフサイクル

会話は作成から終了まで、5つの状態を進みます。

  • idle: システムへ報告された直後で、デバイスが鳴り始めます
  • joining: ユーザーが応答し、アプリが接続を確立しています
  • joined: 接続が完了し、音声や映像の送受信が始まっています
  • leaving: ユーザーが切断し、アプリがリソースを片付けています
  • left: 片付けが完了し、会話が終了しています

04:10

各状態の切り替えは、アプリがConversationManagerを通じて能動的に報告します。システムはその状態に応じて、Lock Screen、Dynamic Island、RecentsのUIを更新します。

統一された状態管理

LiveCommunicationKitの中心設計は、「単一のデータソース」です。ユーザーがLock Screenからスライドして応答しても、Dynamic Islandで切断をタップしても、アプリ内のボタンを押しても、すべての操作はConversationActionに変換され、同じConversationManagerDelegateコールバックからアプリへ入ります。

そのため、アプリは状態ロジックを2系統持つ必要がありません。システムUIとアプリUIは常に同期します。

06:00

詳細

ConversationManagerを設定する

LiveCommunicationKitを使うアプリには、ConversationManagerインスタンスが必要です。通常はアプリ起動時に作成し、アプリのライフサイクル全体で保持します。

import LiveCommunicationKit

let configuration = ConversationManager.Configuration(
  ringtoneName: "SampleRingtone.caf",
  iconTemplateImageData: UIImage(named: "SampleIcon")?.pngData(),
  maximumConversationGroups: 1,
  maximumConversationsPerConversationGroup: 2,
  includesConversationInRecents: true,
  supportsVideo: true,
  supportedHandleTypes: [.phoneNumber, .emailAddress]
)

let manager = ConversationManager(configuration: configuration)
manager.delegate = self

ポイント:

  • ringtoneName: アプリbundle内の着信音ファイルで、着信時に再生されます
  • iconTemplateImageData: システムUIに表示されるアプリアイコンのテンプレートです
  • maximumConversationGroupsmaximumConversationsPerConversationGroup: 結合会話の階層を制限します
  • includesConversationInRecents: 通話記録をPhoneアプリのRecentsに表示するかどうかです
  • supportsVideo: システムUI内のビデオボタンを有効にします
  • supportedHandleTypes: アプリがサポートする連絡先タイプで、連絡先照合に影響します

06:41

着信を処理する

着信はPushKitのVoIP pushでアプリを起こします。アプリはdelegateメソッドが返る前に会話を報告しなければなりません。そうしないとシステムによりアプリが終了されます。

import LiveCommunicationKit
import PushKit

final class SamplePushHandler: NSObject, PKPushRegistryDelegate {
  func pushRegistry(
    _ registry: PKPushRegistry,
    didReceiveIncomingVoIPPushWith payload: PKPushPayload,
    metadata: PKVoIPPushMetadata) async {

    guard let (handle, uuid) = parseConversationPayload(from: payload) else { return }

    let capabilities = [.video, .pausing, .merging]
    let update = Conversation.Update(members: [handle], capabilities: capabilities)
    try? await manager.reportNewIncomingConversation(uuid: uuid, update: update)
  }
}

ポイント:

  • parseConversationPayload: push payloadからhandle、つまり発信者の識別子と、UUID、つまり会話の一意な識別子を取り出します
  • Conversation.Update: 会話の初期状態を表し、メンバーと能力を含みます
  • capabilities配列は会話が対応する機能を宣言し、システムはそれに基づいて表示するコントロールを決めます
  • reportNewIncomingConversationはメソッドが返る前に完了しなければならず、完了しない場合アプリは終了されます

09:22

システムアクションに応答する

すべてのユーザー操作は、ConversationManagerDelegateperform actionメソッドを通ってアプリへ入ります。

import LiveCommunicationKit

final class SampleDelegate: ConversationManagerDelegate {
  func conversationManager(
    _ manager: ConversationManager,
    perform action: ConversationAction
  ) {
    switch action {
    case let action as JoinConversationAction:
      handleJoinAction(action)
    case let action as EndConversationAction:
      handleEndAction(action)
    case let action as StartConversationAction:
      handleStartAction(action)
    case let action as MergeConversationAction:
      handleMergeAction(action)
    default:
      action.fail()
    }
  }
}

ポイント:

  • JoinConversationAction: ユーザーが着信に応答したことを表します
  • EndConversationAction: ユーザーが切断したことを表します
  • StartConversationAction: ユーザーがアプリ内から通話を開始したことを表します
  • MergeConversationAction: ユーザーが2つの会話を結合したことを表します
  • 認識できないactionでは必ずfail()を呼び、システムがUI状態を片付けられるようにします

09:57

応答アクションを処理する

extension SampleDelegate {
  func handleJoinAction(_ action: JoinConversationAction) {
    guard let conversation = manager.conversations.first(where: { $0.uuid == action.conversationUUID }) else {
      return action.fail()
    }

    manager.reportConversationEvent(.conversationStartedConnecting(.now), for: conversation)

    Task {
      do {
        try await setupMediaStream(with: action.conversationUUID)
        manager.reportConversationEvent(.conversationConnected(.now), for: conversation)
        action.fulfill(dateConnected: .now)
      } catch {
        action.fail()
      }
    }
  }
}

ポイント:

  • まずUUIDで対応するconversationを探します。見つからなければfail()します
  • conversationStartedConnecting: システムにjoining状態へ入ったことを通知し、UIには「接続中」と表示されます
  • setupMediaStream: アプリ自身のメディア接続ロジックで、delegateの応答性を保つためTask内で実行します
  • conversationConnected: メディアが準備でき、joined状態へ入ったことをシステムに通知します
  • fulfill(dateConnected:): actionの完了を示し、システムがUIを更新します
  • 例外はすべてfail()へ流し、システムが自動で片付けます

10:13

発信する

アプリ内から通話を開始するときは、StartConversationActionを作成し、ConversationManagerから実行します。

let startAction = StartConversationAction(
  conversationUUID: UUID(),
  handles: [Handle(type: .phoneNumber, value: "+1-650-555-0199", displayName: "Ryan Notch")],
  isVideo: false
)

try await manager.perform([startAction])

ポイント:

  • conversationUUID: 新しく生成した一意な識別子です
  • handles: 通話相手の連絡先Handle配列です
  • isVideo: ビデオ通話かどうかです
  • manager.perform: actionをシステムへ送信します。システムがUIを更新したあと、delegateへ転送して処理させます

12:14

グループ会話とセッション結合

グループ会話では、2種類のメンバーリストを区別します。

  • members: 招待されたすべての参加者
  • activeRemoteMembers: 現在メディアを送信している参加者
let adam = Handle(type: .emailAddress,
                  value: "[email protected]",
                  displayName: "Adam Halwani")
let david = Handle(type: .emailAddress,
                   value: "[email protected]",
                   displayName: "David Evans")
let ryan = Handle(type: .phoneNumber,
                  value: "+16505550199",
                  displayName: "Ryan Notch")

let startAction = StartConversationAction(
  conversationUUID: UUID(),
  handles: [david, ryan],
  isVideo: false
)
try await manager.perform([startAction])

メンバーが変化したら更新を報告します。

let update = Conversation.Update(
  localMember: adam,
  members: [david, ryan],
  activeRemoteMembers: [david, ryan],
  capabilities: [.merging, .pausing, .unmerging]
)

manager.reportConversationEvent(
  .conversationUpdated(update),
  for: conversation
)

13:51

2つの会話を結合する

ユーザーが2つの並行会話を1つのグループ会話へ結合すると、システムはMergeConversationActionを送ります。

extension SampleDelegate {
  func handleMergeAction(_ action: MergeConversationAction) {
    let sourceUUID = action.conversationUUID
    let targetUUID = action.conversationUUIDToMergeWith

    guard manager.conversations.contains(where: { $0.uuid == sourceUUID }),
          manager.conversations.contains(where: { $0.uuid == targetUUID }) else {
      return action.fail()
    }

    Task {
      do {
        let update = try await combineStreams(from: sourceUUID, into: targetUUID)
        manager.reportConversationEvent(.conversationUpdated(update), for: target)
        action.fulfill()
      } catch {
        action.fail()
      }
    }
  }
}

ポイント:

  • conversationUUID: 元の会話の識別子です
  • conversationUUIDToMergeWith: 結合先会話の識別子です
  • 結合前に2つの会話がどちらも存在することを確認します
  • combineStreams: 2つのメディアストリームを結合する、アプリ独自のサーバー側ロジックです
  • 結合が完了したら更新を報告し、fulfill()します
  • 結合解除(unmerging)もまったく同じdelegateパターンに従います

15:33

実装アイデア

1. あらゆるリアルタイム対話アプリを「システム通話」にする

オンライン客服、リモート協業、ゲーム音声チャットはすべてLiveCommunicationKitに接続できます。ユーザーはLock Screenから直接応答し、Dynamic Islandで状態を確認し、電話と同じ体験を得られます。入口APIはConversationManagerreportNewIncomingConversationです。

2. グループ結合で「人を会話に招待する」を実現する

サポート場面では、一次対応者がまずユーザーと1対1で話し、技術支援が必要になったら専門家の会話を結合できます。ユーザーに切断して掛け直してもらう必要はありません。実装の入口は.merging capabilityの宣言と、MergeConversationActionの処理です。

3. 通話履歴をアプリの入口にする

includesConversationInRecents: trueを有効にすると、ユーザーはPhoneアプリのRecentsから直接折り返し通話できます。StartCallIntentの寄付と組み合わせると、Siriからも直接通話を開始できます。これはユーザー離脱を減らす自然な導線になります。

4. 音声とビデオを動的に切り替える

Capabilitiesは会話のライフサイクル中に動的更新できます。音声通話の途中でカメラをオンにする場合、capabilitiesへ.videoを追加して更新するだけで、システムUIがすぐ反応します。会話全体を作り直す必要はありません。

5. Handleで連絡先システムとつなぐ

アプリ内のユーザー識別子を.phoneNumberまたは.emailAddress型のHandleへマッピングすると、システムは連絡先のアバターや名前を自動照合できます。未知の番号ではdisplayNameがフォールバックとして表示されます。

関連セッション

  • 121 - SiriとiPhoneの新機能。StartCallIntentを統合し、Siriから直接通話を開始する方法を学びます
  • 223 - Live Activities。通話中にLive Activityで追加情報を表示する方法を学びます
  • 310 - Shortcuts。App Intentsの寄付機構を理解し、通話履歴をシステム検索やショートカットから使えるようにします
  • 345 - App Intentsの新機能。intent寄付とNSUserActivityの統合を詳しく理解します
  • 369 - Bluetooth。通話中のBluetoothヘッドセットのルート切り替えと音声経路管理を学びます

コメント

GitHub Issues · utterances