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 no front-end do Flutter e no 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, na sigla em inglês) 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 no back-end?

Muitos aplicativos na nuvem usam o Dart para a interface do usuário 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 Node.js ou 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. Quando você clica 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

Neste codelab, você pôde testar funções sem um projeto do Firebase ou uma conta de faturamento usando o Pacote de emuladores locais do Firebase. Se você quiser usar suas funções em um ambiente real (como um ambiente de produção), será necessário configurar um projeto do Firebase e o faturamento.

Criar um projeto do Firebase

  1. Faça login no console do Firebase usando sua Conta do Google.
  2. Clique no botão para criar um novo projeto e insira um nome para ele.
  3. Clique em Continuar.
  4. Se solicitado, leia e aceite os Termos do Firebase e clique em Continuar.
  5. (Opcional) Ative a assistência de IA no console do Firebase (chamada de "Gemini no Firebase").
  6. Neste codelab, você não precisa do Google Analytics. Portanto, desative a opção do Google Analytics.
  7. Clique em Criar projeto, aguarde o provisionamento do projeto e clique em Continuar.

Fazer upgrade do plano de preços do Firebase

Para usar os serviços do Firebase neste codelab, seu projeto do Firebase precisa estar no plano de preços de pagamento por uso (Blaze), o que significa que ele está vinculado a uma conta do Cloud Billing.

  • Uma conta do Cloud Billing exige uma forma de pagamento, como cartão de crédito.
  • Durante promoções especiais ou se você estiver fazendo este codelab como parte de um evento, pode haver créditos do Google Cloud disponíveis.
  • Se você ainda não conhece o Firebase e o Google Cloud, confira se tem qualificação para receber um crédito de US$300 e uma conta do Cloud Billing de teste sem custo financeiro.

Para fazer upgrade do projeto para o plano Blaze, siga estas etapas:

  1. No console do Firebase, selecione Fazer upgrade do seu plano.
  2. Selecione o plano Blaze. Siga as instruções na tela para vincular uma conta do Cloud Billing ao seu projeto.
    • Se você estiver usando créditos do Google Cloud para este codelab, a conta de faturamento provavelmente será chamada de Google Cloud Platform Trial Billing Account ou My Billing Account.
    • Se você precisou criar uma conta do Cloud Billing como parte desse upgrade, talvez seja necessário voltar para o fluxo de upgrade no console do Firebase para concluir o upgrade.

Implantar no seu projeto do Firebase

Para implantar o back-end do Dart, execute o seguinte comando usando a CLI do Firebase:

firebase use <PROJECT_ID>
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 está atualizado. É possível instalar usando npm: npm install -g firebase-tools.

Dart ausente dos modelos de funções init

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 emulador do Android, 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 de 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 seus 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.