本文主要內容翻譯自 Flutter 官方文件:Simple app state management,狀態管理系列文章會比較多,先從官方的示例文件開始,能夠更好地理解狀態管理的概念。
前言
宣告式 UI 程式的主要特點是 UI 介面的實際繪製和宣告介面的程式碼是分離的。本人剛接觸 Flutter 的時候就很不適應,以前 iOS 寫個文字控制元件,修改文字內容時直接修改 UIText 的 text 屬性即可,但是對於 Flutter 而言,Text元件的內容初始化之後不可以直接修改,而是需要通過狀態管理更改資料後再觸發對應的方法重新構建 UI 介面(典型的就是呼叫 setState方法觸發 build)。這也是現代響應式框架的特點,像 React,Vue,SwiftUI 都是類似的思路。
由於資料和介面分離,使得程式碼的業務邏輯更清晰,也易於封裝和共用。狀態管理成為了核心業務所在,因此十分重要。
購物車示例
為了演示狀態管理,我們以簡單的購物車為例。我們的應用有兩個獨立的頁面:商品列表(GoodsList)和購物車(MyCart)。業務邏輯也很簡單:
- 從商品列表點選新增按鈕時就把商品新增到購物車
- 從購物車頁面可以看到已經新增進去的商品。
- 商品列表的商品如果已經加入到了購物車就打勾,不再允許重複加入。
為了簡化業務邏輯,這裡沒有實現商品修改數量和購物車的移除商品功能。應用的元件結構如下圖所示。
這裡我們就會有一個問題,我們在哪裡管理購物車的狀態?是在 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 元件會消失,然後用新的來替換。
從這裡也能夠看出來為什麼說元件(Widget)時不可變的,他們不會改變,而是被替換。知道在哪裡管理狀態了,下面我們來看如何訪問狀態。
訪問狀態
當使用者點選商品列表的一個元素後,它將被加入購物車。但是我們的購物車在商品元素的上一級,這個時候怎麼辦? 一個簡單的辦法時給每個元素一個回撥方法,當被點選後呼叫該方法。在 Dart 中,函式是一等物件,因此可以將函式作為引數傳遞。因此,在商品列表中我們可以用程式碼這麼實現:
@override
Widget build(BuildContext context) {
return SomeWidget(myTapCallback);
}
void myTapCallback(Item item) {
// 處理商品點選事件
}
複製程式碼
這樣也能正常工作,但是,如果我們的應用很多地方都要用到商品列表這個元件,那麼我們的商品點選處理方法會散落在各個元件中,結果很難維護(當相同的程式碼被重複使用2次以上時,就要考慮你的設計是不是有問題了)。
幸運的是,Flutter 提供了元件為下級元件(包括子元件,以及子元件的下級元件)提供資料的機制。如同 Flutter 中一切皆是元件的理念,資料傳遞也是一種特殊的元件:InheritedWidget
,InheritedNotifier
,InheritedModel
等等。本篇暫時不會涉及這些元件的內容,因為這些元件在更深層級實現。
這裡我們需要外掛 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:簡單狀態管理示例。執行效果如下(對原示例做了些許改動,以像真正的購物車)。可以看到,使用了狀態管理有下面幾個好處:
- 頁面間的資料是同步的。
- 即便退出頁面後,再進入之前的狀態還是保持的,這也是為什麼要把狀態管理放置在更上層級的原因之一。
- 業務程式碼和介面是分離的,介面只負責頁面的渲染和互動,而具體的業務邏輯在狀態管理中實現。程式碼更容易維護。
- 大部分頁面可以設定為無狀態元件,通過 Provider 實現區域性重新整理從而提高效能。
我是島上碼農,微信公眾號同名,這是Flutter 入門與實戰的專欄文章。
??:覺得有收穫請點個贊鼓勵一下!
?:收藏文章,方便回看哦!
?:評論交流,互相進步!