T
traeai
登录
返回首页
freeCodeCamp.org

如何使用 Dart Cloud Functions 与 Firebase Admin SDK:开发者手册

7.8Score
如何使用 Dart Cloud Functions 与 Firebase Admin SDK:开发者手册

TL;DR · AI 摘要

Firebase Cloud Functions 现已实验性支持 Dart,Flutter 开发者可使用同一语言编写前后端逻辑,通过 Dart Admin SDK 统一数据模型与业务逻辑,显著降低全栈开发摩擦。

核心要点

  • Firebase Cloud Functions 现支持实验性 Dart 运行时,可与 Flutter 共享数据模型与验证逻辑。
  • Dart Admin SDK 支持 Firestore、Authentication、Cloud Storage 等核心 Firebase 服务,CLI 部署流
  • 需使用 Blaze 计划(按量付费)才能部署生产级函数;本地开发可用 Local Emulator Suite 免费测试。

结构提纲

按章节快速跳转。

  1. Flutter 开发者长期面临前后端语言割裂(Dart vs TypeScript)导致的模型同步、类型不一致、调试困难等结构性摩擦。

  2. Firebase 宣布实验性支持 Dart Cloud Functions,搭配 Dart Admin SDK,实现前后端语言统一与逻辑复用。

  3. 本文是面向工程团队的完整指南,非快速入门,旨在帮助团队评估并落地生产级 Dart 全栈架构。

  4. 需具备 Flutter/Dart 熟练度、Firebase 基础知识、命令行能力,并使用 Blaze 计划部署生产函数。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Dart Cloud Functions + Firebase Admin SDK 全栈指南
    • 核心价值
      • 语言统一:Dart 前后端一致
      • 模型复用:共享包消除重复定义
    • 技术组件
      • Dart Cloud Functions
      • Dart Admin SDK
      • Firebase CLI 部署
    • 落地前提
      • Blaze 计划(生产部署)
      • 本地 Emulator Suite(免费测试)

金句 / Highlights

值得收藏与分享的关键句。

  • 你整天在前端用表达力强、空安全、强类型的 Dart 编码;但一打开新标签页写 Cloud Function,就掉进 TypeScript 文件,重复声明刚在 Dart 中定义的 `User` 模型。

    第 1 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 开发者多年呼吁的统一 Dart 全栈梦想,如今正式落地。

    第 3 段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 你可在后端使用与前端相同的语言,通过共享 Dart 包复用数据模型与验证逻辑,并用熟悉的 `firebase` CLI 部署服务端代码。

    第 3 段

    ⬇︎ 下载 PNG𝕏 分享到 X
#Dart#Firebase#Cloud Functions#Flutter#Serverless
打开原文
图1:如何使用 Dart 云函数与 Firebase Admin SDK:开发者手册

每位曾尝试编写后端的 Flutter 开发者,都曾经历过一种特定的“摩擦感”。你整天在前端用表达力强、空安全、强类型的 Dart 代码进行开发;你的数据模型清晰简洁;async/await 异步流程读起来如同散文;类型系统甚至在代码运行前就捕获了大量潜在的错误类型。但当你打开一个新标签页去编写云函数时,却突然发现自己身处一个 TypeScript 文件中——你不得不重新声明刚刚在 Dart 中定义好的同一个 User 模型,手动维持两份代码的一致性,并调试一个 cannot read property of undefined(无法读取未定义属性)的错误,而这类问题本可在毫秒内被 Dart 编译器发现。

这种“摩擦”并非小麻烦,而是对希望掌控全栈的 Flutter 开发者施加的一种结构性负担:你必须维护两套代码库、两种语言、两种并发模型、两种类型系统、两种包生态系统以及两套工具链。每次对共享数据结构的修改都需要同步编辑两处;客户端与服务端之间数据契约的任何 bug,都迫使你在不同语言之间来回切换思维上下文进行排查。许多使用 Firebase 构建后端的 Flutter 团队,甚至专门雇佣后端开发者,只因 JavaScript 带来的认知负担实在过于沉重。

如今,这一局面终于改变了。Firebase 的云函数(Cloud Functions)已宣布支持 Dart(实验性功能),并同步推出了实验性的 Dart Admin SDK,让你能在函数代码中直接与 Firestore、Authentication、Cloud Storage 等 Firebase 服务进行交互。你可以在后端使用与前端相同的语言——共享数据模型与验证逻辑(通过一个双方共同引用的通用 Dart 包实现),并使用你早已熟悉的 firebase CLI 工具部署服务端代码。开发者们多年来一直期盼的“统一 Dart 全栈”梦想,如今正式落地。

本手册是一份面向该统一技术栈的完整工程指南,内容涵盖:

  • Dart 云函数的工作原理;
  • 与 Node.js 函数在架构与部署上的差异;
  • Admin SDK 如何将你的函数与 Firebase 服务连接;
  • 如何借助通用 Dart 包在 Flutter 应用与后端之间共享逻辑;
  • 如何从 Flutter 客户端调用这些函数;
  • 以及在将实验性功能用于生产环境前,你必须了解的所有当前限制。

这不是一篇五分钟快速入门教程,而是为那些正在评估是否、以及如何基于 Dart 构建真实产品的团队量身定制的权威指南。

阅读完毕后,你将:

  • 从原理层面理解全栈 Dart 架构;
  • 掌握设置、编写、本地模拟与部署 Dart 云函数的全流程;
  • 理解 Admin SDK 的能力边界;
  • 构建一个可消除数据模型重复的共享包;
  • 并基于充分认知,对实验性功能何时适用于你的生产场景做出清醒判断。

目录

前置要求

在开始本手册的学习前,请确保你已具备以下基础。本指南不预设你具备云基础设施专业知识,但会持续基于 Flutter 与 Firebase 的相关知识展开。

Flutter 与 Dart 熟练度 你需要能熟练编写多文件 Dart 应用,掌握 async/awaitFuture,理解 Dart 的空安全机制,并能使用 pub 管理依赖包。由于文末的端到端示例会从 Flutter 客户端调用函数,因此期望你具备 Flutter 应用开发经验(若你已上线过任意 Flutter 应用,即可满足要求)。

Firebase 基础知识 你需要有使用 Firebase 的经验:例如在 Firebase 控制台中创建项目、通过 FlutterFire CLI 将其与 Flutter 应用连接,并最好使用过至少一种 Firebase 服务(如 Firestore 或 Authentication)。无需具备云函数经验,但若熟悉“无服务器函数”的概念,将有助于理解。

命令行操作能力 整个 Dart 云函数工作流均在终端中完成。你需要能熟练执行命令、阅读终端输出,并通过命令行浏览文件系统。

计费方案意识 将任意类型的云函数部署至生产环境,均需你的 Firebase 项目启用 Blaze(按需付费)计费方案。Firebase 本地模拟器套件(Local Emulator Suite)允许你在无计费账户的情况下进行开发与测试,因此你可以基本免费地在本地完成本指南的大部分内容。但请注意:部署阶段必须启用 Blaze 方案

必备工具准备 请在开始前确保以下工具已安装并可通过终端访问:

  • Flutter SDK 3.x 或更高版本(其中已包含 Dart SDK 3.x)
  • Firebase CLI 15.15.0 或更高版本(运行 firebase --version 检查版本;可通过 npm install -g firebase-tools 更新)
  • Node.js 18 或更高版本(Firebase CLI 所需,与你的 Dart 代码无关)
  • 安装了 Dart 插件的代码编辑器(如 VS Code + Dart 扩展,或 Android Studio)
  • 在 Firebase 控制台中创建好的 Firebase 项目

本指南所用依赖包 你的函数目录中的 pubspec.yaml 文件应包含以下依赖:

code
dependencies:
  firebase_functions: ^0.1.0
  google_cloud_firestore: ^0.1.0

firebase_functions 是核心的 Dart 包,它提供了 fireUponRequestonCall 的注册 API,以及函数代码中使用的各类类型定义。google_cloud_firestore 是一个独立的 Dart Firestore SDK,专用于 Cloud Functions 的服务端环境。它与你在 Flutter 应用中使用的 cloud_firestore 包并非同一包。两者虽均访问 Firestore,但它们是为不同运行环境设计的两个不同库:前者面向运行于 Firebase 安全规则之下的 Flutter 客户端,后者则面向拥有完整管理员权限的服务端进程。

你的共享包(稍后将深入介绍)将不包含任何 Firebase 相关依赖。你 Flutter 应用的 pubspec.yaml 文件将继续使用现有的标准 firebase_corecloud_firestore 等 FlutterFire 包。

关于此功能实验性质的重要说明:本指南所依据的内容,源自 Google Cloud Next 2026 上宣布的 Dart 实验性支持。所谓“实验性”,意味着 API 可能随时发生变更,部分 Node.js 函数中已有的功能尚未在 Dart 中实现,且 Firebase 控制台目前尚不支持显示 Dart 函数——你需通过 Google Cloud 控制台中的 Cloud Run 函数页面来查看和管理它们。这确实是一片全新领域,相关团队正在积极开发中。本指南将在遇到每一项限制时明确标注,确保你始终清楚当前能力边界。

什么是 Cloud Functions?为何 Dart 的加入彻底改变了局面?

Cloud Functions 是什么

Firebase Cloud Functions 是一个无服务器(Serverless)计算平台。“无服务器”意味着你只需编写函数、部署它,其余一切(包括服务器、自动扩缩容、负载均衡、操作系统更新及高可用性保障)均由 Google 负责管理。你仅需为函数实际使用的计算时间付费(按毫秒级计费),而你的函数能自动从零请求扩展至百万级请求,无需你进行任何基础设施配置。

其核心价值十分明确:若不使用 Cloud Functions,为 Flutter 应用添加后端逻辑通常意味着两种选择——要么自行运维服务器(成本高昂、管理复杂),要么将业务逻辑硬编码进客户端(存在安全隐患,且更新需依赖应用商店审核)。Cloud Functions 则为你提供了一个轻量、安全、可扩展的后端层,可独立于客户端进行更新,并能以客户端所不具备的高级权限访问所有 Firebase 服务。

在 Dart 支持推出前,编写 Cloud Functions 的语言选项仅有 JavaScript、TypeScript、Python、Java、Go 和 Ruby。对 Flutter 开发者而言,这意味着必须切换出 Dart 生态,学习新语言的工具链与周边生态,并在客户端与服务端之间重复实现共享逻辑。如今 Dart 已加入该列表,而由于你的 Flutter 应用本身即为 Dart 编写,其影响深远。

统一技术栈:实际发生了哪些变化

最直观的变化是语言:你现在编写的是 .dart 文件,而非 .ts.py 文件。但更深层的变化在于代码复用

在 TypeScript + Flutter 架构中,你的 User 模型实际上存在两份:一份在服务端的 TypeScript 中定义 Firestore 文档结构及函数返回值;另一份在客户端的 Dart 中定义 Flutter 应用如何解析与展示用户数据。当字段变更时,你需要同步更新两处;一旦开发者遗漏更新其中一处,Bug 便悄然诞生。此类 Bug 常在开发阶段难以察觉(因服务端与客户端通常独立构建与测试),往往仅在集成测试或生产环境中才暴露。

而在全栈 Dart 架构下,你的 User 模型仅存在于一个共享的 Dart 包中,供函数与 Flutter 应用共同引用。你只需在一处修改,两侧即刻同步生效。Dart 分析器会强制校验两侧对类型的正确使用:字段重命名只需一次重构操作,IDE 会自动在整个代码库中同步重命名,并由编译器验证结果。

图 2:实际架构变化示意图

该图展示了核心架构差异:左侧,技术栈两端各自独立定义 User,导致一方变更无法自动触发另一方同步;右侧,两端均从单一 shared 包导入模型,模型仅定义一次。Dart 编译器同时校验两侧的使用,使模型漂移在结构上不可能发生,而非仅靠人工谨慎规避。

为何 Dart 特别契合无服务器模型

Dart 是一种提前编译(AOT)语言,即在运行前即编译为原生二进制代码,而非在运行时解释执行。这一特性直接影响无服务器函数中最受关注的问题之一:冷启动(Cold Start)。

冷启动指函数长时间空闲后,有新请求到达时,平台需启动全新实例。若该过程需加载重量级运行时(如 Node.js)或虚拟机(如 Java),首次请求的响应时间可能长达数秒。相比之下,Dart 函数编译为无运行时开销的原生二进制文件,其冷启动时间显著低于同等规模的 Node.js 或 Python 函数,因此更适用于对首请求延迟敏感的场景。

部署流程体现了这一架构设计。当你部署一个 Dart 函数时,Firebase CLI 并不会像 Node.js 部署那样将源代码上传至云端进行编译;而是先在你的开发机器上将 Dart 代码编译为原生二进制文件,再将该二进制文件直接上传至 Cloud Run。这意味着你的开发机器需安装 Dart SDK(如果你正在开发 Flutter,通常已具备该环境),同时也意味着在生产环境中运行的二进制文件与你在本地测试的版本完全一致。

此方案解决的问题:Dart 上服务端之前的开发体验

Flutter 团队面临的“语言税”

在该功能推出前,若 Flutter 团队希望构建后端服务,就必须在组织层面做出艰难抉择:

  • 聘请熟悉 TypeScript 或 Python 的后端开发者,从而在代码库中长期维持两种语言的割裂状态;
  • 要求 Flutter 开发者深入学习 TypeScript 或 Python,以编写生产级后端代码——这需要大量时间投入,且最终产出的后端代码往往由非后端语言专家编写;
  • 或干脆放弃自定义后端,试图将整个产品功能强行塞进 Firebase 客户端 SDK 所能支持的范围内——这有时意味着必须将敏感业务逻辑置于客户端,使其可被用户读取甚至篡改。

上述任一选择都非理想方案,每一种都会持续带来开发效率、代码质量或产品完整性的损耗。

数据契约问题

除了语言切换之外,Flutter 客户端与 TypeScript 后端之间的数据契约也需手动维护。每次客户端与服务端之间的 API 调用,都依赖双方共同约定的数据结构。实践中,通常出现以下几种情况:

  • 合约仅以 README 文档形式记录,但很快过时;
  • 通过共享 OpenAPI 或 protobuf 模式文件强制约束合约,但这引入了大量工具链复杂性;
  • 或合约为非正式约定,导致问题只能在集成测试阶段甚至更糟——在生产环境中才被发现。

而 Dart 的类型系统在调用双方间共享,从结构上消除了这一问题:契约即 Dart 类型本身,Dart 编译器会同时在两端强制执行该契约,无需维护 README,也无需生成额外的 schema。

工具链鸿沟

使用 Dart 的 Flutter 开发者享有丰富且集成的开发体验:强大的静态分析工具、热重载、优秀的 IDE 插件支持、dart fix 自动化代码修复,以及覆盖绝大多数常见需求的 pub.dev 包生态。然而,当这些开发者转向 TypeScript 编写后端代码时,便不得不离开熟悉的工具环境,进入另一套需要独立配置格式化工具、lint 工具、依赖管理等的体系。这种认知负担是真实存在的,尤其对那些每位开发者身兼多职的团队而言,更成为持续的摩擦源。

而借助 Dart 服务端函数,dart analyzedart formatdart pub 等命令在 Flutter 应用与 Cloud Functions 代码中均可通用,IDE 插件同样适用,团队知识也能无缝复用。

Dart Cloud Functions 的工作原理:核心架构

入口点与 fireUp

每个 Dart Cloud Function 均始于一个入口文件,默认为 functions/bin/server.dart。其中的 main 函数调用 fireUp,该函数由 firebase_functions 包提供,用于初始化服务。fireUp 会建立 HTTP 服务器以接收请求,并将其路由至对应处理函数;自动使用 Google 应用默认凭据(Application Default Credentials)初始化 Firebase Admin SDK;并监听正确的端口以开始处理请求。

code
// functions/bin/server.dart

import 'package:firebase_functions/firebase_functions.dart';

void main(List<String> args) async {
  await fireUp(args, (firebase) {
    firebase.https.onRequest(
      name: 'helloWorld',
      options: const HttpsOptions(cors: Cors(['*'])),
      (request) async {
        return Response.ok('Hello from Dart Cloud Functions!');
      },
    );
  });
}

fireUpfirebase_functions 包提供的运行时引导函数。其第一个参数 args 是 Cloud Functions 环境在启动二进制文件时传入的命令行参数列表,其中包含监听端口及其他运行时配置信息。fireUp 会解析这些参数,并用于配置底层的 Shelf HTTP 服务器。第二个参数是一个回调函数,接收一个 firebase 对象作为参数,该对象是你访问 Cloud Functions 运行时所有能力的入口。在该回调内部,你将注册所有函数。firebase.https 暴露了两种注册方法:onRequest 用于原始 HTTP 函数,onCall 用于可调用函数。name 参数是该函数的唯一标识符,会显示在 Cloud Run 日志中,并用于请求路由。HttpsOptions 中的 cors: Cors(['*']) 表示允许来自任意域的跨域请求,这在开发阶段是合适的,但在生产环境中应限制为特定域名。Response.ok(...) 返回一个 HTTP 200 状态码及指定的响应体文本。

使用 onRequest 实现 HTTP 函数

HTTP 函数用于响应原始 HTTP 请求,是灵活性最高的函数类型:你可以完全控制请求与响应,包括检查请求头、解析任意格式的请求体,以及返回任意 HTTP 状态码与响应体。

code
firebase.https.onRequest(
  name: 'getUserProfile',
  options: const HttpsOptions(
    cors: Cors(['https://yourapp.com', 'https://staging.yourapp.com']),
    minInstances: 0,
  ),
  (request) async {
    if (request.method != 'GET') {
      return Response(405, body: 'Method not allowed');
    }

    final userId = request.url.queryParameters['userId'];

    if (userId == null || userId.isEmpty) {
      return Response(400, body: 'userId query parameter is required');
    }
dart
    try {
      final doc = await firebase.adminApp
          .firestore()
          .collection('users')
          .doc(userId)
          .get();

      if (!doc.exists) {
        return Response(404, body: 'User not found');
      }

      return Response.ok(
        jsonEncode(doc.data()),
        headers: {'content-type': 'application/json'},
      );
    } catch (e) {
      return Response.internalServerError(body: 'Failed to fetch user profile');
    }
  },
);

cors: Cors([...]) 显式列出了允许从浏览器调用该函数的域名。在生产环境中,将此限制为实际应用的域名可防止其他网站代表您的用户向后端发起请求。minInstances: 0 表示不保留任何常驻实例,因此函数在一段空闲时间后可能会经历冷启动。将其设置为 1 或更高值可使实例始终处于运行状态,从而消除冷启动,但即使没有请求处理时也会产生费用。request.method 是传入请求的 HTTP 动词,此处用于强制要求该端点仅接受 GET 请求。request.url.queryParameters 提供解析后的查询字符串,以 Map<String, String> 形式表示。Response(405, ...) 构造一个具有特定状态码的 HTTP 响应。Response.ok(...) 是用于构造 200 响应的便捷构造器。headers: {'content-type': 'application/json'} 告知调用方响应体为 JSON 格式,这对使用内容协商的任何客户端都至关重要。Response.internalServerError(...) 返回 500 状态码,此处在 catch 块中使用,以避免向调用方暴露内部错误详情。

使用 `onCall` 实现可调用函数

可调用函数是一种特殊的 HTTP 函数,专为直接从 Firebase 客户端 SDK 调用而设计。与原始 HTTP 函数不同,可调用函数会自动处理 Firebase 身份验证上下文:如果调用客户端已登录用户,函数将自动接收该用户的 UID 和令牌声明,而无需您手动解析 Authorization 请求头。

code
firebase.https.onCall(
  name: 'createPost',
  options: const CallableOptions(
    cors: Cors(['*']),
  ),
  (request, response) async {
    if (request.auth == null) {
      throw FirebaseFunctionsException(
        code: 'unauthenticated',
        message: 'You must be signed in to create a post.',
      );
    }

    final uid = request.auth!.uid;

    final data = request.data as Map<String, dynamic>;
    final title = data['title'] as String?;
    final content = data['content'] as String?;

    if (title == null || title.trim().isEmpty) {
      throw FirebaseFunctionsException(
        code: 'invalid-argument',
        message: 'Post title is required.',
      );
    }

    if (content == null || content.trim().isEmpty) {
      throw FirebaseFunctionsException(
        code: 'invalid-argument',
        message: 'Post content is required.',
      );
    }

    final postRef = await firebase.adminApp
        .firestore()
        .collection('posts')
        .add({
      'title': title.trim(),
      'content': content.trim(),
      'authorId': uid,
      'createdAt': FieldValue.serverTimestamp(),
    });

    return CallableResult({'postId': postRef.id, 'success': true});
  },
);

request.auth 由 Firebase Functions 运行时自动填充,前提是调用客户端在请求中包含有效的 Firebase 身份验证 ID 令牌。若调用方未通过身份验证,则 request.authnull。检查 null 并抛出带有 'unauthenticated' 错误码的 FirebaseFunctionsException 是拒绝未认证调用方的正确做法。此处 FirebaseFunctionsException 至关重要,因为当您在可调用函数中抛出该异常时,Firebase Functions 运行时会捕获它,并向客户端发送结构化错误响应;Flutter 客户端 SDK 可将其解析为类型化的 FirebaseFunctionsException 对象,从而实现跨边界机器可读的错误码,而无需解析原始 HTTP 错误响应体。request.auth!.uid 是已登录用户的已验证 Firebase 身份验证 UID,由于运行时已验证令牌,因此可安全用于授权决策。request.data 是 Flutter 客户端发送的请求体载荷,已反序列化为 Map<String, dynamic>CallableResult(...) 将返回值封装为可调用协议所期望的格式,Flutter 客户端会将其接收为 HttpsCallableResult.data

当前限制:您必须了解的内容

这是本手册中最重要的部分,在做出架构决策前必须仔细阅读

仅支持部署 onRequest onCall 类型的函数。 后台触发器(如 Firestore 文档触发器、身份验证触发器、Pub/Sub 触发器、Cloud Storage 触发器及定时任务函数)可在本地模拟器中用于开发测试,但在当前实验性发布版本中无法部署至生产环境。若您的架构依赖于 Firestore 文档创建时触发的函数,则目前仍需将该触发器保留在 Node.js 函数中,并仅在 Dart 中编写不依赖后台触发器的业务逻辑。

`httpsCallable` 无法通过函数名调用 Dart 可调用函数。 标准 Firebase 客户端 SDK 方法 FirebaseFunctions.instance.httpsCallable('functionName') 通过函数在服务器上的名称进行识别,但当前版本中该识别机制不适用于 Dart 函数。取而代之的是,您必须使用 httpsCallableFromURL 并传入部署函数时获得的完整 Cloud Run URL。这一差异显著影响 Flutter 客户端的配置方式,是重要的工作流区别。

Firebase 控制台不会显示 Dart 函数。 当你部署一个 Dart 函数后,再打开 Firebase 控制台的 Functions(函数)部分时,你将看不到该函数。你必须前往 Google Cloud 控制台中的 Cloud Run 函数页面,才能查看、管理和监控已部署的 Dart 函数。这是工具链方面的一个缺口,随着该功能从实验性状态逐步成熟,预计未来会得到弥补。

图 3:当前 Dart Cloud Functions 支持矩阵示意图

在规划架构时,此表格是最关键的参考依据。在决定为任何依赖于“否”(No)列所列触发器类型的函数使用 Dart 之前,请务必仔细阅读“已上线生产环境”(Deployed to Production)一栏。在部署阶段才发现限制并重新设计,远比在设计初期就了解并规避这些限制要痛苦得多。

Firebase Admin SDK 是什么

Firebase Admin SDK 是一组服务端库,允许你的函数代码以提升的权限与 Firebase 服务进行交互。你 Flutter 应用使用的客户端 SDK 受 Firebase 安全规则(Security Rules)约束:用户只能读取其被授权访问的文档,只能修改其被允许更改的字段,等等。而 Admin SDK 则完全绕过安全规则,以完全管理员权限访问你的 Firebase 项目。

正因如此,Admin SDK 的代码绝不能在客户端运行。它仅在安全的服务端环境中运行(例如 Cloud Functions、Cloud Run 或你自己的服务器),此时授予管理员权限的凭据受到妥善保护。在 Cloud Functions 中,Admin SDK 会自动使用函数关联的服务账号进行初始化,你无需额外配置。

Cloud Functions 中的自动初始化

当你的 Dart 函数在 Cloud Functions 环境中运行时,Admin SDK 会自动使用 Google 应用默认凭据(Application Default Credentials)进行初始化。这些凭据即为函数所绑定的服务账号,它拥有对你的 Firebase 项目的管理员访问权限。你无需手动配置凭据、加载服务账号的 JSON 文件,也无需调用任何初始化函数——它开箱即用。

code
await fireUp(args, (firebase) {
  firebase.https.onRequest(
    name: 'adminExample',
    (request) async {
      final sensitiveDoc = await firebase.adminApp
          .firestore()
          .collection('admin_only')
          .doc('config')
          .get();

      return Response.ok(jsonEncode(sensitiveDoc.data()));
    },
  );
});

firebase.adminApp 是已预先初始化好的 Admin SDK 实例。它在 fireUp 回调函数内部立即可用,因为 fireUp 会在你的回调执行前完成初始化工作——它使用的是 Cloud Run 附加到函数执行环境的服务账号。firebase.adminApp.firestore() 返回一个具有完整管理员权限的 Firestore 实例,可绕过数据库中所有的安全规则。collection('admin_only').doc('config').get() 可读取一个普通客户端 SDK 用户永远无法访问的集合中的文档(因为对应的安全规则会阻止他们访问)。而 Admin SDK 没有此类限制。这正是服务端代码的力量与责任所在:它能读写任意数据,正因如此,它绝不能在客户端运行。

使用 Admin SDK 进行 Firestore 操作

Dart Admin SDK 提供了完整的 Firestore API,涵盖读取、写入、更新、删除、查询及批量操作。其结构与客户端的 cloud_firestore Flutter 包非常相似,因此上手非常直观,尽管二者并不完全相同。

code
// 读取单个文档
final docRef = firebase.adminApp
    .firestore()
    .collection('posts')
    .doc(postId);

final snapshot = await docRef.get();

if (!snapshot.exists) {
  return Response(404, body: 'Post not found');
}

final data = snapshot.data()!;
final title = data['title'] as String;
final authorId = data['authorId'] as String;

firebase.adminApp.firestore().collection('posts').doc(postId) 构建一个指向特定文档的引用,但不执行任何网络调用。该引用是一个轻量级对象,仅描述 Firestore 中的路径。真正的网络请求发生在 .get() 调用时。它返回一个 DocumentSnapshot 对象,其 .exists 属性可判断该 ID 对应的文档是否存在。snapshot.data() 返回文档字段组成的 Map<String, dynamic>?,若文档不存在则为 null。此处 data() 后的 ! 是空值断言操作符,由于上一行已检查 .exists,因此此处使用是安全的。data['title'] as String 则以期望的 Dart 类型提取对应字段。

code
// 使用服务器生成的 ID 写入新文档
final newPostRef = await firebase.adminApp
    .firestore()
    .collection('posts')
    .add({
  'title': 'My Post',
  'authorId': uid,
  'createdAt': FieldValue.serverTimestamp(),
});

final newPostId = newPostRef.id;

.add({...}) 会在集合中创建一个新文档,并让 Firestore 自动生成一个随机唯一 ID。它返回一个指向新文档的 DocumentReferencenewPostRef.id 可获取该自动生成的 ID,通常你会将其返回给客户端,以便客户端跳转或引用该新文档。FieldValue.serverTimestamp() 是一个特殊值,它告诉 Firestore:在写入操作提交时,用服务器当前时间戳替换该字段,而非使用客户端或函数代码中的本地时钟。这确保了时间戳始终准确,不受系统时钟差异影响。

code
// 更新现有文档中的特定字段
await firebase.adminApp
    .firestore()
    .collection('posts')
    .doc(postId)
    .update({
  'likeCount': FieldValue.increment(1),
  'lastModified': FieldValue.serverTimestamp(),
});

.update({...}) 仅修改您明确指定的字段,而文档中的其他字段保持不变。当您只想更新部分字段时,这是正确的操作方式。相比之下,.set({...}) 会用您提供的字段完全替换整个文档,删除所有未包含在内的字段。FieldValue.increment(1) 是另一个 Firestore Sentinel 值,用于以原子方式将数值字段增加指定的量。由于 Firestore 在服务端以原子方式处理该增量操作,因此它能安全应对并发写入,避免了您先读取当前值、在函数中加一、再写回结果时可能发生的竞态条件。

code
// 使用过滤条件与排序进行查询
final querySnapshot = await firebase.adminApp
    .firestore()
    .collection('posts')
    .where('authorId', isEqualTo: uid)
    .orderBy('createdAt', descending: true)
    .limit(10)
    .get();

final posts = querySnapshot.docs.map((doc) {
  return {'id': doc.id, ...doc.data()};
}).toList();

.where('authorId', isEqualTo: uid) 将查询结果限定为仅返回 authorId 字段等于给定 uid 的文档。您可以通过多次链式调用 .where() 来添加更多过滤条件。.orderBy('createdAt', descending: true)createdAt 字段对结果排序,最新发布的排在最前。当您对某个字段使用 orderBy 时,Firestore 要求该字段必须建立索引;对于简单查询,Firestore 会自动为您完成索引创建。.limit(10) 将结果集限制为最多 10 个文档,以防止无限制读取。querySnapshot.docs 是与查询匹配的 DocumentSnapshot 对象列表。通过将每个文档映射为 {'id': doc.id, ...doc.data()},您可以将 Firestore 自动生成的文档 ID(该 ID 不存储在文档字段内部)与文档的实际字段数据合并为一个单独的 Map。

code
// 批量写入:多个操作以原子方式提交
final batch = firebase.adminApp.firestore().batch();

batch.set(
  firebase.adminApp.firestore().collection('posts').doc(newPostId),
  {'title': 'New Post', 'authorId': uid},
);

batch.update(
  firebase.adminApp.firestore().collection('users').doc(uid),
  {'postCount': FieldValue.increment(1)},
);

await batch.commit();

firestore().batch() 创建一个 WriteBatch 对象,用于累积多个写入操作,再统一发送至 Firestore。batch.set(...)batch.update(...) 仅将操作加入队列,并不会立即执行。batch.commit() 才是真正将所有排队操作一次性提交至 Firestore 并以原子方式执行的地方:若其中任一操作失败,则全部操作都会回滚。当您的业务逻辑要求多个文档必须作为一个整体同时变更时(例如创建新帖子的同时递增作者的帖子计数),应采用此模式。若不使用批量写入,一旦两次操作之间发生崩溃,数据库将处于不一致状态。

使用 Admin SDK 进行身份验证操作

Admin SDK 赋予您的函数验证 ID 令牌、通过 UID 或邮箱查找用户、创建与删除用户、以及为用户令牌设置自定义声明(custom claims)的能力。这些操作需要管理员权限,而客户端 SDK 并不具备此类权限。

code
firebase.https.onRequest(
  name: 'securedEndpoint',
  (request) async {
    final authHeader = request.headers['authorization'];

    if (authHeader == null || !authHeader.startsWith('Bearer ')) {
      return Response(401, body: 'Unauthorized');
    }

    final idToken = authHeader.substring(7);

    try {
      final decodedToken = await firebase.adminApp
          .auth()
          .verifyIdToken(idToken);

      final uid = decodedToken.uid;

      return Response.ok(jsonEncode({'uid': uid, 'success': true}));
    } on FirebaseAuthException catch (e) {
      return Response(401, body: 'Invalid or expired token: ${e.message}');
    }
  },
);

request.headers['authorization'] 从传入的 HTTP 请求中读取 Authorization 请求头。Firebase Authentication 的 ID 令牌以 Bearer Token 形式发送,即请求头的值为字符串 "Bearer " 后接实际令牌。.startsWith('Bearer ') 用于验证请求头格式是否正确,随后 .substring(7) 去除 "Bearer " 前缀(共 7 个字符),从而提取出原始令牌字符串。firebase.adminApp.auth().verifyIdToken(idToken) 将令牌发送至 Firebase 的令牌验证服务,该服务会验证签名、检查是否过期,并确认该令牌确实由您的 Firebase 项目签发。若验证成功,将返回一个 DecodedIdToken 对象,其中包含用户的 UID 及任何自定义声明。若令牌无效或已过期,则抛出 FirebaseAuthException 异常,您可捕获该异常并转换为 401 响应。此模式专用于 onRequest 类型函数——当您需要明确识别调用者身份时使用。对于 onCall 类型函数,整个认证流程由运行时自动处理,这也是使用可调用函数(callable functions)相较于原生 HTTP 函数的主要优势之一。

code
await firebase.adminApp
    .auth()
    .setCustomUserClaims(uid, {'role': 'admin', 'premiumUser': true});

setCustomUserClaims(uid, {...}) 将任意键值对数据附加到用户的 Firebase Authentication 令牌上。此后该用户获取的每个 ID 令牌中都会包含这些数据,使得您在 Admin SDK 代码中可通过 decodedToken.claims 访问,或在 Firestore 安全规则中通过 request.auth.token.role 访问。自定义声明是 Firebase 应用中实现基于角色的访问控制(RBAC)的标准方式。这些声明将在用户下一次刷新令牌时生效,而令牌默认每小时自动刷新一次;您也可以在客户端调用 user.getIdToken(true) 强制立即刷新。

搭建 Dart Cloud Functions:分步指南

第一步:启用实验性功能

由于 Dart 支持目前仍处于实验阶段,需通过 Firebase CLI 的功能开关(feature flag)进行启用。在 CLI 提供 Dart 作为选项前,您必须先启用该开关。

code
firebase experiments:enable dartfunctions

该命令会将一个标志写入您本地的 Firebase CLI 配置文件中。这是一次性设置步骤,可在同一台机器上的多个项目和终端之间持久生效。

code
firebase experiments

运行此命令可列出当前已启用的所有实验功能,让您在继续后续步骤前确认 dartfunctions 是否出现在输出结果中。若未出现,则下一步中的 firebase init functions 命令将不会提供 Dart 作为语言选项——这是初次设置时最常见的失败原因。

步骤 2:验证 CLI 版本

Dart Cloud Functions 要求 Firebase CLI 版本不低于 15.15.0。

code
firebase --version

该命令会打印当前已安装的 CLI 版本号。若输出版本低于 15.15.0,请先运行更新命令再继续操作。

code
npm install -g firebase-tools

该命令会将 Firebase CLI 全局更新至最新版本。-g 标志表示全局安装,使得 firebase 命令可在任意目录下使用。

code
firebase login

在 CLI 更新后重新登录,可确保您的身份验证凭据为最新状态,并正确关联到您的 Google 账户。若您近期已登录且确信凭据仍有效,可跳过此步骤。

步骤 3:使用 Dart 初始化 Cloud Functions

code
firebase init functions

当 CLI 提示选择语言时,请选择 Dart;当询问是否立即安装依赖项时,请选择 Yes。CLI 将生成如下项目结构:

图 4:项目结构示意图

其中 functions/bin/server.dart 是程序入口点。Firebase CLI 知道应从此处查找,因为 firebase.json 已配置指向该路径。functions/lib/ 目录用于存放 server.dart 所导入的其他 Dart 文件,便于随着函数数量增长而保持逻辑结构清晰。functions/pubspec.yaml 是该函数代码库的 Dart 包配置清单,与 Flutter 应用的 pubspec.yaml 相互独立。firebase.json 也会由 CLI 自动更新,加入函数相关配置,包括编译后二进制文件的路径及运行时设置。

CLI 自动生成的 server.dart 包含一个可立即运行的“Hello World”示例函数,可用于验证整体配置是否正确:

code
import 'package:firebase_functions/firebase_functions.dart';

void main(List<String> args) async {
  await fireUp(args, (firebase) {
    firebase.https.onRequest(
      name: 'helloWorld',
      options: const HttpsOptions(cors: Cors(['*'])),
      (request) async {
        return Response.ok('Hello from Dart Cloud Functions!');
      },
    );
  });
}

这是一个精简但完整的 Dart Cloud Function。main 函数接收 CLI 启动二进制文件时传入的命令行参数 args,并将其传递给 fireUp,后者从中解析出端口配置。onRequest 注册方式为函数指定名称,并定义处理所有 HTTP 请求的逻辑,返回状态码 200 及纯文本响应体。在本地运行此代码可验证模拟器能否成功编译并启动您的函数,从而避免在更复杂的逻辑上浪费时间。

步骤 4:运行本地模拟器

code
firebase emulators:start

模拟器启动后将输出类似如下内容:

图 5:模拟器启动界面

firebase emulators:start 会启动 firebase.json 中配置的所有模拟器。Dart 模拟器在启动服务前会先在本地编译您的函数,因此您会在短暂构建过程后看到 “Dart emulator ready” 提示。函数模拟器默认运行在 5001 端口;Firestore 模拟器则运行在 8080 端口。当您的函数在模拟器环境中运行时,其代码将自动连接至模拟的 Firestore 实例,而非生产数据库。您的 helloWorld 函数可通过 http://127.0.0.1:5001/your-project-id/us-central1/helloWorld 访问。Dart 模拟器的一大优势是支持热重载:当您保存 .dart 文件的修改时,模拟器会自动检测变更并重新编译及重启函数,无需手动执行任何命令。

步骤 5:将 Flutter 应用连接至模拟器

code
import 'package:cloud_functions/cloud_functions.dart';

void _connectToEmulators() {
  FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
}

useFunctionsEmulator('localhost', 5001) 会让 Flutter 应用中的 Firebase Functions 客户端将所有函数调用发送至本地 5001 端口的模拟器,而非生产环境。请在应用中首次调用函数前(通常在 main() 函数中紧接 Firebase.initializeApp() 之后)调用此方法。注意,该方法仅影响函数调用;若需模拟 Firestore 或 Authentication,需分别调用其对应的模拟器配置方法。

code
if (Platform.isAndroid) {
  FirebaseFunctions.instance.useFunctionsEmulator('10.0.2.2', 5001);
} else {
  FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001);
}

Android 模拟器运行于独立的虚拟机中,拥有自己的网络命名空间。对 Android 模拟器而言,localhost 指向其自身,而非您的开发主机。而特殊地址 10.0.2.2 则是 Android 模拟器访问宿主机 localhost 的方式。iOS 模拟器不存在此问题,因其与宿主机共享网络环境,因此 localhost 可正常工作。通过 Platform.isAndroid 判断,可在运行时选择正确的地址,使同一份代码在开发阶段即可在两个平台正确运行。

步骤 6:部署至生产环境

code
firebase deploy --only functions

--only functions 标志告诉 CLI 仅部署函数,跳过其他所有 Firebase 资源(如 Firestore 规则、Hosting 等)。Dart 的部署流程与 Node.js 有显著差异:Firebase CLI 会在您的开发机器上运行 dart compile exe,生成一个原生二进制文件;随后将该二进制文件上传至 Cloud Run。部署输出中会包含已部署函数的 URL:

code
✔  functions: 预部署脚本执行完成。
✔  functions: helloWorld(us-central1) 成功部署。

函数 URL(helloWorld(us-central1)):
  https://helloworld-abc123def456-uc.a.run.app

请保存该 URL。由于当前 httpsCallable 名称解析存在限制,在 Flutter 中调用函数时需直接传入此 URL。URL 中的哈希值(abc123def456)对您的项目和函数是唯一的,且在同名函数的多次部署间保持不变,因此可安全地将其硬编码于 Flutter 应用中,或通过 Firebase Remote Config 加载。

在 Flutter 中调用 Dart 函数

使用 `httpsCallableFromURL` 调用

由于当前版本中 httpsCallable('functionName') 不适用于 Dart 函数,您需改用 httpsCallableFromURL 并传入完整的 Cloud Run URL:

code
// lib/services/functions_service.dart

import 'package:cloud_functions/cloud_functions.dart';

class FunctionsService {
  static const _createPostUrl =
      'https://createpost-abc123def456-uc.a.run.app';

  static const _getUserProfileUrl =
      'https://getuserprofile-abc123def456-uc.a.run.app';

  Future<String> createPost({
    required String title,
    required String content,
  }) async {
    try {
      final callable = FirebaseFunctions.instance.httpsCallableFromURL(
        _createPostUrl,
      );

      final result = await callable.call({
        'title': title,
        'content': content,
      });

      return result.data['postId'] as String;
    } on FirebaseFunctionsException catch (e) {
      throw _mapFunctionException(e);
    }
  }

  Exception _mapFunctionException(FirebaseFunctionsException e) {
    switch (e.code) {
      case 'unauthenticated':
        return UnauthorizedException('请先登录以继续操作。');
      case 'invalid-argument':
        return ValidationException(e.message ?? '输入无效。');
      case 'not-found':
        return NotFoundException(e.message ?? '资源未找到。');
      default:
        return ServerException(
          e.message ?? '发生意外错误。',
        );
    }
  }
}

将函数 URL 集中定义为服务类顶部的 static const 字符串,便于查找与维护。在大型应用中,建议通过 Firebase Remote Config 加载这些 URL,以便无需发布新版本即可更新 URL。FirebaseFunctions.instance.httpsCallableFromURL(_createPostUrl) 会创建一个指向指定 URL 的 HttpsCallable 对象。该对象封装了可调用函数格式的所有协议细节,包括将数据序列化为请求体、反序列化响应等。callable.call({...}) 会执行函数调用,将传入的 Map 作为请求负载发送,并在函数执行完成后返回 HttpsCallableResultresult.data 是服务器端 CallableResult(...) 返回的 Map<String, dynamic>。捕获 FirebaseFunctionsException 可捕获服务器端抛出的所有结构化错误;e.code 是机器可读的错误码,而 _mapFunctionException 会将其转换为您应用自定义异常层次结构中的具体类型异常,从而避免业务逻辑层直接依赖 Firebase 特定类型。

直接调用 HTTP 函数

对于 onRequest 类型的 HTTP 函数,可像调用其他 HTTP 接口一样,使用 Dart 的 http 包进行调用:

code
import 'package:http/http.dart' as http;
import 'dart:convert';

class ProfileService {
  static const _getUserProfileUrl =
      'https://getuserprofile-abc123def456-uc.a.run.app';

  Future<Map<String, dynamic>> getUserProfile(String userId) async {
    final user = FirebaseAuth.instance.currentUser;
    final idToken = await user?.getIdToken();

    final response = await http.get(
      Uri.parse('$_getUserProfileUrl?userId=$userId'),
      headers: {
        if (idToken != null) 'Authorization': 'Bearer $idToken',
        'Content-Type': 'application/json',
      },
    );

    if (response.statusCode == 200) {
      return jsonDecode(response.body) as Map<String, dynamic>;
    }

    throw ServerException('获取用户资料失败:${response.statusCode}');
  }
}

FirebaseAuth.instance.currentUser 从本地 Firebase Auth 缓存中获取当前登录用户,不发起网络请求。user?.getIdToken() 获取用户的当前 ID 令牌,若已过期则自动刷新;其中 ? 表示若无用户登录则返回 null,而条件性 header 插入机制可优雅处理该情况。if (idToken != null) 'Authorization': 'Bearer $idToken' 是 Dart 的集合 if 语法,仅在令牌存在时才添加 Authorization 请求头,使得同一服务方法能同时支持已认证与匿名请求(无令牌时自动省略该请求头)。Uri.parse('$_getUserProfileUrl?userId=$userId') 将查询参数附加至 URL。jsonDecode(response.body) as Map<String, dynamic> 将 JSON 响应体解析为 Dart Map。若状态码非 200,则抛出包含状态码信息的 ServerException,便于调试。

共享包(shared package)是全栈 Dart 方案中最具架构意义的部分:它是一个独立的 Dart 包,不依赖 Flutter 或 Firebase,用于定义 Cloud Functions 后端与 Flutter 前端共同使用的数据模型、校验逻辑、常量及工具函数。

创建共享包

dart
dart create --template=package packages/shared

dart create --template=package 会生成一个符合标准库布局的新 Dart 包:包含用于公开代码的 lib/ 目录、测试目录 test/,以及配置文件 pubspec.yamlpackages/shared 路径将其置于项目根目录下的 packages/ 文件夹中,这是单体仓库(mono-repository)结构中存放内部包的常规位置。运行该命令后,项目结构如下所示:

图 6:项目结构示意图

共享包的 pubspec.yaml 文件刻意保持极简:

code
name: shared
description: Shared data models and logic for the Kopa app.
version: 0.1.0

environment:
  sdk: ^3.0.0

dependencies:
  json_annotation: ^4.8.0

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.7.0
  test: ^1.24.0

pubspec.yaml 最关键的特征在于其缺失的内容:没有引入 flutterfirebase_corefirebase_functionscloud_firestore。共享包仅依赖纯 Dart 库,这使其能够同时被服务端函数包和 Flutter 应用导入,而不会引发版本冲突。其中,json_annotation 提供了 @JsonSerializable() 注解,供模型类使用;json_serializable 是一个构建时代码生成器,它读取这些注解并自动生成 fromJson/toJson 方法——由于它仅在开发阶段运行而非运行时执行,因此被列为 dev_dependenciesbuild_runner 是执行代码生成任务的工具,同样属于 dev_dependenciestest 则用于对共享逻辑进行单元测试。

定义共享模型

code
// packages/shared/lib/src/models/post.dart

import 'package:json_annotation/json_annotation.dart';

part 'post.g.dart';

@JsonSerializable()
class Post {
  final String id;
  final String title;
  final String content;
  final String authorId;
  final int likeCount;
  final DateTime createdAt;

  const Post({
    required this.id,
    required this.title,
    required this.content,
    required this.authorId,
    required this.likeCount,
    required this.createdAt,
  });

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
  Map<String, dynamic> toJson() => _$PostToJson(this);
}

part 'post.g.dart' 声明了名为 post.g.dart 的生成文件属于当前库的一部分。当你运行 dart run build_runner build 时,json_serializable 代码生成器会创建该文件。@JsonSerializable() 注解用于告知 json_serializable 为该类生成序列化代码。所有字段均为 final,因为模型对象应为不可变对象:一旦创建,Post 实例本身不会就地修改,而是通过创建新实例来表示变更。使用 DateTime 类型表示 createdAt 字段,而非原始整型时间戳或字符串,有助于将模型保持在恰当的抽象层级。无论是 Flutter 应用还是函数端,都会在本地完成 DateTime 与各自特定时间戳格式之间的转换,从而确保共享模型不依赖于任一端的具体实现细节。factory Post.fromJson(...)toJson() 方法委托给自动生成的 _$PostFromJson_$PostToJson 函数,从而避免了手动编写序列化逻辑。手动序列化往往是数据契约错误的高发源头:可能遗漏字段、键名拼写错误、忘记空值检查等。而代码生成则彻底消除了这一类错误。

code
// packages/shared/lib/src/validation/post_validation.dart

class PostValidation {
  static const int titleMaxLength = 120;
  static const int contentMaxLength = 10000;
  static const int titleMinLength = 3;

  static String? validateTitle(String? title) {
    if (title == null || title.trim().isEmpty) {
      return 'Title is required.';
    }
    if (title.trim().length < titleMinLength) {
      return 'Title must be at least $titleMinLength characters.';
    }
    if (title.trim().length > titleMaxLength) {
      return 'Title cannot exceed $titleMaxLength characters.';
    }
    return null;
  }

  static String? validateContent(String? content) {
    if (content == null || content.trim().isEmpty) {
      return 'Content is required.';
    }
    if (content.trim().length > contentMaxLength) {
      return 'Content cannot exceed $contentMaxLength characters.';
    }
    return null;
  }

  static bool isValid({required String title, required String content}) {
    return validateTitle(title) == null && validateContent(content) == null;
  }
}

所有成员均为 static,因为 PostValidation 是一个函数命名空间,而非需要实例化的类。长度常量 titleMaxLengthcontentMaxLengthtitleMinLength 均为 static const,意味着它们在编译期即已确定,运行时不占用内存,并可同时用于运行时验证逻辑及 Flutter 组件配置(例如作为 TextFieldmaxLength 参数)。每个验证方法遵循 Dart 表单验证的惯例:返回 null 表示有效,返回 String 则表示无效,并附带对应错误信息。validateTitle 方法在检查长度前先调用 .trim(),以防止仅含空白字符的字符串通过长度校验。isValid 是一个便捷方法,适用于仅需布尔结果(而非具体错误信息)的场景,例如用于启用或禁用提交按钮。

code
// packages/shared/lib/src/constants/api_constants.dart

class ApiConstants {
  static const String createPostFunction = 'createPost';
  static const String getUserProfileFunction = 'getUserProfile';
  static const String likePostFunction = 'likePost';

  static const String postsCollection = 'posts';
  static const String usersCollection = 'users';
}
markdown
`ApiConstants` 存储了函数名称和 Firestore 集合名称的字符串标识符,供整个技术栈的两端(服务端与客户端)共同引用。使用常量而非散落在代码各处的字符串字面量,可防止拼写错误;同时,若某名称发生变更,只需在一处更新,编译器便会自动提示所有引用该名称的位置。函数名称常量在服务端的 `firebase.https.onRequest(name: ApiConstants.createPostFunction)` 中使用,也用于客户端的 URL 构造或日志记录;集合名称常量则确保服务端与客户端始终对同名集合进行读写操作,从而避免一类常见 Bug:例如函数写入 `"Posts"`(P 大写),而客户端却查询 `"posts"`(p 小写)。

// packages/shared/lib/shared.dart

export 'src/models/post.dart'; export 'src/models/user.dart'; export 'src/validation/post_validation.dart'; export 'src/constants/api_constants.dart';

code

该文件为 barrel 文件(桶文件),通过单一入口点重新导出本包提供的全部内容。包的使用者只需编写 `import 'package:shared/shared.dart'`,即可立即访问 `Post`、`PostValidation`、`ApiConstants` 及该包导出的其他所有内容。若无此 barrel 文件,使用者需了解包内部的目录结构,并逐个导入各文件——而此类细节本应由包本身加以隐藏。

### 在 Functions 中引用 Shared 包

functions/pubspec.yaml

name: kopa_functions version: 0.1.0

environment: sdk: ^3.0.0

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

code

`shared: path: ../packages/shared` 是路径依赖(path dependency),它告知 Dart 的 `pub` 工具:从文件系统中指定的相对路径(而非 pub.dev)解析 `shared` 包。路径 `../packages/shared` 表示从 `functions/` 目录向上返回一级至项目根目录,再进入 `packages/shared/`。当 Firebase CLI 编译并部署你的 Dart 函数时,它会在开发机器上本地解析该路径依赖,并将其打包进最终编译产物,因此尽管路径是本地相对路径,部署后仍能正常工作。

### 在 Flutter 中引用 Shared 包

pubspec.yaml (Flutter app)

dependencies: flutter: sdk: flutter firebase_core: ^3.0.0 cloud_firestore: ^5.0.0 firebase_auth: ^5.0.0 cloud_functions: ^5.0.0 shared: path: packages/shared

code

Flutter 应用通过 `path: packages/shared` 引用 shared 包,该路径是相对于 Flutter 项目根目录的相对路径。注意此处路径为 `packages/shared`,不带 `../` 前缀——这是因为 Flutter 的 `pubspec.yaml` 位于项目根目录,而 Functions 的 `pubspec.yaml` 则位于 `functions/` 子目录中。两者虽路径写法不同,但实际指向的是磁盘上的同一物理目录。关键在于:两个不同包(各自拥有独立的 `pubspec.yaml` 文件,且视角不同)引用了同一份源代码。

### 在 Cloud Function 中使用 Shared 逻辑

// 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) { firebase.https.onCall( name: ApiConstants.createPostFunction, (request, response) async { if (request.auth == null) { throw FirebaseFunctionsException( code: 'unauthenticated', message: 'You must be signed in.', ); }

final data = request.data as Map<String, dynamic>; final title = data['title'] as String?; final content = data['content'] as String?;

final titleError = PostValidation.validateTitle(title); if (titleError != null) { throw FirebaseFunctionsException( code: 'invalid-argument', message: titleError, ); }

final contentError = PostValidation.validateContent(content); if (contentError != null) { throw FirebaseFunctionsException( code: 'invalid-argument', message: contentError, ); }

final ref = await firebase.adminApp .firestore() .collection(ApiConstants.postsCollection) .add({ 'title': title!.trim(), 'content': content!.trim(), 'authorId': request.auth!.uid, 'likeCount': 0, 'createdAt': FieldValue.serverTimestamp(), });

return CallableResult({'postId': ref.id}); }, ); }); }

code

`import 'package:shared/shared.dart'` 一行即可导入整个 shared 包。`ApiConstants.createPostFunction` 使用共享常量替代字符串字面量指定函数名,确保服务端注册的名称与任何日志或监控系统所预期的完全一致。`PostValidation.validateTitle(title)` 与 `PostValidation.validateContent(content)` 执行的正是 Flutter 表单在客户端运行的同一套验证逻辑。即便恶意攻击者绕过客户端验证(因客户端代码不可信,此类情况始终可能发生),服务端仍会独立执行相同规则。`ApiConstants.postsCollection` 是共享的集合名称常量,确保函数写入的集合路径与 Flutter 应用读取的路径完全一致。

### 在 Flutter 应用中使用 Shared 逻辑

// lib/features/create_post/create_post_screen.dart

import 'package:flutter/material.dart'; import 'package:shared/shared.dart';

class CreatePostScreen extends StatefulWidget { const CreatePostScreen({super.key});

@override State<CreatePostScreen> createState() => _CreatePostScreenState(); }

code

class _CreatePostScreenState extends State<CreatePostScreen> { final _titleController = TextEditingController(); final _contentController = TextEditingController();

@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('New Post')), body: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ TextFormField( controller: _titleController, decoration: const InputDecoration(labelText: 'Title'), validator: (value) => PostValidation.validateTitle(value), maxLength: PostValidation.titleMaxLength, ), const SizedBox(height: 16), TextFormField( controller: _contentController, decoration: const InputDecoration(labelText: 'Content'), validator: (value) => PostValidation.validateContent(value), maxLength: PostValidation.contentMaxLength, maxLines: 8, ), ], ), ), ); }

@override void dispose() { _titleController.dispose(); _contentController.dispose(); super.dispose(); } }

code

`validator: (value) => PostValidation.validateTitle(value)` 将共享验证逻辑直接传递给 `TextFormField` 的 `validator` 属性。当用户提交表单时,Flutter 的表单系统会调用该函数,其返回值要么为 `null`(表示验证通过),要么为错误提示字符串(表示验证失败),这与 `PostValidation` 所采用的约定完全一致。`maxLength: PostValidation.titleMaxLength` 则利用共享常量来配置字段的字符长度上限,从而确保 UI 展示的限制与验证规则保持一致。例如,若将来将最大长度从 120 提升至 200,只需在共享包中更新该常量,即可同步更新客户端与服务端的字符计数器与验证规则,仅需一次修改即可生效。

## 架构:全栈如何协同工作

![Image 7: The Full-Stack Dart Request Lifecycle](https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/340c7856-c0c1-4e00-8398-da3a54d7fa22.png)  
该图展示了单次请求的完整生命周期:Flutter 应用首先使用共享逻辑进行本地验证,随后调用可调用函数;Firebase 基础设施接收请求、验证身份认证令牌,并将请求路由至 Cloud Run 上运行的 Dart 二进制文件;Dart 函数再次执行自身验证(使用相同的共享逻辑),并借助 Admin SDK 向 Firestore 写入数据;最后将结果返回给 Flutter 客户端,客户端以结构化数据形式接收。在整个流程中,所有可在客户端与服务端之间共享的代码均被共享,而必须独立实现的部分(如 Flutter 组件、Firebase Admin 操作)则被合理分离。

### 全栈 Dart 项目的项目结构

![Image 8: Project Structure for a Full-Stack Dart Project](https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/18ea5dcb-1e19-4d09-aba8-3af78ab4fc05.png)  
项目根目录下的三层目录结构是组织代码的核心原则:`lib/` 用于 Flutter 应用,`functions/` 用于后端服务,`packages/` 用于存放二者共享的代码。这种结构使得任意代码片段的归属一目了然。Flutter 应用中的 `services/` 目录存放 `FunctionsService` 等类,从而将函数调用逻辑与 UI 组件解耦;`functions/lib/` 内的 `handlers/` 目录则用于存放各领域(domain-specific)的函数逻辑,使 `server.dart` 保持简洁,仅专注于注册逻辑。

## 高级概念

### 管理多个函数

随着后端功能不断扩展,将所有函数注册逻辑集中于单一 `fireUp` 回调中将变得难以维护。可将各处理器提取至独立文件,并在服务入口点中统一导入:

// functions/lib/handlers/post_handler.dart

import 'package:firebase_functions/firebase_functions.dart'; import 'package:google_cloud_firestore/google_cloud_firestore.dart' show FieldValue; import 'package:shared/shared.dart';

void registerPostHandlers(FirebaseApp firebase) { firebase.https.onCall( name: ApiConstants.createPostFunction, (request, response) async { // 处理逻辑 }, );

firebase.https.onCall( name: ApiConstants.likePostFunction, (request, response) async { // 处理逻辑 }, );

firebase.https.onRequest( name: ApiConstants.getUserProfileFunction, (request) async { // 处理逻辑 }, ); }

code

`registerPostHandlers(FirebaseApp firebase)` 是一个顶层函数,接收 `firebase` 对象并使用它注册所有与帖子相关的函数。其参数类型 `FirebaseApp firebase` 由 `firebase_functions` 包提供,确保类型正确。该方式与 Flutter 应用的 `main.dart` 类似:单一入口点调用多个负责不同配置区域的初始化函数。

// functions/bin/server.dart

import 'package:firebase_functions/firebase_functions.dart'; import '../lib/handlers/post_handler.dart'; import '../lib/handlers/user_handler.dart';

void main(List<String> args) async { await fireUp(args, (firebase) { registerPostHandlers(firebase); registerUserHandlers(firebase); }); }

code

此时 `server.dart` 已成为一份整洁的编排文件:它从各领域处理器文件中导入注册函数,并在 `fireUp` 回调内依次调用。新增一个领域仅需创建新的处理器文件,并在此处添加一行导入语句即可。由于 `fireUp` 回调是唯一可获取 `firebase` 对象的地方,因此必须将其传递给所有需要它的注册函数。

### 错误处理模式

生产环境中的 Cloud Functions 需要一致且可预测的错误处理机制。应定义一个集中式的错误处理器,而非在每个函数中分散使用 `try-catch` 块:

// functions/lib/utils/error_handler.dart

import 'package:firebase_functions/firebase_functions.dart';

typedef CallableHandler = Future<CallableResult> Function( CallableRequest request, CallableResponse response, );

CallableHandler withErrorHandling(CallableHandler handler) { return (request, response) async { try { return await handler(request, response); } on FirebaseFunctionsException { rethrow; } on ArgumentError catch (e) { throw FirebaseFunctionsException( code: 'invalid-argument', message: e.message, ); } catch (e, stackTrace) { print('Unhandled error in function: $e'); print(stackTrace); throw FirebaseFunctionsException( code: 'internal', message: 'An internal error occurred. Please try again.', ); } }; }

code

`typedef CallableHandler` 定义了一个 Dart 函数类型别名,用于表示 `onCall` 所期望的处理器签名。这使得 `withErrorHandling` 可以明确类型,避免在各处重复书写完整函数签名。`withErrorHandling` 是一个高阶函数:它接收一个处理器函数,并返回一个新的函数,该函数将原始处理器包裹在 `try-catch` 中。`on FirebaseFunctionsException { rethrow; }` 允许你在处理器中主动抛出的结构化错误原封不动地传递下去,因为它们已符合客户端所需格式。`on ArgumentError catch (e)` 将 Dart 内置的 `ArgumentError`(通常由校验逻辑抛出)转换为客户端可识别的 `FirebaseFunctionsException`,并使用 `invalid-argument` 错误码。最后的 `catch (e, stackTrace)` 是兜底机制,用于捕获所有未处理异常:它在服务端完整记录错误及其调用栈,但向客户端返回一条经过清理、不泄露内部细节的通用错误消息。

firebase.https.onCall( name: 'createPost', withErrorHandling((request, response) async { if (request.auth == null) { throw FirebaseFunctionsException( code: 'unauthenticated', message: 'Authentication required.', ); } return CallableResult({'success': true}); }), );

code

`withErrorHandling(...)` 在注册时对处理器进行包装。`onCall` 的第三个位置参数(即处理器函数)被替换为 `withErrorHandling` 的返回值——一个具有正确签名的函数。其内部处理器本身不再需要任何 `try-catch` 块,因为错误处理逻辑已由 `withErrorHandling` 统一接管。

### 测试 Dart Cloud Functions

用 Dart 编写的 Cloud Functions 本质上是标准的 Dart 代码,因此可完全借助标准 Dart 测试工具进行测试。你可以将处理器中的业务逻辑提取为无 Firebase 依赖的纯函数,再直接进行单元测试:

// functions/lib/handlers/post_logic.dart

import 'package:shared/shared.dart';

PostInput validateCreatePostRequest(Map<String, dynamic> data) { final title = data['title'] as String?; final content = data['content'] as String?;

final titleError = PostValidation.validateTitle(title); if (titleError != null) throw ArgumentError(titleError);

final contentError = PostValidation.validateContent(content); if (contentError != null) throw ArgumentError(contentError);

return PostInput(

content: content!.trim(), ); }

class PostInput { final String title; final String content; const PostInput({required this.title, required this.content}); }

code

`validateCreatePostRequest` 是一个纯函数:它接收一个 `Map<String, dynamic>`,要么返回 `PostInput`,要么抛出 `ArgumentError`。它不依赖 Firebase、不涉及异步调用、无副作用,因此仅需一条 `dart test` 命令即可完成测试,无需启动 Firebase 模拟器。`PostInput` 是一个简单的值类,用于承载已校验并去除首尾空白的数据。返回类型化的结果而非原始 Map,可确保调用方获得编译器可验证的、结构明确的校验后数据。

// functions/test/post_logic_test.dart

import 'package:test/test.dart'; import '../lib/handlers/post_logic.dart';

void main() { group('validateCreatePostRequest', () { test('returns valid PostInput for correct data', () { final result = validateCreatePostRequest({ 'title': 'Valid Title', 'content': 'This is valid post content.', });

expect(result.title, equals('Valid Title')); expect(result.content, equals('This is valid post content.')); });

test('throws ArgumentError when title is empty', () { expect( () => validateCreatePostRequest({'title': '', 'content': 'Content'}), throwsA(isA<ArgumentError>()), ); });

test('throws ArgumentError when title exceeds max length', () { final longTitle = 'A' * 200; expect( () => validateCreatePostRequest({ 'title': longTitle, 'content': 'Content', }), throwsA(isA<ArgumentError>()), ); });

test('trims whitespace from title and content', () { final result = validateCreatePostRequest({ 'title': ' Padded Title ', 'content': ' Padded content. ', });

expect(result.title, equals('Padded Title')); expect(result.content, equals('Padded content.')); }); }); }

code

`group('validateCreatePostRequest', ...)` 将相关的测试归类到一个共享标签下,从而生成结构清晰的输出,便于快速定位失败用例。每个 `test(...)` 调用分别验证一种特定行为:正常路径(happy path)、标题为空的情况、标题过长的情况以及去除首尾空白的情况。`expect(result.title, equals('Valid Title'))` 是断言语句,用于检查实际值是否与预期值一致。`throwsA(isA<ArgumentError>())` 是一种匹配器,仅当被调用对象抛出 `ArgumentError` 异常时才视为通过——这正是 `validateCreatePostRequest` 对无效输入所定义的契约行为。`'A' * 200` 是 Dart 中的字符串重复操作,生成一个长度为 200 的字符串,超过了共享包中定义的 `titleMaxLength`(120 字符)限制。

cd functions dart test

code

运行函数测试无需 Firebase 模拟器、无需网络访问,也无需额外配置,只需已安装 Dart SDK 即可。测试可在毫秒级时间内完成。

cd packages/shared dart test

code

共享包的测试运行方式完全相同。上述两条命令均使用标准的 `dart test` 测试运行器,它会递归查找并执行 `test/` 目录下所有以 `_test.dart` 结尾的文件。

### 函数配置选项

`onRequest` 和 `onCall` 均接受一个配置对象,用于控制运行时行为:

firebase.https.onRequest( name: 'highTrafficEndpoint', options: const HttpsOptions( cors: Cors(['https://yourapp.com']), minInstances: 1, maxInstances: 10, concurrency: 80, memory: Memory.mb512, timeoutSeconds: 120, region: 'europe-west1', ), (request) async { return Response.ok('Hello from a configured function!'); }, );

code

`minInstances: 1` 会始终保持一个该函数实例处于“热”状态,从而彻底消除该函数的冷启动延迟。代价是:即使没有请求到达,也会持续计费一个实例的运行开销。仅在冷启动延迟确实不可接受的场景下使用此选项,例如用户直接交互的实时功能。  
`maxInstances: 10` 将并发实例数量上限设为 10,防止突发流量导致函数实例扩展至数百个,从而保护你的账单支出及下游服务(如数据库)免受高并发冲击。  
`concurrency: 80` 告诉 Cloud Run 单个实例可同时处理的请求数量。由于 Dart 的异步模型能高效处理 I/O 密集型并发请求(无需线程),该值可设置得比 Node.js 更高。  
`memory: Memory.mb512` 为每个函数实例分配 512MB 内存。对于图像处理或加载大型数据集等内存密集型任务,应适当提高该值。CPU 分配随内存成比例增长,因此增加内存也意味着提升了处理能力。  
`timeoutSeconds: 120` 设置单次请求的最大运行时间(超时后 Cloud Run 将终止请求)。对于耗时较长的操作,请适当调高该值。  
`region: 'europe-west1'` 将该函数部署至位于比利时的 Google 数据中心,从而降低欧洲用户的访问延迟。默认情况下,函数部署在 `us-central1` 区域。

## 生产环境最佳实践

### 将“实验性”特性视为实验性

最重要的实践是根据功能的实际成熟度来合理评估其在生产环境中的适用性。Dart Cloud Functions 目前仍处于实验阶段,这意味着以下两点需特别注意:

首先,API 可能随时发生变更。未来 Firebase CLI 的更新可能改变 `fireUp` 的用法、函数注册方式或 Admin SDK 的访问方式。在更新使用了 Dart 函数的项目所依赖的 CLI 工具前,请务必查阅变更日志,并在预发布环境中先行测试,切勿盲目升级生产环境工具链。

其次,某些功能目前尚不支持。例如:后台触发器、基于名称的 `httpsCallable` 调用方式,以及 Firebase 控制台中的函数可视化展示,均存在缺失。应从项目初期就围绕这些限制进行架构设计,而非等到部署阶段才发现问题。

### 保持处理函数精简,将业务逻辑统一复用

通过 `firebase.https.onCall` 或 `firebase.https.onRequest` 注册的处理函数应尽量精简:仅负责请求身份验证、提取输入参数、调用执行实际业务逻辑的纯函数,并返回结果。该纯函数应位于 functions 库或 shared 包中。这种结构使逻辑可在无 Firebase 环境下进行测试,也便于后续将逻辑迁移至 shared 包,供 Flutter 应用复用。

### 所有时间戳统一使用 `FieldValue.serverTimestamp()`

切勿由客户端发送时间戳,也勿在函数代码中使用 `DateTime.now()` 生成时间戳。服务器时间戳由 Firestore 在写入操作发生的瞬间设定,能确保时间准确性,不受客户端设备时钟偏差影响。而客户端生成的时间戳在用户设备时钟不准确时可能出错;函数中生成的 `DateTime.now()` 时间戳虽准确,但会遗漏函数执行与 Firestore 写入提交之间的时间窗口。

### 适度记录日志,避免过度日志

Cloud Functions 的日志可在 Google Cloud 控制台及 Cloud Run 日志中查看。Dart 函数中的 `print()` 语句会写入这些日志。建议记录有助于排查生产问题的关键事件:函数调用记录(含输入结构,但不含敏感数据)、成功完成事件(含结果结构)、错误信息(含完整错误详情及堆栈跟踪)、以及影响性能的关键事件(如外部 API 调用)。避免记录每行代码执行过程或每次数据转换细节,以免日志泛滥,导致真正的问题难以被发现。

### 默认启用限流与身份验证

每个可通过互联网访问的 Cloud Function 都可能被任何发现其 URL 的人调用。可调用函数(Callable Functions)会自动验证 Firebase Authentication,但 HTTP 函数则不会。对于每一个需要身份验证的 `onRequest` 函数,必须显式验证 ID 令牌。无论函数类型如何,建议在上线前为每个函数实现基于用户的速率限制,以防止意外循环和恶意滥用。

## 何时使用 Dart Cloud Functions,何时避免使用

### Dart Cloud Functions 真正发挥价值的场景

Dart Cloud Functions 最适合那些以 Flutter 为主、希望编写后端逻辑而无需切换出 Dart 环境的团队。其架构价值主要体现在“共享包”(shared package)模式中:当客户端与服务端都需要共享验证规则、数据模型、常量或工具逻辑时,将这些代码统一放在一个 Dart 包中,可彻底避免一大类数据契约(data contract)相关 bug。

轻量级、I/O 密集型的 API 逻辑非常适合 Dart。Dart 的异步模型在处理大量等待 Firestore 查询、外部 API 调用或其他网络操作的场景中效率很高,而非用于重计算任务。例如:从 Firestore 读取若干文档、应用业务逻辑,再将结果写回——这类工作负载正是 Dart 的强项。

“面向前端的移动后端”(Mobile Backend for Frontend)模式是其自然适用场景:如将多个 Firestore 集合的数据聚合为一个适配特定界面的响应;执行需原子性更新多个文档的写入操作;或需要管理员权限来创建/更新客户端不应直接修改的记录。

### 当前 Dart Cloud Functions 并非合适选择的场景

目前尚不支持后台触发器(Background Triggers)。若架构依赖于“Firestore 文档创建/更新时”“用户注册时”“定时执行”或“响应 Pub/Sub 消息时”自动触发的函数,则当前无法使用 Dart 编写此类函数,需改用 Node.js 或 Python,并等待未来版本支持后台触发器。

生产环境关键基础设施在采用实验性工具前应谨慎评估。若函数失败可能导致数据丢失、财务错误或严重用户影响,则 Dart 支持的“实验性”标签构成显著风险因素:API 可能变更、行为可能调整,且 Firebase 团队对实验性功能的紧急生产问题响应能力,与对稳定功能的承诺不可同日而语。

对于高并发且需精细调优性能的工作负载,建议在正式采用前结合真实流量进行测试。尽管 Dart 函数在冷启动速度和异步 I/O 处理方面理论上表现优异,但生产环境的真实流量可能暴露出本地测试无法复现的边界情况。

## 常见错误

### 忘记启用实验性标志

首次使用中最常见的问题是运行 `firebase init functions` 时未看到 Dart 作为语言选项。解决方法始终一致:**先运行** `firebase experiments:enable dartfunctions`,**再运行** `firebase init functions`。必须在 Firebase CLI 中提前启用该实验标志,Dart 才会成为可用选项。

### 在 `pubspec.yaml` 中错误使用相对路径

共享包(shared package)需在 `functions/pubspec.yaml` 和 Flutter 应用的 `pubspec.yaml` 中均通过相对路径依赖引用。若路径错误(因目录结构与代码预期不符,或包被移动),会导致函数编译和 Flutter 构建均失败并报包解析错误。部署前请在 `functions` 目录下运行 `dart pub get`,确认无错误后再部署。

### 忽略 `httpsCallable` 的命名限制

当前版本最常见的集成问题是:使用 `FirebaseFunctions.instance.httpsCallable('functionName')` 调用 Dart 函数时,却收到“未找到”错误。当前版本不支持基于名称解析 Dart 函数,必须改用 `httpsCallableFromURL` 并传入完整的 Cloud Run URL。请从部署输出中保存该 URL,并在 Flutter 代码中显式使用。

### 在 Firebase 控制台中查找函数

部署 Dart 函数后,若打开 Firebase 控制台的 Functions 页面却看不到任何内容,可能会感到困惑。实际上,Dart 函数部署在 Cloud Run 上,需在 Google Cloud 控制台的 Cloud Run 页面查看,而非 Firebase 控制台。这是实验性版本的已知缺陷,待功能正式发布(GA)后将予以修复。

### 将 Firebase 依赖项放入共享包

共享包必须保持无 Firebase 和 Flutter 依赖。若在共享包中添加 `firebase_functions` 或 `cloud_firestore`,将破坏核心架构:这会导致共享包将服务端 Firebase 依赖引入 Flutter 客户端,或将客户端 Firebase 依赖引入函数,从而引发版本冲突和编译错误。共享包应仅包含纯 Dart 逻辑与模型;Firebase 交互应分别在函数包和 Flutter 应用中独立实现,两者均导入该共享包。

将所有业务逻辑直接写在 `onCall` 或 `onRequest` 回调函数中,会导致无法在不运行 Firebase 模拟器的情况下进行单元测试。而 Dart 的优势之一正是其良好的可测试性。应将验证、数据转换及业务逻辑提取为纯函数,放置于 functions 库或共享包(shared package)中,并使用 `dart test` 对这些纯函数进行测试,无需依赖任何 Firebase 基础设施。回调处理函数仅保留为薄层封装,负责将 Firebase 的输入输出与上述纯逻辑进行对接。

## 简易端到端示例

我们将构建一个完整的、可运行的全栈 Dart 应用:一个支持创建帖子(post)的功能模块,包含共享模型、共享验证逻辑、一个写入 Firestore 的 Dart 云函数,以及一个调用该函数的 Flutter 界面。本示例整合了本手册中的所有核心概念,形成一个可执行项目。

### 共享包(Shared Package)

// packages/shared/lib/src/models/post.dart

class Post { final String id; final String title; final String content; final String authorId; final int likeCount;

const Post({ required this.id, required this.title, required this.content, required this.authorId, required this.likeCount, });

factory Post.fromMap(String id, Map<String, dynamic> data) { return Post( id: id,

content: data['content'] as String? ?? '', authorId: data['authorId'] as String? ?? '', likeCount: data['likeCount'] as int? ?? 0, ); }

Map<String, dynamic> toMap() => { 'title': title, 'content': content, 'authorId': authorId, 'likeCount': likeCount, }; }

code

`Post.fromMap` 接收文档 ID(Firestore 将其存储在文档数据外部)和文档字段映射(field map),并将其组合为一个完整的 `Post` 实例。其中 `as String? ?? ''` 是一种安全类型转换后接空值兜底的写法:若字段缺失或为 `null`,则使用空字符串替代,避免空指针异常。`toMap()` 方法将 `Post` 对象序列化为适用于写入 Firestore 的 `Map`,刻意排除了 `id` 字段,因为 Firestore 会自动生成并独立于文档内容存储文档 ID。新建帖子时 `likeCount` 初始值为 0,后续由服务端的自增操作更新。

// packages/shared/lib/src/validation/post_validation.dart

class PostValidation { static const int titleMaxLength = 120; static const int contentMaxLength = 5000;

static String? validateTitle(String? value) { if (value == null || value.trim().isEmpty) return 'Title is required.'; if (value.trim().length > titleMaxLength) { return 'Title cannot exceed $titleMaxLength characters.'; } return null; }

static String? validateContent(String? value) { if (value == null || value.trim().isEmpty) return 'Content is required.'; if (value.trim().length > contentMaxLength) { return 'Content cannot exceed $contentMaxLength characters.'; } return null; } }

code

这是端到端示例中简化的 `PostValidation` 版本。两个验证方法均遵循验证器契约:返回 `null` 表示有效,返回 `String` 表示无效,并附带具体错误原因。检查顺序按常见失败情形(空输入)到更具体情形(超长)排列,既符合逻辑,又具备效率优势——空值检查会短路执行,避免后续长度检查不必要的运行。

// packages/shared/lib/src/constants/api_constants.dart

class ApiConstants { static const String createPost = 'createPost'; static const String postsCollection = 'posts'; }

code

在端到端示例中,`ApiConstants` 被精简为仅包含本功能所需的两个常量:函数名称与集合名称,以保持示例聚焦。在真实项目中,该类会持续扩展,涵盖整个应用中使用的所有函数名与集合名。

// packages/shared/lib/shared.dart

export 'src/models/post.dart'; export 'src/validation/post_validation.dart'; export 'src/constants/api_constants.dart';

code

该 barrel 文件(桶文件)导出了全部三个模块。无论前端还是后端,任何文件若导入 `package:shared/shared.dart`,即可立即使用 `Post`、`PostValidation` 和 `ApiConstants`,无需关心其具体子目录路径。

### 云函数(Cloud Function)

// 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) { firebase.https.onCall( name: ApiConstants.createPost, options: const CallableOptions(cors: Cors(['*'])), (request, response) async { if (request.auth == null) { throw FirebaseFunctionsException( code: 'unauthenticated', message: 'You must be signed in to create a post.', ); }

final uid = request.auth!.uid; final data = request.data as Map<String, dynamic>? ?? {};

final title = data['title'] as String?; final content = data['content'] as String?;

final titleError = PostValidation.validateTitle(title); if (titleError != null) { throw FirebaseFunctionsException( code: 'invalid-argument', message: titleError, ); }

final contentError = PostValidation.validateContent(content); if (contentError != null) { throw FirebaseFunctionsException( code: 'invalid-argument', message: contentError, ); }

code

try { final ref = await firebase.adminApp .firestore() .collection(ApiConstants.postsCollection) .add({ 'title': title!.trim(), 'content': content!.trim(), 'authorId': uid, 'likeCount': 0, 'createdAt': FieldValue.serverTimestamp(), });

return CallableResult({ 'postId': ref.id, 'success': true, }); } catch (e) { print('Error writing post to Firestore: $e'); throw FirebaseFunctionsException( code: 'internal', message: 'Failed to create post. Please try again.', ); } }, ); }); }

code

`final data = request.data as Map<String, dynamic>? ?? {}` 通过在客户端发送空请求体时回退为空映射,安全地处理了该情况,从而避免在后续提取各字段前发生空指针解引用。此时,`title!.trim()` 和 `content!.trim()` 中的 `!` 操作符是安全的,因为此前的验证逻辑已确保这两个值非空且非空字符串。围绕 Firestore 写入操作的 `try/catch` 是最后一道安全防线:若 Admin SDK 写入因任何原因失败(如网络问题、Firestore 配额超限或意外错误),函数将捕获该异常,使用 `print` 记录完整的内部错误信息(该信息将写入 Cloud Run 日志),并向客户端抛出一个经过脱敏处理的 `'internal'` 类型错误,其中不透露任何具体失败原因。

### Flutter 应用端

// lib/services/functions_service.dart

import 'package:cloud_functions/cloud_functions.dart';

class FunctionsService { static const String _createPostUrl = 'https://createpost-REPLACE-WITH-YOUR-HASH.a.run.app';

Future<String> createPost({ required String title, required String content, }) async { try { final callable = FirebaseFunctions.instance .httpsCallableFromURL(_createPostUrl);

final result = await callable.call({'title': title, 'content': content});

return result.data['postId'] as String; } on FirebaseFunctionsException catch (e) { throw _mapError(e); } }

Exception _mapError(FirebaseFunctionsException e) { switch (e.code) { case 'unauthenticated': return Exception('Please sign in to continue.'); case 'invalid-argument': return Exception(e.message ?? 'Invalid input.'); default: return Exception('Something went wrong. Please try again.'); } } }

code

`FunctionsService` 是对可调用函数调用的轻量级封装,其职责仅限于:使用正确的 URL 构造可调用对象、传递数据、提取结果,并将结构化的服务端错误映射为领域异常。`_mapError` 方法将携带 Firebase 特定错误码的 `FirebaseFunctionsException` 对象转换为带有用户友好提示的普通 `Exception` 对象。这样可避免 Firebase 相关类型泄露至 Bloc 或 Widget 层,防止与 Firebase SDK 形成强耦合,从而提升代码的可测试性与可替换性。

// lib/features/create_post/create_post_screen.dart

import 'package:flutter/material.dart'; import 'package:shared/shared.dart'; import '../../services/functions_service.dart';

class CreatePostScreen extends StatefulWidget { const CreatePostScreen({super.key});

@override State<CreatePostScreen> createState() => _CreatePostScreenState(); }

class _CreatePostScreenState extends State<CreatePostScreen> { final _formKey = GlobalKey<FormState>(); final _titleController = TextEditingController(); final _contentController = TextEditingController(); final _service = FunctionsService();

bool _isSubmitting = false; String? _errorMessage;

@override void dispose() { _titleController.dispose(); _contentController.dispose(); super.dispose(); }

Future<void> _submit() async { if (!(_formKey.currentState?.validate() ?? false)) return;

setState(() { _isSubmitting = true; _errorMessage = null; });

try { final postId = await _service.createPost(

content: _contentController.text, );

if (!mounted) return;

ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Post created successfully! ID: $postId')), );

Navigator.of(context).pop(); } catch (e) { setState(() => _errorMessage = e.toString()); } finally { if (mounted) setState(() => _isSubmitting = false); } } }

code

@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('New Post')), body: Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(16), children: [ if (_errorMessage != null) Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(8), ), child: Text( _errorMessage!, style: TextStyle(color: Colors.red.shade800), ), ), TextFormField( controller: _titleController, decoration: InputDecoration( labelText: 'Title', hintText: 'What is your post about?', counterText: '\({_titleController.text.length}/\){PostValidation.titleMaxLength}', ), maxLength: PostValidation.titleMaxLength, validator: (value) => PostValidation.validateTitle(value), onChanged: (_) => setState(() {}), ), const SizedBox(height: 16), TextFormField( controller: _contentController, decoration: InputDecoration( labelText: 'Content', hintText: 'Write your post here...', counterText: '\({_contentController.text.length}/\){PostValidation.contentMaxLength}', alignLabelWithHint: true, ), maxLength: PostValidation.contentMaxLength, maxLines: 10, validator: (value) => PostValidation.validateContent(value), onChanged: (_) => setState(() {}), ), const SizedBox(height: 24), FilledButton( onPressed: _isSubmitting ? null : _submit, child: _isSubmitting ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text('Publish Post'), ), ], ), ), ); }

code

`GlobalKey<FormState>` 使 `_submit()` 方法能够访问表单的状态,从而触发所有字段的同时校验。`_formKey.currentState?.validate()` 会对表单中每个 `TextFormField` 调用其 `validator` 函数,并仅在所有校验器均返回 `null` 时返回 `true`。若校验失败则提前返回,可避免在表单无效时发起网络请求。`_isSubmitting` 控制 UI 状态:在请求进行期间,按钮被禁用(`onPressed: null`),并以 `CircularProgressIndicator` 替代按钮文本,向用户清晰反馈当前正在执行操作。在异步 `_submit()` 方法内部的 `if (!mounted) return` 可防止对已从树中移除的组件调用 `setState` 或 `Navigator`,否则将抛出 “setState called after dispose” 错误。`finally` 块确保即使发生异常,`_isSubmitting` 也始终被重置为 `false`,从而防止按钮永久卡在加载状态。

// lib/main.dart

import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:cloud_functions/cloud_functions.dart'; import 'dart:io' show Platform; import 'firebase_options.dart'; import 'features/create_post/create_post_screen.dart';

void main() async { WidgetsFlutterBinding.ensureInitialized();

await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, );

if (const bool.fromEnvironment('USE_EMULATOR', defaultValue: false)) { final host = Platform.isAndroid ? '10.0.2.2' : 'localhost'; FirebaseFunctions.instance.useFunctionsEmulator(host, 5001); }

runApp(const MyApp()); }

class MyApp extends StatelessWidget { const MyApp({super.key});

@override Widget build(BuildContext context) { return MaterialApp(

debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, ), home: const CreatePostScreen(), ); } }

code

`WidgetsFlutterBinding.ensureInitialized()` 必须在任何 Flutter 插件代码运行前调用,这包括 Firebase 初始化。若未调用此方法,直接在 `runApp()` 前调用 `Firebase.initializeApp()` 将导致报错。`DefaultFirebaseOptions.currentPlatform` 会从自动生成的 `firebase_options.dart` 文件中读取当前平台对应的 Firebase 项目配置。`const bool.fromEnvironment('USE_EMULATOR', defaultValue: false)` 读取一个编译时常量,可通过向 `flutter run` 命令传入 `--dart-define=USE_EMULATOR=true` 进行设置。这种模拟器切换方式比使用 `kDebugMode` 更安全,因为若在发布版中使用 `kDebugMode`(其值为 `false`),将导致模拟器失效;而通过 `--dart-define=USE_EMULATOR=true` 编译的发布版则显式控制是否启用模拟器。`Platform.isAndroid` 用于根据当前平台选择正确的模拟器主机地址,具体细节参见前文“设置”部分。

## 结论

Dart 在 Cloud Functions 中的应用,是 Flutter 社区期待多年的功能;在 Google Cloud Next 2026 大会上的发布,引发了热烈反响——这种程度的兴奋,往往只有当一个长期存在的痛点终于被解决时才会出现。自 2023 年起持续积累用户呼声的讨论帖,瞬间被欢欣鼓舞的留言填满。那些曾勉强学习 TypeScript 以编写后端函数、却始终感到不适的开发者们,如今终于有了回归自己熟悉语言的路径。

其技术基础确实坚实有力:Dart 的 AOT(Ahead-of-Time)编译显著降低了冷启动时间,优于解释型运行时;其空安全、强类型系统使共享包模式从“愿景”变为“现实”;其异步模型高效处理 I/O 密集型的无服务器工作负载;而 `firebase_functions` 包在设计上与 Flutter 开发者已熟悉的 FlutterFire 包保持一致,极大降低了学习曲线——任何已在客户端集成 Firebase 的开发者都能快速上手。

然而,该功能目前仍处于实验阶段,必须予以充分重视:背景触发器尚不可部署;Firebase 控制台暂不显示 Dart 函数;基于名称的可调用函数调用尚不支持。这些并非表面限制,而是切实影响架构决策的关键因素,团队应主动围绕这些限制进行设计,而非假设它们会在上线前自动解决。Firebase 团队正积极开发该功能,自发布以来进展令人鼓舞,但生产级系统仍需采取审慎规划。

无论 Dart 函数功能最终成熟度如何,**共享包**这一理念都值得作为架构的核心。即便因触发器限制暂时仍需将部分后端逻辑保留在 Node.js 中,将共享数据模型与验证逻辑构建为一个双方共同依赖的通用 Dart 包,也能立即显著提升代码质量——每一次消除重复的类型定义或手动维护的 API 合约,都移除了一类即使经过大量测试也无法完全避免的缺陷。共享包是当下即可实现的直接收益,而 Dart 函数功能则是放大器,使整个统一技术栈成为可能。

Flutter 社区才刚刚开始探索大规模全栈 Dart 的可能性:如何组织共享包、如何设计可测试的函数结构、如何权衡可调用函数与 HTTP 函数、如何优雅应对当前限制等实践模式,仍在真实项目中逐步建立与验证。本手册提供基础框架,而社区将在更多团队将生产负载上线并分享经验的过程中,共同完善其余部分。

## 参考资料

### 官方 Firebase 文档

*   **开始使用实验性 Dart SDK**

Firebase 官方文档,介绍如何配置 Dart Cloud Functions,涵盖 CLI 设置、实验性标志、本地模拟及部署。这是官方推荐的入门指南。[https://firebase.google.com/docs/functions/start-dart](https://firebase.google.com/docs/functions/start-dart)

*   **Cloud Functions for Firebase 概览**

Cloud Functions 主文档页,现包含横幅公告,宣布实验性 Dart 支持,并链接至 Dart 专项指南。[https://firebase.google.com/docs/functions](https://firebase.google.com/docs/functions)

*   **从应用调用函数(Dart)**

Firebase 文档,介绍如何从 Flutter 应用调用可调用函数,包括当前 `httpsCallable` 名称解析的限制及 `httpsCallableFromURL` 的变通方案。[https://firebase.google.com/docs/functions/callable](https://firebase.google.com/docs/functions/callable)

*   **Firebase AI 逻辑文档**

适用于结合 Dart Cloud Functions 与 Gemini AI 功能的团队(通过 [Firebase](https://firebase.google.com/docs/ai-logic))。[https://firebase.google.com/docs/ai-logic](https://firebase.google.com/docs/ai-logic)

### 公告与博客文章

*   **宣布在 Cloud Functions for Firebase 中支持 Dart**

Google Cloud Next 2026 上 Firebase 官方博客文章,介绍引入 Dart 支持的动机、Admin SDK、共享代码架构及 AOT 编译带来的性能优势。[https://firebase.blog/posts/2026/05/dart-functions-exp](https://firebase.blog/posts/2026/05/dart-functions-exp)

*   **X 上的 Dart 语言:Dart 无处不在**

Dart 团队的公告帖,用一句话总结全栈 Dart 的愿景。

[https://x.com/dart_lang/status/2047418350268273060](https://x.com/dart_lang/status/2047418350268273060)

### 相关包(Packages)

*   **firebase_functions(pub.dev)**

Cloud Functions 的官方 Dart 包,提供 `fireUp`、`onRequest`、`onCall`、`HttpsOptions`、`CallableOptions` 和 `FirebaseFunctionsException` 等 API。[https://pub.dev/packages/firebase_functions](https://pub.dev/packages/firebase_functions)

*   **firebase_functions(GitHub)**

`firebase_functions` Dart 包的源码、问题跟踪与示例。README 中包含额外示例及最新限制说明。[https://github.com/firebase/firebase-functions-dart](https://github.com/firebase/firebase-functions-dart)

*   **dart_firebase_admin(pub.dev)**

适用于 Cloud Functions 外部场景(如 Cloud Run、独立服务器、命令行脚本)的 Dart Admin SDK,由 Invertase 维护。[https://pub.dev/packages/dart_firebase_admin](https://pub.dev/packages/dart_firebase_admin)

*   **dart_firebase_admin(GitHub)**

Dart Admin SDK 的源码与文档,包含 Firestore、Authentication、Cloud Storage 和 FCM 的使用示例。[https://github.com/invertase/dart_firebase_admin](https://github.com/invertase/dart_firebase_admin)

*   **google_cloud_firestore(pub.dev)**

Dart Firestore SDK(独立版)用于 Dart Cloud Functions 中的 Firestore 操作。

[https://pub.dev/packages/google_cloud_firestore](https://pub.dev/packages/google_cloud_firestore)

### 编程实践与教程

*   **使用 Cloud Functions 构建完整的 Dart 全栈应用**

Google 官方编程实践(Codelab),引导您构建一个使用共享 Dart 包、Dart Cloud Functions 以及 Flutter 前端的多人计数器应用。这是目前最全面的动手入门指南。  
[https://codelabs.developers.google.com/deploy-dart-on-firebase-functions](https://codelabs.developers.google.com/deploy-dart-on-firebase-functions)

### 相关 Flutter 与 Dart 包

*   **cloud_functions(FlutterFire)**

用于调用 Cloud Functions 的 Flutter 客户端包,本指南中用于 `httpsCallableFromURL`。

[https://pub.dev/packages/cloud_functions](https://pub.dev/packages/cloud_functions)

*   **firebase_core**

所有 FlutterFire 包所需的底层基础包。  
[https://pub.dev/packages/firebase_core](https://pub.dev/packages/firebase_core)

*   **json_annotation 与 json_serializable**

用于共享包中自动生成 `fromJson` 和 `toJson` 方法,从而避免手动编写序列化代码。  
[https://pub.dev/packages/json_annotation](https://pub.dev/packages/json_annotation)

_本手册撰写于 2026 年 5 月,反映的是 2026 年 Google Cloud Next 大会上宣布的实验性 Dart Cloud Functions 支持、`firebase_functions` 包的 0.1.x 版本,以及由 Invertase 维护的 `dart_firebase_admin` 包。由于该功能尚处于实验阶段,其 API 及支持的触发器类型在未来版本中可能发生变化。升级前请务必查阅 Firebase 官方文档及各包的更新日志。_

* * *

* * *

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成功入职开发者岗位。[开始学习](https://www.freecodecamp.org/learn)

AI 可能会生成不准确的信息,请核实重要内容

如何使用 Dart Cloud Functions 与 Firebase Admin SDK:开发者手册 | freeCodeCamp.org | traeai