Criar um app Dart de pilha completa com o Cloud Functions para Firebase

1. Introdução

Neste codelab, você vai criar um app de contador multiplayer. Você vai aprender a usar o Dart para o front-end do Flutter e o back-end do Firebase.

Você também vai aprender a compartilhar modelos de dados entre o app e o servidor, eliminando a necessidade de duplicar a lógica.

O que você vai aprender

  • Extrair a lógica de negócios compartilhada em um pacote Dart independente.
  • Escrever e implantar o Cloud Functions para Firebase nativamente no Dart.
  • Aproveitar a compilação Ahead-of-Time (AOT) do Dart para reduzir as inicializações a frio sem servidor.
  • Testar sua pilha localmente usando o Pacote de emuladores do Firebase.

2. Pré-requisitos

  • SDK do Flutter (versão estável mais recente).
  • CLI do Firebase (é necessário a versão 15.15.0 ou mais recente).
  • Um editor de código, como Antigravity, Visual Studio Code, IntelliJ ou Android Studio, com os plug-ins Dart e Flutter instalados.
  • Conhecimento básico do Flutter e do Firebase.

3. Por que usar o Dart para o back-end?

Muitos aplicativos na nuvem usam o Dart para a interface do front-end e outra linguagem, como TypeScript, Python ou Go, para o back-end. Isso exige a manutenção de dois conjuntos separados de modelos de dados. Quando um esquema de banco de dados muda, é necessário atualizar os dois códigos.

Observação: o uso do Dart no back-end permite combinar a experiência do usuário responsiva do Flutter no cliente com a validação segura no servidor, sem duplicar o código.

4. Criar o app do Flutter

Crie um app do Flutter padrão:

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

Em um app do Flutter padrão, lib/main.dart processa o estado do contador localmente:

int _counter = 0;

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

Essa abordagem funciona para o estado local, mas não é escalonável para um aplicativo multiplayer em que o servidor precisa atuar como a fonte da verdade. Para oferecer suporte a vários jogadores, vamos mover essa lógica para o back-end nas etapas a seguir.

5. Criar o pacote compartilhado

Para evitar a duplicação de modelos no front-end e no back-end, crie um pacote Dart compartilhado no repositório do projeto. O app do Flutter e as funções do Firebase dependem desse pacote.

Na raiz do projeto my_counter, execute os seguintes comandos:

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

Adicionar dependências

Em packages/shared/pubspec.yaml, adicione as ferramentas de serialização JSON:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

Definir seus modelos compartilhados

Crie packages/shared/lib/src/models.dart. Esse arquivo define a estrutura de dados usada pelo app e pelo 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';

Em packages/shared/lib/shared.dart, exporte seus modelos:

library shared;

export 'src/models.dart';

No diretório packages/shared, execute o build runner para gerar o código de serialização JSON:

dart run build_runner build

6. Configurar o Cloud Functions para Firebase

O Cloud Functions para Firebase é um framework sem servidor que permite executar automaticamente o código de back-end sem precisar gerenciar e escalonar seus próprios servidores. O Dart é uma ótima opção porque ele é compilado Ahead-of-Time (AOT) em um binário e não exige um ambiente de execução pesado como o Node.js ou o Java. Isso reduz significativamente os tempos de inicialização a frio das funções.

Navegue até a raiz do projeto e inicialize o Cloud Functions para Firebase:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • Quando solicitado o idioma, selecione Dart.

Em functions/pubspec.yaml, adicione um caminho relativo ao pacote compartilhado:

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

7. Escrever a função

Para escrever a lógica de back-end, abra functions/bin/server.dart e substitua o conteúdo pelo seguinte 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. Testar localmente com o Pacote de emuladores do Firebase

É possível executar o front-end e o back-end localmente sem implantação.

Na raiz do projeto, inicie o Pacote de emuladores do Firebase:

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

Em pubspec.yaml, adicione um caminho relativo ao pacote compartilhado e adicione o pacote http:

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

No projeto do Flutter, abra lib/main.dart e substitua o conteúdo pelo seguinte código. Esse código de front-end usa a mesma classe IncrementResponse do back-end.

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

Execute o app do Flutter. Ao clicar no botão de ação flutuante, o app chama o back-end local do Dart, recupera a nova contagem e atualiza a interface.

9. Implantar no Firebase

Para implantar o back-end do Dart, execute o seguinte comando:

firebase deploy --only functions

Depois que o comando for executado, copie o URL e substitua FIREBASE_FUNCTIONS_URL_HERE no código-fonte do app do Flutter que adicionamos anteriormente.

10. Solução de problemas

firebase: command not found

Verifique se a CLI do Firebase está instalada e se o PATH foi atualizado. É possível instalá-la usando o npm: npm install -g firebase-tools.

O Dart está ausente dos modelos de funções de inicialização

Para que o Dart apareça como uma lista de opções para implantação e crie o código do modelo ao executar firebase init functions, uma flag de experimento precisa ser definida executando firebase experiments:enable dartfunctions.

O emulador de funções não está conectando

Verifique se você está usando localhost e a porta 5001. Se você estiver testando em um Android Emulator, o dispositivo não resolverá localhost na máquina host. Atualize a configuração do emulador em main.dart para usar 10.0.2.2.

O pacote compartilhado não foi encontrado

Verifique o caminho relativo em functions/pubspec.yaml. Se a estrutura de pastas for diferente do codelab, ajuste path: ../packages/shared para apontar para o diretório correto.

Preciso usar json_serializable?

Embora não seja estritamente necessário, o uso de json_serializable evita erros causados pela gravação manual dos métodos fromJson e toJson. Ele garante que o front-end e o back-end esperem o mesmo formato de dados.

11. Parabéns

Você criou um aplicativo Dart de pilha completa. Ao manter os modelos de dados em um pacote compartilhado, você garante que as respostas da API e a interface do cliente permaneçam sincronizadas, usando uma única linguagem de programação em toda a pilha.