Flutter ライブラリ仕様
内部ドキュメント
このドキュメントは内部実装の詳細です。ゲーム開発者向けの情報は docs.code.uzu-app.com を参照してください。
ソースコード: dart/packages/uzu_code/
ゲーム表示フレームワークとして提供される Flutter パッケージ。 WebView によるゲーム実行、Flutter ↔ JS ブリッジ、サウンド再生を提供する。
ライブラリであり、アプリケーションではない。 main.dart を持たず、他の Flutter アプリ (Emulator 等) から依存して使用する。
技術スタック
| パッケージ | バージョン | 用途 |
|---|---|---|
| Flutter / Dart | SDK ^3.11.0 | フレームワーク |
| webview_flutter | ^4.10.0 | ゲーム表示用 WebView |
| uzu_webview_windows | (path) | webview_flutter の Windows 実装(後述) |
| path | ^1.8.0 | パスユーティリティ |
ディレクトリ構造
dart/packages/uzu_code/
├── pubspec.yaml
├── lib/
│ ├── uzu_code.dart # バレルエクスポート
│ ├── play_screen_controller.dart # Host ↔ Game 双方向通信コントローラ
│ ├── helpers/
│ │ ├── query_params_builder.dart # URL クエリパラメータ構築
│ │ ├── web_content_crash_recovery.dart # 描画プロセスのクラッシュからの復帰
│ │ └── webview_controller_factory.dart # inline 再生を許可した WebViewController
│ ├── screens/
│ │ ├── game_play_screen.dart # ゲームプレイ画面 (WebView)
│ │ ├── content_builder_native.dart # Native 用コンテンツビルダー
│ │ ├── content_builder_web.dart # Web 用コンテンツビルダー
│ │ ├── dir_mode_content.dart # dir モード (ローカル HTTP 配信)
│ │ ├── url_mode_content_native.dart # URL モード Native (WebView)
│ │ └── url_mode_content_web.dart # URL モード Web (iframe)
│ └── services/
│ ├── local_server_service.dart # ローカル HTTP サーバー
│ └── bridge_service.dart # Flutter ↔ JS 通信
└── test/エクスポート
uzu_code.dart から公開 API をバレルエクスポートする。
export 'play_screen_controller.dart';
export 'screens/game_play_screen.dart';利用側:
import 'package:uzu_code/uzu_code.dart';GamePlayScreen
WebView (Native) / iframe (Web) でゲームを実行する StatefulWidget。
パラメータ
| パラメータ | 型 | 必須 | 説明 |
|---|---|---|---|
gameDir | String? | No | ゲームファイルのローカルディレクトリパス (dir モード) |
gameUrl | String? | No | ゲームのリモート URL (url モード) |
controller | PlayScreenController | Yes | ホストとの双方向通信コントローラ |
buttonActions | List<HudAction> | No | UZU ボタン左の追加オーバーレイボタン (マイク、チャット等) |
onMenuPressed | ValueChanged<Rect>? | No | UZU ボタンタップ時のコールバック (null で非表示)。引数はボタンの矩形 (グローバル座標) で、メニューをボタンの位置から出すのに使う |
pauseRoster | Widget? | No | 緊急一時停止の overlay で、既定の顔ぶれ(roster のアイコンと名前)の代わりに出すウィジェット。通話はホストの持ち物なので、話者表示を出したいホストが渡す |
seatName | String Function(String seatId)? | No | 切断の帯に出す名前を席 ID から引く。null なら roster の Player.name。プレイ中はキャラクターの名前で呼び合うので、mobile はキャラクターの名前を渡す |
extraQueryParams | Map<String, String> | No | URL に追加するクエリパラメータ |
左上の HUD(UZU ボタン + アクションバー)は GamePlayHud、緊急一時停止の overlay のカード(タイトル・顔ぶれ・下段)は OverlayCard として公開している。ゲームの画面を出す前の画面(mobile のゲームデータの取得中)でもホストが同じ位置・同じ絵で出せ、ゲームに切り替わっても HUD が動かない。
GamePlayScreen は 接続と通話の状態 の HUD も自分で描く。切断の帯(PresenceBanners)と、ロックされたマイクを押したときのダイアログ(LockedMicDialog、controller.lockedMicAttempts を見て出す)で、ホストごとに実装しない。
制約: gameDir と gameUrl は排他的 (XOR)。どちらか一方のみ指定する。
動作モード
| モード | 条件 | 動作 |
|---|---|---|
| dir モード | gameDir を指定 | LocalServerService でローカル HTTP 配信、WebView でロード |
| url モード (Native) | gameUrl を指定 (iOS/Android/Desktop) | WebView で直接 URL をロード |
| url モード (Web) | gameUrl を指定 (Web) | iframe で URL をロード |
URL パラメータの構築
QueryParamsBuilder が PlayScreenController のプロパティから以下のクエリパラメータを構築する:
| パラメータ | ソース |
|---|---|
roomId | controller.roomId |
server | controller.serverUrl |
seatId | controller.playerId |
players | controller.players (roster。JSON エンコード) |
seatKind | controller.seatKind (player / spectator / admin) |
monitor | controller.monitor が true のときだけ 1。SDK が WebSocket の接続 URL へ引き継ぐ |
+ extraQueryParams | GamePlayScreen.extraQueryParams |
サウンドパス解決の違い
| モード | 解決方法 |
|---|---|
| dir モード | gameDir + 相対パスを結合して絶対ファイルパスに解決 |
| url モード | 相対パスをそのままコールバックに渡す |
PlayScreenController
ホスト (Emulator、モバイルアプリ等) と GamePlayScreen 間の双方向通信コントローラ。
コンストラクタ
| パラメータ | 型 | 説明 |
|---|---|---|
roomId | String (required) | ルーム ID |
playerId | String (required) | このコントローラが表すプレイヤーの ID |
serverUrl | String (required) | WebSocket サーバーの URL (例: wss://uzu-code play-server-dev.uzu-account.workers.dev) |
players | List<Player> (required) | roster。配役を受け取る参加者だけを渡す (観測者は載せない) |
seatKind | SeatKind (default player) | この席の種別。観測席を開くホスト (エミュレータ) だけが player 以外を渡す |
monitor | bool (default false) | 本物のプレイヤーが遊ぶ部屋を、監視のために開いた接続。止める操作を出さず、サーバーもこの接続を数えない |
voiceOffReason | VoiceReason (default thisDevice) | 通話を持たないホストが申告する既定の理由。卓そのものが通話を使わないホスト(UZU TOKYO・mobile の一人用)は table |
Player クラス
class Player {
final String id;
final String characterId;
final String name;
final String iconUrl;
}characterId はシナリオ manifest の characters[].id。roster に載る = 配役を受け取る、なので必須。 シナリオは配役の無い席を無言で roster 順の配役に倒すため、配役が引けないときは roster を組まずに接続を遅らせる (ホスト側でローディングを出す)。
コールバック (Game → Host)
| コールバック | 型 | 説明 |
|---|---|---|
onPlaySound | void Function(String, UzuCodeSoundOptions)? | 効果音再生リクエスト。UzuCodeAudioMixer.playSound に繋ぐ |
onPlayBgm | void Function(String, UzuCodeSoundOptions)? | BGM 再生リクエスト。UzuCodeAudioMixer.playBgm に繋ぐ |
onStopBgm | void Function(Duration)? | BGM 停止。引数はフェードアウトの長さ。UzuCodeAudioMixer.stopBgm に繋ぐ |
onPresence | void Function(PresenceSnapshot)? | サーバーから presence が届いたとき。通話の部屋とマイクの方針を myPlan に合わせるのはホストの責務 |
onSetListeningRoom | void Function(String?)? | 観測席のシナリオが聴く部屋を選んだとき。プレイヤー席では無視する |
onSetMicMuted | void Function(bool)? | シナリオが本人のマイクをミュートしたとき。引数は常に true。解除の要求は handleSetMicMuted が捨てるので、ここへ来ない |
onSetMicEnabled | void Function(bool)? | bridge 1 の作品がマイクをオフにしたとき。引数は常に false。オンの要求は handleSetMicEnabled が捨てる |
onChangeRoom | void Function(String?)? | bridge 1 の作品のボイスチャットルームの移動リクエスト。null = デフォルトルーム(全体) |
onFinishGame | void Function()? | ゲームが終わり、この端末のプレイ画面を閉じる要求。シナリオは再描画のたびに送りうるので、処理中の 2 回目以降は無視する |
onReady | void Function()? | SDK 準備完了 (ready 受信時) |
onPauseState | void Function(bool, String?)? | 緊急一時停止の確定。overlay は GamePlayScreen が自分で出すので、ホストが繋ぐのは音声の共通ルーム移動など overlay の外側だけ |
onPauseRequestFailed | void Function()? | 停止要求が 5 秒で確定しなかったとき |
haptic(端末の振動)にはコールバックが無い。PlayScreenController が受けて HapticFeedback を直接鳴らすので、ホストは結線しなくてよい。
コマンド (Host → Game)
| メソッド | 説明 |
|---|---|
updateVoice(self, heard, missing) | 自分の通話の状態を渡す。詳細は下記参照 |
showLockedMicDialog() | ロックされたマイクのダイアログを出す。ホストのマイクボタンがロック中に押されたときに呼ぶ |
setPlayersState(...) | bridge 1 の作品へ各プレイヤーのリアルタイム状態(音声・在席)を送信。詳細は下記参照 |
requestPause() / requestResume() | 緊急一時停止の要求。requestPause() は確定を待たずに overlay を出す (楽観表示)。新旧の SDK のどちらでも止まるよう、platform の pause.request / pause.resume と sdk の requestPause / requestResume の両方を送る |
requestState() | Future<Map<String, dynamic>?> を返す。getState を送信し、stateResponse を待つ (5秒タイムアウト) |
pauseState
ValueNotifier<PauseDisplayState> として公開する。GamePlayScreen がこれを見て overlay を出す。
| 値 | 意味 |
|---|---|
none | 停止していない |
requesting | 停止を要求したが、サーバーの確定を待っている (overlay は既に出ている)。isPaused には数えないので、この間はマイクのロックを外さない |
paused | サーバーが停止を確定した |
接続と通話の状態
接続と通話の状態 のために持つ値。
| プロパティ | 型 | 説明 |
|---|---|---|
presence | ValueNotifier<PresenceSnapshot?> | サーバーが配った最新の presence。新しい SDK の作品でだけ届く |
gameConnection | ValueNotifier<GameConnectionStatus> | SDK が張っている WebSocket の状態(connectionChanged) |
voiceSelf | ValueNotifier<VoiceSelf> | 自分の通話の状態(updateVoice で更新) |
micLocked | ValueNotifier<bool> | 自分の plan の mic が locked で、一時停止中(確定した停止だけ)でないか。ホストはマイクボタンを鍵にする |
myPlan | VoicePlan? | 自分の部屋割りとマイクの方針。観測席と、presence が届いていない間は null |
isRosterSeat / canControlPause | bool | roster にいる席か / 止める操作を出してよいか |
void updateVoice({
required VoiceSelf self,
Map<String, HeardVoice> heard = const {},
Map<String, DateTime> missing = const {},
})- SDK へは
voiceChangedを中身が変わるたびに送る(発話やミュートも含む) - サーバーへの申告(
platformのvoice.report)はsession/reason/roomが変わったときだけ送る。SDK のreadyと、WebSocket がつながり直したとき(connectionChangedがonline)は必ず送り直す heardは同じ部屋で聞こえている相手、missingは同じ部屋にいるはずなのに声が届かない相手。「いるはず」は部屋割り(plan)で同じ部屋にいて、この端末で一度は聞こえた人(expectedVoicesOf)。サーバーの接続や通話の状態は見ない。猶予はホストが見るmissingはmissingVoicesにも載り、切断の帯(他の人の「〇〇との通話が切れています」)はここだけを見る- 方針の状態機械(
MicPolicyTracker)と、声が届かない相手の判定(MissingVoiceTracker)はlib/voice_policy.dartにある。mobile とエミュレータが共有する MicPolicyTracker.applyはミュートするときtrueを返し、オンにする値は返さない。場面が明けても切ったマイクは切れたまま残し、オンに戻すのは本人だけ
setPlayersState
void setPlayersState({
required Map<String, Map<String, dynamic>> players,
})bridge 1 の作品へプレイヤーのリアルタイム状態を送信する。Flutter 側の音声状態(VoiceState)が変化するたびに呼び出される。bridge 2 の SDK は知らない type として捨てる。
パラメータ:
| パラメータ | 型 | 説明 |
|---|---|---|
players | Map<String, Map<String, dynamic>> | キーがプレイヤー ID、値が {audioStatus} |
内部メソッド
| メソッド | 説明 |
|---|---|
attach(sender) | GamePlayScreen が接続時に呼び出す。送信関数を保存 |
detach() | GamePlayScreen が切断時に呼び出す。送信関数をクリア |
handleStateResponse(state) | BridgeService が stateResponse を受信した際に呼び出す |
handleReady() | ready を受信した際に呼び出す。自分の通話の状態を渡し直してから onReady を呼ぶ |
handlePlatformMessage(type, payload) | platform チャネルの伝言を受信した際に呼び出す。presence を読む |
handleConnectionChanged(status) / handleSetListeningRoom(room) / handleSetMicMuted(muted) | 対応する sdk チャネルのメッセージを受信した際に呼び出す |
サービス
LocalServerService
ゲームファイルをローカル HTTP サーバーで配信する。WebView は file:// プロトコルに制限があるため、http://localhost 経由で配信する。
| メソッド | 説明 |
|---|---|
start(gameDir) | 指定ディレクトリをルートに HTTP サーバーを起動。ベース URL を返す |
stop() | サーバーを停止 |
- ランダムポートでバインド (
InternetAddress.loopbackIPv4, port 0) - 対応 MIME タイプ: HTML, JS, CSS, JSON, PNG, JPEG, GIF, SVG, ICO, WOFF, WOFF2, TTF, MP3, OGG, WAV, WASM
BridgeService
Flutter ↔ JS 間の双方向メッセージングを管理する (Native のみ)。詳細は bridge.md を参照。
| メソッド / プロパティ | 説明 |
|---|---|
registerChannels() | FlutterHost JavaScriptChannel を登録 |
sendToGame(message) | Flutter → JS メッセージ送信 (ready 前はキューイング)。メッセージは { channel, type, payload } 形式 |
resetForReload() | WebView 再読み込み前に ready を落とし、次の ready まで送信をキューへ戻す |
onPlaySound | 効果音再生リクエスト受信時のコールバック。volume / slot を UzuCodeSoundOptions に読んで渡す |
onPlayBgm | BGM 再生リクエスト受信時のコールバック。volume / fadeMs を UzuCodeSoundOptions に読んで渡す |
onStopBgm | BGM 停止コールバック。fadeMs を Duration に読んで渡す |
onSetMicMuted | setMicMuted 受信時のコールバック(controller の handleSetMicMuted へ渡す) |
onSetListeningRoom | setListeningRoom 受信時のコールバック |
onConnectionChanged | connectionChanged 受信時のコールバック |
onPlatformMessage | platform チャネルの伝言を受信したときのコールバック |
onSetMicEnabled | bridge 1 の setMicEnabled 受信時のコールバック(controller の handleSetMicEnabled へ渡す) |
onChangeRoom | bridge 1 の changeRoom 受信時のコールバック (null = デフォルトルーム) |
onHaptic | haptic 受信時のコールバック (kind を PlayScreenController.handleHaptic へ渡す) |
onGameEnded | gameEnded 受信時のコールバック |
onFinishGame | finishGame 受信時のコールバック |
onReady | SDK 準備完了 (ready 受信) 時のコールバック |
onPauseState | pauseState 受信時のコールバック (緊急一時停止の確定) |
onStateResponse | stateResponse 受信時のコールバック |
サウンド再生
サウンドファイル (効果音・BGM) は各 UGC ゲームが自身のアセットとして持つ。ゲームは SDK 経由で Flutter にサウンド再生をリクエストする。
dir モード:
playSound('sounds/clear.mp3')
→ Bridge: { channel: 'sdk', type: 'playSound', payload: { sound: 'sounds/clear.mp3' } }
→ GamePlayScreen: gameDir + 'sounds/clear.mp3' に結合 → 絶対ファイルパス
→ ホストアプリ: onPlaySound(絶対パス, options) で DeviceFileSource 再生url モード:
playSound('sounds/clear.mp3')
→ Bridge: { channel: 'sdk', type: 'playSound', payload: { sound: 'sounds/clear.mp3' } }
→ GamePlayScreen: 相対パスをゲーム URL 基準でフル URL に解決
→ ホストアプリ: onPlaySound(フル URL, options)BGM も同様に playBgm / stopBgm で制御する。
音量とフェード
BGM の切り替えとフェードは UzuCodeAudioMixer が管理する。ホストは音を出す先 (UzuCodeAudioOutput) を実装し、controller の 3 つのコールバックを mixer の同名メソッドに繋ぐ。画面を閉じるときは dispose() で BGM をフェードせずに止める。
final mixer = UzuCodeAudioMixer(output);
controller
..onPlaySound = mixer.playSound
..onPlayBgm = mixer.playBgm
..onStopBgm = mixer.stopBgm;UzuCodeAudioOutput | 説明 |
|---|---|
start(id, path, kind:, gain:) | 鳴らし始める。kind が bgm ならループ。同じ id は鳴らし直す |
setGain(id, gain) | 鳴っている音の gain を変える。フェード中は 50ms ごとに呼ばれる |
stop(id) | 止めて破棄する |
gain は 0〜1 で、プレイヤーの BGM / SE の音量設定はまだ掛かっていない。設定を掛けるのは出力側で、設定が変わったら覚えている gain に掛け直す。音量の設定を持たないホスト (エミュレータ・UZU TOKYO) は、mobile の初期値と同じ uzuCodeDefaultPlayerVolume (0.6) を掛ける。
mixer の規則:
- 今の BGM は常に 1 本。別の曲が来たら、前の曲は今の音量から 0 へ、新しい曲は 0 から
volumeへ、新しい呼び出しのfadeで動かす。前の曲は 0 まで下がったら止める - 同じ曲が来たら頭には戻さず、
volumeへfadeかけて音量だけ寄せる。シナリオは state から BGM を導いて送り直すので、読み込み直しのたびに曲が頭へ戻らないようにするため - 下げている途中の曲が来たら、頭には戻さず今の音量から上げ直す
- 下げている途中の曲は 2 本まで。超えたら一番古いものをその場で止める
stopBgmのfadeが 0 なら、下げている途中の曲も含めて全部その場で止める- 曲線は等パワー (上げるときは sin、下げるときは cos)。線形だとクロスフェードの中ほどで音が痩せて聞こえる
- SE は
volumeを gain にして鳴らすだけで、フェードは無い slot付きの SE は slot ごとに 1 本。slot ごとに最後に鳴らした再生 ID を覚え、同じ slot に次の音が来たら前の音をstopしてからstartする。前の音が鳴り終わっていてもstopは害が無いので、鳴り終わりは拾わない。同じ音の頼み直しもstopしてから頭から鳴らすので、同じ ID をstartし直したときの出力側の挙動 (Agora のplayEffectなど) に依らないslotの無い SE は前の音を止めない。別の音源は重なり、同じ音源は同じ ID なので鳴らし直しになる
uzu dev (javascript/uzu_code/cli/src/harness/audio-mixer.ts) も同じ規則で鳴らす。片方を変えるときはもう片方も揃える。
ゲーム ZIP の取得(dir モード)
dir モードの gameDir は {v3ContentsEndpointUrl}/__zip__ を落として展開したディレクトリ。マルチ / ソロ / dev プレビュー / エミュレータは、どれも lib/services/game_archive.dart の downloadGameArchive を通る。
- ZIP はメモリに載せず一時ファイルへストリームで書き、展開もファイルからストリームで行う。作品によっては 150MB を超え、
http.get+decodeBytesだと空き RAM の少ない端末で応答が返らないまま止まる - 展開は isolate で行い、別ディレクトリに展開してから
gameDirへ rename する。gameDirが存在すれば展開は完了している - タイムアウトは接続 30 秒と、受信が途切れてから 30 秒。回線が遅いだけの端末でも落とし切れるよう、全体の所要時間では切らない
- 成否に関わらず一時 ZIP は消す
mobile は uzuCodeGameArchiveProvider(views/features/uzu_code_game_archive/)で進捗と再試行を持つ。
| 項目 | 内容 |
|---|---|
| 開始 | マルチは待合室(_ImagePreCacheWrapper)から。開始後のプレイ画面は同じ結果を使う。ソロはプレイ画面から |
| キャッシュ | getApplicationCacheDirectory()/uzu_code_games/<scenarioRevision.id>。展開済みなら取り直さず、他のリビジョンは消す。ただし PiP 中のプレイ画面など、生きている Provider のリビジョンは残す |
| 同一性 | リビジョン ID だけで決める。endpoint は 24 時間で失効するトークンを含み、購読の更新で変わりうるため |
| 進捗表示 | 待合室の設定画面・プレイ中の UZU Code 設定画面のタイルと、開始後のローディング画面。失敗時はどちらからも再試行できる |
| ローディング画面 | 緊急一時停止と同じ OverlayCard に「ゲームデータをダウンロード中」と進捗を出す。地の色はスプラッシュ(gameSplashColor)と揃える。マルチはゲーム中と同じ GamePlayHud(UZU ボタン + マイク)を重ね、カードに話者表示を出す。UZU メニューは設定と一時退出だけで、ゲームが動いていないので緊急一時停止は出さない。取得し終えて配役を待つ間も同じ画面のまま |
SafeArea(inset を配る)
ゲームは常に全画面へ描き、ホストは safe area を値として配る。ホストがゲームの描画領域を safe area の内側へ縮めることはしない。
ゲーム開発者向けの説明は SafeArea 対応ガイド を参照。
なぜ配る必要があるか
env(safe-area-inset-*) は iframe / WebView の境界を越えない。GamePlayScreen を起動する ホストは 4 つあり、うち 3 つはゲームを iframe に載せている(url_mode_content_web.dart)。 env() は UA 定義で CSS からも JS からも上書きできず、iframe での挙動は csswg-drafts#4670 で未解決のままなので、 ホストが値を配る以外に手が無い。Discord Activities / Telegram Mini Apps / Capacitor も 例外なく同じ形を採っている。
値の出どころ
| ホスト | ソース |
|---|---|
| Flutter native (mobile / uzutokyo) | MediaQuery.paddingOf(context) |
| Flutter Web (uzu_code emulator / uzutokyo web) | 親文書の env()。MediaQuery は Web では safe area を報告しないので、padding に env() を置いた計測用の要素を挿して算出値を読む |
実装は helpers/device_insets_native.dart / helpers/device_insets_web.dart の条件付き インポートで分けている。Flutter Web 側は web/index.html に viewport-fit=cover 付きの viewport meta が要る(無いとブラウザが env() を 0 のまま返す)。
報告する inset は「ゲームの viewport から見た値」とする。WebView / iframe は画面いっぱいに 敷くので、画面の padding がそのまま入る。
配り方
| 経路 | いつ | 何を |
|---|---|---|
| クエリパラメータ | 起動時 | uzuSafeAreaInset{Top,Right,Bottom,Left} / uzuHudInsetX / uzuHudInsetY |
insetsChanged メッセージ | 回転 / PiP / HUD の枚数変化、起動後に値が動いていれば ready のたび | { safeArea: {top,right,bottom,left}, hud: {x,y} } |
初期値をクエリパラメータで渡すのは、初回ペイント前に確定させるため。起動後に push で 配る形にすると最初の 1 フレームだけ inset が空のまま描かれる (discord/embedded-app-sdk#304 が踏んだ形)。
組み立ては helpers/host_insets.dart の HostInsets が持つ。GamePlayScreen は didChangeDependencies(native は MediaQuery の変化がここに来る)と、Web の resize 購読(subscribeDeviceSafeAreaChanges)の両方から値を取り直し、動いたときだけ PlayScreenController.sendInsets() で push する。
WebView を読み込み直すと(描画プロセスのクラッシュからの復帰) 起動 URL がそのまま使われ、SDK は起動時の inset に戻る。そのため controller は最後に push した 値を覚えておき、ready のたびに insetsChanged で渡し直す。
ゲーム側 SDK はこれを --uzu-safe-area-inset-* / --uzu-hud-inset-* / --uzu-content-inset-* の 3 層と getInsets() / onInsetsChange() へ展開する。
Windows の WebView
公式の webview_flutter は Windows を実装していない。Windows の Flutter に PlatformView が無いため。 そこで dart/packages/uzu_webview_windows が WebViewPlatform.instance に Windows 実装を登録する(dartPluginClass なので起動時に自動で登録される)。 uzu_code の画面・ブリッジ・ローカルサーバーは全 OS で同じコードのまま動く。
描画は webview_flutter_windows に任せる。WebView2 を visual hosting で作り、描画を Windows.Graphics.Capture で取り込んで Flutter の Texture に描く方式。
- 下敷き方式(WebView2 を Flutter のウィンドウの真後ろに置き、Flutter を透過させる)は採らない。ウィンドウ単位の画面共有(Discord 等)や Alt+Tab のプレビューにゲームが写らないため
- 入力は Flutter のヒットテストを通る。
WebViewWidgetに届いたポインターだけが WebView2 へ渡るので、HUD やダイアログを重ねても誤入力しない - 取り込みは画面の変化があったフレームだけで起き、GPU 上で 1 回コピーされる。表示は約 1 フレーム遅れる
ブリッジ
addJavaScriptChannel('FlutterHost') は、文書生成時のスクリプトとして window.FlutterHost.postMessage を生やす。 中身は chrome.webview.postMessage にチャンネル名を付けて送るだけ。 SDK はモジュール評価時に window.FlutterHost の有無で hosted かを決めるので、ページのスクリプトより先に生えている必要がある。
未対応の端末
次の端末では落とさず、読み込み失敗として「この端末では遊べません」を出す。
- WebView2 Runtime が無い
Windows.Graphics.Captureが無い(Windows 10 1809 未満)
制約
webview_flutterの controller には破棄の API が無い。そのため、WebViewWidgetが外れた時点で WebView2 を破棄する。同じ controller を別のWebViewWidgetに付け直すことはできない- console の出力は Flutter に届かない。開発時は WebView2 の DevTools で見る
- FPS 上限(
setFpsLimit)は使わない。上限で間引いたフレームが最後の 1 枚だった場合、次に画面が変わるまで表示されずに残るため
描画プロセスのクラッシュからの復帰
iOS の WKWebView は描画を別プロセス (WebContent) で行う。ピンチズームの再ラスタライズ などでメモリが尽きるとこのプロセスだけが落ち、Flutter 側は生きたまま白いビューが残る。 WKWebView は自分では再起動しないため、リロードしない限りアプリごと立ち上げ直すしか 復帰手段が無くなる。
webview_flutter_wkwebview は webViewWebContentProcessDidTerminate を onWebResourceError へ WebResourceErrorType.webContentProcessTerminated として流す。 WebContentCrashRecovery がこれを受けて WebView を読み込み直す。
判定には errorType を使う。WKErrorCode は webview_flutter_wkwebview の 公開 API に含まれておらず、生の errorCode はプラットフォームごとに意味が違う。
復帰の流れ
onRecoveryStartedでBridgeService.resetForReload()を呼び、ホストへ通知するGamePlayScreenがスプラッシュを再表示する(無言のリロードは「固まった」ように見えるため)- WebView を
reload()する。初回は即時、2 回目以降はretryInterval(2 秒) 待つ - ゲームが
firstFrameReadyを送るとmarkRecovered()で試行回数が振り出しに戻る
打ち切り
描き直せないまま maxAttempts (3 回) を超えたら諦め、onError 経由で読み込み失敗として表示する。 試行回数は復帰のたびにリセットされるため、長時間プレイ中に何度落ちても復帰できる限りは読み込み直す。
アプリが裏にいる間のクラッシュ
iOS は裏に回ったアプリの WebContent プロセスも終了させる(メモリの少ない端末や、前面で別アプリを使っているときに起きやすい)。 裏ではリロードしてもまた終了させられ、描画されないので markRecovered() も呼ばれない。
そのため前面 (AppLifecycleState.resumed) 以外で届いたクラッシュ通知は試行回数に数えず、リロードもしない。 前面に戻ったときに、裏で落ちていれば 1 回リロードする。そこから先は前面でのクラッシュと同じく maxAttempts で打ち切る。
対象プラットフォーム
効くのは iOS / macOS だけ。
Android の描画プロセス死 (onRenderProcessGone) は webview_flutter_android が拾っておらず、 Flutter 側に届く通知が無いため手当てできない。Windows も同じで、WebView2 の ProcessFailed を webview_flutter_windows が公開していない。Web ビルドは iframe で描画するので、 この通知経路自体が存在しない。