0%

【文章翻譯】Testable Flutter and Cloud Firestore

【文章內容使用 Gemini 1.5 Pro 自動產生】

為什麼選擇 Cloud Firestore?

FlutterFire 技術堆疊,由 Flutter 和 Firebase(特別是 Cloud Firestore)組成,在您構建和發佈應用程式時,為您解鎖了前所未有的開發速度。在本文中,您將探索這兩種技術之間的強大整合,重點關注測試和使用乾淨的架構模式。但是,您將一步一步地構建自己的方法,而不是直接跳到最終實作,這樣每個步驟背後的理由就清晰明瞭。

您將構建什麼?

為了展示將 Cloud Firestore 作為您應用程式後端的乾淨方法,您將構建經典 Flutter 計數器應用程式的修改版本。唯一的區別是,每次點擊的時間戳都儲存在 Cloud Firestore 中,並且顯示的計數是從儲存的時間戳的數量中推導出來的。您將使用 Provider 和 ChangeNotifier 保持依賴項和狀態管理程式碼乾淨,並且您將更新生成的測試以保持程式碼的 正確性

開始之前

本文假設您已 觀看並按照此教學中的步驟操作 將您的應用程式與 Firebase 整合。簡要說明一下:

  1. 建立一個新的 Flutter 專案,並命名為 firebasecounter。
  2. Firebase 主控台 中建立一個 Firebase 應用程式。
  3. 將您的應用程式連結到 iOS 和/或 Android,具體取決於您的開發環境和目標受眾。
注意:如果您將您的應用程式設定為在 Android 客戶端上運行,請務必 [建立一個 `debug.keystore` 檔案](https://gist.github.com/henriquemenezes/70feb8fff20a19a65346e48786bedb8f),然後再生成您的 SHA1 憑證。

在您在 Firebase 中生成 iOS 或 Android 應用程式之後,您就可以繼續進行。影片的其餘部分包含您在實際專案中可能需要的精彩內容,但對於本教學來說不是必需的。

如果您遇到問題

如果本教學中的任何步驟對您不起作用,請諮詢 這個公開的儲存庫,它將更改分解為不同的提交。在整個教學中,您將在適當的位置找到指向每個提交的連結。請隨時使用它來驗證您是否已按照預期的方式進行!

建立一個簡單的狀態管理員

要開始將您的應用程式與 Cloud Firestore 整合的過程,您必須首先重構生成的程式碼,以便初始 StatefulWidget 與單獨的類別進行通訊,而不是與自身的屬性進行通訊。這讓您最終可以指示該單獨的類別使用 Cloud Firestore。

在您的專案的自動生成的 main.dart 檔案旁邊,建立一個名為 counter_manager.dart 的新檔案,並將以下程式碼複製到其中:

1
2
3
4
5
6
7
8
9
10
11
12
class CounterManager {
/// 建立一個私有整數來儲存計數。將其設為私有,
/// 因此 Widget 無法直接修改它,而是必須
/// 使用官方方法。
int _count = 0;

/// 公開可存取的狀態參考。
int get count => _count;

/// 公開可存取的狀態修改器。
void increment() => _count++;
}

在程式碼就位後,將以下行新增到 firebasecounter/lib/main.dart 的頂部:

1
import 'package:firebasecounter/counter_manager.dart';

然後,將 _MyHomePageState 的程式碼更改為以下內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class _MyHomePageState extends State<MyHomePage> {
final manager = CounterManager();

void _incrementCounter() {
setState(() => manager.increment());
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('You have pushed the button this many times:'),
Text(
'${manager.count}',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}

儲存此程式碼更改後,您的應用程式可能會顯示崩潰並顯示一個紅色的錯誤螢幕。這是因為您引入了一個新的變數 manager,它初始化的機會已經過了。當您更改狀態的 初始化方式 時,這在 Flutter 中很常見,並且可以使用熱重新啟動輕鬆解決。

熱重新啟動後,您應該回到開始的地方:計數為 0,並且可以根據需要點擊浮動動作按鈕。

現在是運行 Flutter 在任何新的專案中提供的單一測試的好時機。您可以在 test/widget_test.dart 中找到它的定義,並透過運行以下命令執行它:

1
$ flutter test

假設測試通過,您就可以繼續進行!

注意:如果您在此部分遇到問題,請將您的更改與教學儲存庫中的 [這個提交](https://github.com/craiglabenz/flutter-firestore-counter/commit/483dd3b3833bf710b04db4a3ba347b1d1ecbe5de) 進行比較。

持續時間戳

初始應用程式描述中提到了持續儲存每次點擊的時間戳。到目前為止,您尚未添加任何基礎架構來滿足第二個要求,因此請建立另一個名為 app_state.dart 的新檔案,並添加以下類別:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// 應用程式狀態的完整容器。
/// 此類別的實例應該能夠告知任何時候
/// 渲染的內容。
class AppState {
/// 完整的點擊歷史記錄。用於非常重要的審計目的。
/// 點擊的計數變為此列表的 `length` 屬性。
final List<DateTime> clicks;

/// 預設生成式建構函式。對常數友好,以實現最佳
/// 效能。
const AppState([List<DateTime> clicks])
: clicks = clicks ?? const <DateTime>[];

/// 方便的輔助函式。
int get count => clicks.length;

/// 複製方法,它返回一個新的 AppState 實例,而不是
/// 修改現有的副本。
AppState copyWith(DateTime latestClick) => AppState([
latestClick,
...clicks,
]);
}

從現在開始,AppState 類別的工作是表示應該渲染的內容的狀態。該類別不包含任何可以修改自身的方法,只有一個單獨的 copyWith 方法,其他類別將使用它。

牢記測試,您可以開始更改 CounterManager 概念。從長遠來看,使用單一類別將行不通,因為應用程式最終會與 Cloud Firestore 進行互動。但是,您不想在每次運行測試時都建立真實的記錄。為此,您需要一個抽象介面來定義應用程式應該如何表現。

再次打開 counter_manager.dart,並在檔案的頂部添加以下程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import 'package:firebasecounter/app_state.dart';

/// 定義操作
/// 應用程式狀態所需的函式的介面。
///
/// 定義為抽象類別,以便測試可以在不
/// 與 Firebase 通訊的版本上運行。
abstract class ICounterManager {
/// 任何 `CounterManager` 都必須具有狀態的實例
/// 物件。
AppState state;

/// 處理必須儲存新的點擊的事件。不需要
/// 任何參數,因為它只會導致時間戳
/// 持續存在。
void increment();
}

下一步是更新 CounterManager 以顯式地從 ICounterManager 繼承。將其定義更新為以下內容:

1
2
3
4
5
class CounterManager implements ICounterManager {
AppState state = AppState();

void increment() => state = state.copyWith(DateTime.now());
}

在這個階段,我們的輔助程式碼看起來已經很好了,但是 main.dart 落後了。main.dart 中沒有 ICounterManager 的參考,而事實上,它是它應該知道的 唯一Manager 類別。在 main.dart 中,更新並應用以下更改:

  1. 將遺漏的匯入新增到 main.dart 的頂部:
1
import 'package:firebasecounter/app_state.dart';
  1. _MyHomePageState 更新為以下內容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class _MyHomePageState extends State<MyHomePage> {
final ICounterManager manager;
_MyHomePageState({@required this.manager});

void _incrementCounter() => setState(() => manager.increment());

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('You have pushed the button this many times:'),
Text(
// 參考 `widget.manager` 而不是
// `manager` 直接
'${manager.state.count}',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
// 參考 `widget.manager` 而不是 `manager` 直接
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}

此更改應該會移除 IDE 中 _MyHomePageState 的任何紅色波浪線,但是現在 MyHomePage 會抱怨,因為它的 createState() 方法沒有向 _MyHomePageState 提供所有必需的參數。您可以讓 MyHomePage 要求此變數,並將物件傳遞到它的基於狀態的類別,但是這可能會導致很長的 Widget 鏈,這些 Widget 會要求並傳遞它們實際上不在乎的物件,僅僅因為某些後代 Widget 要求它,而某些祖先 Widget 提供它。顯然,這需要一個更好的策略。

輸入: Provider

使用 Provider 存取應用程式狀態

Provider 是一个函式庫,它简化了 Flutter 的 InheritedWidget 模式的使用。Provider 允許頂級 Widget 在您的 Widget 樹中被所有後代 Widget 直接存取。這可能感覺像一個全域變數,但替代方案是將您的資料模型透過每個中間 Widget 傳遞下去,其中許多 Widget 本身並不感興趣。這種「變數 桶式傳遞」反模式會模糊您的應用程式中的關注點分離,並且會使重構佈局變得不必要地繁瑣。InheritedWidget 和 Provider 透過讓 Widget 樹中的任何位置的 Widget 都可以直接獲取所需的資料模型來避免這些問題。

要將 Provider 添加到您的應用程式,請打開 pubspec.yaml,並在 dependencies 區段中添加它:

1
2
3
4
5
dependencies:
flutter:
sdk: flutter
# Add this
provider: ^4.3.2+2

在將該行添加到您的 pubspec.yaml 檔案後,運行以下命令將 Provider 下載到您的機器上:

1
$ flutter pub get

main.dart 旁邊,建立一個名為 dependencies.dart 的新檔案,並將以下程式碼複製到其中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import 'package:firebasecounter/counter_manager.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class DependenciesProvider extends StatelessWidget {
final Widget child;
DependenciesProvider({@required this.child});

@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<ICounterManager>(create: (context) => CounterManager()),
],
child: child,
);
}
}

關於 DependenciesProvider 的一些注意事項:

  1. 它使用 MultiProvider,儘管它的列表中只有一個條目。從技術上講,這可以摺疊為一個單獨的 Provider Widget,但是實際的應用程式可能會包含許多這樣的服務,因此最好從一開始就使用 MultiProvider
  2. 它需要一個子 Widget,它遵循 Flutter 中 Widget 組合的慣例,允許我們將此輔助程式碼插入 Widget 樹的頂部,從而使 ICounterManager 實例可用於整個應用程式。

接下來,讓新的 DependenciesProvider 可用於整個應用程式。一種簡單的方法是使用它來包裝整個 MaterialApp Widget。打開 main.dart,並將 main 方法更新為如下所示:

1
2
3
4
5
void main() {
runApp(
DependenciesProvider(child: MyApp()),
);
}

您還需要在 main.dart 中匯入 dependencies.dart

1
import 'package:firebasecounter/dependencies.dart';

使用 Consumer Widget

您已經看到了 MultiProvider Widget 的作用(實際上它只是一種宣告一系列單獨的 Provider Widget 的更便捷方式)。下一步是透過使用 Consumer Widget 來存取 ICounterManager 物件。

依賴注入

如果您使用過 Cloud Firestore 撰寫過 Flutter 應用程式,那麼您可能會發現 Firestore 會讓單元測試更難撰寫。畢竟,當 Firestore 整合直接連接到您的 Widget 樹中時,您如何避免在資料庫中生成真實的記錄?

如果您有過這種體驗,那麼您就會發現將依賴項直接烘焙到 UI 程式碼中的局限性,在 Flutter 的情況下,UI 程式碼是 Widget。這就是依賴注入的強大之處:如果您的 Widget 接受輔助類別,這些輔助類別促進它們與依賴項(如 Firebase、設備的檔案系統,甚至網路請求)的互動,那麼您可以在測試期間提供模擬或偽造物件,而不是真實的類別。這讓您可以測試 Widget 是否按預期工作,而無需等待緩慢的網路請求、填滿檔案系統或產生 Firebase 計費費用。

要實現這一點,您需要重構應用程式,以便有一個清晰的點,讓測試可以注入模擬真實 Cloud Firestore 行為的偽造物件。幸運的是,Consumer Widget 非常適合此工作。

打開 main.dart,並將您的 MyApp Widget 替換為以下程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 在頂部添加此匯入
import 'package:firebasecounter/firebase_waiter.dart';

// 將 `MyApp` 替換為此
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: FirebaseWaiter(
builder: (context) => Consumer<ICounterManager>(
builder: (context, manager, _child) => MyHomePage(
manager: manager,
title: 'Flutter Demo Home Page',
),
),
// 這是放置您的啟動頁面的好地方!
waitingChild: Scaffold(
body: const Center(child: CircularProgressIndicator()),
),
),
);
}
}

此外,在 main.dart 的頂部匯入 Provider

1
import 'package:provider/provider.dart';

MyHomePage 包裹在 Consumer Widget 中,允許您到達 Widget 樹中任意高的地方,以存取所需的資源,並將它們注入到需要它們的 Widget 中。在本教學中,這可能感覺沒有必要的工作,因為您只向上到達 MyApp() 一層,但是這在實際的生產應用程式中可能會透過數十個 Widget 延伸。

接下來,在同一個檔案中,對 MyHomePage 進行以下編輯:

注意:如果在儲存此更改後您看到紅色螢幕,請不要擔心。需要更多編輯才能完成重構!
1
2
3
4
5
6
7
8
9
class MyHomePage extends StatefulWidget {
final ICounterManager manager;
MyHomePage({@required this.manager, Key key, this.title}) : super(key: key);

final String title;

@override
_MyHomePageState createState() => _MyHomePageState();
}

這個簡單的建構函式更改允許程式碼接受在先前程式碼片段中傳入的變數。

最後,透過對 _MyHomePageState 進行以下編輯來完成重構:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class _MyHomePageState extends State<MyHomePage> {

// 不再期望接收 `ICounterManager` 物件

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('You have pushed the button this many times:'),
Text(
// 參考 `widget.manager` 而不是
// `manager` 直接
'${widget.manager.state.count}',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
// 參考 `widget.manager` 而不是 `manager` 直接
onPressed: () => setState(() => widget.manager.increment()),
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
注意:您可能需要執行熱重新啟動來修復您的應用程式。

如您所知,所有 State 物件都包含對它們在 Widget 屬性中包含的 StatefulWidget 包裹器的參考。因此,_MyHomePageState 物件可以透過將其程式碼從 manager 更改為 widget.manager 來存取這個新的 manager 屬性。

就這樣!您已經將依賴項注入到需要它們的 Widget 中,而不是硬編碼生產實作。

測試應用程式

如果您現在運行 flutter test,您將會看到測試套件不再通過。當您檢查 widget_test.dart 時,原因可能很清楚:測試函式實例化了 MyApp(),但沒有像您在真實程式碼中所做的那樣用 DependenciesProvider 包裹它,因此 MyApp 中添加的 Consumer Widget 無法在其祖先 Widget 中找到滿足的 Provider

這就是依賴注入開始發揮作用的地方。您不必在測試中模仿生產程式碼(透過將 MyApp 包裹在 DependenciesProvider 中),而是更改測試以初始化 MyHomePage。將 widget_test.dart 更新為如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import 'package:firebasecounter/counter_manager.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:firebasecounter/main.dart';

void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(
MaterialApp(
home: MyHomePage(
manager: CounterManager(),
title: 'Test Widget',
),
),
);

// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);

// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();

// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

透過直接使用 MyHomePage 實例(以及一個包裝的 MaterialApp 來提供有效的 BuildContext 物件),您已經為自己建立了與 Cloud Firestore 的單元測試整合!

注意:如果您在此部分遇到問題,請將您的更改與教學儲存庫中的 [這個提交](https://github.com/craiglabenz/flutter-firestore-counter/commit/bb68c1d3bb3746eca5f2dea16bd799c98ff232f1) 進行比較。

實作 Cloud Firestore

到目前為止,您已經移動了很多程式碼,並引入了幾個輔助類別,但是您還沒有更改應用程式的任何工作方式。好消息是,一切都已就位,可以開始撰寫一些了解 Cloud Firestore 的程式碼。首先,打開 pubspec.yaml,並添加以下兩行:

1
2
3
4
5
6
7
8
dependencies:
# Add this
cloud_firestore: ^0.14.1
# Add this
firebase_core: ^0.5.0
flutter:
sdk: flutter
provider: ^4.3.2+2

與往常一樣,當您對 pubspec.yaml 進行更改時(除非您的 IDE 替您完成此操作),請運行以下命令來下載和連結新的函式庫:

1
$ flutter pub get
注意:如果您尚未建立資料庫:請訪問 Firebase 主控台的專案,點擊 **Cloud Firestore** 標籤,然後點擊 **建立資料庫** 按鈕。

等待 Firebase

成功使用 Cloud Firestore 的第一步是初始化 Firebase,最關鍵的是 在任務成功之前不要嘗試使用任何 Firebase 資源。幸運的是,您可以使用一個 StatefulWidget 來包含該邏輯,而不是將該任務散佈在整個程式碼中。

firebasecounter/lib/firebase_waiter.dart 中建立一個新檔案,並添加以下程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';

class FirebaseWaiter extends StatefulWidget {
final Widget Function(BuildContext) builder;
final Widget waitingChild;
const FirebaseWaiter({
@required this.builder,
this.waitingChild,
Key key,
}) : super(key: key);

@override
_FirebaseWaiterState createState() => _FirebaseWaiterState();
}

class _FirebaseWaiterState extends State<FirebaseWaiter> {
Future<FirebaseApp> firebaseReady;

@override
void initState() {
super.initState();
firebaseReady = Firebase.initializeApp();
}

@override
Widget build(BuildContext context) => FutureBuilder<FirebaseApp>(
future: firebaseReady,
builder: (context, snapshot) => //<
snapshot.connectionState == ConnectionState.done
? widget.builder(context)
: widget.waitingChild,
);
}

此類別使用 Flutter 中的模式,利用特定 Widget 來完全處理應用程式中的特定依賴項或問題。要使用此 FirebaseWaiter Widget,請返回 main.dart,並對 MyApp 應用以下更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 在頂部添加此匯入
import 'package:firebasecounter/firebase_waiter.dart';

// 將 `MyApp` 替換為此
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: FirebaseWaiter(
builder: (context) => Consumer<ICounterManager>(
builder: (context, manager, _child) => MyHomePage(
manager: manager,
title: 'Flutter Demo Home Page',
),
),
// 這是放置您的啟動頁面的好地方!
waitingChild: Scaffold(
body: const Center(child: CircularProgressIndicator()),
),
),
);
}
}

現在,應用程式可以等待 Firebase 初始化,但可以在測試期間透過簡單地不使用 FirebaseWaiter 來跳過此過程。

注意:上述更改可能會導致 Flutter 抱怨缺少 Firebase Plugin。如果出現這種情況,請完全關閉應用程式並重新開始除錯,這將允許 Flutter 安裝所有特定於平台的依賴項。

從 Cloud Firestore 獲取資料

首先,透過將以下行添加到 counter_manager.dart 的頂部來匯入 Cloud Firestore:

1
import 'package:cloud_firestore/cloud_firestore.dart';

接下來,也在 counter_manager.dart 中,添加以下類別:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class FirestoreCounterManager implements ICounterManager {
AppState state;
final FirebaseFirestore _firestore;

FirestoreCounterManager()
: _firestore = FirebaseFirestore.instance,
state = const AppState() {
_watchCollection();
}

void _watchCollection() {
// Part 1
_firestore
.collection('clicks')
.snapshots()
// Part 2
.listen((QuerySnapshot snapshot) {
// Part 3
if (snapshot.docs.isEmpty) return;
// Part 4
final _clicks = snapshot.docs
.map<DateTime>((doc) {
final timestamp = doc.data()['timestamp'];
return (timestamp != null)
? (timestamp as Timestamp).toDate()
: null;
})
// Part 5
.where((val) => val != null)
// Part 6
.toList();
// Part 7
state = AppState(_clicks);
});
}

@override
void increment() {
_firestore.collection('clicks').add({
'timestamp': FieldValue.serverTimestamp(),
});
}
}
注意:此類別幾乎是正確的,但它會建立一個稍後會探討的錯誤。如果您現在將此程式碼添加到您的應用程式中並運行它,您將會看到行為與您想要的不同。請繼續閱讀,以了解詳細的解釋!

這裡有很多事情正在發生,因此讓我們一步一步地進行。

首先,FirestoreCounterManager 實作了 ICounterManager 介面,因此它是生產 Widget 中可用的候選者。(最終,它將由 DependenciesProvider 提供!)FirestoreCounterManager 也維護了 FirebaseFirestore 的實例,它與生產資料庫的實時連接。FirestoreCounterManager 在初始化期間也調用 _watchCollection() 來建立與您關心的特定資料的連接,這就是事情變得有趣的地方。

_watchCollection() 方法做了很多事情,值得單獨研究。

在第一部分中,_watchCollection() 調用 _firestore.collection('clicks').snapshots(). 這會返回一個流,每當集合中的資料發生更改時,就會更新。

在第二部分中,_watchCollection() 立即使用 .listen() 為該流註冊一個監聽器。傳遞給 listen() 的回調在資料發生任何更改時接收一個新的 QuerySnapshot 物件。此更新物件稱為快照,因為它反映了資料庫在某一時刻的正確狀態,但是,在任何時候都可能會被新的快照替換。

在第三部分中,回調會在集合為空時短路。

在第四部分中,回調會遍歷快照的文件,並返回一個包含混合的 null 和 DateTime 值的列表。

在第五部分中,回調會捨棄任何 null 值。這些是因將要修復的錯誤而產生的,但是這種防禦性程式設計在處理來自 Cloud Firestore 的資料時總是一個好主意。

在第六部分中,回調會解決 map() 返回的是迭代器而不是列表的事實。對迭代器調用 .toList() 會強制它處理整個集合,這正是您想要的。

最後,在第七部分中,回調會更新狀態物件。

要使用這個新的類別,請打開 dependencies.dart,並將其內容替換為以下內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import 'package:firebasecounter/counter_manager.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class DependenciesProvider extends StatelessWidget {
final Widget child;
DependenciesProvider({@required this.child});

@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// `Provider` 已被 `ChangeNotifierProvider` 替換
ChangeNotifierProvider<ICounterManager>(
create: (context) => FirestoreCounterManager(),
),
],
child: child,
);
}
}

診斷錯誤

如果您按原樣運行此程式碼,您 幾乎 會看到所需的行為。一切都看起來正確,除了螢幕始終落後於實際點擊次數一次。這是怎麼回事?

問題出現在初始計數器實作和當前基於流的實作之間的不相容性。浮動動作按鈕的 onPressed 處理器看起來像這樣:

1
2
3
4
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => widget.manager.increment()),
...
)

該處理器調用 increment() 並立即調用 setState(),這會告訴 Flutter 重新渲染。

當同步更新儲存在設備記憶體中的狀態時,這非常有效。但是,新的基於流的實作會啟動一系列異步步驟。這意味著,按原樣,程式碼會立即調用 setState(),然後僅在一個未知的未來時間點,管理員物件才會更新其狀態屬性。簡而言之,onPressed 處理器中的 setState() 調用過早了!更糟糕的是,因為所有這些活動都發生在回調內,深深地位於 FirestoreCounterManager 中,沒有任何 Widget 知道它,因此沒有任何 Future 可以讓 Widget 等待來解決問題。

這就像管理員物件需要能夠告訴 Widget 何時重新繪製一樣。 🤔

輸入: ChangeNotifier

注意:如果您在此部分遇到問題,請將您的更改與公開儲存庫中的 [這個提交](https://github.com/craiglabenz/flutter-firestore-counter/commit/3bf17b9bfac6c907b8650e1c668fa19b1160a51d) 進行比較。這些更改包括添加 Firebase 後產生的 Xcode 和 build.gradle 的更改,但是您可能可以專注於 Dart 檔案中的更改。

使用 ChangeNotifier 重新渲染 Widget 樹

ChangeNotifier 是一個類別,它完全像它的名字所暗示的那樣做:當發生需要重新渲染的更改時,它會通知 Widget。

此過程的第一步是更新 ICounterManager 介面以擴展 ChangeNotifier。要執行此操作,請打開 firebasecounter/lib/counter_manager.dart,並對 ICounterManager 宣告進行以下更改:

1
2
3
4
// 將 `extends ChangeNotifier` 添加到您的宣告中
abstract class ICounterManager extends ChangeNotifier {
// 類別中的所有內容都相同。
}

如果您尚未匯入 flutter/material.dart,請打開 firebasecounter/lib/counter_manager.dart,並將其添加到底部:

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

您現在已準備好更新 CounterManagerFirestoreCounterManager 的定義。對於 CounterManager,請將其程式碼替換為以下實作:

1
2
3
4
5
6
7
8
9
10
11
12
class CounterManager extends ChangeNotifier implements ICounterManager {
AppState state = AppState();

/// 使用最近一次點擊的時間戳複製狀態物件,
/// 並告訴流更新。
void increment() {
state = state.copyWith(DateTime.now());
// 添加這行是 `ChangeNotifier` 告訴 Widget
// 重新渲染自身的方式。
notifyListeners();
}
}

對於 FirestoreCounterManager,應用以下更改:

  1. 修改它的簽章以匹配以下內容:
1
2
3
4
class FirestoreCounterManager extends ChangeNotifier
implements ICounterManager {
...
}
  1. 將相同的 notifyListeners(); 行添加到 _watchCollection() 的末尾,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void _watchCollection() {
_firestore
.collection('clicks')
.snapshots()
.listen((QuerySnapshot snapshot) {

// 為了清晰起見,省略了 `_clicks` 的生成,但不要
// 更改該程式碼。

state = AppState(_clicks);

// 唯一必要的更改是添加這行!
notifyListeners();
});
}

您現在已經建立了讓 ICounterManager 類別告訴 Widget 何時重新渲染的必要更改的一半。管理員類別正在告訴 Widget 重新渲染,但是如果您現在運行您的應用程式,您將會看到 Widget 沒有在監聽。

要修復此問題,請打開 dependencies.dart,並將 DependenciesProvider 的實作替換為以下內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class DependenciesProvider extends StatelessWidget {
final Widget child;
DependenciesProvider({@required this.child});

@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// `Provider` 已被 `ChangeNotifierProvider` 替換
ChangeNotifierProvider<ICounterManager>(
create: (context) => FirestoreCounterManager(),
),
],
child: child,
);
}
}

作為最後的更改,請從 _MyHomePageState 中移除 setState(),以跳過不必要的重新渲染。將其 FloatingActionButton 更新為如下所示:

1
2
3
4
5
6
floatingActionButton: FloatingActionButton(
// 移除 `setState()`!
onPressed: widget.manager.increment,
tooltip: 'Increment',
child: Icon(Icons.add),
),

就這樣!ChangeNotifierProvider 確保 Widget 是「監聽器」,因此當 ICounterManager 類別調用 notifyListeners() 時,Widget 會收到重新渲染的訊息。

在這個階段,您應該能夠熱重新啟動您的應用程式,並看到所有內容都正常工作!

注意:如果您在此部分遇到問題,請將您的更改與公開儲存庫中的 這個提交 進行比較。

修復測試