使用 Cloud Functions for Firebase 构建全栈 Dart 应用

1. 简介

在此 Codelab 中,您将构建一个多人计数器应用。您将学习如何将 Dart 用于 Flutter 前端和 Firebase 后端。

您还将了解如何在应用和服务器之间共享数据模型,从而无需重复编写逻辑。

学习内容

  • 将共享业务逻辑提取到独立的 Dart 软件包中。
  • 使用 Dart 原生编写和部署 Cloud Functions for Firebase。
  • 利用 Dart 的预先 (AOT) 编译来减少无服务器冷启动。
  • 使用 Firebase Emulator Suite 在本地测试您的堆栈。

2. 前提条件

  • Flutter SDK(最新稳定版)。
  • Firebase CLI(需要 v15.15.0 或更高版本)。
  • 一个代码编辑器,例如 Antigravity、Visual Studio Code、IntelliJ 或 Android Studio,并已安装 Dart 和 Flutter 插件。
  • 基本熟悉 Flutter 和 Firebase。

3. 为何使用 Dart 作为后端?

许多云应用使用 Dart 作为前端界面语言,并使用 TypeScript、Python 或 Go 等其他语言作为后端语言。这需要维护两组单独的数据模型。当数据库架构发生更改时,您必须更新这两个代码库。

注意:在后端使用 Dart 可让您将 Flutter 在客户端的响应式用户体验与服务器上的安全验证相结合,而无需重复编写代码。

4. 创建 Flutter 应用

创建标准 Flutter 应用:

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

在标准 Flutter 应用中,lib/main.dart 在本地处理计数器状态:

int _counter = 0;

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

这种方法适用于本地状态,但无法扩展到服务器必须充当可靠数据源的多人游戏应用。为了支持多位玩家,我们将按照以下步骤将此逻辑移至后端。

5. 创建共享软件包

为避免在前端和后端重复创建模型,请在项目代码库中创建一个共享 Dart 软件包。Flutter 应用和 Firebase 函数都依赖于此软件包。

my_counter 项目的根目录下,运行以下命令:

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

添加依赖项

packages/shared/pubspec.yaml 中,添加 JSON 序列化工具:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

定义共享模型

创建 packages/shared/lib/src/models.dart。此文件定义了应用和服务器所使用的数据结构。

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

packages/shared/lib/shared.dart 中,导出模型:

library shared;

export 'src/models.dart';

packages/shared 目录中,运行 build runner 以生成 JSON 序列化代码:

dart run build_runner build

6. 设置 Cloud Functions for Firebase

Cloud Functions for Firebase 是一种无服务器框架,您可以利用它自动运行后端代码,而无需管理和扩展自己的服务器。Dart 非常适合,因为它会预先 (AOT) 编译为二进制文件,不需要像 Node.js 或 Java 那样庞大的运行时环境。这可显著缩短函数的冷启动时间。

前往项目根目录,然后初始化 Cloud Functions for Firebase:

cd ../..
firebase experiments:enable dartfunctions
firebase init functions
dart pub add google_cloud_firestore
  • 当系统提示您选择语言时,请选择 Dart

functions/pubspec.yaml 中,添加共享软件包的相对路径:

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

7. 编写函数

如需编写后端逻辑,请打开 functions/bin/server.dart 并将内容替换为以下代码:

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. 使用 Firebase Emulator Suite 在本地进行测试

您可以在本地运行前端和后端,而无需进行部署。

从项目根目录中启动 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

pubspec.yaml 中,添加共享软件包的相对路径并添加 http 软件包:

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

在 Flutter 项目中,打开 lib/main.dart 并将其内容替换为以下代码。此前端代码使用与后端相同的 IncrementResponse 类。

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

运行 Flutter 应用。当您点击悬浮操作按钮时,应用会调用本地 Dart 后端,检索新计数并更新界面。

9. 部署到 Firebase

在此 Codelab 中,您能够使用 Firebase Local Emulator Suite 在没有 Firebase 项目或结算账号的情况下试用函数。如果您想在实际环境(例如生产环境)中使用函数,则需要设置 Firebase 项目和结算。

创建 Firebase 项目

  1. 使用您的 Google 账号登录 Firebase 控制台
  2. 点击相应按钮以创建新项目,然后输入项目名称。
  3. 点击继续
  4. 如果看到相关提示,请查看并接受 Firebase 条款,然后点击继续
  5. (可选)在 Firebase 控制台中启用 AI 辅助功能(称为“Gemini in Firebase”)。
  6. 在此 Codelab 中,您不需要使用 Google Analytics,因此请关闭 Google Analytics 选项。
  7. 点击创建项目,等待项目完成预配,然后点击继续

升级您的 Firebase 定价方案

如需使用本 Codelab 中的 Firebase 服务,您的 Firebase 项目必须采用随用随付 (Blaze) 定价方案,这意味着该项目与一个 Cloud Billing 账号相关联。

  • Cloud Billing 账号要求提供付款方式,例如信用卡。
  • 在特殊促销活动期间,或者如果您是在活动中完成此 Codelab,则可能会有 Google Cloud 赠金可用。
  • 如果您刚开始接触 Firebase 和 Google Cloud,请确认您是否有资格获得 $300 赠金和免费试用 Cloud Billing 账号

如需将项目升级到 Blaze 方案,请按以下步骤操作:

  1. 在 Firebase 控制台中,选择升级您的方案
  2. 选择 Blaze 方案。按照屏幕上的说明将 Cloud Billing 账号关联到您的项目。
    • 如果您在此 Codelab 中使用 Google Cloud 赠金,则结算账号可能名为 Google Cloud Platform Trial Billing AccountMy Billing Account
    • 如果您需要在此升级过程中创建 Cloud Billing 账号,则可能需要返回 Firebase 控制台中的升级流程以完成升级。

部署到 Firebase 项目

如需部署 Dart 后端,请使用 Firebase CLI 运行以下命令:

firebase use <PROJECT_ID>
firebase deploy --only functions

命令运行完毕后,复制该网址并替换我们之前添加的 Flutter 应用源代码中的 FIREBASE_FUNCTIONS_URL_HERE

10. 问题排查

firebase: command not found

确保已安装 Firebase CLI,并且 PATH 已更新。您可以使用 npm 安装它:npm install -g firebase-tools

初始函数模板中缺少 Dart

为了让 Dart 显示为可用于部署的选项列表,并在运行 firebase init functions 时创建模板代码,需要通过运行 firebase experiments:enable dartfunctions 来设置实验标志。

函数模拟器未连接

验证您是否使用了 localhost 和端口 5001。如果您在 Android 模拟器上进行测试,设备不会将 localhost 解析为宿主机。更新 main.dart 中的模拟器配置以使用 10.0.2.2

找不到共享的软件包

验证 functions/pubspec.yaml 中的相对路径。如果您的文件夹结构与 Codelab 不同,请调整 path: ../packages/shared 以指向正确的目录。

我是否需要使用 json_serializable

虽然不是严格要求,但使用 json_serializable 可防止因手动编写 fromJsontoJson 方法而导致的错误。它可确保前端和后端预期的数据格式完全相同。

11. 恭喜

您已成功构建一个全栈 Dart 应用。通过在共享软件包中维护数据模型,您可以确保 API 响应和客户端界面保持同步,并在整个堆栈中使用单一编程语言。