Full-Stack-Dart-App mit Cloud Functions für Firebase erstellen

1. Einführung

In diesem Codelab erstellen Sie eine Multiplayer-Counter-App. Sie erfahren, wie Sie Dart sowohl für das Flutter-Frontend als auch für das Firebase-Backend verwenden.

Außerdem lernen Sie, wie Sie Datenmodelle zwischen Ihrer App und Ihrem Server freigeben, sodass Sie keine Logik duplizieren müssen.

Lerninhalte

  • Gemeinsame Geschäftslogik in ein eigenständiges Dart-Paket extrahieren
  • Cloud Functions for Firebase nativ in Dart schreiben und bereitstellen
  • Die Ahead-of-Time-Kompilierung (AOT) von Dart nutzen, um serverlose Kaltstarts zu reduzieren
  • Ihren Stack mit der Firebase Emulator Suite lokal testen

2. Vorbereitung

  • Flutter SDK (neueste stabile Version)
  • Firebase CLI (Version 15.15.0 oder höher erforderlich)
  • Ein Code-Editor wie Antigravity, Visual Studio Code, IntelliJ oder Android Studio mit den installierten Dart- und Flutter-Plug-ins
  • Grundkenntnisse in Flutter und Firebase

3. Warum Dart für das Backend verwenden?

Viele Cloud-Anwendungen verwenden Dart für die Frontend-UI und eine andere Sprache wie TypeScript, Python oder Go für das Backend. Dazu müssen zwei separate Sätze von Datenmodellen verwaltet werden. Wenn sich ein Datenbankschema ändert, müssen Sie beide Codebasen aktualisieren.

Hinweis: Wenn Sie Dart im Backend verwenden, können Sie die reaktionsschnelle Nutzererfahrung von Flutter auf dem Client mit der sicheren Validierung auf dem Server kombinieren, ohne Code zu duplizieren.

4. Flutter-App erstellen

So erstellen Sie eine Standard-Flutter-App:

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

In einer Standard-Flutter-App verarbeitet lib/main.dart den Counter-Status lokal:

int _counter = 0;

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

Dieser Ansatz funktioniert für den lokalen Status, lässt sich aber nicht auf eine Multiplayer-Anwendung skalieren, bei der der Server als Source of Truth fungieren muss. Um mehrere Spieler zu unterstützen, verschieben wir diese Logik in den folgenden Schritten in das Backend.

5. Freigegebenes Paket erstellen

Um Modelle im Frontend und Backend nicht zu duplizieren, erstellen Sie ein freigegebenes Dart-Paket in Ihrem Projekt-Repository. Sowohl die Flutter-App als auch die Funktionen für Firebase sind von diesem Paket abhängig.

Führen Sie im Stammverzeichnis des Projekts my_counter die folgenden Befehle aus:

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

Abhängigkeiten hinzufügen

Fügen Sie in packages/shared/pubspec.yaml die JSON-Serialisierungstools hinzu:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

Freigegebene Modelle definieren

Erstellen Sie packages/shared/lib/src/models.dart. In dieser Datei wird die Datenstruktur definiert, die sowohl von der App als auch vom Server verwendet wird.

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';

Exportieren Sie Ihre Modelle in packages/shared/lib/shared.dart:

library shared;

export 'src/models.dart';

Führen Sie im Verzeichnis packages/shared den Build Runner aus, um den JSON-Serialisierungscode zu generieren:

dart run build_runner build

6. Cloud Functions for Firebase einrichten

Cloud Functions for Firebase ist ein serverloses Framework, mit dem Sie automatisch Backend-Code ausführen können, ohne eigene Server verwalten und skalieren zu müssen. Dart ist eine gute Wahl, da es Ahead-of-Time (AOT) in eine Binärdatei kompiliert wird und keine umfangreiche Laufzeitumgebung wie Node.js oder Java erfordert. Dadurch werden die Kaltstartzeiten für Ihre Funktionen erheblich reduziert.

Navigieren Sie zum Stammverzeichnis Ihres Projekts und initialisieren Sie Cloud Functions for Firebase:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • Wählen Sie bei der Aufforderung zur Eingabe der Sprache Dart aus.

Fügen Sie in functions/pubspec.yaml einen relativen Pfad zum freigegebenen Paket hinzu:

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

7. Funktion schreiben

Öffnen Sie functions/bin/server.dart und ersetzen Sie den Inhalt durch den folgenden Code, um die Backend-Logik zu schreiben:

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. Mit der Firebase Emulator Suite lokal testen

Sie können sowohl das Frontend als auch das Backend lokal ausführen, ohne sie bereitzustellen.

Starten Sie die Firebase Emulator Suite im Stammverzeichnis Ihres Projekts:

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

Fügen Sie in pubspec.yaml einen relativen Pfad zum freigegebenen Paket und das HTTP-Paket hinzu:

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

Öffnen Sie in Ihrem Flutter-Projekt lib/main.dart und ersetzen Sie den Inhalt durch den folgenden Code. Dieser Frontend-Code verwendet dieselbe IncrementResponse-Klasse wie das Backend.

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),
      ),
    );
  }
}

Führen Sie die Flutter-App aus. Wenn Sie auf die Floating Action Button klicken, ruft die App das lokale Dart-Backend auf, ruft die neue Anzahl ab und aktualisiert die UI.

9. In Firebase bereitstellen

Führen Sie den folgenden Befehl aus, um Ihr Dart-Backend bereitzustellen:

firebase deploy --only functions

Kopieren Sie nach Ausführung des Befehls die URL und ersetzen Sie FIREBASE_FUNCTIONS_URL_HERE im Quellcode der Flutter-App, den wir zuvor hinzugefügt haben.

10. Fehlerbehebung

firebase: command not found

Prüfen Sie, ob die Firebase CLI installiert ist und Ihr PATH aktualisiert wurde. Sie können sie mit npm installieren: npm install -g firebase-tools.

Dart fehlt in den Vorlagen für Initialisierungsfunktionen

Damit Dart als Liste von Optionen für die Bereitstellung angezeigt wird und der Vorlagencode beim Ausführen von firebase init functions erstellt wird, muss ein Testflag festgelegt werden. Führen Sie dazu firebase experiments:enable dartfunctions aus.

Der Functions Emulator stellt keine Verbindung her

Prüfen Sie, ob Sie localhost und Port 5001 verwenden. Wenn Sie auf einem Android-Emulator testen, löst das Gerät localhost nicht auf Ihren Hostcomputer auf. Aktualisieren Sie die Emulatorkonfiguration in main.dart, um 10.0.2.2 zu verwenden.

Das freigegebene Paket wurde nicht gefunden

Prüfen Sie den relativen Pfad in functions/pubspec.yaml. Wenn sich Ihre Ordnerstruktur von der im Codelab unterscheidet, passen Sie path: ../packages/shared so an, dass sie auf das richtige Verzeichnis verweist.

Muss ich json_serializable verwenden?

json_serializable ist zwar nicht unbedingt erforderlich, verhindert aber Fehler, die durch das manuelle Schreiben von fromJson- und toJson-Methoden verursacht werden. Dadurch wird sichergestellt, dass Frontend und Backend genau dasselbe Datenformat erwarten.

11. Glückwunsch

Sie haben eine Full-Stack-Dart-Anwendung erstellt. Wenn Sie Ihre Datenmodelle in einem freigegebenen Paket verwalten, sorgen Sie dafür, dass Ihre API-Antworten und die Client-UI synchronisiert bleiben. Dabei verwenden Sie eine einzige Programmiersprache für den gesamten Stack.