第6章

Firebaseでデータを保存・読み込みする

Firebaseの接続設定、認証、Realtime Databaseの読み書きと購読を、カウンターアプリで学びます。

この章で学ぶこと

  • FlutterアプリとFirebaseを接続する方法
  • ログインした利用者ごとにデータを保存する方法
  • 保存した値の変更を受け取り、画面を更新する方法
  • Security Rulesとエミュレーターの使い方

前の章では、端末の変数に回数を持つカウンターを作りました。今回は、Firebaseに回数を保存するカウンターへ変更します。画面を作り直しても、同じ利用者として保存済みの回数を読み出せます。

Firebaseの役割

Firebaseは、アプリに必要なサーバー側の機能を提供するサービス群です。

サービス 役割 この章で使うか
Authentication 利用者を認証し、ユーザーID(uid)で識別する 使う
Realtime Database データを保存し、その変更を端末へ知らせる 使う
Cloud Firestore コレクション・ドキュメント単位でデータを管理する別のデータベース 使わない
Cloud Storage 画像などのファイルを保存する 使わない
Cloud Functions サーバー側でプログラムを実行する 使わない
Hosting Webアプリのファイルを公開する 使わない

この章では、もりアプリでも使っているRealtime Databaseを選びます。FirestoreとはパッケージやAPI、ルールの書き方が違うので、調べるときもサービス名を確認しましょう。

1. 接続の準備をする

前章で作ったhello_appを開きます。今回はChromeで実習します。

Firebaseプロジェクトを用意する

  1. Firebaseコンソールで、学習用プロジェクトを作成します。
  2. Authenticationを開き、ログイン方法の「匿名」を有効にします。
  3. Realtime Databaseを作成します。リージョンを選び、初期ルールはロックモードを選びます。

匿名認証は、メールアドレスやパスワードを入力せずに、利用者をuidで区別する仕組みです。未認証の状態とは異なります。通常のログイン画面を作る前に、認証とデータ保存の関係を学ぶために使います。匿名認証の公式ガイド

CLIとパッケージを追加する

Node.jsとnpmが使える環境で、次を実行します。

npm install -g firebase-tools
firebase login
dart pub global activate flutterfire_cli

続いて、hello_appのフォルダで実行します。

flutter pub add firebase_core firebase_auth firebase_database
dart pub global run flutterfire_cli:flutterfire configure

作成した学習用プロジェクトを選び、対象プラットフォームにWebを含めてください。lib/firebase_options.dartが生成されます。これは接続先の設定ファイルです。データベースを作成したあとに設定を生成することで、その接続情報も反映できます。

導入手順の詳細はFlutter向けFirebaseの公式セットアップを参照してください。

2. ローカルのFirebaseを起動する

実習中の読み書きは、PC上で動くFirebase Local Emulator Suiteに向けます。クラウド上の学習用データベースと、ローカルのデータベースは別の保存先です。

Firebase CLIに対応するNode.jsとJDKを用意し、java -versionでもJavaが使えることを確認してください。バージョン要件やインストール方法はEmulator Suiteの公式ガイドを確認します。

hello_appで実行します。

firebase init database emulators

既存の学習用プロジェクトを選び、Databaseのルールファイル名はdatabase.rules.jsonにします。エミュレーターはAuthenticationとRealtime Databaseを選び、Emulator UIも有効にします。ポートはAuthが9099、Databaseが9000、UIが4000になるよう指定してください。初回は必要なエミュレーターをダウンロードします。

database.rules.jsonを次に置き換えます。

{
  "rules": {
    "counters": {
      "$uid": {
        ".read": "auth != null && auth.uid == $uid",
        ".write": "auth != null && auth.uid == $uid",
        ".validate": "newData.isNumber() && newData.val() >= 0 && newData.val() % 1 == 0"
      }
    }
  }
}

このルールでは、本人のカウンターだけを読み書きでき、保存値を0以上の整数に制限します。$uidはパスに入るユーザーID、auth.uidは認証された利用者のIDです。ルート全体への読み書き許可は与えていません。Realtime Databaseのルールの概要

次のコマンドで起動し、このターミナルは開いたままにします。

firebase emulators:start --only auth,database

Emulator UIを開ければ準備完了です。今回、クラウドへルールやアプリをデプロイする操作は必要ありません。

3. カウンターをFirebaseにつなぐ

lib/main.dartを次のコードに置き換えます。firebase_options.dartは先ほど生成したものを使います。

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_database/firebase_database.dart';
import 'firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  try {
    await Firebase.initializeApp(
      options: DefaultFirebaseOptions.currentPlatform,
    );

    // この実習はChrome用。Firebaseを使う前に接続先を切り替える。
    await FirebaseAuth.instance.useAuthEmulator('localhost', 9099);
    FirebaseDatabase.instance.useDatabaseEmulator('localhost', 9000);

    // 保存済みの認証状態の復元を待ち、未ログインの場合だけ匿名認証する。
    final user = await FirebaseAuth.instance.authStateChanges().first;
    final uid = user?.uid ??
        (await FirebaseAuth.instance.signInAnonymously()).user!.uid;

    runApp(MaterialApp(home: CounterPage(uid: uid)));
  } catch (error) {
    runApp(MaterialApp(
      home: Scaffold(
        body: Center(child: Text('起動に失敗しました: $error')),
      ),
    ));
  }
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key, required this.uid});
  final String uid;

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  late final DatabaseReference _counterRef;
  late final Stream<DatabaseEvent> _counterStream;
  bool _saving = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    _counterRef = FirebaseDatabase.instance.ref('counters/${widget.uid}');
    _counterStream = _counterRef.onValue;
  }

  Future<void> _save({bool reset = false}) async {
    setState(() {
      _saving = true;
      _error = null;
    });
    try {
      if (reset) {
        await _counterRef.set(0);
      } else {
        // 表示中の値ではなく、サーバー側の値に1を加える。
        await _counterRef.set(ServerValue.increment(1));
      }
    } catch (error) {
      if (mounted) setState(() => _error = '保存に失敗しました: $error');
    } finally {
      if (mounted) setState(() => _saving = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Firebaseカウンター')),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: StreamBuilder<DatabaseEvent>(
            stream: _counterStream,
            builder: (context, snapshot) {
              if (snapshot.hasError) {
                return Text('読み込みに失敗しました: ${snapshot.error}');
              }
              if (!snapshot.hasData) {
                return const CircularProgressIndicator();
              }
              final value = snapshot.data!.snapshot.value;
              final count = value is num ? value.toInt() : 0;
              return Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  SelectableText('ユーザーID: ${widget.uid}'),
                  Text('$count 回', style: const TextStyle(fontSize: 40)),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: _saving ? null : () => _save(),
                    child: const Text('1増やす'),
                  ),
                  TextButton(
                    onPressed: _saving ? null : () => _save(reset: true),
                    child: const Text('リセット'),
                  ),
                  if (_saving) const Text('保存中…'),
                  if (_error != null) Text(_error!),
                ],
              );
            },
          ),
        ),
      ),
    );
  }
}

別のターミナルを開き、hello_appで起動します。前章のアプリが動いている場合は一度終了してから実行してください。

flutter run -d chrome --web-port=7357

Webの認証状態はブラウザーの保存領域に関係するため、ここではポートを固定します。同じブラウザープロファイルでページを再読み込みして確認してください。シークレットウィンドウや別のブラウザーでは、別の利用者になることがあります。

4. 保存と変更通知を確認する

  1. 「1増やす」を3回押し、「3 回」になることを確認する。
  2. Emulator UIのAuthenticationで、画面に表示されたuidの利用者を確認する。
  3. Databaseでcountersの下に、そのuidと値3があることを確認する。
  4. ブラウザーを再読み込みして、同じuidなら「3 回」を読み出せることを確認する。
  5. Emulator UIで自分のカウンターの値を10に変更する。Flutter側も「10 回」になることを確認する。
  6. Flutterで「リセット」を押し、Database側も0になることを確認する。

Emulator UIからの編集は管理操作です。アプリ利用者に適用されるルールの検証とは分けて考えてください。

+ Flutterのボタンを押す
|        ↓ 書き込み
|  Realtime Databaseに値を保存
|        ↓ onValueによる変更通知
+ StreamBuilderが新しい値で画面を作る

エミュレーターを終了すると、通常はローカルのデータが失われます。終了後も残す場合は、次回の起動から保存先を指定します。

firebase emulators:start --only auth,database --export-on-exit=./emulator-data

保存されたデータを次回読み込むには、同じコマンドに--import=./emulator-dataを追加します。初回に保存先がまだない場合は--importを付けません。学習用データはGitで管理する必要がないので、.gitignoreにemulator-data/を追加しておきましょう。

コードの役割を整理する

初期化と認証

Firebase.initializeApp()は接続設定を読み込みます。useAuthEmulator()とuseDatabaseEmulator()は接続先をPC上へ切り替えます。この順序で、実際の認証や読み書きより先に実行します。

signInAnonymously()で得たuidを、保存先のcounters/{uid}に使います。匿名アカウントは、別端末で同じ人としてログインするためのメールアドレス・パスワードを持ちません。複数端末で同じ利用者のデータを使うアプリでは、メール認証などを導入します。

一度だけ読む・変更を購読する

今回のonValueは、購読開始時と値の変更時にデータを受け取ります。StreamBuilderが購読と画面更新を担当するため、回数そのものをsetState()で増やす必要はありません。コード中のsetState()は「保存中」やエラー表示に使っています。

一度だけ値を取得したい場合は、次のようにget()を使います。以下はCounterPageのState内で使う補足例です。

final snapshot = await _counterRef.get();
final value = snapshot.value;
debugPrint('保存されている値: $value');

get()はその後の変更を購読しません。また、保存前は値が存在せずnullになるため、実習コードでは画面上の初期値を0にしています。

set・update・removeの違い

方法 動作 用途
set(value) 指定した場所の値を置き換える カウンターを0に戻す
update(map) 指定した子項目を更新する 名前だけを変更し、他の項目を残す
remove() 指定した場所のデータを削除する 保存データを消す
runTransaction() 現在値に基づいて更新し、競合時に再評価する 条件付きの更新

たとえば「名前と回数」を持つデータなら、update({'name': 'もり'})で名前だけを更新できます。一方、set({'name': 'もり'})は指定場所全体を置き換えるため、同じ場所の回数は残りません。この実習のカウンターは整数を保存するルールなので、名前を保存する例を試すにはデータ構造とルールの変更が必要です。

今回の加算はServerValue.increment(1)を使います。画面の古い値に1を足して保存する方法では、同時操作で加算を失う可能性があります。サーバー側での加算は、その競合を避けるための方法です。詳しくは公式の読み書きガイドを参照してください。

通信完了を待つ

通信処理はすぐには終わらないため、Futureとawaitで完了を待ちます。try・catchは失敗を扱います。画面を閉じたあとに状態を変更しないよう、待機後にはmountedも確認しています。

SDKは書き込みをローカルに先行反映することがあります。表示が変わっただけでサーバーへの保存完了とは判断せず、書き込みのFutureの完了とエラーを扱いましょう。

クラウド上で使うときは

この実習コードは、毎回エミュレーターへ接続します。クラウド上の学習用プロジェクトを利用する場合は、次の変更が必要です。

  1. Authenticationで匿名認証が有効になっていることと、Realtime Databaseの作成を確認する。
  2. コンソールのDatabaseルールを、実習で使った本人限定のルールへ更新する。
  3. useAuthEmulator()とuseDatabaseEmulator()の2行を外し、アプリを再起動する。

接続先が変わるとアカウントと保存データも別になります。切り替え時はブラウザーのアプリ用サイトデータを消してから認証し直してください。エミュレーターの値が自動でクラウドへ移るわけではありません。

firebase_options.dartは接続設定であり、アクセス権限を決めるものではありません。本人だけの読み書きという条件は、Security Rulesで制限します。

やってみよう

  • Emulator UIから値を変え、get()での一度だけの取得とonValueでの購読の違いを説明してみる。
  • 別のブラウザーでアプリを開き、異なるuidと別のカウンターが作られることを確かめる。
  • State内に削除ボタンの処理を追加し、await _counterRef.remove()を呼んでみる。保存先が消え、画面上は0に戻ることを確認する。読み書きルールで削除が許可されていれば、.validateは削除時には適用されません。

うまく動かないとき

症状 確認すること
firebase_options.dartが見つからない flutterfire configureを実行し、Webを選んだか
データベースURLのエラー Database作成後にflutterfire configureを再実行したか
起動失敗・読み込み中のまま エミュレーターが起動中か、ポートが一致しているか
permission-denied 認証のuidとパスが一致するか、ルールファイルが読み込まれているか
保存値がルールで拒否される 整数の場所に文字列や負の値を書いていないか
再読み込み後に値が違う uid、ブラウザープロファイル、Webのポート、接続先が同じか
スマホから接続できない この例はPCのChrome用。Androidエミュレーターでは通常10.0.2.2、実機ではPCへ届くアドレスとネットワーク設定が必要

ルールファイルを修正した場合は、エミュレーターのログで反映を確認してください。接続設定を変更した場合は、ホットリロードだけでなくアプリを再起動します。エミュレーターへの接続ガイド

次につなげよう

今回はFlutterからデータを保存し、変更通知を画面に反映しました。次の章では、Firebase HostingでWebアプリを公開する方法を学びます。実際のアプリでの役割分担は、コース末尾の付録:もりアプリにおけるFlutterとFirebaseの関係で確認できます。