Crea un'app Dart full-stack con Cloud Functions for Firebase

1. Introduzione

In questo codelab creerai un'app contatore multigiocatore. Imparerai a utilizzare Dart sia per il frontend Flutter sia per il backend Firebase.

Imparerai anche a condividere i modelli di dati tra l'app e il server, eliminando la necessità di duplicare la logica.

Obiettivi didattici

  • Estrarre la logica di business condivisa in un pacchetto Dart autonomo.
  • Scrivere ed eseguire il deployment di Cloud Functions for Firebase in modo nativo in Dart.
  • Sfruttare la compilazione Ahead-of-Time (AOT) di Dart per ridurre gli avvii a freddo serverless.
  • Testare lo stack localmente utilizzando Firebase Emulator Suite.

2. Prerequisiti

3. Perché utilizzare Dart per il backend?

Molte applicazioni cloud utilizzano Dart per l'interfaccia utente del frontend e un altro linguaggio, come TypeScript, Python o Go, per il backend. Ciò richiede la gestione di due set separati di modelli di dati. Quando lo schema di un database cambia, devi aggiornare entrambi i codebase.

Nota: l'utilizzo di Dart sul backend ti consente di combinare l'esperienza utente reattiva di Flutter sul client con la convalida sicura sul server, senza duplicare il codice.

4. Creare l'app Flutter

Crea un'app Flutter standard:

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

In un'app Flutter standard, lib/main.dart gestisce lo stato del contatore localmente:

int _counter = 0;

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

Questo approccio funziona per lo stato locale, ma non è scalabile per un'applicazione multigiocatore in cui il server deve fungere da fonte di verità. Per supportare più giocatori, nei passaggi seguenti sposteremo questa logica nel backend.

5. Creare il pacchetto condiviso

Per evitare di duplicare i modelli sul frontend e sul backend, crea un pacchetto Dart condiviso all'interno del repository del progetto. Sia l'app Flutter sia le funzioni per Firebase dipendono da questo pacchetto.

Dalla radice del progetto my_counter, esegui i seguenti comandi:

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

Aggiungere dipendenze

In packages/shared/pubspec.yaml, aggiungi gli strumenti di serializzazione JSON:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

Definire i modelli condivisi

Crea packages/shared/lib/src/models.dart. Questo file definisce la struttura dei dati utilizzata sia dall'app sia dal server.

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

In packages/shared/lib/shared.dart, esporta i modelli:

library shared;

export 'src/models.dart';

Nella directory packages/shared, esegui il generatore di build per generare il codice di serializzazione JSON:

dart run build_runner build

6. Configurare Cloud Functions for Firebase

Cloud Functions for Firebase è un framework serverless che ti consente di eseguire automaticamente il codice di backend senza dover gestire e scalare i tuoi server. Dart è una soluzione ideale perché viene compilato Ahead-of-Time (AOT) in un file binario e non richiede un ambiente di runtime pesante come Node.js o Java. Ciò riduce significativamente i tempi di avvio a freddo delle funzioni.

Vai alla radice del progetto e inizializza Cloud Functions for Firebase:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • Quando ti viene chiesto il linguaggio, seleziona Dart.

In functions/pubspec.yaml, aggiungi un percorso relativo al pacchetto condiviso:

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

7. Scrivere la funzione

Per scrivere la logica di backend, apri functions/bin/server.dart e sostituisci i contenuti con il seguente codice:

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. Testare localmente con Firebase Emulator Suite

Puoi eseguire sia il frontend sia il backend localmente senza eseguire il deployment.

Dalla radice del progetto, avvia 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

In pubspec.yaml, aggiungi un percorso relativo al pacchetto condiviso e aggiungi il pacchetto http:

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

Nel progetto Flutter, apri lib/main.dart e sostituisci i contenuti con il seguente codice. Questo codice frontend utilizza la stessa classe IncrementResponse del 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),
      ),
    );
  }
}

Esegui l'app Flutter. Quando fai clic sul pulsante di azione mobile, l'app chiama il backend Dart locale, recupera il nuovo conteggio e aggiorna l'interfaccia utente.

9. Eseguire il deployment in Firebase

Per eseguire il deployment del backend Dart, esegui il seguente comando:

firebase deploy --only functions

Dopo l'esecuzione del comando, copia l'URL e sostituisci FIREBASE_FUNCTIONS_URL_HERE nel codice sorgente dell'app Flutter che abbiamo aggiunto in precedenza.

10. Risoluzione dei problemi

firebase: command not found

Assicurati che l'interfaccia a riga di comando di Firebase sia installata e che il PATH sia aggiornato. Puoi installarla utilizzando npm: npm install -g firebase-tools.

Dart mancante dai modelli di funzioni di inizializzazione

Affinché Dart venga visualizzato come elenco di opzioni per il deployment e per creare il codice del modello quando esegui firebase init functions, è necessario impostare un flag di esperimento eseguendo firebase experiments:enable dartfunctions.

L'emulatore di funzioni non si connette

Verifica di utilizzare localhost e la porta 5001. Se stai eseguendo test su un emulatore Android, il dispositivo non risolve localhost sulla macchina host. Aggiorna la configurazione dell'emulatore in main.dart per utilizzare 10.0.2.2.

Il pacchetto condiviso non è stato trovato

Verifica il percorso relativo in functions/pubspec.yaml. Se la struttura delle cartelle è diversa da quella del codelab, modifica path: ../packages/shared in modo che punti alla directory corretta.

Devo utilizzare json_serializable?

Sebbene non sia strettamente necessario, l'utilizzo di json_serializable impedisce gli errori causati dalla scrittura manuale dei metodi fromJson e toJson. Garantisce che il frontend e il backend si aspettino esattamente lo stesso formato di dati.

11. Complimenti

Hai creato un'applicazione Dart full-stack. Mantenendo i modelli di dati in un pacchetto condiviso, ti assicuri che le risposte API e l'interfaccia utente del client rimangano sincronizzate, utilizzando un unico linguaggio di programmazione in tutto lo stack.