使用 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

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

firebase deploy --only functions

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

10. 问题排查

firebase: command not found

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

初始化函数模板中缺少 Dart

如需在运行 firebase init functions 时将 Dart 显示为可用于部署的选项列表并创建模板代码,需要通过运行 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 响应和客户端界面保持同步,并在整个堆栈中使用单一编程语言。