使用 Cloud Functions for Firebase 建構全端 Dart 應用程式

1. 簡介

在本程式碼研究室中,您將建構多人計數器應用程式,並瞭解如何將 Dart 用於 Flutter 前端和 Firebase 後端。

您也會瞭解如何在應用程式和伺服器之間共用資料模型,不必重複邏輯。

課程內容

  • 將共用的商業邏輯擷取至獨立的 Dart 套件。
  • 以 Dart 原生語言編寫及部署 Cloud Functions for Firebase。
  • 運用 Dart 的預先 (AOT) 編譯功能,減少無伺服器冷啟動次數。
  • 使用 Firebase 模擬器套件在本機測試堆疊。

2. 先決條件

  • Flutter SDK (最新穩定版)。
  • Firebase CLI (必須為 15.15.0 以上版本)。
  • 程式碼編輯器,例如 Antigravity、Visual Studio Code、IntelliJ 或 Android Studio,並安裝 Dart 和 Flutter 外掛程式。
  • 熟悉 Flutter 和 Firebase 的基本知識。

3. 為什麼要使用 Dart 做為後端?

許多雲端應用程式會使用 Dart 做為前端 UI,並使用 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 目錄中,執行建構執行器來產生 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 模擬器套件在本機測試

您可以在本機執行前端和後端,不必部署。

從專案根目錄啟動 Firebase 模擬器套件:

# 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 後端、擷取新計數,並更新 UI。

9. 部署至 Firebase

在本程式碼研究室中,您使用 Firebase 本機模擬器套件,在沒有 Firebase 專案或帳單帳戶的情況下試用函式。如要在實際環境 (例如正式環境) 中使用函式,您必須設定 Firebase 專案和帳單。

建立 Firebase 專案

  1. 使用 Google 帳戶登入 Firebase 控制台
  2. 按一下按鈕建立新專案,然後輸入專案名稱。
  3. 按一下「繼續」
  4. 如果系統提示,請詳閱並接受 Firebase 條款,然後按一下「繼續」
  5. (選用) 在 Firebase 控制台中啟用 AI 輔助功能 (稱為「Gemini in Firebase」)。
  6. 本程式碼研究室不需要 Google Analytics,因此請關閉 Google Analytics 選項。
  7. 按一下「建立專案」,等待專案佈建完成,然後按一下「繼續」

升級 Firebase 定價方案

如要在本程式碼研究室中使用 Firebase 服務,Firebase 專案必須採用即付即用 (Blaze) 定價方案,也就是連結至 Cloud Billing 帳戶

  • Cloud Billing 帳戶需要付款方式,例如信用卡。
  • 在特別促銷活動期間,或如果您是參加活動並完成本程式碼研究室,可能可以獲得 Google Cloud 抵免額。
  • 如果您剛開始使用 Firebase 和 Google Cloud,請確認是否符合 $300 美元抵免額和免費試用 Cloud Billing 帳戶的資格。

如要將專案升級至 Blaze 方案,請按照下列步驟操作:

  1. 在 Firebase 控制台中,選取「升級方案」
  2. 選取 Blaze 方案。按照畫面上的指示,將 Cloud Billing 帳戶連結至專案。
    • 如果您使用 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 Emulator 上進行測試,裝置不會將 localhost 解析為您的主體機器。更新 main.dart 中的模擬器設定,改用 10.0.2.2

找不到共用套件

驗證 functions/pubspec.yaml 中的相對路徑。如果資料夾結構與程式碼實驗室不同,請調整 path: ../packages/shared 指向正確的目錄。

我需要使用 json_serializable 嗎?

雖然並非必要,但使用 json_serializable 可避免手動編寫 fromJsontoJson 方法時發生錯誤。確保前端和後端預期採用完全相同的資料格式。

11. 恭喜

您已成功建構全端 Dart 應用程式。在共用套件中維護資料模型,可確保 API 回應和用戶端 UI 保持同步,並在整個堆疊中使用單一程式設計語言。