1. はじめに
この Codelab では、マルチプレーヤー カウンタ アプリを構築します。Flutter フロントエンドと Firebase バックエンドの両方で Dart を使用する方法を学びます。
また、アプリとサーバー間でデータモデルを共有する方法も学習し、ロジックを重複させる必要がなくなります。
学習内容
- 共有ビジネス ロジックをスタンドアロンの Dart パッケージに抽出する。
- Cloud Functions for Firebase を Dart でネイティブに記述してデプロイする。
- Dart の Ahead-of-Time(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 にデプロイする
Dart バックエンドをデプロイするには、次のコマンドを実行します。
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 がない
firebase init functions を実行してデプロイするオプションのリストに Dart を表示し、テンプレート コードを作成するには、firebase experiments:enable dartfunctions を実行して試験運用版フラグを設定する必要があります。
関数のエミュレータが接続しない
localhost とポート 5001 を使用していることを確認します。Android エミュレータでテストしている場合、デバイスは localhost をホストマシンに解決しません。main.dart でエミュレータ構成を更新して 10.0.2.2 を使用します。
共有パッケージが見つからない
functions/pubspec.yaml の相対パスを確認します。フォルダ構造が Codelab と異なる場合は、正しいディレクトリを指すように path: ../packages/shared を調整します。
json_serializable を使用する必要がありますか?
厳密には必須ではありませんが、json_serializable を使用すると、fromJson メソッドと toJson メソッドを手動で記述することによるエラーを防ぐことができます。これにより、フロントエンドとバックエンドでまったく同じデータ形式が想定されます。
11. 完了
フルスタックの Dart アプリケーションを正常に構築できました。共有パッケージでデータモデルを維持することで、API レスポンスとクライアント UI の同期を維持し、スタック全体で単一のプログラミング言語を使用できます。