FlutterでiOS向けライブ配信画面を作ると、最初の入室は比較的すぐ動きます。難しいのは、その次です。
- 入室ボタンを連打する
- 接続中に前画面へ戻る
- すぐ別の配信を開く
- その後、最初の配信から遅れて「接続完了」が届く
このとき、単体テストでは正常でも、実画面では別ルームの映像やローディング表示が復活することがあります。問題はRTCの接続可否だけではなく、Flutterの画面寿命とネイティブ側の非同期処理の寿命が一致しないことです。
この記事では、Tencent RTCのInteractive LiveをFlutter/iOSアプリへ組み込む際に、入退室を世代番号で直列化し、古いコールバックを採用しない境界を作ります。
結論
ライブ画面の安定性を、isLoadingのようなBooleanだけで管理してはいけません。最低でも次の3つを保持します。
- 現在の要求を表す単調増加の
generation - ネイティブ側へ渡した
sessionToken -
idle / opening / live / closing / failedの状態
新しい入室・退室要求が来たらgenerationを進めます。ネイティブから戻るイベントは、現在のgenerationとsessionTokenが両方一致した場合だけ画面へ反映します。
これにより「接続処理そのものを完全にキャンセルできるか」に依存せず、少なくとも古い結果が現在のUIを壊すことを防げます。
前提:先に組み込み方式を決める
Tencent RTCの公式セットアップガイドでは、ライブ配信の組み込み方として、構築済みUIを使う方法と、ヘッドレスなAtomicXCoreを使う方法が案内されています。
- 公式実装ガイド: https://trtc.io/document/78279
- TUILiveKit概要: https://trtc.io/document/60034?product=live
判断の目安は次のとおりです。
| 要件 | 選びやすい方式 | Flutter側の主な責務 |
|---|---|---|
| 標準的な配信・視聴画面を短期間で組み込みたい | 構築済みUI | 画面遷移、認証情報の受け渡し、終了検知 |
| Flutter独自UIへ細かく統合したい | AtomicXCore | 状態表示、操作導線、イベント反映、エラー表示 |
| チャット、いいね、ギフト、共同配信などを段階的に導入したい | 要件ごとに比較 | 機能ごとの画面寿命と権限管理 |
TUILiveKitの公式概要には、視聴、チャット、いいね、ギフト、視聴者管理、共同配信、PK、弱いネットワークへの最適化、Beauty AR連携などが示されています。ただし、すべてを最初からFlutter側へ再実装する必要はありません。
本記事のコーディネーターは、どちらの方式を選んでも、その外側に配置できます。SDK固有のAPI名はアダプター内だけに閉じ込めます。
手順1:Flutterとネイティブの契約を小さくする
Flutterから直接多数のRTC操作を呼ぶのではなく、まずアプリ内の契約を4つに限定します。
enum LiveEventType { ready, closed, error }
class LiveEvent {
const LiveEvent({
required this.token,
required this.type,
this.message,
});
final int token;
final LiveEventType type;
final String? message;
}
abstract interface class LiveBridge {
Stream<LiveEvent> get events;
Future<void> open({
required String roomId,
required int token,
});
Future<void> close({required int token});
}
openとcloseはこの記事で定義するアプリ内APIです。実際のTencent RTC呼び出しは、選択した公式の組み込み方式に従ってLiveBridgeの実装へ対応付けます。
重要なのは、roomIdだけでイベントを照合しないことです。同じルームへ再入室した場合、古い接続と新しい接続を区別できないためです。
手順2:状態をBooleanではなくデータとして持つ
enum LivePhase { idle, opening, live, closing, failed }
class LiveViewState {
const LiveViewState({
required this.phase,
this.roomId,
this.error,
});
const LiveViewState.idle()
: phase = LivePhase.idle,
roomId = null,
error = null;
final LivePhase phase;
final String? roomId;
final String? error;
}
openingとclosingを分ける理由は、画面上の操作可否が異なるからです。
-
opening: 入室ボタンを再度有効にしない -
live: 退出操作を受け付ける -
closing: 新しい画面表示を確定させない -
failed: 再試行か前画面へ戻るかを利用者が選べる
コード:要求を直列化し、古いイベントを捨てる
次のコーディネーターはFlutterのWidgetから独立しているため、RTC SDKを起動せずにテストできます。
import 'dart:async';
class LiveSessionCoordinator {
LiveSessionCoordinator(
this._bridge, {
this.commandTimeout = const Duration(seconds: 8),
}) {
_subscription = _bridge.events.listen(_onEvent);
}
final LiveBridge _bridge;
final Duration commandTimeout;
final _states = StreamController<LiveViewState>.broadcast();
late final StreamSubscription<LiveEvent> _subscription;
Future<void> _tail = Future.value();
int _generation = 0;
int? _activeToken;
LiveViewState _state = const LiveViewState.idle();
Stream<LiveViewState> get states => _states.stream;
LiveViewState get current => _state;
Future<void> open(String roomId) {
final token = ++_generation;
_emit(LiveViewState(
phase: LivePhase.opening,
roomId: roomId,
));
return _enqueue(() async {
if (token != _generation) return;
final previous = _activeToken;
if (previous != null) {
await _closeBestEffort(previous);
}
if (token != _generation) return;
_activeToken = token;
try {
await _bridge
.open(roomId: roomId, token: token)
.timeout(commandTimeout);
} catch (error) {
await _closeBestEffort(token);
if (token == _generation) {
_activeToken = null;
_emit(LiveViewState(
phase: LivePhase.failed,
roomId: roomId,
error: '入室処理を完了できませんでした: $error',
));
}
}
});
}
Future<void> close() {
final closeGeneration = ++_generation;
final token = _activeToken;
if (token == null) {
_emit(const LiveViewState.idle());
return Future.value();
}
_emit(LiveViewState(
phase: LivePhase.closing,
roomId: _state.roomId,
));
return _enqueue(() async {
await _closeBestEffort(token);
if (_generation == closeGeneration) {
_activeToken = null;
_emit(const LiveViewState.idle());
}
});
}
void _onEvent(LiveEvent event) {
// 別世代の接続から届いたイベントはUIへ反映しない。
if (event.token != _activeToken ||
event.token != _generation) {
return;
}
switch (event.type) {
case LiveEventType.ready:
_emit(LiveViewState(
phase: LivePhase.live,
roomId: _state.roomId,
));
case LiveEventType.closed:
_activeToken = null;
_emit(const LiveViewState.idle());
case LiveEventType.error:
_activeToken = null;
_emit(LiveViewState(
phase: LivePhase.failed,
roomId: _state.roomId,
error: event.message ?? 'ライブ接続でエラーが発生しました',
));
}
}
Future<void> _closeBestEffort(int token) async {
try {
await _bridge.close(token: token).timeout(commandTimeout);
} catch (_) {
// UIの世代は先へ進める。
// 実運用ではtoken付きでログを残し、ネイティブ側でも後始末する。
}
}
Future<void> _enqueue(Future<void> Function() operation) {
final next = _tail.then((_) => operation());
_tail = next.catchError((Object _, StackTrace __) {});
return next;
}
void _emit(LiveViewState state) {
_state = state;
_states.add(state);
}
Future<void> dispose() async {
++_generation;
final token = _activeToken;
_activeToken = null;
if (token != null) {
await _closeBestEffort(token);
}
await _subscription.cancel();
await _states.close();
}
}
8秒は製品性能の保証値ではなく、このサンプルの停止条件です。実際には、入室操作からネイティブ側の受付完了までを計測し、ネットワーク条件とUX方針に合わせて決めます。
手順3:iOS側ではtokenを往復させる
FlutterのMethodChannelや独自Pluginを使う場合、次の対応関係を維持します。
| Flutterからの値 | iOS側の扱い |
|---|---|
roomId |
選択したライブ配信実装へ渡す |
token |
接続単位で保持する |
open |
ネイティブ画面の表示またはヘッドレス実装の開始 |
close |
対象tokenの画面・接続を終了する |
ready/closed/error |
同じtokenを付けてFlutterへ返す |
ネイティブ側のコールバックで「現在表示中のルームID」を後から参照するだけでは不十分です。コールバックを登録した時点のtokenをキャプチャしてください。
概念的には次の形です。
func open(roomID: String, token: Int64) {
liveRuntime.present(
roomID: roomID,
onReady: { [weak self] in
self?.sendEvent(type: "ready", token: token)
},
onClosed: { [weak self] in
self?.sendEvent(type: "closed", token: token)
},
onError: { [weak self] message in
self?.sendEvent(
type: "error",
token: token,
message: message
)
}
)
}
ここでのliveRuntimeとpresentは説明用のアプリ内境界であり、Tencent RTCのAPI名ではありません。具体的な実装は、公式ガイドで選んだ構築済みUIまたはAtomicXCoreへ接続します。
共同配信でカメラやマイクを使う場合は、iOSの利用目的説明を設定し、実際に必要になる直前に権限を要求します。視聴開始時点で不要な権限まで一括要求しない方が、利用者に操作理由を説明しやすくなります。
確認方法:正常な1回の入室ではなく、順序を壊す
Fake Bridgeを用意し、イベントの順番を手動で入れ替えます。
import 'dart:async';
class FakeLiveBridge implements LiveBridge {
final controller = StreamController<LiveEvent>.broadcast();
final opened = <({String roomId, int token})>[];
final closed = <int>[];
@override
Stream<LiveEvent> get events => controller.stream;
@override
Future<void> open({required String roomId, required int token}) async {
opened.add((roomId: roomId, token: token));
}
@override
Future<void> close({required int token}) async {
closed.add(token);
}
}
最低限、次のケースを自動テストと実機テストの両方で確認します。
1. 入室ボタンを連打する
room-aへのopenを連続で呼び、最後のtoken以外のreadyがliveへ遷移させないことを確認します。
2. Aの接続中にBを開く
AのreadyをBのopen後に送ります。画面がAへ戻らず、Bのイベントだけが採用されれば合格です。
3. 接続中に前画面へ戻る
close後に古いreadyを送ります。ライブ画面やローディングが復活しないことを確認します。
4. ネイティブ呼び出しが返らない
Fake Bridgeのopenを完了させず、タイムアウト後にfailedへ遷移することを確認します。スピナーを無期限に表示しないことも受入条件にします。
5. iOSで中断と復帰を行う
コントロールセンター、着信相当の中断、バックグラウンド移行を試します。ただし、バックグラウンド移行時に退出するか維持するかはアプリ要件です。OSイベントを受けたという理由だけで、一律にcloseへ結び付けないでください。
注意点とトレードオフ
古いイベントの無視だけではネイティブ資源は解放されない
世代番号はUIの逆戻りを防ぎますが、古い接続を自動的に破棄する仕組みではありません。Flutterで破棄したtokenについても、iOS側で画面、購読、コールバック、メディア処理を終了させる必要があります。
roomIdを秘密情報として扱わない
ルーム識別子と、入室を許可する認証情報は別物です。認証情報をソースコード、ログ、MethodChannelのデバッグ出力へ残さない設計にします。
すべてのRTCイベントをFlutterへ流さない
高頻度イベントを無加工でFlutterへ渡すと、UI更新と診断ログが増えます。Flutterが判断に使う状態へ集約し、詳細なSDKイベントはネイティブ側の観測に残す方が境界を保ちやすくなります。
先に「終了」を定義する
ライブ配信の終了には、利用者の退出、配信者による終了、エラー、画面破棄など複数の経路があります。どの経路でも最終的にidleへ収束し、二重closeを安全に処理できるようにします。
実装完了のチェックリスト
- 構築済みUIとAtomicXCoreのどちらを採用するか決めた
-
RTC固有処理を
LiveBridgeの外へ漏らしていない - 入退室ごとに新しいtokenを発行している
-
古いtokenの
readyとerrorを無視できる -
openが返らない場合の停止条件がある - Flutterの画面破棄時にネイティブ側も後始末する
- 連打、戻る、別ルーム遷移を実機で確認した
- カメラ・マイク権限を必要な機能とタイミングに限定した
「ライブ画面を一度開けた」は接続確認にすぎません。リリース判断では、利用者が操作をやり直したときに、以前の非同期結果が現在の画面を上書きしないことまで確認する必要があります。
関係性の開示: 筆者はTencent RTCと関係があり、本記事の実装判断にはTencent RTC公式ドキュメントを参照しています。