Tworzenie aplikacji full stack w Dart przy użyciu Cloud Functions dla Firebase

1. Wprowadzenie

W tym laboratorium stworzysz aplikację do liczenia dla wielu graczy. Dowiesz się, jak używać języka Dart zarówno w przypadku frontendu Fluttera, jak i backendu Firebase.

Dowiesz się też, jak udostępniać modele danych między aplikacją a serwerem, co eliminuje konieczność duplikowania logiki.

Czego się nauczysz

  • Wyodrębnianie wspólnej logiki biznesowej do samodzielnego pakietu Dart.
  • Pisanie i wdrażanie Cloud Functions dla Firebase natywnie w języku Dart.
  • Wykorzystywanie kompilacji AOT (Ahead-of-Time) w języku Dart do ograniczania uruchomień „na zimno” w środowisku bezserwerowym.
  • Lokalne testowanie stosu za pomocą Pakietu emulatorów Firebase.

2. Wymagania wstępne

  • Pakiet SDK Fluttera (najnowsza stabilna wersja).
  • Wiersz poleceń Firebase (wymagana jest wersja 15.15.0 lub nowsza).
  • Edytor kodu, np. Antigravity, Visual Studio Code, IntelliJ lub Android Studio, z zainstalowanymi wtyczkami Dart i Flutter.
  • Podstawowa znajomość Fluttera i Firebase.

3. Dlaczego warto używać języka Dart w backendzie?

Wiele aplikacji w chmurze używa języka Dart w interfejsie frontendu, a innego języka, np. TypeScript, Python lub Go, w backendzie. Wymaga to utrzymywania 2 oddzielnych zestawów modeli danych. Gdy schemat bazy danych ulegnie zmianie, musisz zaktualizować obie bazy kodu.

Uwaga: używanie języka Dart w backendzie umożliwia połączenie responsywnego interfejsu użytkownika Fluttera na kliencie z bezpieczną weryfikacją na serwerze bez duplikowania kodu.

4. Tworzenie aplikacji we Flutterze

Utwórz standardową aplikację we Flutterze:

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

W standardowej aplikacji we Flutterze plik lib/main.dart obsługuje stan licznika lokalnie:

int _counter = 0;

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

To podejście sprawdza się w przypadku stanu lokalnego, ale nie skaluje się do aplikacji dla wielu graczy, w której serwer musi pełnić rolę źródła prawdy. Aby obsługiwać wielu graczy, w kolejnych krokach przeniesiemy tę logikę do backendu.

5. Tworzenie pakietu udostępnionego

Aby uniknąć duplikowania modeli w frontendzie i backendzie, utwórz w repozytorium projektu udostępniony pakiet Dart. Z tego pakietu zależą zarówno aplikacja we Flutterze, jak i funkcje Firebase.

W katalogu głównym projektu my_counter uruchom te polecenia:

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

Dodawanie zależności

W pliku packages/shared/pubspec.yaml dodaj narzędzia do serializacji JSON:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

Definiowanie modeli udostępnionych

Utwórz plik packages/shared/lib/src/models.dart. Ten plik definiuje strukturę danych używaną zarówno przez aplikację, jak i serwer.

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

W pliku packages/shared/lib/shared.dart wyeksportuj modele:

library shared;

export 'src/models.dart';

W katalogu packages/shared uruchom narzędzie do tworzenia kodu, aby wygenerować kod serializacji JSON:

dart run build_runner build

6. Konfigurowanie Cloud Functions dla Firebase

Cloud Functions dla Firebase to framework bezserwerowy, który umożliwia automatyczne uruchamianie kodu backendu bez konieczności zarządzania własnymi serwerami i ich skalowania. Język Dart doskonale się do tego nadaje, ponieważ jest kompilowany AOT (Ahead-of-Time) do postaci binarnej i nie wymaga rozbudowanego środowiska wykonawczego, takiego jak Node.js czy Java. Znacząco skraca to czas uruchamiania „na zimno” funkcji.

Przejdź do katalogu głównego projektu i zainicjuj Cloud Functions dla Firebase:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • Gdy pojawi się prośba o wybranie języka, wybierz Dart.

W pliku functions/pubspec.yaml dodaj ścieżkę względną do pakietu udostępnionego:

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

7. Pisanie funkcji

Aby napisać logikę backendu, otwórz plik functions/bin/server.dart i zastąp jego zawartość tym kodem:

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. Lokalne testowanie za pomocą Pakietu emulatorów Firebase

Możesz uruchomić frontend i backend lokalnie bez wdrażania.

W katalogu głównym projektu uruchom Pakiet emulatorów Firebase:

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

W pliku pubspec.yaml dodaj ścieżkę względną do pakietu udostępnionego i dodaj pakiet http:

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

W projekcie we Flutterze otwórz plik lib/main.dart i zastąp jego zawartość tym kodem. Ten kod frontendu używa tej samej klasy IncrementResponse co 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),
      ),
    );
  }
}

Uruchom aplikację we Flutterze. Gdy klikniesz pływający przycisk polecenia (FAB), aplikacja wywoła lokalny backend Dart, pobierze nową liczbę i zaktualizuje interfejs.

9. Wdrażanie w Firebase

W tym ćwiczeniu możesz wypróbować Funkcje bez projektu w Firebase ani konta rozliczeniowego, korzystając z Pakietu emulatorów lokalnych Firebase. Jeśli chcesz używać funkcji w rzeczywistym środowisku (np. środowisku produkcyjnym), musisz skonfigurować projekt w Firebase i rozliczenia.

Tworzenie projektu w Firebase

  1. Zaloguj się w konsoli Firebase za pomocą konta Google.
  2. Kliknij przycisk, aby utworzyć nowy projekt, a następnie wpisz nazwę projektu.
  3. Kliknij Dalej.
  4. Jeśli pojawi się prośba, zapoznaj się z warunkami korzystania z Firebase i zaakceptuj je, a następnie kliknij Dalej.
  5. (Opcjonalnie) Włącz w konsoli Firebase pomoc AI (nazywaną „Gemini w Firebase”).
  6. W tym laboratorium nie potrzebujesz Google Analytics, więc wyłącz tę opcję.
  7. Kliknij Utwórz projekt, poczekaj na jego utworzenie, a następnie kliknij Dalej.

Uaktualnianie abonamentu Firebase

Aby korzystać z usług Firebase w tym ćwiczeniu, Twój projekt w Firebase musi być objęty abonamentem z płatnością według wykorzystania (Blaze), co oznacza, że jest on połączony z kontem rozliczeniowym Cloud.

Aby przejść na abonament Blaze, wykonaj te czynności:

  1. W konsoli Firebase wybierz opcję przejścia na wyższy abonament.
  2. Wybierz abonament Blaze. Postępuj zgodnie z instrukcjami wyświetlanymi na ekranie, aby połączyć konto rozliczeniowe Cloud z projektem.
    • Jeśli w tym laboratorium używasz środków Google Cloud, konto rozliczeniowe prawdopodobnie będzie się nazywać Google Cloud Platform Trial Billing Account lub My Billing Account.
    • Jeśli w ramach tego przejścia na wyższy abonament musisz utworzyć konto rozliczeniowe Cloud, może być konieczne powrócenie do procesu przejścia na wyższy abonament w konsoli Firebase, aby go dokończyć.

Wdrażanie w projekcie w Firebase

Aby wdrożyć backend Dart, uruchom to polecenie za pomocą wiersza poleceń Firebase:

firebase use <PROJECT_ID>
firebase deploy --only functions

Po uruchomieniu polecenia skopiuj adres URL i zastąp FIREBASE_FUNCTIONS_URL_HERE w kodzie źródłowym aplikacji we Flutterze, który dodaliśmy wcześniej.

10. Rozwiązywanie problemów

firebase: command not found

Upewnij się, że wiersz poleceń Firebase jest zainstalowany, a ścieżka PATH jest zaktualizowana. Możesz go zainstalować za pomocą npm: npm install -g firebase-tools.

Brak języka Dart w szablonach funkcji init

Aby język Dart był widoczny na liście opcji wdrażania i aby podczas uruchamiania polecenia firebase init functions tworzony był kod szablonu, należy ustawić flagę eksperymentu, uruchamiając polecenie firebase experiments:enable dartfunctions.

Emulator funkcji nie łączy się

Sprawdź, czy używasz adresu localhost i portu 5001. Jeśli testujesz na Android Emulator, urządzenie nie rozpoznaje adresu localhost jako komputera hosta. Zaktualizuj konfigurację emulatora w pliku main.dart, aby używać adresu 10.0.2.2.

Nie znaleziono pakietu udostępnionego

Sprawdź ścieżkę względną w pliku functions/pubspec.yaml. Jeśli struktura folderów różni się od struktury w laboratorium, dostosuj ścieżkę path: ../packages/shared, aby wskazywała prawidłowy katalog.

Czy muszę używać json_serializable?

Chociaż nie jest to bezwzględnie wymagane, używanie json_serializable zapobiega błędom spowodowanym ręcznym pisaniem metod fromJson i toJson. Dzięki temu frontend i backend oczekują dokładnie tego samego formatu danych.

11. Gratulacje

Udało Ci się utworzyć pełną aplikację w języku Dart. Dzięki utrzymywaniu modeli danych w pakiecie udostępnionym masz pewność, że odpowiedzi API i interfejs klienta pozostają zsynchronizowane, a w całym stosie używasz jednego języka programowania.