Cloud Functions for Firebase を使用してフルスタックの Dart アプリを構築する

1. はじめに

この Codelab では、マルチプレイヤー カウンター アプリを構築します。Flutter フロントエンドと Firebase バックエンドの両方で Dart を使用する方法を学びます。

また、アプリとサーバー間でデータモデルを共有して、ロジックの重複を回避する方法についても説明します。

学習内容

  • 共有ビジネス ロジックをスタンドアロンの Dart パッケージに抽出します。
  • Cloud Functions for Firebase を Dart でネイティブに記述してデプロイします。
  • Dart の事前(AOT)コンパイルを活用して、サーバーレスのコールド スタートを減らします。
  • Firebase Emulator Suite を使用してローカルでスタックをテストします。

2. 前提条件

  • Flutter SDK(最新の安定版)。
  • Firebase CLI(v15.15.0 以降が必要です)。
  • Dart プラグインと Flutter プラグインがインストールされているコードエディタ(Antigravity、Visual Studio Code、IntelliJ、Android Studio など)。
  • Flutter と Firebase に関する基本的な知識。

3. バックエンドに Dart を使用する理由

多くのクラウド アプリケーションでは、フロントエンド UI に Dart を使用し、バックエンドに TypeScript、Python、Go などの別の言語を使用しています。これには、2 つの別々のデータモデルのセットを維持する必要があります。データベース スキーマが変更された場合は、両方のコードベースを更新する必要があります。

注: バックエンドで Dart を使用すると、コードを複製することなく、クライアントの Flutter のレスポンシブなユーザー エクスペリエンスとサーバーの安全な検証を組み合わせることができます。

4. Flutter アプリを作成する

標準の Flutter アプリを作成します。

flutter create my_counter
cd my_counter
# Run the app to see the default counter example
flutter run

標準の Flutter アプリでは、lib/main.dart がカウンタの状態をローカルで処理します。

int _counter = 0;

void _incrementCounter() {
  setState(() {
    _counter++;
  });
}

このアプローチはローカル状態では機能しますが、サーバーが信頼できる情報源として機能する必要があるマルチプレーヤー アプリケーションには拡張できません。複数のプレーヤーをサポートするために、次の手順でこのロジックをバックエンドに移動します。

5. 共有パッケージを作成する

フロントエンドとバックエンドでモデルが重複しないようにするには、プロジェクト リポジトリ内に共有 Dart パッケージを作成します。Flutter アプリと Firebase の関数はどちらもこのパッケージに依存しています。

my_counter プロジェクトのルートから次のコマンドを実行します。

mkdir -p packages
cd packages
dart create -t package shared

依存関係を追加する

packages/shared/pubspec.yaml で、JSON シリアル化ツールを追加します。

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

共有モデルを定義する

packages/shared/lib/src/models.dart を作成します。このファイルは、アプリとサーバーの両方で使用されるデータ構造を定義します。

import 'package:json_annotation/json_annotation.dart';

part 'models.g.dart';

@JsonSerializable()
class IncrementResponse {
  final bool success;
  final String? message;
  final int? newCount;

  const IncrementResponse({required this.success, this.message, this.newCount});

  factory IncrementResponse.fromJson(Map<String, dynamic> json) =>
      _$IncrementResponseFromJson(json);

  Map<String, dynamic> toJson() => _$IncrementResponseToJson(this);
}

// Store the function name as a constant to ensure consistency between client and server.
const incrementCallable = 'increment';

packages/shared/lib/shared.dart で、モデルをエクスポートします。

library shared;

export 'src/models.dart';

packages/shared ディレクトリでビルド ランナーを実行して、JSON シリアル化コードを生成します。

dart run build_runner build

6. Cloud Functions for Firebase を設定する

Cloud Functions for Firebase はサーバーレス フレームワークで、独自のサーバーを管理したりスケーリングしたりすることなく、バックエンド コードを自動的に実行できます。Dart は、Ahead-of-Time(AOT)でバイナリにコンパイルされ、Node.js や Java のような重いランタイム環境を必要としないため、この用途に最適です。これにより、関数のコールド スタート時間が大幅に短縮されます。

プロジェクトのルートに移動し、Cloud Functions for Firebase を初期化します。

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • 言語の選択を求められたら、[Dart] を選択します。

functions/pubspec.yaml で、共有パッケージへの相対パスを追加します。

dependencies:
  firebase_functions:
  google_cloud_firestore:
  shared:
    path: ../packages/shared

7. 関数を記述する

バックエンド ロジックを作成するには、functions/bin/server.dart を開き、内容を次のコードに置き換えます。

import 'dart:convert';
import 'package:firebase_functions/firebase_functions.dart';
import 'package:google_cloud_firestore/google_cloud_firestore.dart'
    show FieldValue;
import 'package:shared/shared.dart';

void main(List<String> args) async {
  await fireUp(args, (firebase) {

    // Listen for calls to the http request and name defined in the shared package.
    firebase.https.onRequest(name: incrementCallable, (request) async {

      // In a production app, verify the user with request.auth?.uid here.
      print('Incrementing counter on the server...');

      // Get firestore database instance
      final firestore = firebase.adminApp.firestore();

      // Get a reference to the counter document
      final counterDoc = firestore.collection('counters').doc('global');

      // Get the current snapshot for the count data
      final snapshot = await counterDoc.get();

      // Increment response we will send back
      IncrementResponse incrementResponse;

      // Check for the current count and if the snapshot exists
      if (snapshot.data() case {'count': int value} when snapshot.exists) {
        if (request.method == 'GET') {
          // Get the current result
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Read-only sync complete',
            newCount: value,
          );
        } else if (request.method == 'POST') {
          // Increment count by one
          final step = request.url.queryParameters['step'] as int? ?? 1;
          await counterDoc.update({'count': FieldValue.increment(step)});
          incrementResponse = IncrementResponse(
            success: true,
            message: 'Atomic increment complete',
            newCount: value + step,
          );
        } else {
          throw FailedPreconditionError(
            'only GET and POST requests are allowed',
          );
        }
      } else {
        // Create a new document with a count of 1
        await counterDoc.set({'count': 1});
        incrementResponse = const IncrementResponse(
          success: true,
          message: 'Cloud-sync complete',
          newCount: 1,
        );
      }

      // Return the response as JSON
      return Response(
        200,
        body: jsonEncode(incrementResponse.toJson()),
        headers: {'Content-Type': 'application/json'},
      );
    });

  });
}

8. Firebase Emulator Suite を使用してローカルでテストする

フロントエンドとバックエンドの両方をデプロイせずにローカルで実行できます。

プロジェクトのルートから Firebase Emulator Suite を起動します。

# Enable functions and firestore for the emulators
firebase init emulators
# Start the emulators and optionally open up the Admin UI
firebase emulators:start

pubspec.yaml で、共有パッケージへの相対パスを追加し、http パッケージを追加します。

dependencies:
  http: ^1.6.0
  shared:
    path: /packages/shared

Flutter プロジェクトで lib/main.dart を開き、その内容を次のコードに置き換えます。このフロントエンド コードは、バックエンドと同じ IncrementResponse クラスを使用します。

import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared/shared.dart';

/// Get from emulator output when running or when deploying:
/// ✔ functions[us-central1-increment]: http function initialized
///  (http://127.0.0.1:5001/demo-no-project/us-central1/increment).
const incrementUrl = 'FIREBASE_FUNCTIONS_URL_HERE';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
    home: const CounterPage(),
  );
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});
  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    // Fetch the current count
    _increment(readOnly: true).ignore();
  }

  Future<void> _increment({bool readOnly = false}) async {
    setState(() => _loading = true);
    try {
       // Call the Dart function.
      final uri = Uri.parse(incrementUrl);
      final response = readOnly ? await http.get(uri) : await http.post(uri);

      // Parse the response back into the shared Dart object.
      final responseData = jsonDecode(response.body);
      final incrementResponse = IncrementResponse.fromJson(responseData);

      if (incrementResponse.success) {
        setState(() => _count = incrementResponse.newCount ?? _count);
      }
    } catch (e) {
      print("Error calling function: $e");
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Multiplayer Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('You have pushed the button this many times:'),
            Text(
              '$_count',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _loading ? null : _increment,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Flutter アプリを実行します。フローティング アクション ボタンをクリックすると、アプリはローカルの Dart バックエンドを呼び出し、新しいカウントを取得して、UI を更新します。

9. Firebase にデプロイする

この Codelab では、Firebase Local Emulator Suite を使用して、Firebase プロジェクトや請求先アカウントなしで関数を試すことができました。実際の環境(本番環境など)で関数を使用する場合は、Firebase プロジェクトと課金を設定する必要があります。

Firebase プロジェクトを作成する

  1. Google アカウントを使用して Firebase コンソールにログインします。
  2. ボタンをクリックして新しいプロジェクトを作成し、プロジェクト名を入力します。
  3. [続行] をクリックします。
  4. Firebase の利用規約が表示されたら、内容を読み、同意して [続行] をクリックします。
  5. (省略可)Firebase コンソールで AI アシスタンス(「Gemini in Firebase」)を有効にします。
  6. この Codelab では Google アナリティクスは必要ないため、Google アナリティクスのオプションをオフにします。
  7. [プロジェクトを作成] をクリックし、プロジェクトのプロビジョニングが完了するまで待ってから、[続行] をクリックします。

Firebase の料金プランをアップグレードする

この Codelab で Firebase サービスを使用するには、Firebase プロジェクトが従量課金制(Blaze)のお支払いプランに登録されている必要があります。つまり、Cloud 請求先アカウントにリンクされている必要があります。

  • Cloud 請求先アカウントには、クレジット カードなどの支払い方法が必要です。
  • 特別なプロモーション期間中や、イベントの一環としてこの Codelab を実施している場合は、Google Cloud クレジットを利用できることがあります。
  • Firebase と Google Cloud を初めて使用する場合は、$300 のクレジットと無料トライアル用 Cloud 請求先アカウントを利用できるかどうかご確認ください。

プロジェクトを Blaze プランにアップグレードする手順は次のとおりです。

  1. Firebase コンソールで、プランをアップグレードします。
  2. Blaze プランを選択します。画面上の手順に沿って、Cloud 請求先アカウントをプロジェクトにリンクします。
    • この Codelab で Google Cloud クレジットを使用している場合、請求先アカウントの名前は Google Cloud Platform Trial Billing Account または My Billing Account になっている可能性があります。
    • このアップグレードの一環として Cloud 請求先アカウントを作成する必要があった場合は、Firebase コンソールのアップグレード フローに戻ってアップグレードを完了する必要がある場合があります。

Firebase プロジェクトにデプロイする

Dart バックエンドをデプロイするには、Firebase CLI を使用して次のコマンドを実行します。

firebase use <PROJECT_ID>
firebase deploy --only functions

コマンドを実行したら、URL をコピーして、先ほど追加した Flutter アプリのソースコードの FIREBASE_FUNCTIONS_URL_HERE を置き換えます。

10. トラブルシューティング

firebase: command not found

Firebase CLI がインストールされ、PATH が更新されていることを確認します。npm を使用してインストールできます(npm install -g firebase-tools)。

init 関数のテンプレートに Dart がない

Dart をデプロイ オプションのリストとして表示し、firebase init functions の実行時にテンプレート コードを作成するには、firebase experiments:enable dartfunctions を実行してテストフラグを設定する必要があります。

Functions エミュレータが接続していない

localhost とポート 5001 を使用していることを確認します。Android エミュレータでテストしている場合、デバイスは localhost をホストマシンに解決しません。10.0.2.2 を使用するように main.dart のエミュレータ構成を更新します。

共有パッケージが見つからない

functions/pubspec.yaml で相対パスを確認します。フォルダ構造が Codelab と異なる場合は、正しいディレクトリを指すように path: ../packages/shared を調整します。

json_serializable を使用する必要がありますか?

必須ではありませんが、json_serializable を使用すると、fromJson メソッドと toJson メソッドを手動で記述することによるエラーを防ぐことができます。これにより、フロントエンドとバックエンドでまったく同じデータ形式が想定されるようになります。

11. 完了

フルスタックの Dart アプリケーションを正常に構築しました。データモデルを共有パッケージで維持することで、API レスポンスとクライアント UI の同期を維持し、スタック全体で単一のプログラミング言語を使用できます。