認識 Firebase for Flutter

1. 事前準備

在本程式碼研究室中,您將瞭解 Firebase 的一些基本概念,以便建立 Android 和 iOS 版 Flutter 行動應用程式。

事前準備

課程內容

  • 如何使用 Flutter 在 Android、iOS、網頁和 macOS 上建構活動回覆和留言板聊天室應用程式。
  • 如何使用 Firebase 驗證功能驗證使用者,並透過 Firestore 同步處理資料。

Android 版應用程式主畫面

iOS 版應用程式主畫面

事前準備

下列任一裝置:

  • 已連接至電腦並設為開發人員模式的實體 Android 或 iOS 裝置。
  • iOS 模擬器 (需要 Xcode 工具)。
  • Android Emulator (需要在 Android Studio 中設定)。

你還需要下列項目:

  • 你偏好的瀏覽器,例如 Google Chrome。
  • 您選擇的 IDE 或文字編輯器,並已設定 Dart 和 Flutter 外掛程式,例如 Android StudioVisual Studio Code
  • 最新的 stableFlutter,或是 beta (如果您喜歡嘗試最新功能)。
  • 用於建立及管理 Firebase 專案的 Google 帳戶。
  • Firebase CLI 已登入您的 Google 帳戶。

2. 取得程式碼範例

請從 GitHub 下載專案的初始版本:

  1. 在指令列中,複製 flutter-codelabs 目錄中的 GitHub 存放區
git clone https://github.com/flutter/codelabs.git flutter-codelabs

flutter-codelabs 目錄包含一系列程式碼研究室的程式碼。本程式碼研究室的程式碼位於 flutter-codelabs/firebase-get-to-know-flutter 目錄中。目錄包含一系列快照,顯示專案在每個步驟結束時的樣貌。舉例來說,假設你正在進行第二步。

  1. 找出第二步驟的對應檔案:
cd flutter-codelabs/firebase-get-to-know-flutter/step_02

如要略過某些步驟,或查看某個步驟後的內容,請前往該步驟名稱對應的資料夾。

匯入範例應用程式

  • 在想用的 IDE 中開啟或匯入 flutter-codelabs/firebase-get-to-know-flutter/step_02 目錄。這個目錄包含程式碼研究室的範例程式碼,其中包含尚未啟用的 Flutter 聚會應用程式。

找出需要處理的檔案

這個應用程式中的程式碼分散在多個目錄中。這種功能分割方式可依功能分組程式碼,讓工作更輕鬆。

  • 找出下列檔案:
    • lib/main.dart:這個檔案包含主要進入點和應用程式小工具。
    • lib/home_page.dart:這個檔案包含首頁小工具。
    • lib/src/widgets.dart:這個檔案包含一些小工具,可協助將應用程式樣式標準化,並組成啟動器應用程式的畫面。
    • lib/src/authentication.dart:這個檔案包含部分實作的驗證,其中包含一組小工具,可為 Firebase 電子郵件驗證建立登入使用者體驗。這些驗證流程小工具尚未用於入門應用程式,但您很快就會新增這些小工具。

您可以視需要新增其他檔案,以建構應用程式的其餘部分。

查看 lib/main.dart 檔案

這個應用程式會利用 google_fonts 套件,將 Roboto 設為應用程式中的預設字型。您可以瀏覽 fonts.google.com,並在應用程式的不同部分使用該處提供的字型。

您可以使用 lib/src/widgets.dart 檔案中的輔助小工具,格式為 HeaderParagraphIconAndDetail。這些小工具可消除重複的程式碼,減少 HomePage 中所述的頁面版面配置雜亂情形。這也能讓外觀與風格保持一致。

以下是應用程式在 Android、iOS、網頁和 macOS 上的外觀:

Android 版應用程式主畫面

iOS 版應用程式主畫面

應用程式在網頁上的主畫面

macOS 上的應用程式主畫面

3. 建立及設定 Firebase 專案

顯示事件資訊對住客來說很實用,但對其他人來說並沒有太大幫助。您需要在應用程式中加入一些動態功能。如要這麼做,您必須將 Firebase 連結至應用程式。如要開始使用 Firebase,您必須建立及設定 Firebase 專案。

建立 Firebase 專案

  1. 登入 Firebase
  2. 在控制台中,按一下「新增專案」或「建立專案」
  3. 在「Project name」欄位中輸入「Firebase-Flutter-Codelab」,然後按一下「Continue」

4395e4e67c08043a.png

  1. 點選專案建立選項。如果系統提示,請接受 Firebase 條款,但略過 Google Analytics 設定,因為您不會在這個應用程式中使用這項服務。

b7138cde5f2c7b61.png

如要進一步瞭解 Firebase 專案,請參閱「瞭解 Firebase 專案」一文。

應用程式會使用下列 Firebase 產品,這些產品可用於網頁應用程式:

  • 驗證:讓使用者登入應用程式。
  • Firestore:在雲端儲存結構化資料,並在資料變更時立即收到通知。
  • Firebase 安全性規則:可保護資料庫的安全。

部分產品需要特殊設定,或您必須在 Firebase 控制台中啟用。

啟用電子郵件登入驗證

  1. 在 Firebase 控制台的「專案總覽」窗格中,展開「建構」選單。
  2. 依序點選「驗證」>「開始使用」>「登入方式」>「電子郵件/密碼」>「啟用」>「儲存」

58e3e3e23c2f16a4.png

設定 Firestore

這個網頁應用程式會使用 Firestore 儲存即時通訊訊息,並接收新的即時通訊訊息。

以下說明如何在 Firebase 專案中設定 Firestore:

  1. 在 Firebase 主控台的左側面板中展開「Build」,然後選取「Firestore database」
  2. 按一下 [Create database] (建立資料庫)。
  3. 將「資料庫 ID」設為 (default)
  4. 選取資料庫的位置,然後按一下「Next」
    如果是實際應用程式,請選擇距離使用者較近的位置。
  5. 按一下「以測試模式啟動」。請詳閱安全性規則免責事項。
    在本程式碼研究室的後續部分,您將新增安全性規則來保護資料。請勿發布或公開應用程式,除非您已為資料庫新增安全性規則。
  6. 按一下「建立」

4. 設定 Firebase

如要搭配 Flutter 使用 Firebase,您必須完成下列工作,設定 Flutter 專案以正確使用 FlutterFire 程式庫:

  1. FlutterFire 依附元件新增至專案。
  2. 在 Firebase 專案中註冊所需平台。
  3. 下載特定平台的設定檔,然後將其加入程式碼。

在 Flutter 應用程式的頂層目錄中,有 androidiosmacosweb 子目錄,分別用於儲存 iOS 和 Android 的特定平台設定檔。

設定依附元件

您需要為此應用程式中使用的兩項 Firebase 產品 (驗證和 Firestore) 新增 FlutterFire 程式庫。

  • 在指令列中新增下列相依項目:
$ flutter pub add firebase_core

firebase_core 套件是所有 Firebase Flutter 外掛程式所需的通用程式碼。

$ flutter pub add firebase_auth

firebase_auth 套件可讓您整合驗證功能。

$ flutter pub add cloud_firestore

cloud_firestore 套件可讓您存取 Firestore 資料儲存空間。

$ flutter pub add provider

firebase_ui_auth 套件提供一組小工具和公用程式,可透過驗證流程提高開發人員的開發速度。

$ flutter pub add firebase_ui_auth

您已新增必要套件,但也需要設定 iOS、Android、macOS 和 Web Runner 專案,才能妥善使用 Firebase。您也使用 provider 套件,可將商業邏輯與顯示邏輯分開。

安裝 FlutterFire CLI

FlutterFire CLI 會依附於基礎 Firebase CLI。

  1. 如果您尚未在電腦上安裝 Firebase CLI,請先完成這項操作。
  2. 安裝 FlutterFire CLI:
$ dart pub global activate flutterfire_cli

安裝完畢後,flutterfire 指令即可在全球範圍內使用。

設定應用程式

CLI 會從 Firebase 專案和所選專案應用程式擷取資訊,為特定平台產生所有設定。

在應用程式的根目錄中,執行 configure 指令:

$ flutterfire configure

設定指令會引導您完成下列程序:

  1. 根據 .firebaserc 檔案或 Firebase 主控台選取 Firebase 專案。
  2. 決定設定的平台,例如 Android、iOS、macOS 和網頁。
  3. 找出要擷取設定的 Firebase 應用程式。根據預設,CLI 會嘗試根據目前的專案設定自動比對 Firebase 應用程式。
  4. 在專案中產生 firebase_options.dart 檔案。

設定 macOS

macOS 上的 Flutter 會建構完全沙箱化的應用程式。由於這個應用程式會整合網路,以便與 Firebase 伺服器通訊,因此您必須設定應用程式的網路用戶端權限。

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
	<key>com.apple.security.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.network.server</key>
	<true/>
  <!-- Add the following two lines -->
	<key>com.apple.security.network.client</key>
	<true/>
</dict>
</plist>

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
  <!-- Add the following two lines -->
	<key>com.apple.security.network.client</key>
	<true/>
</dict>
</plist>

詳情請參閱「Flutter 的電腦支援功能」。

5. 新增回覆功能

在應用程式中加入 Firebase 後,您可以建立回覆出席與否按鈕,讓使用者透過驗證功能註冊。針對 Android 原生、iOS 原生和網頁,您可以使用預先建構的 FirebaseUI Auth 套件,但必須為 Flutter 建構這項功能。

您先前擷取的專案包含一組小工具,可為大部分驗證流程實作使用者介面。您會實作商業邏輯,將驗證功能與應用程式整合。

使用 Provider 套件新增商業邏輯

使用 provider 套件,讓應用程式狀態物件可在應用程式 Flutter 小工具樹狀結構中使用:

  1. 建立名為 app_state.dart 的新檔案,並在其中加入下列內容:

lib/app_state.dart

import 'package:firebase_auth/firebase_auth.dart'
    hide EmailAuthProvider, PhoneAuthProvider;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ui_auth/firebase_ui_auth.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';

class ApplicationState extends ChangeNotifier {
  ApplicationState() {
    init();
  }

  bool _loggedIn = false;
  bool get loggedIn => _loggedIn;

  Future<void> init() async {
    await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform);

    FirebaseUIAuth.configureProviders([
      EmailAuthProvider(),
    ]);

    FirebaseAuth.instance.userChanges().listen((user) {
      if (user != null) {
        _loggedIn = true;
      } else {
        _loggedIn = false;
      }
      notifyListeners();
    });
  }
}

import 陳述式會引入 Firebase Core 和 Auth,並匯入 provider 套件,讓應用程式狀態物件可在整個小工具樹狀結構中使用,並納入 firebase_ui_auth 套件的驗證小工具。

這個 ApplicationState 應用程式狀態物件在這個步驟中負責通知小工具樹狀結構,已對已驗證狀態進行更新。

您只需使用提供者,即可將使用者的登入狀態傳達給應用程式。如要讓使用者登入,您可以使用 firebase_ui_auth 套件提供的 UI,這是在應用程式中快速啟動登入畫面的絕佳方法。

整合驗證流程

  1. 修改 lib/main.dart 檔案頂端的匯入項目:

lib/main.dart

import 'package:firebase_ui_auth/firebase_ui_auth.dart'; // new
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';               // new
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';                 // new

import 'app_state.dart';                                 // new
import 'home_page.dart';
  1. 將應用程式狀態連結至應用程式初始化,然後將驗證流程新增至 HomePage

lib/main.dart

void main() {
  // Modify from here...
  WidgetsFlutterBinding.ensureInitialized();

  runApp(ChangeNotifierProvider(
    create: (context) => ApplicationState(),
    builder: ((context, child) => const App()),
  ));
  // ...to here.
}

修改 main() 函式後,供應商套件會負責使用 ChangeNotifierProvider 小工具將應用程式狀態物件例項化。您使用這個特定的 provider 類別,是因為應用程式狀態物件會擴充 ChangeNotifier 類別,讓 provider 套件知道何時重新顯示依附的小工具。

  1. 建立 GoRouter 設定,更新應用程式,以便處理導覽至 FirebaseUI 提供的不同畫面:

lib/main.dart

// Add GoRouter configuration outside the App class
final _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
      routes: [
        GoRoute(
          path: 'sign-in',
          builder: (context, state) {
            return SignInScreen(
              actions: [
                ForgotPasswordAction(((context, email) {
                  final uri = Uri(
                    path: '/sign-in/forgot-password',
                    queryParameters: <String, String?>{
                      'email': email,
                    },
                  );
                  context.push(uri.toString());
                })),
                AuthStateChangeAction(((context, state) {
                  final user = switch (state) {
                    SignedIn state => state.user,
                    UserCreated state => state.credential.user,
                    _ => null
                  };
                  if (user == null) {
                    return;
                  }
                  if (state is UserCreated) {
                    user.updateDisplayName(user.email!.split('@')[0]);
                  }
                  if (!user.emailVerified) {
                    user.sendEmailVerification();
                    const snackBar = SnackBar(
                        content: Text(
                            'Please check your email to verify your email address'));
                    ScaffoldMessenger.of(context).showSnackBar(snackBar);
                  }
                  context.pushReplacement('/');
                })),
              ],
            );
          },
          routes: [
            GoRoute(
              path: 'forgot-password',
              builder: (context, state) {
                final arguments = state.uri.queryParameters;
                return ForgotPasswordScreen(
                  email: arguments['email'],
                  headerMaxExtent: 200,
                );
              },
            ),
          ],
        ),
        GoRoute(
          path: 'profile',
          builder: (context, state) {
            return ProfileScreen(
              providers: const [],
              actions: [
                SignedOutAction((context) {
                  context.pushReplacement('/');
                }),
              ],
            );
          },
        ),
      ],
    ),
  ],
);
// end of GoRouter configuration

// Change MaterialApp to MaterialApp.router and add the routerConfig
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Firebase Meetup',
      theme: ThemeData(
        buttonTheme: Theme.of(context).buttonTheme.copyWith(
              highlightColor: Colors.deepPurple,
            ),
        primarySwatch: Colors.deepPurple,
        textTheme: GoogleFonts.robotoTextTheme(
          Theme.of(context).textTheme,
        ),
        visualDensity: VisualDensity.adaptivePlatformDensity,
        useMaterial3: true,
      ),
      routerConfig: _router, // new
    );
  }
}

每個畫面都會根據驗證流程的新狀態,與不同類型的動作相關聯。在驗證程序中,大多數狀態都會發生變化,您可以將流程重新導向至偏好的畫面,無論是主畫面或其他畫面 (例如個人資料) 皆可。

  1. HomePage 類別的建構方法中,將應用程式狀態與 AuthFunc 小工具整合:

lib/home_page.dart

import 'package:firebase_auth/firebase_auth.dart' // new
    hide EmailAuthProvider, PhoneAuthProvider;    // new
import 'package:flutter/material.dart';           // new
import 'package:provider/provider.dart';          // new

import 'app_state.dart';                          // new
import 'src/authentication.dart';                 // new
import 'src/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Firebase Meetup'),
      ),
      body: ListView(
        children: <Widget>[
          Image.asset('assets/codelab.png'),
          const SizedBox(height: 8),
          const IconAndDetail(Icons.calendar_today, 'October 30'),
          const IconAndDetail(Icons.location_city, 'San Francisco'),
          // Add from here
          Consumer<ApplicationState>(
            builder: (context, appState, _) => AuthFunc(
                loggedIn: appState.loggedIn,
                signOut: () {
                  FirebaseAuth.instance.signOut();
                }),
          ),
          // to here
          const Divider(
            height: 8,
            thickness: 1,
            indent: 8,
            endIndent: 8,
            color: Colors.grey,
          ),
          const Header("What we'll be doing"),
          const Paragraph(
            'Join us for a day full of Firebase Workshops and Pizza!',
          ),
        ],
      ),
    );
  }
}

您可以將 AuthFunc 小工具例項化,並將其包裝在 Consumer 小工具中。在應用程式狀態變更時,使用者可透過 Consumer 小工具,以 provider 套件重建樹狀結構的部分內容。AuthFunc 小工具是您要測試的輔助小工具。

測試驗證流程

cdf2d25e436bd48d.png

  1. 在應用程式中輕觸「回覆」按鈕,即可啟動 SignInScreen

2a2cd6d69d172369.png

  1. 輸入電子郵件地址。如果您已註冊,系統會提示您輸入密碼。否則系統會提示您填寫註冊表單。

e5e65065dba36b54.png

  1. 輸入密碼 (長度不超過六個字元),以便檢查錯誤處理流程。如果已註冊,畫面上會顯示密碼。
  2. 輸入錯誤的密碼,以便檢查錯誤處理流程。
  3. 請輸入正確的密碼。您會看到登入體驗,使用者可以登出。

4ed811a25b0cf816.png

6. 將訊息寫入 Firestore

很高興知道使用者會前來,但您需要在應用程式中提供其他內容,讓訪客可以留言。他們可以分享為何期待參加活動,或是想認識哪些人。

如要儲存使用者在應用程式中輸入的即時通訊訊息,請使用 Firestore

資料模型

Firestore 是 NoSQL 資料庫,儲存在資料庫中的資料會分割為集合、文件、欄位和子集合。您可以將每則聊天訊息儲存為 guestbook 集合中的文件,這是頂層集合。

7c20dc8424bb1d84.png

將訊息新增至 Firestore

在本節中,您將新增功能,讓使用者將訊息寫入資料庫。首先新增表單欄位和傳送按鈕,然後新增程式碼,將這些元素連結至資料庫。

  1. 建立名為 guest_book.dart 的新檔案,新增 GuestBook 有狀態的小工具,以建構訊息欄位和傳送按鈕的 UI 元素:

lib/guest_book.dart

import 'dart:async';

import 'package:flutter/material.dart';

import 'src/widgets.dart';

class GuestBook extends StatefulWidget {
  const GuestBook({required this.addMessage, super.key});

  final FutureOr<void> Function(String message) addMessage;

  @override
  State<GuestBook> createState() => _GuestBookState();
}

class _GuestBookState extends State<GuestBook> {
  final _formKey = GlobalKey<FormState>(debugLabel: '_GuestBookState');
  final _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Form(
        key: _formKey,
        child: Row(
          children: [
            Expanded(
              child: TextFormField(
                controller: _controller,
                decoration: const InputDecoration(
                  hintText: 'Leave a message',
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Enter your message to continue';
                  }
                  return null;
                },
              ),
            ),
            const SizedBox(width: 8),
            StyledButton(
              onPressed: () async {
                if (_formKey.currentState!.validate()) {
                  await widget.addMessage(_controller.text);
                  _controller.clear();
                }
              },
              child: Row(
                children: const [
                  Icon(Icons.send),
                  SizedBox(width: 4),
                  Text('SEND'),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

這裡有幾個值得注意的要點。首先,您需要將表單例項化,以便驗證訊息是否確實包含內容,並在沒有內容時向使用者顯示錯誤訊息。如要驗證表單,您可以使用 GlobalKey 存取表單後方的表單狀態。如要進一步瞭解金鑰及使用方式,請參閱「使用金鑰的時機」。

請注意小工具的版面配置方式,您有一個 Row,其中包含 TextFormFieldStyledButton,後者包含 Row。請注意,TextFormField 會包裝在 Expanded 小工具中,這會強制 TextFormField 填入列中的任何額外空間。如要進一步瞭解為何需要這麼做,請參閱「瞭解限制」一文。

您現在已經有了可讓使用者輸入文字並新增至留言板的小工具,接下來請將小工具顯示在畫面上。

  1. 編輯 HomePage 的內容,在 ListView 的子項結尾處加入下列兩行:
const Header("What we'll be doing"),
const Paragraph(
  'Join us for a day full of Firebase Workshops and Pizza!',
),
// Add the following two lines.
const Header('Discussion'),
GuestBook(addMessage: (message) => print(message)),

雖然這足以顯示小工具,但無法執行任何實用功能。您很快就會更新這段程式碼,讓程式碼正常運作。

應用程式預覽

Android 上整合了即時通訊功能的應用程式主畫面

iOS 版應用程式主畫面 (已整合聊天功能)

整合了即時通訊功能的應用程式網頁版主畫面

macOS 上應用程式的主畫面,已整合即時通訊功能

使用者點選「SEND」時,系統會觸發下列程式碼片段。這會將訊息輸入欄位的內容新增至資料庫的 guestbook 集合。具體來說,addMessageToGuestBook 方法會將訊息內容新增至 guestbook 集合中自動產生 ID 的新文件。

請注意,FirebaseAuth.instance.currentUser.uid 是指系統為所有登入使用者自動產生的專屬 ID。

  • lib/app_state.dart 檔案中,新增 addMessageToGuestBook 方法。您會在下一個步驟中,將這項功能連結至使用者介面。

lib/app_state.dart

import 'package:cloud_firestore/cloud_firestore.dart'; // new
import 'package:firebase_auth/firebase_auth.dart'
    hide EmailAuthProvider, PhoneAuthProvider;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ui_auth/firebase_ui_auth.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';

class ApplicationState extends ChangeNotifier {

  // Current content of ApplicationState elided ...

  // Add from here...
  Future<DocumentReference> addMessageToGuestBook(String message) {
    if (!_loggedIn) {
      throw Exception('Must be logged in');
    }

    return FirebaseFirestore.instance
        .collection('guestbook')
        .add(<String, dynamic>{
      'text': message,
      'timestamp': DateTime.now().millisecondsSinceEpoch,
      'name': FirebaseAuth.instance.currentUser!.displayName,
      'userId': FirebaseAuth.instance.currentUser!.uid,
    });
  }
  // ...to here.
}

連結 UI 和資料庫

您有一個 UI,使用者可以輸入要新增到留言簿的文字,而且您有程式碼可將項目新增至 Firestore。接下來,您只需要連結這兩個裝置即可。

  • lib/home_page.dart 檔案中,對 HomePage 小工具進行以下變更:

lib/home_page.dart

import 'package:firebase_auth/firebase_auth.dart'
    hide EmailAuthProvider, PhoneAuthProvider;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'app_state.dart';
import 'guest_book.dart';                         // new
import 'src/authentication.dart';
import 'src/widgets.dart';

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Firebase Meetup'),
      ),
      body: ListView(
        children: <Widget>[
          Image.asset('assets/codelab.png'),
          const SizedBox(height: 8),
          const IconAndDetail(Icons.calendar_today, 'October 30'),
          const IconAndDetail(Icons.location_city, 'San Francisco'),
          Consumer<ApplicationState>(
            builder: (context, appState, _) => AuthFunc(
                loggedIn: appState.loggedIn,
                signOut: () {
                  FirebaseAuth.instance.signOut();
                }),
          ),
          const Divider(
            height: 8,
            thickness: 1,
            indent: 8,
            endIndent: 8,
            color: Colors.grey,
          ),
          const Header("What we'll be doing"),
          const Paragraph(
            'Join us for a day full of Firebase Workshops and Pizza!',
          ),
          // Modify from here...
          Consumer<ApplicationState>(
            builder: (context, appState, _) => Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                if (appState.loggedIn) ...[
                  const Header('Discussion'),
                  GuestBook(
                    addMessage: (message) =>
                        appState.addMessageToGuestBook(message),
                  ),
                ],
              ],
            ),
          ),
          // ...to here.
        ],
      ),
    );
  }
}

您已將在本步驟開頭新增的兩行程式碼,替換為完整實作內容。您再次使用 Consumer<ApplicationState>,讓應用程式狀態可供您轉譯的樹狀結構的一部分使用。這樣一來,您就能回應使用者在 UI 中輸入的訊息,並將訊息發布至資料庫。在下一節中,您將測試新增的訊息是否已發布至資料庫。

測試傳送訊息

  1. 如有需要,請登入應用程式。
  2. 輸入訊息 (例如 Hey there!),然後按一下「傳送」

這項動作會將訊息寫入 Firestore 資料庫。不過,您不會在實際的 Flutter 應用程式中看到這則訊息,因為您仍需實作資料擷取作業,這會在下一個步驟中進行。不過,您可以在 Firebase 主控台的「資料庫」資訊主頁中,查看 guestbook 集合中的新增訊息。如果您傳送更多訊息,就會在 guestbook 集合中新增更多文件。例如,請參閱以下程式碼片段:

713870af0b3b63c.png

7. 讀取訊息

雖然訪客可以將訊息寫入資料庫,但他們目前無法在應用程式中查看這些訊息。是時候修正這個問題了!

同步處理訊息

如要顯示訊息,您必須新增會在資料變更時觸發的事件監聽器,然後建立可顯示新訊息的 UI 元素。您可以在應用程式狀態中加入程式碼,讓應用程式監聽新加入的訊息。

  1. 建立新檔案 guest_book_message.dart,然後新增下列類別,以便顯示儲存在 Firestore 中的資料結構化檢視畫面。

lib/guest_book_message.dart

class GuestBookMessage {
  GuestBookMessage({required this.name, required this.message});

  final String name;
  final String message;
}
  1. lib/app_state.dart 檔案中,新增下列匯入項目:

lib/app_state.dart

import 'dart:async';                                     // new

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart'
    hide EmailAuthProvider, PhoneAuthProvider;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ui_auth/firebase_ui_auth.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';
import 'guest_book_message.dart';                        // new
  1. 在定義狀態和 getter 的 ApplicationState 部分中,新增以下行:

lib/app_state.dart

  bool _loggedIn = false;
  bool get loggedIn => _loggedIn;

  // Add from here...
  StreamSubscription<QuerySnapshot>? _guestBookSubscription;
  List<GuestBookMessage> _guestBookMessages = [];
  List<GuestBookMessage> get guestBookMessages => _guestBookMessages;
  // ...to here.
  1. ApplicationState 的初始化部分中,加入以下幾行程式碼,以便在使用者登入時訂閱文件集合查詢,並在使用者登出時取消訂閱:

lib/app_state.dart

  Future<void> init() async {
    await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform);

    FirebaseUIAuth.configureProviders([
      EmailAuthProvider(),
    ]);
    
    FirebaseAuth.instance.userChanges().listen((user) {
      if (user != null) {
        _loggedIn = true;
        _guestBookSubscription = FirebaseFirestore.instance
            .collection('guestbook')
            .orderBy('timestamp', descending: true)
            .snapshots()
            .listen((snapshot) {
          _guestBookMessages = [];
          for (final document in snapshot.docs) {
            _guestBookMessages.add(
              GuestBookMessage(
                name: document.data()['name'] as String,
                message: document.data()['text'] as String,
              ),
            );
          }
          notifyListeners();
        });
      } else {
        _loggedIn = false;
        _guestBookMessages = [];
        _guestBookSubscription?.cancel();
      }
      notifyListeners();
    });
  }

這個部分很重要,因為您會在此建構 guestbook 集合的查詢,並處理訂閱和取消訂閱這個集合。您會監聽串流,在其中重建 guestbook 集合中訊息的本機快取,並儲存此訂閱項目的參照,以便日後取消訂閱。這裡有許多工作要執行,因此您應該在偵錯工具中探索,檢查發生的情況,以便建立更清晰的概念模型。詳情請參閱「透過 Firestore 取得即時更新」一文。

  1. lib/guest_book.dart 檔案中,新增下列匯入項目:
import 'guest_book_message.dart';
  1. GuestBook 小工具中,將訊息清單加入設定,以便將這個變化狀態連結至使用者介面:

lib/guest_book.dart

class GuestBook extends StatefulWidget {
  // Modify the following line:
  const GuestBook({
    super.key, 
    required this.addMessage, 
    required this.messages,
  });

  final FutureOr<void> Function(String message) addMessage;
  final List<GuestBookMessage> messages; // new

  @override
  _GuestBookState createState() => _GuestBookState();
}
  1. _GuestBookState 中,修改 build 方法,如下所示,以公開此設定:

lib/guest_book.dart

class _GuestBookState extends State<GuestBook> {
  final _formKey = GlobalKey<FormState>(debugLabel: '_GuestBookState');
  final _controller = TextEditingController();

  @override
  // Modify from here...
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // ...to here.
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Form(
            key: _formKey,
            child: Row(
              children: [
                Expanded(
                  child: TextFormField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      hintText: 'Leave a message',
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Enter your message to continue';
                      }
                      return null;
                    },
                  ),
                ),
                const SizedBox(width: 8),
                StyledButton(
                  onPressed: () async {
                    if (_formKey.currentState!.validate()) {
                      await widget.addMessage(_controller.text);
                      _controller.clear();
                    }
                  },
                  child: Row(
                    children: const [
                      Icon(Icons.send),
                      SizedBox(width: 4),
                      Text('SEND'),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
        // Modify from here...
        const SizedBox(height: 8),
        for (var message in widget.messages)
          Paragraph('${message.name}: ${message.message}'),
        const SizedBox(height: 8),
      ],
      // ...to here.
    );
  }
}

您可以使用 Column 小工具包裝 build() 方法的先前內容,然後在 Column 子項的尾端新增收集,為訊息清單中的每則訊息產生新的 Paragraph

  1. 更新 HomePage 的內容,以便使用新的 messages 參數正確建構 GuestBook

lib/home_page.dart

Consumer<ApplicationState>(
  builder: (context, appState, _) => Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      if (appState.loggedIn) ...[
        const Header('Discussion'),
        GuestBook(
          addMessage: (message) =>
              appState.addMessageToGuestBook(message),
          messages: appState.guestBookMessages, // new
        ),
      ],
    ],
  ),
),

測試訊息同步處理

Firestore 會自動與訂閱資料庫的用戶端即時同步處理資料。

測試訊息同步處理:

  1. 在應用程式中,找出先前在資料庫中建立的訊息。
  2. 撰寫新訊息。這些更新會立即顯示。
  3. 在多個視窗或分頁中開啟工作區。訊息會在各個視窗和分頁中即時同步。
  4. 選用:在 Firebase 主控台的「資料庫」選單中,手動刪除、修改或新增訊息。所有變更都會顯示在 UI 中。

恭喜!您在應用程式中讀取 Firestore 文件!

應用程式預覽

Android 上整合了即時通訊功能的應用程式主畫面

iOS 版應用程式主畫面 (已整合聊天功能)

整合了即時通訊功能的應用程式網頁版主畫面

macOS 上應用程式的主畫面,已整合即時通訊功能

8. 設定基本安全性規則

您一開始設定 Firestore 時,會使用測試模式,也就是說資料庫會開放讀取和寫入。不過,您應該只在開發初期使用測試模式。最佳做法是,在開發應用程式時為資料庫設定安全性規則。安全性是應用程式結構和行為不可或缺的一部分。

Firebase 安全性規則可讓您控管資料庫中文件和集合的存取權。您可以使用彈性的規則語法,建立可比對所有寫入作業 (從整個資料庫到特定文件的作業) 的規則。

設定基本安全性規則:

  1. 在 Firebase 主控台的「Develop」選單中,依序點選「Database」>「Rules」。您應該會看到下列預設安全性規則,以及關於規則處於公開狀態的警告:

7767a2d2e64e7275.png

  1. 找出應用程式寫入資料的集合:

match /databases/{database}/documents 中,找出要保護的集合:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /guestbook/{entry} {
     // You'll add rules here in the next step.
  }
}

由於您在每份留言板文件中使用驗證 UID 做為欄位,因此可以取得驗證 UID,並驗證任何嘗試寫入文件的使用者是否有相符的驗證 UID。

  1. 將讀取和寫入規則加入規則組合:
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /guestbook/{entry} {
      allow read: if request.auth.uid != null;
      allow write:
        if request.auth.uid == request.resource.data.userId;
    }
  }
}

目前,只有登入的使用者可以閱讀留言板中的訊息,但只有訊息作者可以編輯訊息。

  1. 新增資料驗證,確保文件中包含所有預期的欄位:
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /guestbook/{entry} {
      allow read: if request.auth.uid != null;
      allow write:
      if request.auth.uid == request.resource.data.userId
          && "name" in request.resource.data
          && "text" in request.resource.data
          && "timestamp" in request.resource.data;
    }
  }
}

9. 額外步驟:練習所學內容

記錄與會者的回覆狀態

目前,只有對活動感興趣的使用者才能在應用程式中進行即時通訊。此外,你只能透過即時通訊,瞭解對方是否會參加。

在這個步驟中,您可以安排活動並讓其他人知道有多少人會參加。您可以在應用程式狀態中新增幾項功能。第一項是讓已登入的使用者指定是否要參加。第二個是出席人數計數器。

  1. lib/app_state.dart 檔案中,將下列程式行新增至 ApplicationState 的存取子區段,讓 UI 程式碼能與此狀態互動:

lib/app_state.dart

int _attendees = 0;
int get attendees => _attendees;

Attending _attending = Attending.unknown;
StreamSubscription<DocumentSnapshot>? _attendingSubscription;
Attending get attending => _attending;
set attending(Attending attending) {
  final userDoc = FirebaseFirestore.instance
      .collection('attendees')
      .doc(FirebaseAuth.instance.currentUser!.uid);
  if (attending == Attending.yes) {
    userDoc.set(<String, dynamic>{'attending': true});
  } else {
    userDoc.set(<String, dynamic>{'attending': false});
  }
}
  1. 請更新 ApplicationStateinit() 方法,如下所示:

lib/app_state.dart

  Future<void> init() async {
    await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform);

    FirebaseUIAuth.configureProviders([
      EmailAuthProvider(),
    ]);

    // Add from here...
    FirebaseFirestore.instance
        .collection('attendees')
        .where('attending', isEqualTo: true)
        .snapshots()
        .listen((snapshot) {
      _attendees = snapshot.docs.length;
      notifyListeners();
    });
    // ...to here.

    FirebaseAuth.instance.userChanges().listen((user) {
      if (user != null) {
        _loggedIn = true;
        _emailVerified = user.emailVerified;
        _guestBookSubscription = FirebaseFirestore.instance
            .collection('guestbook')
            .orderBy('timestamp', descending: true)
            .snapshots()
            .listen((snapshot) {
          _guestBookMessages = [];
          for (final document in snapshot.docs) {
            _guestBookMessages.add(
              GuestBookMessage(
                name: document.data()['name'] as String,
                message: document.data()['text'] as String,
              ),
            );
          }
          notifyListeners();
        });
        // Add from here...
        _attendingSubscription = FirebaseFirestore.instance
            .collection('attendees')
            .doc(user.uid)
            .snapshots()
            .listen((snapshot) {
          if (snapshot.data() != null) {
            if (snapshot.data()!['attending'] as bool) {
              _attending = Attending.yes;
            } else {
              _attending = Attending.no;
            }
          } else {
            _attending = Attending.unknown;
          }
          notifyListeners();
        });
        // ...to here.
      } else {
        _loggedIn = false;
        _emailVerified = false;
        _guestBookMessages = [];
        _guestBookSubscription?.cancel();
        _attendingSubscription?.cancel(); // new
      }
      notifyListeners();
    });
  }

這段程式碼會新增一項一律訂閱的查詢,用於判斷與會者人數,以及另一項只在使用者登入時才會啟用的查詢,用於判斷使用者是否出席。

  1. lib/app_state.dart 檔案頂端新增下列列舉。

lib/app_state.dart

enum Attending { yes, no, unknown }
  1. 建立新的檔案 yes_no_selection.dart,定義可做為單選按鈕的新小工具:

lib/yes_no_selection.dart

import 'package:flutter/material.dart';

import 'app_state.dart';
import 'src/widgets.dart';

class YesNoSelection extends StatelessWidget {
  const YesNoSelection(
      {super.key, required this.state, required this.onSelection});
  final Attending state;
  final void Function(Attending selection) onSelection;

  @override
  Widget build(BuildContext context) {
    switch (state) {
      case Attending.yes:
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: Row(
            children: [
              FilledButton(
                onPressed: () => onSelection(Attending.yes),
                child: const Text('YES'),
              ),
              const SizedBox(width: 8),
              TextButton(
                onPressed: () => onSelection(Attending.no),
                child: const Text('NO'),
              ),
            ],
          ),
        );
      case Attending.no:
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: Row(
            children: [
              TextButton(
                onPressed: () => onSelection(Attending.yes),
                child: const Text('YES'),
              ),
              const SizedBox(width: 8),
              FilledButton(
                onPressed: () => onSelection(Attending.no),
                child: const Text('NO'),
              ),
            ],
          ),
        );
      default:
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: Row(
            children: [
              StyledButton(
                onPressed: () => onSelection(Attending.yes),
                child: const Text('YES'),
              ),
              const SizedBox(width: 8),
              StyledButton(
                onPressed: () => onSelection(Attending.no),
                child: const Text('NO'),
              ),
            ],
          ),
        );
    }
  }
}

起初處於未定狀態,既未選取「是」也未選取「否」。使用者選取是否出席後,您可以使用填滿的按鈕醒目顯示該選項,並以平面顯示方式顯示其他選項。

  1. 更新 HomePagebuild() 方法,充分運用 YesNoSelection,讓登入的使用者可以指定是否出席,並顯示活動的出席者人數:

lib/home_page.dart

Consumer<ApplicationState>(
  builder: (context, appState, _) => Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      // Add from here...
      switch (appState.attendees) {
        1 => const Paragraph('1 person going'),
        >= 2 => Paragraph('${appState.attendees} people going'),
        _ => const Paragraph('No one going'),
      },
      // ...to here.
      if (appState.loggedIn) ...[
        // Add from here...
        YesNoSelection(
          state: appState.attending,
          onSelection: (attending) => appState.attending = attending,
        ),
        // ...to here.
        const Header('Discussion'),
        GuestBook(
          addMessage: (message) =>
              appState.addMessageToGuestBook(message),
          messages: appState.guestBookMessages,
        ),
      ],
    ],
  ),
),

新增規則

您已設定一些規則,因此透過按鈕新增的資料會遭到拒絕。您必須更新規則,允許新增 attendees 集合。

  1. attendees 集合中,取得您用於文件名稱的驗證 UID,並驗證提交者的 uid 是否與他們所寫的文件相同:
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // ... //
    match /attendees/{userId} {
      allow read: if true;
      allow write: if request.auth.uid == userId;
    }
  }
}

這樣一來,所有人都能查看與會者名單,因為名單中沒有任何私人資料,但只有活動建立者可以更新名單。

  1. 新增資料驗證,確保文件中包含所有預期的欄位:
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // ... //
    match /attendees/{userId} {
      allow read: if true;
      allow write: if request.auth.uid == userId
          && "attending" in request.resource.data;

    }
  }
}
  1. 選用:在應用程式中點選按鈕,即可在 Firebase 主控台的 Firestore 資訊主頁中查看結果。

應用程式預覽

Android 版應用程式主畫面

iOS 版應用程式主畫面

應用程式在網頁上的主畫面

macOS 上的應用程式主畫面

10. 恭喜!

您已使用 Firebase 建構互動式即時網頁應用程式!

瞭解詳情