簡單的應用狀態管理

現在大家已經瞭解了 宣告式的程式設計思維短時 (ephemeral) 與應用 (app) 狀態 之間的區別,現在可以學習如何管理簡單的全域應用狀態。

在這裡,我們打算使用 provider package。如果你是 Flutter 的初學者,而且也沒有很重要的理由必須選擇別的方式來實現(Redux、Rx、hooks 等等),那麼這就是你應該入門使用的。provider 非常好理解而且不需要寫很多程式碼。它也會用到一些在其它實現方式中用到的通用概念。

即便如此,如果你已經從其它響應式框架上積累了豐富的狀態管理經驗的話,那麼可以在 狀態 (State) 管理參考 中找到相關的 package 和課程。

範例

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 widget 來展示)。雖然看上去是一個購物應用程式,但是你也可以和社交網路應用類比(把類別頁面替換成朋友圈,把購物車替換成關注的人)。

類別頁面包含一個自訂的 app bar (MyAppBar) 以及一個包含元素列表的可滑動的檢視 (MyListItems)。

這是應用程式對應的視覺化的 widget 樹。

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 中,有必要將儲存狀態的物件置於 widget 樹中對應 widget 的上層。

為什麼呢?在類似 Flutter 的宣告式框架中,如果你想要修改 UI,那麼你需要重構它。並沒有類似 MyCart.updateWith(somethingNew) 的簡單呼叫方法。換言之,你很難透過外部呼叫方法修改一個 widget。即便你自己實現了這樣的模式,那也是和整個框架不相相容。

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

即使你實現了上面的程式碼,也得處理 MyCart widget 中的程式碼:

// 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 的狀態,然後把最新的資料新增進去。但是這樣的方式很難避免出現 bug。

在 Flutter 中,每次當 widget 內容發生改變的時候,你就需要構造一個新的。你會呼叫 MyCart(contents)(建構函式),而不是 MyCart.updateWith(somethingNew)(呼叫方法)。因為你只能透過父類別的 build 方法來建構新 widget,如果你想修改 contents,就需要呼叫 MyCart 的父類甚至更高一級的類別。

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

這裡 MyCart 可以在各種版本的 UI 中呼叫同一個程式碼路徑。

// 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 widget 就會消失,完全被新的 widget 替代。

和上面的空間樹一樣,不過我們在 MyApp 的旁邊顯示一個 'cart' 標記。這裡有兩個箭頭。一個從 MyListItems 指向 'cart',另一個從 'cart' 指向 MyCart

這就是我們所說的 widget 是不可變的。因為它們會直接被替換。

現在我們知道在哪裡放置購物車的狀態,接下來看一下如何讀取該狀態。

讀取狀態

當用戶點選類別頁面中的一個元素,它會被新增到購物車裡。然而當購物車在 widget 樹中,處於 MyListItem 的層級之上時,又該如何存取狀態呢?

一個簡單的實現方法是提供一個回呼(Callback)函式,當 MyListItem 被點選的時候可以呼叫。 Dart 的函式都是 first class 物件,所以你可以以任意方式傳遞它們。所以在 MyCatalog 裡你可以使用下面的程式碼:

@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');
}

這段程式碼是沒問題的,但是對於全域應用狀態來說,你需要在不同的地方進行修改,可能需要大量傳遞迴調函式——這些回呼(Callback)很快就會過時。

幸運的是 Flutter 在 widget 中存在一種機制,能夠為其子孫節點提供資料和服務。(換言之,不僅僅是它的子節點,所有在它下層的 widget 都可以)。就像你所瞭解的, Flutter 中的 Everything is a Widget™。這裡的機制也是一種 widget —InheritedWidget, InheritedNotifier, InheritedModel等等。我們這裡不會詳細解釋他們,因為這些 widget 都太底層。

我們會用一個 package 來和這些底層的 widget 打交道,就是 provider package。

在使用 provider 之前,請不要忘記在 pubspec.yaml 檔案里加入依賴。

name: my_name
description: Blah blah blah.

# ...

dependencies:
  flutter:
    sdk: flutter

  provider: ^6.0.0

dev_dependencies:
  # ...

現在可以在程式碼里加入 import 'package:provider/provider.dart'; 進而開始建構你的應用了/

provider package 中,你無須關心回呼(Callback)或者 InheritedWidgets。但是你需要理解三個概念:

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

ChangeNotifier

ChangeNotifier 是 Flutter SDK 中的一個簡單的類別。它用於向監聽器傳送通知。換言之,如果被定義為 ChangeNotifier,你可以訂閱它的狀態變化。(這和大家所熟悉的觀察者模式相類似)。

provider 中,ChangeNotifier 是一種能夠封裝應用程式狀態的方法。對於特別簡單的程式,你可以透過一個 ChangeNotifier 來滿足全部需求。在相對複雜的應用中,由於會有多個模型,所以可能會有多個 ChangeNotifier。 (不是必須得把 ChangeNotifierprovider 結合起來用,不過它確實是一個特別簡單的類)。

在我們的購物應用範例中,我們打算用 ChangeNotifier 來管理購物車的狀態。我們建立一個新類,繼承它,像下面這樣:

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 中任何高級別類別。測試起來非常簡單(你都不需要使用 widget 測試)。比如,這裡有一個針對 CartModel 簡單的單元測試:

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

ChangeNotifierProvider

ChangeNotifierProvider widget 可以向其子孫節點暴露一個 ChangeNotifier 例項。它屬於 provider package。

我們已經知道了該把 ChangeNotifierProvider 放在什麼位置:在需要存取它的 widget 之上。在 CartModel 裡,也就意味著將它置於 MyCartMyCatalog 之上。

你肯定不願意把 ChangeNotifierProvider 放的級別太高(因為你不希望破壞整個結構)。但是在我們這裡的例子中,MyCartMyCatalog 之上只有 MyApp

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

請注意我們定義了一個 builder 來建立一個 CartModel 的例項。 ChangeNotifierProvider 非常聰明,它 不會 重複例項化 CartModel,除非在個別場景下。如果該例項已經不會再被呼叫, ChangeNotifierProvider 也會自動呼叫 CartModeldispose() 方法。

如果你想提供更多狀態,可以使用 MultiProvider

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

Consumer

現在 CartModel 已經透過 ChangeNotifierProvider 在應用中與 widget 相關聯。我們可以開始呼叫它了。

完成這一步需要透過 Consumer widget。

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

我們必須指定要存取的模型型別。在這個範例中,我們要存取 CartModel 那麼就寫上 Consumer<CartModel>

Consumer widget 唯一必須的引數就是 builder。當 ChangeNotifier 發生變化的時候會呼叫 builder 這個函式。(換言之,當你在模型中呼叫 notifyListeners() 時,所有相關的 Consumer widget 的 builder 方法都會被呼叫。)

builder 在被呼叫的時候會用到三個引數。第一個是 context。在每個 build 方法中都能找到這個引數。

builder 函式的第二個引數是 ChangeNotifier 的例項。它是我們最開始就能得到的例項。你可以透過該例項定義 UI 的內容。

第三個引數是 child,用於最佳化目的。如果 Consumer 下面有一個龐大的子樹,當模型發生改變的時候,該子樹 並不會 改變,那麼你就可以僅僅建立它一次,然後透過 builder 獲得該例項。

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

最好能把 Consumer 放在 widget 樹儘量低的位置上。你總不希望 UI 上任何一點小變化就全盤重新建構 widget 吧。

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

換成:

// 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> 來實現這個效果,不過這麼實現有點浪費。因為我們讓整體框架重構了一個無需重構的 widget。

所以這裡我們可以使用 Provider.of,並且將 listen 設定為 false

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

在 build 方法中使用上面的程式碼,當 notifyListeners 被呼叫的時候,並不會使 widget 被重構。

把程式碼整合在一起

你可以在文章中 檢視這個範例。如果你想參考稍微簡單一點的範例,可以看看 Counter 應用程式是如何 基於 provider 實現的

透過跟著這些文章的學習,你已經大大提高了建立一個包含狀態管理應用的能力。試著自己用 provider 建構一個應用來掌握這些技能吧!