跳到主內容

簡單的應用狀態管理

一種簡單的狀態管理形式。

既然你已經瞭解了宣告式 UI 程式設計以及臨時狀態和應用狀態之間的區別,那麼你已經準備好學習簡單的應用狀態管理了。

在此頁面中,我們將使用 provider 包。如果你剛接觸 Flutter,且沒有非要選擇其他方案(Redux、Rx、hooks 等)的強烈理由,那麼這很可能是你應該入手的方案。provider 包易於理解,程式碼量少,而且它所使用的概念在其他所有方案中也都適用。

話雖如此,如果你在其他響應式框架的狀態管理方面有深厚的背景,可以在選項頁面檢視列出的相關包和教程。

我們的示例

#
An animated gif showing a Flutter app in use. It starts with the user on a login screen. They log in and are taken to the catalog screen, with a list of items. The click on several items, and as they do so, the items are marked as "added". The user clicks on a button and gets taken to the cart view. They see the items there. They go back to the catalog, and the items they bought still show "added". End of animation.

為了說明問題,請考慮以下簡單的應用。

該應用有兩個獨立的螢幕:商品目錄和購物車(分別由 MyCatalogMyCart 元件表示)。這可能是一個購物應用,但你可以設想一個簡單的社交網路應用也具有相同的結構(將目錄替換為“主頁/牆”,將購物車替換為“收藏夾”)。

目錄螢幕包含一個自定義應用欄 (MyAppBar) 和一個滾動檢視的列表項檢視 (MyListItems)。

這是以元件樹形式展現的應用視覺化圖。

A widget tree with MyApp at the top, and  MyCatalog and MyCart below it. MyCart area leaf nodes, but MyCatalog have two children: MyAppBar and a list of MyListItems.

因此,我們至少有 5 個 Widget 的子類。它們中的許多需要訪問“屬於”其他地方的狀態。例如,每個 MyListItem 都需要能夠將自己新增到購物車中。它可能還想檢視當前顯示的商品是否已經在購物車裡。

這就引出了我們的第一個問題:我們應該把當前的購物車狀態放在哪裡?

提升狀態

#

在 Flutter 中,將狀態放在使用它的元件之上是有意義的。

為什麼?在像 Flutter 這樣的宣告式框架中,如果你想更改 UI,就必須重建它。沒有簡單的方法可以做到 MyCart.updateWith(somethingNew)。換句話說,透過從外部呼叫方法來命令式地改變元件是很困難的。即使你能做到這一點,你也是在與框架對抗,而不是讓它為你提供幫助。

dart
// BAD: DO NOT DO THIS
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}

即使你成功實現了上述程式碼,你也必須在 MyCart 元件中處理以下問題:

dart
// BAD: DO NOT DO THIS
Widget build(BuildContext context) {
  return SomeWidget(
    // The initial state of the cart.
  );
}

void updateWith(Item item) {
  // Somehow you need to change the UI from here.
}

你需要考慮 UI 的當前狀態並將新資料應用到其中。用這種方式很難避免錯誤。

在 Flutter 中,每次元件的內容發生變化時,你都會構建一個新的元件。你使用的不是 MyCart.updateWith(somethingNew)(方法呼叫),而是 MyCart(contents)(建構函式)。因為你只能在父元件的 build 方法中構建新元件,所以如果你想更改 contents,它必須存在於 MyCart 的父元件或更高層級。

dart
// GOOD
void myTapHandler(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  cartModel.add(item);
}

現在 MyCart 構建任何版本的 UI 時都只有一條程式碼路徑。

dart
// GOOD
Widget build(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  return SomeWidget(
    // Just construct the UI once, using the current state of the cart.
    // ···
  );
}

在我們的示例中,contents 需要存放在 MyApp 中。每當它發生變化時,它就會從上方重建 MyCart(稍後會詳細介紹)。正因如此,MyCart 不需要擔心生命週期——它只需要宣告對於給定的 contents 要顯示什麼即可。當內容發生變化時,舊的 MyCart 元件就會消失,並被新的元件完全取代。

Same widget tree as above, but now we show a small 'cart' badge next to MyApp, and there are two arrows here. One comes from one of the MyListItems to the 'cart', and another one goes from the 'cart' to the MyCart widget.

這就是我們所說的元件是不可變的含義。它們不會改變——它們會被替換。

現在我們知道了應該把購物車狀態放在哪裡,接下來看看如何訪問它。

訪問狀態

#

當用戶點選目錄中的某個商品時,它會被新增到購物車中。但是因為購物車位於 MyListItem 之上,我們該怎麼做呢?

一個簡單的選擇是提供一個回撥函式,當 MyListItem 被點選時呼叫它。Dart 的函式是一等物件,所以你可以隨心所欲地傳遞它們。因此,在 MyCatalog 內部,你可以定義以下內容:

dart
@override
Widget build(BuildContext context) {
  return SomeWidget(
    // Construct the widget, passing it a reference to the method above.
    MyListItem(myTapCallback),
  );
}

void myTapCallback(Item item) {
  print('user tapped on $item');
}

這倒也沒問題,但對於需要在許多不同地方修改的應用狀態,你將不得不傳遞大量的回撥——這很快就會變得繁瑣。

幸運的是,Flutter 擁有讓元件向其後代(換句話說,不僅是子元件,還包括下方任何元件)提供資料和服務的機制。正如你對 Flutter 的期待一樣,在“*一切皆為元件™*”的思想下,這些機制只是特殊型別的元件——InheritedWidgetInheritedNotifierInheritedModel 等。我們不會在這裡介紹這些,因為對於我們想要實現的目標來說,它們稍微有點底層。

相反,我們將使用一個與底層元件協同工作但易於使用的包。它叫作 provider

在使用 provider 之前,別忘了在你的 pubspec.yaml 中新增依賴。

要將 provider 包新增為依賴,請執行 flutter pub add

flutter pub add provider

現在你可以 import 'package:provider/provider.dart'; 並開始構建了。

使用 provider,你不需要擔心回撥或 InheritedWidgets。但你需要理解 3 個概念:

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

ChangeNotifier

#

ChangeNotifier 是 Flutter SDK 中包含的一個簡單類,它為監聽器提供更改通知。換句話說,如果某個物件是 ChangeNotifier,你就可以訂閱它的更改。(對於熟悉該術語的人來說,它是一種 Observable。)

provider 中,ChangeNotifier 是封裝應用狀態的一種方式。對於非常簡單的應用,使用單個 ChangeNotifier 即可。在複雜的應用中,你會有多個模型,因此也會有多個 ChangeNotifier。(你完全不需要在 provider 中使用 ChangeNotifier,但它是一個很容易使用的類。)

在我們的購物應用示例中,我們希望在 ChangeNotifier 中管理購物車的狀態。我們建立一個擴充套件它的新類,如下所示:

dart
class CartModel extends ChangeNotifier {
  /// Internal, private state of the cart.
  final List<Item> _items = [];

  /// An unmodifiable view of the items in the cart.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// The current total price of all items (assuming all items cost $42).
  int get totalPrice => _items.length * 42;

  /// Adds [item] to cart. This and [removeAll] are the only ways to modify the
  /// cart from the outside.
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }

  /// Removes all items from the cart.
  void removeAll() {
    _items.clear();
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }
}

唯一特定於 ChangeNotifier 的程式碼是對 notifyListeners() 的呼叫。每當模型發生可能更改應用 UI 的改變時,請呼叫此方法。CartModel 中的其他所有內容都是模型本身及其業務邏輯。

ChangeNotifierflutter:foundation 的一部分,不依賴於 Flutter 中的任何更高級別的類。它很容易測試(你甚至不需要使用元件測試)。例如,下面是 CartModel 的一個簡單單元測試:

dart
test('adding item increases total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  var i = 0;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
    i++;
  });
  cart.add(Item('Dash'));
  expect(i, 1);
});

ChangeNotifierProvider

#

ChangeNotifierProvider 是一個將 ChangeNotifier 例項提供給其後代的元件。它來自 provider 包。

我們已經知道把 ChangeNotifierProvider 放在哪裡了:放在需要訪問它的元件之上。對於 CartModel 而言,這意味著放在 MyCartMyCatalog 二者之上的某個位置。

你不需要把 ChangeNotifierProvider 放得比必要位置更高(因為你不想汙染作用域)。但在我們的例子中,位於 MyCartMyCatalog 之上的唯一元件就是 MyApp

dart
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}

請注意,我們定義了一個構建器來建立一個新的 CartModel 例項。ChangeNotifierProvider 很智慧,除非絕對必要,否則*不會*重建 CartModel。當例項不再需要時,它還會自動對 CartModel 呼叫 dispose()

如果你想提供多個類,可以使用 MultiProvider

dart
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: const MyApp(),
    ),
  );
}

Consumer

#

現在,透過頂部的 ChangeNotifierProvider 宣告,CartModel 已經提供給了應用中的元件,我們可以開始使用它了。

這是透過 Consumer 元件完成的。

dart
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price: ${cart.totalPrice}');
  },
);

我們必須指定我們想要訪問的模型型別。在本例中,我們要的是 CartModel,所以我們寫 Consumer<CartModel>。如果你不指定泛型 (<CartModel>),provider 包將無法幫助你。provider 是基於型別的,沒有型別,它就不知道你想要什麼。

Consumer 元件唯一必需的引數是 builder。Builder 是一個函式,每當 ChangeNotifier 發生變化時就會被呼叫。(換句話說,當你呼叫模型中的 notifyListeners() 時,所有相應 Consumer 元件的 builder 方法都會被呼叫。)

Builder 在呼叫時帶有三個引數。第一個是 context,你在每個 build 方法中都能得到它。

builder 函式的第二個引數是 ChangeNotifier 的例項。這正是我們最初請求的物件。你可以使用模型中的資料來定義 UI 在任何給定點應該看起來是什麼樣子。

第三個引數是 child,它是為了最佳化而存在的。如果你在 Consumer 下有一個龐大的元件子樹,且當模型發生變化時該子樹*不會*改變,你可以構建一次並透過 builder 獲取它。

dart
return Consumer<CartModel>(
  builder: (context, cart, child) => Stack(
    children: [
      // Use SomeExpensiveWidget here, without rebuilding every time.
      ?child,
      Text('Total price: ${cart.totalPrice}'),
    ],
  ),
  // Build the expensive widget here.
  child: const SomeExpensiveWidget(),
);

最佳實踐是將你的 Consumer 元件放在樹中儘可能深的位置。你肯定不希望僅僅因為某處的某個細節發生了變化,就重建 UI 的大部分內容。

dart
// DON'T DO THIS
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);

而是這樣:

dart
// DO THIS
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

Provider.of

#

有時,你並不真正需要模型中的*資料*來更改 UI,但你仍然需要訪問它。例如,一個 ClearCart 按鈕想要允許使用者從購物車中刪除所有內容。它不需要顯示購物車的內容,只需要呼叫 clear() 方法即可。

我們本可以使用 Consumer<CartModel> 來實現,但這會造成浪費。我們會要求框架重建一個實際上不需要重建的元件。

對於這種情況,我們可以使用 Provider.of,並將 listen 引數設定為 false

dart
Provider.of<CartModel>(context, listen: false).removeAll();

在 build 方法中使用上述行,當 notifyListeners 被呼叫時,不會導致該元件重建。

整合所有概念

#

你可以檢視本文涵蓋的示例。如果你想要更簡單的內容,請看看當使用 provider 構建簡單的計數器應用時是什麼樣子。

透過學習這些文章,你已經大大提升了建立基於狀態的應用的能力。嘗試自己使用 provider 構建一個應用,以掌握這些技能。