ジャンルから探す

Web Design & Dev English

Firebase RTDBで連番キーが配列(List)で返る条件と対処

自作のオンライン対戦ゲームで、相手が打った手が盤面に反映されず、持ち時間の残り1秒で画面が固まる不具合がありました。Firebase Realtime Databaseに手番をmoves/0・moves/1……の連番キーで保存していたのが原因です。

結論から書くと、Realtime Databaseは「キーがすべて整数で、0から最大のキーまでの半分を超える位置に値がある」とき、そのデータをオブジェクトではなく配列として返します。Flutterでas Mapとキャストしていると、ここで例外になります。

この記事では、配列になる条件をエミュレータで確かめた結果と、Flutter(Dart)で起きる例外、3つの対処をまとめます。

スポンサーリンク

連番キーが配列で返るのはどんなときか

結論:キーがすべて整数で、0から最大のキーまでの範囲の半分を超える位置に値があるときです。キーが1つでも文字列なら、配列にはなりません。

Firebase公式ブログのBest Practices: Arrays in Firebaseは、配列として返す条件を「すべてのキーが整数で、0から最大のキーまでの半分を超えるキーに空でない値があるとき」と説明しています。

この条件を、Firebase Local Emulator Suite(firebase-tools 15.16.0)のRealtime Databaseと、Firebase JS SDK 12.19.0で確かめました。

保存したキー 読み戻した値
0・1・2(1件ずつset) 配列 [{"b":4,"c":2},{"b":2,"c":7},{"b":7,"c":0}]
0・5(6つの位置のうち2つ) オブジェクト {"0":{...},"5":{...}}
0・1・3(4つの位置のうち3つ) 配列 [{...},{...},null,{...}]
m0・m1(文字列のキー) オブジェクト {"m0":{...},"m1":{...}}

0・1・2は1件ずつ別々に書き込みましたが、読み戻すと配列でした。保存するときに配列を渡したかどうかではなく、読み出す時点のキーの形で決まります。REST APIで同じ場所を読んでも、[{"b":4,"c":2},{"b":2,"c":7},{"b":7,"c":0}]と配列で返りました。

途中のキーが欠けている0・1・3は、欠けた位置にnullが入った配列になります。

Flutterで as Map が例外になるのはなぜか

結論:スナップショットの値がListで返ってくるので、Mapへのキャストが型エラーになります。

ゲームのリスナーでは、手番の一覧をこう読んでいました。

final data = event.snapshot.value as Map;
final moves = data['moves'] as Map; // ここで例外になる

同じ形の値をDart 3.11.5で再現すると、次の例外になりました。

type 'List<Object?>' is not a subtype of type 'Map<dynamic, dynamic>' in type cast

この例外はonValueのハンドラの中で起きるので、データが更新されるたびにハンドラが途中で止まります。私のゲームでは、それが次の症状になって出ました。

▼実際に起きた症状

▶ 相手の手が盤面に1つも反映されない

▶ 手番の持ち時間タイマーが張り直されず、残り1秒付近で固まる

▶ 例外が積み重なり、最後はアプリが強制終了する

どれも、手番のデータを読むところで例外が出ていることを示す症状ではありませんでした。盤面の描画やタイマーの不具合に見えたので、原因にたどり着くまでに時間がかかりました。

スポンサーリンク

対処1:ListとMapの両方を受け付けてパースする

結論:値がListでもMapでも受け取れるパース関数を1つ用意し、読み出す場所では必ずそれを通します。

List<Move> parseMoves(Object? raw) {
  final entries = <int, Map>{};
  if (raw is List) {
    for (var i = 0; i < raw.length; i++) {
      final v = raw[i];
      if (v is Map) entries[i] = v; // 欠けた位置の null は飛ばす
    }
  } else if (raw is Map) {
    raw.forEach((k, v) {
      final index = int.tryParse(k.toString());
      if (index != null && v is Map) entries[index] = v;
    });
  }
  final keys = entries.keys.toList()..sort();
  return [
    for (final k in keys)
      Move((entries[k]!['b'] as num).toInt(), (entries[k]!['c'] as num).toInt()),
  ];
}

Listのときはnullを飛ばし、Mapのときはキーを整数に直して並べ替えるのが要点です。途中の手が欠けて0・1・3のような形になっても、配列とオブジェクトのどちらで返っても、同じ順番の手番の一覧になります。

対処2:配列にならないキーで保存する

結論:最初から配列にしたくないなら、整数ではないキーで保存します。

エミュレータで確かめたとおり、m0・m1のように文字列を含むキーなら、読み戻してもオブジェクトのままです。並び順が必要なら、push()で自動生成されるキーを使う方法もあります。公式ブログも、配列の形で保存するのは書き込むクライアントが常に1つだけのとき、と注意しています。

キーの付け方 向いている場面 気をつける点
連番の整数(0, 1, …) 既存のデータ構造を変えられないとき 読み出し側でListとMapの両方を受ける(対処1)
文字列を含むキー(m0, m1, …) 手番のように番号で管理したいとき 文字列として並ぶので、並べ替えは読み出し側で行う
push()の自動キー 複数の端末から追記するとき 番号ではないので、何手目かは値の中に持たせる

私のゲームでは、オンライン対戦の手番のデータ形式を変えずに済むよう、対処1を選びました。

スポンサーリンク

対処3:1件の壊れた値でリスナー全体を止めない

結論:onValueのハンドラ全体をtry/catchで囲み、1回の例外で以降の処理が止まらないようにします。

今回の不具合が「反映されない」「固まる」「落ちる」まで広がったのは、1か所の型エラーでハンドラの残りの処理がすべて飛んでいたからです。

_sub = roomRef.onValue.listen((event) {
  try {
    final data = event.snapshot.value;
    if (data is! Map) return;
    final moves = parseMoves(data['moves']);
    applyMoves(moves);
  } catch (e) {
    // 壊れた値が1件あっても、次の更新は受け取れるようにする
    debugPrint('[online] onValue failed: $e');
  }
});

握りつぶすと原因が見えなくなるので、ログは必ず残します。リリースビルドで原因を追うなら、Crashlyticsなどに非致命のエラーとして送っておくと、ユーザーの端末で起きたことも後から確かめられます。

関連する記事

▶ アルティメット三目並べのルールと勝ち方|普通のマルバツとの違い…この不具合が起きたオンライン対戦ゲームのルール

▶ 究極マルバツ(アプリのサポートページ)…オンライン対戦を実装しているアプリ

まとめ

▼要点

① Realtime Databaseは、キーがすべて整数で半分を超える位置に値があると配列で返す

② 欠けた位置があると、配列の中にnullが入る

③ Flutterでas Mapとキャストしていると、List<Object?>の型エラーになる

④ 読み出し側でListとMapの両方を受けるか、整数ではないキーで保存する

⑤ onValueのハンドラはtry/catchで囲み、1回の例外で止めない

保存するコードでは一度も配列を使っていないのに、読み出すと配列になる。この非対称さに気づくまでがいちばん長くかかりました。連番の整数キーを使うときは、最初から「配列で返ってくることがある」前提でパースを書いておくのが安全です。

※Firebase Local Emulator Suite(firebase-tools 15.16.0)とFirebase JS SDK 12.19.0、Dart 3.11.5で、2026年9月12日に確認した内容です。