Flutter 中文文檔:簡單的應用狀態管理

  • 2019 年 10 月 31 日
  • 筆記

現在大家已經了解了 狀態管理中的聲明式編程思維短時 (ephemeral) 和應用 (app) 狀態的區別 之間的區別,現在可以學習如何管理簡單的全局應用狀態。

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

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

鏈接:https://flutter.cn/docs/development/data-and-backend/state-mgmt/options

1. 示例

為了演示效果,我們實現下面這個簡單應用。

程式有三個獨立的頁面:一個登陸提示,一個類別頁面,一個購物車頁面(分別用 MyLoginScreen, MyCatalogMyCart widget 來展示)。雖然看上去是一個購物應用程式,但是你也可以和社交網路應用類比(把類別頁面替換成朋友圈,把購物車替換成關注的人)。

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

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

所以我們有至少 6 個 Widget 的子類。他們中有很多需要訪問一些全局的狀態。比如,MyListItem 會被添加到購物車中。但是它可能需要檢查和自己相同的元素是否已經被添加到購物車中。

這裡我們出現了第一個問題:我們把當前購物車的狀態放在哪合適呢?

2. 提高狀態的層級

在 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 替代。

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

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

3. 讀取狀態

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

一個簡單的實現方法是提供一個回調函數,當 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');  }

這段程式碼是沒問題的,但是對於全局應用狀態來說你需要在不同的地方進行修改,可能需要大量傳遞迴調函數—。

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

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

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

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

4. 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). 現在全部商品的總價格(假設他們加起來 $42)    int get totalPrice => _items.length * 42;      /// Adds [item] to cart. This is the only way to modify the cart from outside. 將 [item] 添加到購物車。這是唯一一種能從外部改變購物車的方法。    void add(Item item) {      _items.add(item);      // 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');  });

5. ChangeNotifierProvider

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

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

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

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

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

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

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

6. 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 相關的 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.            child,            Text("Total price: ${cart.totalPrice}"),          ],        ),    // Build the expensive widget here.    child: 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}'),        ),      );    },  );

換成:

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

6.1 Provider.of

有的時候你不需要模型中的 數據 來改變 UI,但是你可能還是需要訪問該數據。比如,ClearCart 按鈕能夠清空購物車的所有商品。它不需要顯示購物車裡的內容,只需要調用 clear() 方法。

我們可以使用 Consumer<CartModel> 來實現這個效果,不過這麼實現有點浪費。因為我們讓整體框架重構了一個無需重構的 widget。

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

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

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

7. 把程式碼集成在一起

你可以在文章中 查看這個示例。

鏈接:https://github.com/flutter/samples/tree/master/provider_shopper

如果你想參考稍微簡單一點的示例,可以看看 Counter 應用程式是如何 基於 provider 實現的。

鏈接:https://github.com/flutter/samples/tree/master/provider_counter

如果你已經學會了並且準備使用 provider 的時候,別忘了先在 pubspec.yaml 中添加相應的依賴。

鏈接:https://github.com/flutter/samples/tree/master/provider_counter

name: my_name  description: Blah blah blah.    # ...    dependencies:    flutter:      sdk: flutter      provider: ^3.0.0    dev_dependencies:    # ...

現在你可以 import 'package:provider/provider.dart';,開始寫程式碼吧。