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
  • Ahead-of-Time-Kompilierung (AOT) von Dart nutzen, um Kaltstarts bei serverlosen Funktionen zu reduzieren
  • 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 Benutzeroberfläche von Flutter auf dem Client mit der sicheren Validierung auf dem Server kombinieren, ohne Code zu duplizieren.

4. Flutter-Anwendung erstellen

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 Angabe 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, um die Backend-Logik zu schreiben, und ersetzen Sie den Inhalt durch den folgenden Code:

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. Lokal mit der Firebase Emulator Suite 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 Aktionsschaltfläche klicken, ruft die App das lokale Dart-Backend auf, ruft die neue Anzahl ab und aktualisiert die UI.

9. In Firebase bereitstellen

In diesem Codelab konnten Sie Funktionen ohne Firebase-Projekt oder Rechnungskonto mit der Firebase Local Emulator Suite testen. Wenn Sie Ihre Funktionen in einer realen Umgebung (z. B. einer Produktionsumgebung) verwenden möchten, müssen Sie ein Firebase-Projekt und die Abrechnung einrichten.

Firebase-Projekt erstellen

  1. Melden Sie sich mit Ihrem Google-Konto in der Firebase Console an.
  2. Klicken Sie auf die Schaltfläche, um ein neues Projekt zu erstellen, und geben Sie dann einen Projektnamen ein.
  3. Klicken Sie auf Weiter.
  4. Überprüfen und akzeptieren Sie gegebenenfalls die Firebase-Nutzungsbedingungen und klicken Sie dann auf Weiter.
  5. (Optional) Aktivieren Sie die KI-Unterstützung in der Firebase Console (Gemini in Firebase genannt).
  6. Für dieses Codelab benötigen Sie kein Google Analytics. Deaktivieren Sie daher die Google Analytics-Option.
  7. Klicken Sie auf Projekt erstellen, warten Sie, bis Ihr Projekt bereitgestellt wurde, und klicken Sie dann auf Weiter.

Firebase-Tarif aktualisieren

Damit Sie die Firebase-Dienste in diesem Codelab verwenden können, muss Ihr Firebase-Projekt den Pay-as-you-go-Tarif (Blaze) haben. Das bedeutet, dass es mit einem Cloud-Rechnungskonto verknüpft ist.

So aktualisieren Sie Ihr Projekt auf den Blaze-Tarif:

  1. Wählen Sie in der Firebase Console aus, um Ihren Tarif zu aktualisieren.
  2. Wählen Sie den Blaze-Tarif aus. Folgen Sie der Anleitung auf dem Bildschirm, um ein Cloud-Rechnungskonto mit Ihrem Projekt zu verknüpfen.
    • Wenn Sie für dieses Codelab Google Cloud-Guthaben verwenden, heißt das Rechnungskonto wahrscheinlich Google Cloud Platform Trial Billing Account oder My Billing Account.
    • Wenn Sie im Rahmen dieses Upgrades ein Cloud-Rechnungskonto erstellen mussten, müssen Sie möglicherweise zum Upgrade-Ablauf in der Firebase Console zurückkehren, um das Upgrade abzuschließen.

In Ihrem Firebase-Projekt bereitstellen

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

firebase use <PROJECT_ID>
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 die 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 Funktionen-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 er auf das richtige Verzeichnis verweist.

Muss ich json_serializable verwenden?

Die Verwendung von json_serializable ist zwar nicht zwingend 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, bleiben Ihre API-Antworten und die Client-UI synchronisiert. Dabei verwenden Sie eine einzige Programmiersprache für den gesamten Stack.