Compila una app de Dart de pila completa con Cloud Functions para Firebase

1. Introducción

En este codelab, compilarás una app de contador multijugador. Aprenderás a usar Dart para el frontend de Flutter y el backend de Firebase.

También aprenderás a compartir modelos de datos entre tu app y tu servidor, lo que elimina la necesidad de duplicar la lógica.

Qué aprenderás

  • Extrae la lógica empresarial compartida en un paquete de Dart independiente.
  • Escribe e implementa Cloud Functions para Firebase de forma nativa en Dart.
  • Aprovecha la compilación Ahead-of-Time (AOT) de Dart para reducir los inicios en frío sin servidores.
  • Prueba tu pila de forma local con Firebase Emulator Suite.

2. Requisitos previos

  • SDK de Flutter (versión estable más reciente)
  • Firebase CLI (se requiere la versión 15.15.0 o posterior)
  • Un editor de código, como Antigravity, Visual Studio Code, IntelliJ o Android Studio, con los complementos de Dart y Flutter instalados
  • Conocimientos básicos de Flutter y Firebase

3. ¿Por qué usar Dart para el backend?

Muchas aplicaciones en la nube usan Dart para la IU del frontend y otro lenguaje, como TypeScript, Python o Go, para el backend. Esto requiere mantener dos conjuntos separados de modelos de datos. Cuando cambia un esquema de base de datos, debes actualizar ambas bases de código.

Nota: Usar Dart en el backend te permite combinar la experiencia del usuario responsiva de Flutter en el cliente con la validación segura en el servidor, sin duplicar código.

4. Crea la app de Flutter

Crea una app de Flutter estándar:

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

En una app de Flutter estándar, lib/main.dart controla el estado del contador de forma local:

int _counter = 0;

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

Este enfoque funciona para el estado local, pero no se adapta a una aplicación multijugador en la que el servidor debe actuar como fuente de verdad. Para admitir varios jugadores, trasladaremos esta lógica al backend en los siguientes pasos.

5. Crea el paquete compartido

Para evitar duplicar modelos en el frontend y el backend, crea un paquete compartido de Dart dentro del repositorio de tu proyecto. Tanto la app de Flutter como las funciones para Firebase dependen de este paquete.

Desde la raíz del proyecto my_counter, ejecuta los siguientes comandos:

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

Cómo agregar dependencias

En packages/shared/pubspec.yaml, agrega las herramientas de serialización de JSON:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

Define tus modelos compartidos

Crea packages/shared/lib/src/models.dart. Este archivo define la estructura de datos que usan tanto la app como el servidor.

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

En packages/shared/lib/shared.dart, exporta tus modelos:

library shared;

export 'src/models.dart';

En el directorio packages/shared, ejecuta el compilador para generar el código de serialización JSON:

dart run build_runner build

6. Configura Cloud Functions para Firebase

Cloud Functions para Firebase es un framework sin servidores que te permite ejecutar automáticamente el código de backend sin tener que administrar ni escalar tus propios servidores. Dart es una excelente opción porque se compila con anticipación (AOT) en un archivo binario y no requiere un entorno de ejecución pesado como Node.js o Java. Esto reduce significativamente los tiempos de inicio en frío de tus funciones.

Navega a la raíz de tu proyecto y, luego, inicializa Cloud Functions para Firebase:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • Cuando se te solicite el idioma, selecciona Dart.

En functions/pubspec.yaml, agrega una ruta relativa al paquete compartido:

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

7. Escribe la función

Para escribir la lógica de backend, abre functions/bin/server.dart y reemplaza el contenido por el siguiente código:

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. Realiza pruebas a nivel local con Firebase Emulator Suite

Puedes ejecutar el frontend y el backend de forma local sin realizar la implementación.

Desde la raíz de tu proyecto, inicia 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

En pubspec.yaml, agrega una ruta de acceso relativa al paquete compartido y agrega el paquete http:

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

En tu proyecto de Flutter, abre lib/main.dart y reemplaza su contenido por el siguiente código. Este código de frontend usa la misma clase IncrementResponse que el 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),
      ),
    );
  }
}

Ejecuta la app de Flutter. Cuando hagas clic en el botón de acción flotante, la app llamará al backend local de Dart, recuperará el nuevo recuento y actualizará la IU.

9. Cómo implementar en Firebase

Para implementar tu backend de Dart, ejecuta el siguiente comando:

firebase deploy --only functions

Después de ejecutar el comando, copia la URL y reemplaza FIREBASE_FUNCTIONS_URL_HERE en el código fuente de la app de Flutter que agregamos antes.

10. Solución de problemas

firebase: command not found

Asegúrate de que Firebase CLI esté instalado y de que tu PATH esté actualizado. Puedes instalarlo con npm: npm install -g firebase-tools.

Falta Dart en las plantillas de funciones de inicialización

Para que Dart aparezca como una lista de opciones para implementar y crear el código de plantilla cuando se ejecuta firebase init functions, se debe configurar una marca de experimento ejecutando firebase experiments:enable dartfunctions.

El emulador de Functions no se conecta

Verifica que estés usando localhost y el puerto 5001. Si realizas pruebas en un emulador de Android, el dispositivo no resuelve localhost en tu máquina host. Actualiza la configuración del emulador en main.dart para usar 10.0.2.2.

No se encontró el paquete compartido

Verifica la ruta relativa en functions/pubspec.yaml. Si la estructura de tu carpeta difiere de la del codelab, ajusta path: ../packages/shared para que apunte al directorio correcto.

¿Debo usar json_serializable?

Si bien no es estrictamente necesario, usar json_serializable evita errores causados por escribir manualmente los métodos fromJson y toJson. Garantiza que tu frontend y backend esperen exactamente el mismo formato de datos.

11. Felicitaciones

Creaste correctamente una aplicación de Dart de pila completa. Si mantienes tus modelos de datos en un paquete compartido, te aseguras de que las respuestas de la API y la IU del cliente permanezcan sincronizadas, con un solo lenguaje de programación en toda tu pila.