Skip to content

Flutter ライブラリ仕様 ​

内部ドキュメント

このドキュメントは内部実装の詳細です。ゲーム開発者向けの情報は docs.code.uzu-app.com を参照してください。

ソースコード: dart/packages/uzu_code/

ゲーム表示フレームワークとして提供される Flutter パッケージ。 WebView によるゲーム実行、Flutter ↔ JS ブリッジ、サウンド再生を提供する。

ライブラリであり、アプリケーションではない。 main.dart を持たず、他の Flutter アプリ (Emulator 等) から依存して使用する。

技術スタック ​

パッケージバージョン用途
Flutter / DartSDK ^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 をバレルエクスポートする。

dart
export 'play_screen_controller.dart';
export 'screens/game_play_screen.dart';

利用側:

dart
import 'package:uzu_code/uzu_code.dart';

GamePlayScreen ​

WebView (Native) / iframe (Web) でゲームを実行する StatefulWidget。

パラメータ ​

パラメータ型必須説明
gameDirString?Noゲームファイルのローカルディレクトリパス (dir モード)
gameUrlString?Noゲームのリモート URL (url モード)
controllerPlayScreenControllerYesホストとの双方向通信コントローラ
buttonActionsList<HudAction>NoUZU ボタン左の追加オーバーレイボタン (マイク、チャット等)
onMenuPressedValueChanged<Rect>?NoUZU ボタンタップ時のコールバック (null で非表示)。引数はボタンの矩形 (グローバル座標) で、メニューをボタンの位置から出すのに使う
pauseRosterWidget?No緊急一時停止の overlay で、既定の顔ぶれ(roster のアイコンと名前)の代わりに出すウィジェット。通話はホストの持ち物なので、話者表示を出したいホストが渡す
seatNameString Function(String seatId)?No切断の帯に出す名前を席 ID から引く。null なら roster の Player.name。プレイ中はキャラクターの名前で呼び合うので、mobile はキャラクターの名前を渡す
extraQueryParamsMap<String, String>NoURL に追加するクエリパラメータ

左上の 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 のプロパティから以下のクエリパラメータを構築する:

パラメータソース
roomIdcontroller.roomId
servercontroller.serverUrl
seatIdcontroller.playerId
playerscontroller.players (roster。JSON エンコード)
seatKindcontroller.seatKind (player / spectator / admin)
monitorcontroller.monitor が true のときだけ 1。SDK が WebSocket の接続 URL へ引き継ぐ
+ extraQueryParamsGamePlayScreen.extraQueryParams

サウンドパス解決の違い ​

モード解決方法
dir モードgameDir + 相対パスを結合して絶対ファイルパスに解決
url モード相対パスをそのままコールバックに渡す

PlayScreenController ​

ホスト (Emulator、モバイルアプリ等) と GamePlayScreen 間の双方向通信コントローラ。

コンストラクタ ​

パラメータ型説明
roomIdString (required)ルーム ID
playerIdString (required)このコントローラが表すプレイヤーの ID
serverUrlString (required)WebSocket サーバーの URL (例: wss://uzu-code play-server-dev.uzu-account.workers.dev)
playersList<Player> (required)roster。配役を受け取る参加者だけを渡す (観測者は載せない)
seatKindSeatKind (default player)この席の種別。観測席を開くホスト (エミュレータ) だけが player 以外を渡す
monitorbool (default false)本物のプレイヤーが遊ぶ部屋を、監視のために開いた接続。止める操作を出さず、サーバーもこの接続を数えない
voiceOffReasonVoiceReason (default thisDevice)通話を持たないホストが申告する既定の理由。卓そのものが通話を使わないホスト(UZU TOKYO・mobile の一人用)は table

Player クラス ​

dart
class Player {
  final String id;
  final String characterId;
  final String name;
  final String iconUrl;
}

characterId はシナリオ manifest の characters[].id。roster に載る = 配役を受け取る、なので必須。 シナリオは配役の無い席を無言で roster 順の配役に倒すため、配役が引けないときは roster を組まずに接続を遅らせる (ホスト側でローディングを出す)。

コールバック (Game → Host) ​

コールバック型説明
onPlaySoundvoid Function(String, UzuCodeSoundOptions)?効果音再生リクエスト。UzuCodeAudioMixer.playSound に繋ぐ
onPlayBgmvoid Function(String, UzuCodeSoundOptions)?BGM 再生リクエスト。UzuCodeAudioMixer.playBgm に繋ぐ
onStopBgmvoid Function(Duration)?BGM 停止。引数はフェードアウトの長さ。UzuCodeAudioMixer.stopBgm に繋ぐ
onPresencevoid Function(PresenceSnapshot)?サーバーから presence が届いたとき。通話の部屋とマイクの方針を myPlan に合わせるのはホストの責務
onSetListeningRoomvoid Function(String?)?観測席のシナリオが聴く部屋を選んだとき。プレイヤー席では無視する
onSetMicMutedvoid Function(bool)?シナリオが本人のマイクをミュートしたとき。引数は常に true。解除の要求は handleSetMicMuted が捨てるので、ここへ来ない
onSetMicEnabledvoid Function(bool)?bridge 1 の作品がマイクをオフにしたとき。引数は常に false。オンの要求は handleSetMicEnabled が捨てる
onChangeRoomvoid Function(String?)?bridge 1 の作品のボイスチャットルームの移動リクエスト。null = デフォルトルーム(全体)
onFinishGamevoid Function()?ゲームが終わり、この端末のプレイ画面を閉じる要求。シナリオは再描画のたびに送りうるので、処理中の 2 回目以降は無視する
onReadyvoid Function()?SDK 準備完了 (ready 受信時)
onPauseStatevoid Function(bool, String?)?緊急一時停止の確定。overlay は GamePlayScreen が自分で出すので、ホストが繋ぐのは音声の共通ルーム移動など overlay の外側だけ
onPauseRequestFailedvoid 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サーバーが停止を確定した

接続と通話の状態 ​

接続と通話の状態 のために持つ値。

プロパティ型説明
presenceValueNotifier<PresenceSnapshot?>サーバーが配った最新の presence。新しい SDK の作品でだけ届く
gameConnectionValueNotifier<GameConnectionStatus>SDK が張っている WebSocket の状態(connectionChanged)
voiceSelfValueNotifier<VoiceSelf>自分の通話の状態(updateVoice で更新)
micLockedValueNotifier<bool>自分の plan の mic が locked で、一時停止中(確定した停止だけ)でないか。ホストはマイクボタンを鍵にする
myPlanVoicePlan?自分の部屋割りとマイクの方針。観測席と、presence が届いていない間は null
isRosterSeat / canControlPauseboolroster にいる席か / 止める操作を出してよいか
dart
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 ​

dart
void setPlayersState({
  required Map<String, Map<String, dynamic>> players,
})

bridge 1 の作品へプレイヤーのリアルタイム状態を送信する。Flutter 側の音声状態(VoiceState)が変化するたびに呼び出される。bridge 2 の SDK は知らない type として捨てる。

パラメータ:

パラメータ型説明
playersMap<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 に読んで渡す
onPlayBgmBGM 再生リクエスト受信時のコールバック。volume / fadeMs を UzuCodeSoundOptions に読んで渡す
onStopBgmBGM 停止コールバック。fadeMs を Duration に読んで渡す
onSetMicMutedsetMicMuted 受信時のコールバック(controller の handleSetMicMuted へ渡す)
onSetListeningRoomsetListeningRoom 受信時のコールバック
onConnectionChangedconnectionChanged 受信時のコールバック
onPlatformMessageplatform チャネルの伝言を受信したときのコールバック
onSetMicEnabledbridge 1 の setMicEnabled 受信時のコールバック(controller の handleSetMicEnabled へ渡す)
onChangeRoombridge 1 の changeRoom 受信時のコールバック (null = デフォルトルーム)
onHaptichaptic 受信時のコールバック (kind を PlayScreenController.handleHaptic へ渡す)
onGameEndedgameEnded 受信時のコールバック
onFinishGamefinishGame 受信時のコールバック
onReadySDK 準備完了 (ready 受信) 時のコールバック
onPauseStatepauseState 受信時のコールバック (緊急一時停止の確定)
onStateResponsestateResponse 受信時のコールバック

サウンド再生 ​

サウンドファイル (効果音・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 をフェードせずに止める。

dart
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 はプラットフォームごとに意味が違う。

復帰の流れ ​

  1. onRecoveryStarted で BridgeService.resetForReload() を呼び、ホストへ通知する
  2. GamePlayScreen がスプラッシュを再表示する(無言のリロードは「固まった」ように見えるため)
  3. WebView を reload() する。初回は即時、2 回目以降は retryInterval (2 秒) 待つ
  4. ゲームが 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 で描画するので、 この通知経路自体が存在しない。