Flutter 入門與實戰(四十):以購物車為例初探狀態管理 | 8月更文挑戰

島上碼農發表於2021-08-03

這是我參與8月更文挑戰的第3天,活動詳情檢視:8月更文挑戰

本文主要內容翻譯自 Flutter 官方文件:Simple app state management,狀態管理系列文章會比較多,先從官方的示例文件開始,能夠更好地理解狀態管理的概念。

前言

宣告式 UI 程式的主要特點是 UI 介面的實際繪製和宣告介面的程式碼是分離的。本人剛接觸 Flutter 的時候就很不適應,以前 iOS 寫個文字控制元件,修改文字內容時直接修改 UIText 的 text 屬性即可,但是對於 Flutter 而言,Text元件的內容初始化之後不可以直接修改,而是需要通過狀態管理更改資料後再觸發對應的方法重新構建 UI 介面(典型的就是呼叫 setState方法觸發 build)。這也是現代響應式框架的特點,像 React,Vue,SwiftUI 都是類似的思路。

由於資料和介面分離,使得程式碼的業務邏輯更清晰,也易於封裝和共用。狀態管理成為了核心業務所在,因此十分重要。

購物車示例

為了演示狀態管理,我們以簡單的購物車為例。我們的應用有兩個獨立的頁面:商品列表(GoodsList)和購物車(MyCart)。業務邏輯也很簡單:

  • 從商品列表點選新增按鈕時就把商品新增到購物車
  • 從購物車頁面可以看到已經新增進去的商品。
  • 商品列表的商品如果已經加入到了購物車就打勾,不再允許重複加入。

為了簡化業務邏輯,這裡沒有實現商品修改數量和購物車的移除商品功能。應用的元件結構如下圖所示。

購物車元件.png

這裡我們就會有一個問題,我們在哪裡管理購物車的狀態?是在 MyCart 中還是別的地方?

狀態管理提升

在 Flutter 中,將狀態管理置於使用狀態的元件的上層會更加合理。這是因為,像 Flutter 這樣的宣告式框架,如果要改變 UI 介面,必須重建元件。我們不能通過 MyCart.updateWith(somethingNew)來更新介面。換言之,我們不能在外部呼叫元件的某個方法來顯示地更改元件。即便是你想這麼做,你得繞過框架的限制而不是利用框架的優勢。

// 糟糕的示例
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}
複製程式碼

即便是上面的程式碼能夠工作,之後我們也需要實現對應的 updateWith 方法。

// 糟糕的示例
Widget build(BuildContext context) {
  return SomeWidget(
    // 購物車的初始狀態
  );
}

void updateWith(Item item) {
  // 更新介面的程式碼
}
複製程式碼

這個程式碼中需要考慮 UI 的當前狀態,然後將新的資料應用到介面上。這樣很難避免 bug。 在 Flutter中,一旦介面對應的內容發生改變了,每次都會新構建一個元件。我們應該使用MyCart(contents)來替換MyCart.updateWith(somethingNew)方法呼叫這種形式。這是因為,我們只能在元件的父節點的 build 方法構建新的元件,這就要求狀態是在 MyCart 的父節點或者更上的層級中管理。

// 好的示例
void myTapHandler(BuildContext context) {
  car cartModel = somehowGetMyCartModel(context);
  cartModel.add(item);
}
複製程式碼

現在,購物車中只會有一個入口來構建 UI。

// 好的示例
Widget build(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  return SomeWidget(
    //只需要利用當前狀態構建一次 UI
  );
}
複製程式碼

在這個例子中,contents 應該是在 MyApp 管理,一旦它發生了改變,應用將從上一層重建 MyCart 元件。這樣的好處是,MyCart 無需生命週期管理,它只是宣告瞭如何按 contents 來展示介面(MyCart 變成了無狀態元件,介面和業務邏輯是分開的)。當狀態發生改變後,舊的 MyCart 元件會消失,然後用新的來替換。

購物車元件變更狀態.png

從這裡也能夠看出來為什麼說元件(Widget)時不可變的,他們不會改變,而是被替換。知道在哪裡管理狀態了,下面我們來看如何訪問狀態。

訪問狀態

當使用者點選商品列表的一個元素後,它將被加入購物車。但是我們的購物車在商品元素的上一級,這個時候怎麼辦? 一個簡單的辦法時給每個元素一個回撥方法,當被點選後呼叫該方法。在 Dart 中,函式是一等物件,因此可以將函式作為引數傳遞。因此,在商品列表中我們可以用程式碼這麼實現:

@override
Widget build(BuildContext context) {
  return SomeWidget(myTapCallback);
}

void myTapCallback(Item item) {
  // 處理商品點選事件
}
複製程式碼

這樣也能正常工作,但是,如果我們的應用很多地方都要用到商品列表這個元件,那麼我們的商品點選處理方法會散落在各個元件中,結果很難維護(當相同的程式碼被重複使用2次以上時,就要考慮你的設計是不是有問題了)。 幸運的是,Flutter 提供了元件為下級元件(包括子元件,以及子元件的下級元件)提供資料的機制。如同 Flutter 中一切皆是元件的理念,資料傳遞也是一種特殊的元件:InheritedWidgetInheritedNotifierInheritedModel 等等。本篇暫時不會涉及這些元件的內容,因為這些元件在更深層級實現。

這裡我們需要外掛 Provider,Provider 為我們隱藏了深層次的資料傳遞元件,從而簡化狀態管理。Provider 的具體使用可以參考 pub 的文件:狀態管理外掛 Provider。後續我們也將深入介紹 Provider外掛的使用。

Provider 之 ChangeNotifier

ChangeNotifer 是 Flutter SDK 內建的簡單類,以便向監聽者提供變化資訊。換言之,如果物件是ChangeNotifier物件(繼承或 mixin),那麼我們就可以訂閱它的變化(其實就和觀察者模式相似)。 在 Provider 中,ChangeNotifer是封裝應用狀態的一種方式。對於簡單的應用,可以使用單個 ChangeNotifer。對於複雜應用,會有多個模型,因此會有多個 ChangeNotifer。雖然不使用 Provider 也能使用 ChangeNotifer,但是有了 Provider,會更加簡單。 在我們的購物車示例中,我們可以在一個 ChangeNotifer 中管理購物車的狀態,因此我們建立一個購物車模型類來繼承 ChangeNotifier

class CartModel extends ChangeNotifier {
  final List<Item> _items = [];
  
  UnmofiableListView<Item> get items => UnmodiableListView(_items);
  
  int get totalPrice => _items.length * 42;
  
  void add(Item item) {
		_items.add(item);
    notifyListeners();
  }
  
  void removeAll() {
    _items.clear();
    notifyListeners();
  }
複製程式碼

ChangeNotifer唯一特殊之處在於呼叫notifyListeners方法。在模型發生改變的任何時候呼叫該方法可能會重新整理 UI 介面。而在 CartModel 的其餘程式碼都是自身的業務邏輯。 ChangeNotifier 是 flutter:foundation 的一部分,並不依賴於其他更高階的類。因此,測試起來十分簡單(甚至都不需要使用元件來測試)。例如,下面時 CartModel 的一個簡單的單元測試:

text('adding item increass total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
  });
  cart.add(Item('Dash'));
複製程式碼

Provider 之 ChangeNotiferProvider

ChangeNotiferProvider是一個為子節點提供 ChangeNotifer 例項的元件。這是在 Provider 包中定義的。 我們之前講到過要在方位狀態的元件上層定義 狀態,即這裡的 ChangeNotifierProvider。對於CartModel 來說,這意味著是商品列表和購物車的上層——那就是我們的 App 這一層。

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const App(),
      ),
    );
}
複製程式碼

需要注意,我們定義了一個構造方法來返回 CartModel的例項物件。ChangeNotifierProvider 在沒有必要的情況下不會重新構建 CartModel。而且,會在例項不再需要的時候呼叫 dispose 來銷燬該物件。如果我們需要提供多個狀態示例物件,可以使用 MultiProvider:

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: const App(),
      ),
    );
}
複製程式碼

Provider 之 Consumer

現在 CartModel 已經能夠通過在應用頂層定義的ChangeNotifierProvider提供給元件了,我們就可以在元件中使用了。

return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price is ${cart.totalPrice}');
  },
);
複製程式碼

在 Consumer 中我們必須指定我們要訪問的模型的類。在這個例子中,我們需要 CartModel,因此我們是使用 Consumer。如果在泛型中不指定那個類,那 Provider 包將無法幫助我們。Provider 是基於型別提供狀態資訊的,如果沒有指定型別那它不知道元件需要什麼資訊。 Consumer只需要一個必填引數,那就是 builder。builder是在 ChangeNotifer 物件發生改變時會被呼叫的函式。也就是在狀態模型的notifyListeners 方法被呼叫到時候,所有響應該狀態模型的Consumer 的builder 方法都會被呼叫。 builder 方法有三個引數,第一個是和元件的build 方法相同的 context;第二個是觸發build 函式呼叫的ChangeNotifier例項物件,我們可以從中獲取 UI 介面所需要的資料。第三個引數是 child,這是用於優化的。如果在我們的 Consumer下有一個很大的子元件樹,而且在模型改變的時候這些子元件樹並不需要改變,那麼我們就可以只需要對這個子元件構建一次:

return Consumer<CartModel>(
  builder: (context, cart, child) => Stack (
    children: [
    	if(child != null) child,
    	Text('Total price is ${cart.totalPrice}'),
    ],
  ),
  child: const SomeExpensiveWidget(),
);
複製程式碼

將 Consumer 元件放置在元件樹的位置越深越好,這樣其他部分的某些細節改變時我無需構建大量的 UI,從而可以提升效能。

// 糟糕的示例
return Consumre<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      child: AnotherMonstrousWidget(
        //...
        child: Text('Total price is ${cart.totalPrice}'),
      ),
    );
  }
);
複製程式碼

正確的做法是這樣:

return HumongousWidget(
  child: AnotherMonstrousWidget(
    //...
    child: Consumre<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price is ${cart.totalPrice}');
      },
    )
  ),
);
複製程式碼

Provider.of 方法

在某些情況下,我們並不需要根據狀態資訊更改介面,而是訪問狀態物件以進行別的操作。例如我們有一個清空購物車的按鈕,點選按鈕的時候需要呼叫 CartModel 的 removeAll 方法,這個時候我們可以這麼寫:

onPressed: () {
  Provider.of<CartModel>(context, listen: false).removeAll();
}
複製程式碼

注意,listen 引數設定為 false 表示當狀態改變的時候無需通知該元件進行重建。

總結

程式碼已上傳至 gitee:簡單狀態管理示例。執行效果如下(對原示例做了些許改動,以像真正的購物車)。可以看到,使用了狀態管理有下面幾個好處:

  1. 頁面間的資料是同步的。
  2. 即便退出頁面後,再進入之前的狀態還是保持的,這也是為什麼要把狀態管理放置在更上層級的原因之一。
  3. 業務程式碼和介面是分離的,介面只負責頁面的渲染和互動,而具體的業務邏輯在狀態管理中實現。程式碼更容易維護。
  4. 大部分頁面可以設定為無狀態元件,通過 Provider 實現區域性重新整理從而提高效能。

螢幕錄製2021-08-02 下午9.05.04.gif


我是島上碼農,微信公眾號同名,這是Flutter 入門與實戰的專欄文章。

??:覺得有收穫請點個贊鼓勵一下!

?:收藏文章,方便回看哦!

?:評論交流,互相進步!

相關文章