コールバック

Sora iOS SDK には WebRTC API イベントや Sora 固有の機能のイベントをハンドリングするためのコールバックを MediaChannelHandlers で提供しています。

ここではそのコールバックの利用方法について説明します。

コールバックと同じイベントは AsyncStream でも購読できます。詳しくは Swift 6 言語モードに対応する Sendable なイベントを購読する をご確認ください。

onConnect

Sora との接続時に呼ばれるコールバックです。

error が nil ではない場合は接続に失敗しています。

let config = Configuration(url: url,
                           channelId: channelId,
                           role: .sendrecv)

config.mediaChannelHandlers.onConnect = { [weak self] error in
    guard let self = self else { return }

    // error が nil でない場合は接続に失敗しているのでエラーハンドリングを行ってください
    if let error = error {
        // エラー処理
    }

}

onDisconnect

Sora との接続を解除したときに呼ばれるコールバックです。

event は SoraCloseEvent という enum で、以下の 2 つのケースがあります。

  • ok: Sora から正常に切断された場合
  • error: Sora との接続に異常が発生した結果切断された場合

ok の例

  • クライアントから切断した場合
  • Sora の API を利用して切断された場合
  • 認証エラーで Sora から切断された場合

error の例

  • 接続中にネットワークエラーが起こった場合
  • WebSocket 切断時に異常が起こった場合
let config = Configuration(url: url,
                           channelId: channelId,
                           role: .sendrecv)

config.mediaChannelHandlers.onDisconnect = { [weak self] event in
    guard let self = self else { return }
    switch event {
    case .ok(let code, let reason):
        // 正常に切断された場合の処理
        print("切断コード: \(code)")
        print("切断理由: \(reason)")
    case .error(let error):
        // エラー内容を表示
        print("エラー内容: \(error.localizedDescription)")
    }
}

SoraCloseEvent の定義は以下のようになっています。

enum SoraCloseEvent {
    // 正常系
    case ok(code: Int, reason: String)
    // 異常系
    case error(Error)
}

onAddStream

ストリームが追加されたときに呼ばれるコールバックです。

自分自身のストリームも入ってくるため、Configuration.publisherStreamId を控えておくことでリモートのストリームを判別することができます。

let config = Configuration(url: url,
                           channelId: channelId,
                           role: .sendrecv)

let publisherStreamId = config.publisherStreamId

config.mediaChannelHandlers.onAddStream = { [weak self] stream in
    guard let self = self else { return }

    // 例: リモートストリームの videoRenderer に receiverVideoView をセットする
    if stream.streamId != publisherStreamId {
        stream.videoRenderer = self.receiverVideoView
    }
}

onRemoveStream

ストリームが削除されたときに呼ばれるコールバックです。

let config = Configuration(url: url,
                           channelId: channelId,
                           role: .sendrecv)

let publisherStreamId = config.publisherStreamId

config.mediaChannelHandlers.onRemoveStream = { [weak self] stream in
    guard let self = self else { return }

    // 例: リモートストリーム用の videoRenderer をクリアする
    if stream.streamId != publisherStreamId {
        self.receiverVideoView.stop()
        self.receiverVideoView.clear()
    }
}

onReceiveSignalingJSON

Sora からシグナリングメッセージを受信したときに呼ばれるコールバックです。

シグナリングメッセージは JSON 文字列として提供されます。

let config = Configuration(url: url,
                           channelId: channelId,
                           role: .sendrecv)

config.mediaChannelHandlers.onReceiveSignalingJSON = { json in
    guard let data = json.data(using: .utf8) else { return }

    do {
        guard let signaling = try JSONSerialization.jsonObject(with: data) as? [String: Any],
              let type = signaling["type"] as? String
        else {
            return
        }

        switch type {
        case "notify":
            guard let eventType = signaling["event_type"] as? String else { return }

            switch eventType {
            case "connection.created":
                // メタデータに含まれる JSON 値 (Any) を任意の型にキャストする
                if let metadata = signaling["metadata"] as? [String: Any] {
                    print("metadata => \(metadata)")
                }
                if let authnMetadata = signaling["authn_metadata"] as? [String: Any] {
                    print("authn => \(authnMetadata)")
                }
                if let authzMetadata = signaling["authz_metadata"] as? [String: Any] {
                    print("authz => \(authzMetadata)")
                }
                ...

            // 他のイベント種別でも同様に処理する
            case "connection.created":
                ...

            default:
                ...
            }

        case "push":
            if let pushData = signaling["data"] as? [String: Any] {
                print("data => \(pushData)")
            }
            ...

        default:
            ...
        }
    } catch {
        print("decode error => \(error)")
    }
}

onReceiveSignaling

Sora からシグナリングメッセージを受信したときに呼ばれるコールバックです。

Sora から受信する type: notify メッセージなどをアプリケーションで利用したい場合にこのコールバックを利用します。

  let config = Configuration(url: url,
                             channelId: channelId,
                             role: .sendrecv)

  mediaChannel.handlers.onReceiveSignaling = { signaling in
      switch signaling {

      // type: notify
      case .notify(let notify):
          switch notify.eventType {
          case "connection.created":
              // メタデータに含まれる JSON 値 (Any) を任意の型にキャストする
              if let metadata = notify.metadata as? [String: Any] {
                  print("metadata => \(metadata)")
              }
              if let authnMetadata = notify.authnMetadata as? [String: Any] {
                  print("authn => \(authnMetadata)")
              }
              if let authzMetadata = notify.authzMetadata as? [String: Any] {
                  print("authz => \(authzMetadata)")
              }
              ...

          // 他のイベント種別でも同様に処理する
          case "connection.created":
              ...

          default:
              ...
          }

      // type: push
      case .push(let push):
          if let data = push.data as? [String: Any] {
              print("data => \(data)")
          }
          ...

      default:
          ...
      }
}

onDataChannel

DataChannel シグナリングに切り替わったタイミングで呼ばれるコールバックです。

let config = Configuration(url: url,
                           channelId: channelId,
                           role: .sendrecv)

config.mediaChannelHandlers.onDataChannel = { [weak self] mediaChannel in
    // ここに DataChannel が利用可能になった際の処理を書いてください
}

onDataChannelOpened

DataChannel がラベルごとに OPEN になったときに呼ばれるコールバックです。

特定のラベルが利用可能になった時点で処理したい場合は、 onDataChannelOpened を利用してください。 たとえばリアルタイムメッセージングで #spam ラベルの利用開始を待ちたい場合や、 RPC で rpc ラベルの利用開始を待ちたい場合に利用できます。

let config = Configuration(url: url,
                           channelId: channelId,
                           role: .sendrecv)

config.mediaChannelHandlers.onDataChannelOpened = { [weak self] mediaChannel, label in
    guard let self = self else { return }

    switch label {
    case "#spam":
        // #spam ラベルが利用可能になった後の処理
        break
    case "rpc":
        // rpc ラベルが利用可能になった後の処理
        break
    default:
        break
    }
}

onDataChannelMessage

DataChannel のメッセージ受信時に呼ばれるコールバックです。

詳しい使い方は リアルタイムメッセージング機能 をご確認ください。

Swift 6 言語モードに対応する Sendable なイベントを購読する

SoraHandlers / MediaChannelHandlers のコールバックと同じイベントを AsyncStream で購読できます。 Sora.subscribeEvents(bufferingPolicy:) は Sora オブジェクトのイベントを配送し、 MediaChannel.subscribeEvents(bufferingPolicy:) は接続ごとのイベントを配送します。 どちらも購読者ごとに独立した AsyncStream<SoraEvent> を返します。

イベントは不変で Sendable な SoraEvent として配送されるため、 actor 境界や Task の @Sendable クロージャへそのまま渡せます。 payload は MediaChannel 、 MediaStream 、 raw WebRTC object を含まず、すべて値として確定しているため、配送後も保持できます。

この API の追加によって、既存のコールバックの型、配送スレッド、配送順序、呼び出し回数は変わりません。

Swift 6 言語モードを利用しない場合は、従来のコールバックをそのまま利用できます。 コールバックは非推奨ではないため、購読 API へ置き換える必要はありません。 購読 API は Swift 5 言語モードでも呼び出せますが、 actor 境界や @Sendable クロージャを越える値の検査が有効になるのは Swift 6 言語モードです。

コールバックとの対応

購読 API は、コールバックと同じ配送点から次のイベントを配送します。

コールバック

イベント

SoraHandlers.onConnect / Sora.connect(configuration:webRTCConfiguration:handler:) の handler

Sora の connected / connectFailed

SoraHandlers.onDisconnect

Sora の disconnected

SoraHandlers.onAddMediaChannel

mediaChannelAdded

SoraHandlers.onRemoveMediaChannel

mediaChannelRemoved

SoraHandlers.onChangeAudioRoute

audioRouteChanged

MediaChannelHandlers.onConnect

MediaChannel の connected / connectFailed

MediaChannelHandlers.onDisconnect

MediaChannel の disconnected

MediaChannelHandlers.onAddStream

streamAdded

MediaChannelHandlers.onRemoveStream

streamRemoved

MediaChannelHandlers.onReceiveSignalingJSON

signalingReceivedJSON

MediaChannelHandlers.onDataChannel

dataChannelAvailable

MediaChannelHandlers.onDataChannelOpened

dataChannelOpened

MediaChannelHandlers.onDataChannelMessage

dataChannelMessage

MediaStreamHandlers.onSwitchVideo

videoEnabledChanged

MediaStreamHandlers.onSwitchAudio

audioEnabledChanged

イベントを購読する実装サンプル

// 接続の前に購読を開始する (mediaChannelAdded と接続の結果を受け取るため)
let soraEvents = Sora.shared.subscribeEvents()
Task {
    for await event in soraEvents {
        switch event.kind {
        case .connected:
            print("connected: \(event.connectionId ?? "-")")
        default:
            // SDK が種別を追加しても、 default があるため既存のコードはビルドできます
            break
        }
    }
}

_ = Sora.shared.connect(configuration: config) { mediaChannel, error in
    guard let mediaChannel, error == nil else { return }

    // 購読を開始してから Task へ渡す (購読を開始する前のイベントは届かない)
    let channelEvents = mediaChannel.subscribeEvents()
    Task {
        for await event in channelEvents {
            switch event.kind {
            case .streamAdded:
                print("stream added: \(event.streamId ?? "-")")
            case .dataChannelMessage:
                print("message: \(event.dataChannelMessage?.count ?? 0) bytes")
            case .disconnected:
                print("disconnected")
            default:
                break
            }
        }
    }
}

購読を解除する場合は、購読している Task を cancel するか、 AsyncStream への参照を解放します。

配送されるイベント

SoraEvent.kind でイベントの種別を判定します。配送される種別は購読する側によって異なります。

Sora オブジェクトのイベント

Sora.subscribeEvents(bufferingPolicy:) が配送する種別です。

  • mediaChannelAdded
  • mediaChannelRemoved
  • audioRouteChanged
  • このインスタンスが開始した接続の connected 、 connectFailed 、 disconnected

MediaChannel のイベント

MediaChannel.subscribeEvents(bufferingPolicy:) が配送する種別です。

  • connected / connectFailed
  • streamAdded / streamRemoved
  • videoEnabledChanged / audioEnabledChanged
  • signalingReceivedJSON
  • dataChannelOpened 、 dataChannelAvailable 、 dataChannelMessage
  • disconnected

イベントの内容

種別ごとに該当する property だけが設定され、それ以外は nil です。

種別

配送元

設定される property

connected

Sora / MediaChannel

connectionId 、 transportEpoch

connectFailed

Sora (設定エラー)

error

connectFailed

Sora / MediaChannel

connectionId 、 transportEpoch 、 error

disconnected

Sora

connectionId 、 transportEpoch 、 error (エラーを伴う切断のみ)

disconnected

MediaChannel

connectionId 、 transportEpoch 、 closeEvent

mediaChannelAdded

Sora

transportEpoch

mediaChannelRemoved

Sora

connectionId 、 transportEpoch

audioRouteChanged

Sora

audioRoute (変更理由と変更前のルート)

streamAdded / streamRemoved

MediaChannel

connectionId 、 transportEpoch 、 streamId

videoEnabledChanged / audioEnabledChanged

MediaChannel

connectionId 、 transportEpoch 、 streamId 、 isEnabled

signalingReceivedJSON

MediaChannel

connectionId 、 transportEpoch 、 signalingJSON

dataChannelOpened

MediaChannel

connectionId 、 transportEpoch 、 dataChannelLabel

dataChannelAvailable

MediaChannel

connectionId 、 transportEpoch

dataChannelMessage

MediaChannel

connectionId 、 transportEpoch 、 dataChannelLabel 、 dataChannelMessage

世代 ( transportEpoch ) は接続ごとの値で、接続の開始時は 0 です。 "type": "redirect" を受信するたびに増えるため、通常は 0 (redirect なし) か 1 (redirect あり) になります。 同じ接続のイベント列で古い世代のイベントを識別するために使い、別の接続の世代とは比較できません (増加の上限も保証しないため、判定は「0 か、1 以上か」で行ってください)。

DataChannel で受信したメッセージの扱いは リアルタイムメッセージング機能 をご確認ください。

buffer と通し番号

bufferingPolicy で購読者ごとに buffer の件数と drop 方針を指定します。指定する値は AsyncStream の BufferingPolicy と同じです。

bufferingPolicy

挙動

.bufferingNewest(n)

最新の n 件を保持し、あふれた場合は最も古いイベントを破棄します (既定は .bufferingNewest(SoraEvent.defaultBufferSize) です)

.bufferingOldest(n)

最も古い n 件を保持し、あふれた場合は新しいイベントを破棄します

.unbounded

件数を制限しません (消費が遅れるとその分のメモリを使い続けます)

イベントが破棄されるのは、配送に対して購読者の消費が追いつかない場合です。配送そのものは成功しており、 buffer のあふれた分だけが破棄されます。 最新の状態だけを扱いたい場合は .bufferingNewest(1) を、イベントを 1 件も破棄したくない場合は .unbounded を指定します。

// 最新の 1 件だけを保持する (消費が遅れても常に最新のイベントを扱える)
let events = mediaChannel.subscribeEvents(bufferingPolicy: .bufferingNewest(1))
Task {
    for await event in events {
        // ここでイベントを処理する
    }
}

SoraEvent.sequence は購読元ごとの通し番号です。値の飛びからイベントの欠落を検出できます。 購読を開始する前に配送されたイベントにも採番されるため、購読者が最初に受け取る値は 1 とは限りません。

BufferingPolicy の詳細については Swift ドキュメント をご確認ください。

購読の開始と終端

  • 購読を開始する前に配送されたイベントは届きません。 buffer は購読ごとに作られるためです
  • 接続の前に購読を開始すれば、 mediaChannelAdded と接続の結果を取り逃しません
  • 購読の解除は、購読している Task の cancel、購読に使った AsyncStream への参照の解放、接続の終了 ( MediaChannel )、 Sora インスタンスの解放です
  • MediaChannel の購読は、接続の終了で disconnected を配送してから終端します。終端後の購読には終端済みの AsyncStream を返します
  • 購読している Task の loop の中から connectionId や disconnect(error:) などの同期 API を呼べます
  • イベントの配送スレッドはイベントの発生元によって異なります。 AsyncStream の再開後に実行される購読者のコードは、購読している Task の executor 上で動きます。 UI 更新は main actor / main queue へ束ねてください
  • 同時に配送されたイベントは、配送順と sequence の順序が一致しないことがあります

Sendable なイベントのサンプルコード

import Sora

// MARK: - SoraEvent を actor へ渡す

/// イベントを受け取る actor です。
///
/// `Sendable` な `SoraEvent` は actor 境界を越えてそのまま渡せます。payload はすべて値として
/// 確定しているため、受け取ったあとも保持できます。
actor EventStore {
  private var events: [SoraEvent] = []

  func append(_ event: SoraEvent) {
    events.append(event)
  }

  /// `streamAdded` のイベントからストリーム ID を取り出します。
  func addedStreamIds() -> [String] {
    events.filter { $0.kind == .streamAdded }.compactMap(\.streamId)
  }
}

// MARK: - Sora インスタンスのイベントを購読する

/// `Sora` インスタンスのイベントを購読します。
///
/// 購読を開始する前に配送されたイベントは届かないため、`mediaChannelAdded` と接続の結果を
/// 受け取る場合は接続の前に購読を開始します。
/// 購読を解除する場合は、戻り値の `Task` を cancel します。
func subscribeSoraEvents(store: EventStore) -> Task<Void, Never> {
  // 購読者ごとに独立した AsyncStream が返ります
  let events = Sora.shared.subscribeEvents()
  return Task {
    for await event in events {
      // SoraEvent は Sendable のため、actor 境界へそのまま渡せます
      // 従来の SoraHandlers の onConnect、onDisconnect、onAddMediaChannel、onRemoveMediaChannel、
      // onChangeAudioRoute と、Sora.connect の handler に対応するイベントが届きます
      await store.append(event)
    }
  }
}

// MARK: - 接続ごとのイベントを購読する

/// 接続ごとのイベントを購読します。
///
/// 購読を解除する場合は、戻り値の `Task` を cancel します。
/// `MediaChannel` は `Sendable` ではないため、チャネルを actor 境界や `Task` の
/// `@Sendable` クロージャへ渡す場合は `nonisolated(unsafe)` などの隔離が必要です。
/// `MediaChannel.subscribeEvents` が返す `AsyncStream<SoraEvent>` は `Sendable` のため、イベントを渡すだけの場合は
/// 隔離は必要ありません。
func subscribeChannelEvents(mediaChannel: MediaChannel, store: EventStore) -> Task<Void, Never> {
  // 購読を開始してから Task へ渡します (購読を開始する前のイベントは届きません)
  let events = mediaChannel.subscribeEvents()
  return Task {
    for await event in events {
      switch event.kind {
      case .connected:
        // 従来の MediaChannelHandlers.onConnect に対応します
        await store.append(event)

      case .dataChannelMessage:
        // 従来の MediaChannelHandlers.onDataChannelMessage に対応します
        // (dataChannelLabel と dataChannelMessage を持ちます)
        await store.append(event)

      case .disconnected:
        // 従来の MediaChannelHandlers.onDisconnect に対応します
        // (このイベントを配送したあとに購読が終端します)
        await store.append(event)

      default:
        // SDK が種別を追加しても、default があるため既存のコードはビルドできます
        // (その他の種別は SoraEventKind を参照してください)
        break
      }
    }
  }
}

// MARK: - 購読を解除する

/// 購読した `Task` を保持して、不要になった時点で解除するサンプル実装です。
///
/// 購読を解除する場合は、購読している `Task` を cancel します。`Task` を保持しないと解除する
/// 手段が無くなるため、購読を開始したら保持します。
/// `Task` を cancel すると `for await` のループが終わり、購読者も解除されます。
///
/// `Sora` インスタンスの購読も同じです ( `subscribeSoraEvents` が返す `Task` を保持して cancel します)。
/// `MediaChannel` の購読は、接続が終了すると `disconnected` を配送したあとに購読が終端するため、
/// 接続の終了に合わせて解除する必要はありません。
///
/// `MediaChannel` 自体は `Sendable` ではないため、チャネルを actor 境界や `Task` の
/// `@Sendable` クロージャへ渡す場合は `nonisolated(unsafe)` などの隔離が必要です。
final class ChannelEventSubscription {
  nonisolated(unsafe) let mediaChannel: MediaChannel
  let store = EventStore()
  private var task: Task<Void, Never>?

  init(mediaChannel: MediaChannel) {
    self.mediaChannel = mediaChannel
  }

  /// 購読を開始し、解除に使う `Task` を保持します。
  func start() {
    task = subscribeChannelEvents(mediaChannel: mediaChannel, store: store)
  }

  /// 購読を解除します。
  func stop() {
    task?.cancel()
    task = nil
  }
}