Flutter實戰(zhàn) 跨組件狀態(tài)共享(Provider)

2021-03-08 11:43 更新

在 Flutter 開發(fā)中,狀態(tài)管理是一個永恒的話題。一般的原則是:如果狀態(tài)是組件私有的,則應該由組件自己管理;如果狀態(tài)要跨組件共享,則該狀態(tài)應該由各個組件共同的父元素來管理。對于組件私有的狀態(tài)管理很好理解,但對于跨組件共享的狀態(tài),管理的方式就比較多了,如使用全局事件總線 EventBus(將在下一章中介紹),它是一個觀察者模式的實現(xiàn),通過它就可以實現(xiàn)跨組件狀態(tài)同步:狀態(tài)持有方(發(fā)布者)負責更新、發(fā)布狀態(tài),狀態(tài)使用方(觀察者)監(jiān)聽狀態(tài)改變事件來執(zhí)行一些操作。下面我們看一個登陸狀態(tài)同步的簡單示例:

定義事件:

  1. enum Event{
  2. login,
  3. ... //省略其它事件
  4. }

登錄頁代碼大致如下:

  1. // 登錄狀態(tài)改變后發(fā)布狀態(tài)改變事件
  2. bus.emit(Event.login);

依賴登錄狀態(tài)的頁面:

  1. void onLoginChanged(e){
  2. //登錄狀態(tài)變化處理邏輯
  3. }
  4. @override
  5. void initState() {
  6. //訂閱登錄狀態(tài)改變事件
  7. bus.on(Event.login,onLogin);
  8. super.initState();
  9. }
  10. @override
  11. void dispose() {
  12. //取消訂閱
  13. bus.off(Event.login,onLogin);
  14. super.dispose();
  15. }

我們可以發(fā)現(xiàn),通過觀察者模式來實現(xiàn)跨組件狀態(tài)共享有一些明顯的缺點:

  1. 必須顯式定義各種事件,不好管理
  2. 訂閱者必須需顯式注冊狀態(tài)改變回調,也必須在組件銷毀時手動去解綁回調以避免內存泄露。

在 Flutter 當中有沒有更好的跨組件狀態(tài)管理方式了呢?答案是肯定的,那怎么做的?我們想想前面介紹的InheritedWidget,它的天生特性就是能綁定InheritedWidget與依賴它的子孫組件的依賴關系,并且當InheritedWidget數(shù)據發(fā)生變化時,可以自動更新依賴的子孫組件!利用這個特性,我們可以將需要跨組件共享的狀態(tài)保存在InheritedWidget中,然后在子組件中引用InheritedWidget即可,F(xiàn)lutter 社區(qū)著名的 Provider 包正是基于這個思想實現(xiàn)的一套跨組件狀態(tài)共享解決方案,接下來我們便詳細介紹一下 Provider 的用法及原理。

#Provider

為了加強讀者的理解,我們不直接去看 Provider 包的源代碼,相反,我會帶著你根據上面描述的通過InheritedWidget實現(xiàn)的思路來一步一步地實現(xiàn)一個最小功能的 Provider。

首先,我們需要一個保存需要共享的數(shù)據InheritedWidget,由于具體業(yè)務數(shù)據類型不可預期,為了通用性,我們使用泛型,定義一個通用的InheritedProvider類,它繼承自InheritedWidget

  1. // 一個通用的InheritedWidget,保存任需要跨組件共享的狀態(tài)
  2. class InheritedProvider<T> extends InheritedWidget {
  3. InheritedProvider({@required this.data, Widget child}) : super(child: child);
  4. //共享狀態(tài)使用泛型
  5. final T data;
  6. @override
  7. bool updateShouldNotify(InheritedProvider<T> old) {
  8. //在此簡單返回true,則每次更新都會調用依賴其的子孫節(jié)點的`didChangeDependencies`。
  9. return true;
  10. }
  11. }

數(shù)據保存的地方有了,那么接下來我們需要做的就是在數(shù)據發(fā)生變化的時候來重新構建InheritedProvider,那么現(xiàn)在就面臨兩個問題:

  1. 數(shù)據發(fā)生變化怎么通知?
  2. 誰來重新構建InheritedProvider?

第一個問題其實很好解決,我們當然可以使用之前介紹的 eventBus 來進行事件通知,但是為了更貼近 Flutter 開發(fā),我們使用 Flutter SDK 中提供的ChangeNotifier類 ,它繼承自Listenable,也實現(xiàn)了一個 Flutter 風格的發(fā)布者-訂閱者模式,ChangeNotifier定義大致如下:

  1. class ChangeNotifier implements Listenable {
  2. List listeners=[];
  3. @override
  4. void addListener(VoidCallback listener) {
  5. //添加監(jiān)聽器
  6. listeners.add(listener);
  7. }
  8. @override
  9. void removeListener(VoidCallback listener) {
  10. //移除監(jiān)聽器
  11. listeners.remove(listener);
  12. }
  13. void notifyListeners() {
  14. //通知所有監(jiān)聽器,觸發(fā)監(jiān)聽器回調
  15. listeners.forEach((item)=>item());
  16. }
  17. ... //省略無關代碼
  18. }

我們可以通過調用addListener()removeListener()來添加、移除監(jiān)聽器(訂閱者);通過調用notifyListeners() 可以觸發(fā)所有監(jiān)聽器回調。

現(xiàn)在,我們將要共享的狀態(tài)放到一個 Model 類中,然后讓它繼承自ChangeNotifier,這樣當共享的狀態(tài)改變時,我們只需要調用notifyListeners() 來通知訂閱者,然后由訂閱者來重新構建InheritedProvider,這也是第二個問題的答案!接下來我們便實現(xiàn)這個訂閱者類:

  1. class ChangeNotifierProvider<T extends ChangeNotifier> extends StatefulWidget {
  2. ChangeNotifierProvider({
  3. Key key,
  4. this.data,
  5. this.child,
  6. });
  7. final Widget child;
  8. final T data;
  9. //定義一個便捷方法,方便子樹中的widget獲取共享數(shù)據
  10. static T of<T>(BuildContext context) {
  11. final type = _typeOf<InheritedProvider<T>>();
  12. final provider = context.dependOnInheritedWidgetOfExactType<InheritedProvider<T>>();
  13. return provider.data;
  14. }
  15. @override
  16. _ChangeNotifierProviderState<T> createState() => _ChangeNotifierProviderState<T>();
  17. }

該類繼承StatefulWidget,然后定義了一個of()靜態(tài)方法供子類方便獲取 Widget 樹中的InheritedProvider中保存的共享狀態(tài)(model),下面我們實現(xiàn)該類對應的_ChangeNotifierProviderState類:

  1. class _ChangeNotifierProviderState<T extends ChangeNotifier> extends State<ChangeNotifierProvider<T>> {
  2. void update() {
  3. //如果數(shù)據發(fā)生變化(model類調用了notifyListeners),重新構建InheritedProvider
  4. setState(() => {});
  5. }
  6. @override
  7. void didUpdateWidget(ChangeNotifierProvider<T> oldWidget) {
  8. //當Provider更新時,如果新舊數(shù)據不"==",則解綁舊數(shù)據監(jiān)聽,同時添加新數(shù)據監(jiān)聽
  9. if (widget.data != oldWidget.data) {
  10. oldWidget.data.removeListener(update);
  11. widget.data.addListener(update);
  12. }
  13. super.didUpdateWidget(oldWidget);
  14. }
  15. @override
  16. void initState() {
  17. // 給model添加監(jiān)聽器
  18. widget.data.addListener(update);
  19. super.initState();
  20. }
  21. @override
  22. void dispose() {
  23. // 移除model的監(jiān)聽器
  24. widget.data.removeListener(update);
  25. super.dispose();
  26. }
  27. @override
  28. Widget build(BuildContext context) {
  29. return InheritedProvider<T>(
  30. data: widget.data,
  31. child: widget.child,
  32. );
  33. }
  34. }

可以看到_ChangeNotifierProviderState類的主要作用就是監(jiān)聽到共享狀態(tài)(model)改變時重新構建 Widget 樹。注意,在_ChangeNotifierProviderState類中調用setState()方法,widget.child始終是同一個,所以執(zhí)行build時,InheritedProvider的 child 引用的始終是同一個子widget,所以widget.child并不會重新build,這也就相當于對child進行了緩存!當然如果ChangeNotifierProvider父級Widget重新build時,則其傳入的child便有可能會發(fā)生變化。

現(xiàn)在我們所需要的各個工具類都已完成,下面我們通過一個購物車的例子來看看怎么使用上面的這些類。

#購物車示例

我們需要實現(xiàn)一個顯示購物車中所有商品總價的功能:

  1. 向購物車中添加新商品時總價更新

定義一個Item類,用于表示商品信息:

  1. class Item {
  2. Item(this.price, this.count);
  3. double price; //商品單價
  4. int count; // 商品份數(shù)
  5. //... 省略其它屬性
  6. }

定義一個保存購物車內商品數(shù)據的CartModel類:

  1. class CartModel extends ChangeNotifier {
  2. // 用于保存購物車中商品列表
  3. final List<Item> _items = [];
  4. // 禁止改變購物車里的商品信息
  5. UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
  6. // 購物車中商品的總價
  7. double get totalPrice =>
  8. _items.fold(0, (value, item) => value + item.count * item.price);
  9. // 將 [item] 添加到購物車。這是唯一一種能從外部改變購物車的方法。
  10. void add(Item item) {
  11. _items.add(item);
  12. // 通知監(jiān)聽器(訂閱者),重新構建InheritedProvider, 更新狀態(tài)。
  13. notifyListeners();
  14. }
  15. }

CartModel即要跨組件共享的 model 類。最后我們構建示例頁面:

  1. class ProviderRoute extends StatefulWidget {
  2. @override
  3. _ProviderRouteState createState() => _ProviderRouteState();
  4. }
  5. class _ProviderRouteState extends State<ProviderRoute> {
  6. @override
  7. Widget build(BuildContext context) {
  8. return Center(
  9. child: ChangeNotifierProvider<CartModel>(
  10. data: CartModel(),
  11. child: Builder(builder: (context) {
  12. return Column(
  13. children: <Widget>[
  14. Builder(builder: (context){
  15. var cart=ChangeNotifierProvider.of<CartModel>(context);
  16. return Text("總價: ${cart.totalPrice}");
  17. }),
  18. Builder(builder: (context){
  19. print("RaisedButton build"); //在后面優(yōu)化部分會用到
  20. return RaisedButton(
  21. child: Text("添加商品"),
  22. onPressed: () {
  23. //給購物車中添加商品,添加后總價會更新
  24. ChangeNotifierProvider.of<CartModel>(context).add(Item(20.0, 1));
  25. },
  26. );
  27. }),
  28. ],
  29. );
  30. }),
  31. ),
  32. );
  33. }
  34. }

運行示例后效果如圖7-2所示:

provider

每次點擊”添加商品“按鈕,總價就會增加20,我們期望的功能實現(xiàn)了!可能有些讀者會疑惑,我們饒了一大圈實現(xiàn)這么簡單的功能有意義么?其實,就這個例子來看,只是更新同一個路由頁中的一個狀態(tài),我們使用ChangeNotifierProvider的優(yōu)勢并不明顯,但是如果我們是做一個購物 APP 呢?由于購物車數(shù)據是通常是會在整個 APP 中共享的,比如會跨路由共享。如果我們將ChangeNotifierProvider放在整個應用的 Widget 樹的根上,那么整個 APP 就可以共享購物車的數(shù)據了,這時ChangeNotifierProvider的優(yōu)勢將會非常明顯。

雖然上面的例子比較簡單,但它卻將 Provider 的原理和流程體現(xiàn)的很清楚,圖7-3是 Provider 的原理圖:

圖7-3

Model 變化后會自動通知ChangeNotifierProvider(訂閱者),ChangeNotifierProvider內部會重新構建InheritedWidget,而依賴該InheritedWidget的子孫 Widget 就會更新。

我們可以發(fā)現(xiàn)使用Provider,將會帶來如下收益:

  1. 我們的業(yè)務代碼更關注數(shù)據了,只要更新 Model,則 UI 會自動更新,而不用在狀態(tài)改變后再去手動調用setState()來顯式更新頁面。
  2. 數(shù)據改變的消息傳遞被屏蔽了,我們無需手動去處理狀態(tài)改變事件的發(fā)布和訂閱了,這一切都被封裝在 Provider 中了。這真的很棒,幫我們省掉了大量的工作!
  3. 在大型復雜應用中,尤其是需要全局共享的狀態(tài)非常多時,使用 Provider 將會大大簡化我們的代碼邏輯,降低出錯的概率,提高開發(fā)效率。

#優(yōu)化

我們上面實現(xiàn)的ChangeNotifierProvider是有兩個明顯缺點:代碼組織問題和性能問題,下面我們一一討論。

#代碼組織問題

我們先看一下構建顯示總價 Text 的代碼:

  1. Builder(builder: (context){
  2. var cart=ChangeNotifierProvider.of<CartModel>(context);
  3. return Text("總價: ${cart.totalPrice}");
  4. })

這段代碼有兩點可以優(yōu)化:

  1. 需要顯式調用ChangeNotifierProvider.of,當 APP 內部依賴CartModel很多時,這樣的代碼將很冗余。
  2. 語義不明確;由于ChangeNotifierProvider是訂閱者,那么依賴CartModel的 Widget 自然就是訂閱者,其實也就是狀態(tài)的消費者,如果我們用Builder 來構建,語義就不是很明確;如果我們能使用一個具有明確語義的 Widget,比如就叫Consumer,這樣最終的代碼語義將會很明確,只要看到Consumer,我們就知道它是依賴某個跨組件或全局的狀態(tài)。

為了優(yōu)化這兩個問題,我們可以封裝一個Consumer Widget,實現(xiàn)如下:

  1. // 這是一個便捷類,會獲得當前context和指定數(shù)據類型的Provider
  2. class Consumer<T> extends StatelessWidget {
  3. Consumer({
  4. Key key,
  5. @required this.builder,
  6. this.child,
  7. }) : assert(builder != null),
  8. super(key: key);
  9. final Widget child;
  10. final Widget Function(BuildContext context, T value) builder;
  11. @override
  12. Widget build(BuildContext context) {
  13. return builder(
  14. context,
  15. ChangeNotifierProvider.of<T>(context), //自動獲取Model
  16. );
  17. }
  18. }

Consumer實現(xiàn)非常簡單,它通過指定模板參數(shù),然后再內部自動調用ChangeNotifierProvider.of獲取相應的 Model,并且Consumer這個名字本身也是具有確切語義(消費者)?,F(xiàn)在上面的代碼塊可以優(yōu)化為如下這樣:

  1. Consumer<CartModel>(
  2. builder: (context, cart)=> Text("總價: ${cart.totalPrice}");
  3. )

是不是很優(yōu)雅!

#性能問題

上面的代碼還有一個性能問題,就在構建”添加按鈕“的代碼處:

  1. Builder(builder: (context) {
  2. print("RaisedButton build"); // 構建時輸出日志
  3. return RaisedButton(
  4. child: Text("添加商品"),
  5. onPressed: () {
  6. ChangeNotifierProvider.of<CartModel>(context).add(Item(20.0, 1));
  7. },
  8. );
  9. }

我們點擊”添加商品“按鈕后,由于購物車商品總價會變化,所以顯示總價的 Text 更新是符合預期的,但是”添加商品“按鈕本身沒有變化,是不應該被重新 build 的。但是我們運行示例,每次點擊”添加商品“按鈕,控制臺都會輸出"RaisedButton build"日志,也就是說”添加商品“按鈕在每次點擊時其自身都會重新 build!這是為什么呢?如果你已經理解了InheritedWidget的更新機制,那么答案一眼就能看出:這是因為構建RaisedButtonBuilder中調用了ChangeNotifierProvider.of,也就是說依賴了 Widget 樹上面的InheritedWidget(即InheritedProvider )Widget,所以當添加完商品后,CartModel發(fā)生變化,會通知ChangeNotifierProvider, 而ChangeNotifierProvider則會重新構建子樹,所以InheritedProvider將會更新,此時依賴它的子孫 Widget 就會被重新構建。

問題的原因搞清楚了,那么我們如何避免這不必要重構呢?既然按鈕重新被 build 是因為按鈕和InheritedWidget建立了依賴關系,那么我們只要打破或解除這種依賴關系就可以了。那么如何解除按鈕和InheritedWidget的依賴關系呢?我們上一節(jié)介紹InheritedWidget時已經講過了:調用dependOnInheritedWidgetOfExactType()getElementForInheritedWidgetOfExactType()的區(qū)別就是前者會注冊依賴關系,而后者不會。所以我們只需要將ChangeNotifierProvider.of的實現(xiàn)改為下面這樣即可:

  1. //添加一個listen參數(shù),表示是否建立依賴關系
  2. static T of<T>(BuildContext context, {bool listen = true}) {
  3. final type = _typeOf<InheritedProvider<T>>();
  4. final provider = listen
  5. ? context.dependOnInheritedWidgetOfExactType<InheritedProvider<T>>()
  6. : context.getElementForInheritedWidgetOfExactType<InheritedProvider<T>>()?.widget
  7. as InheritedProvider<T>;
  8. return provider.data;
  9. }

然后我們將調用部分代碼改為:

  1. Column(
  2. children: <Widget>[
  3. Consumer<CartModel>(
  4. builder: (BuildContext context, cart) =>Text("總價: ${cart.totalPrice}"),
  5. ),
  6. Builder(builder: (context) {
  7. print("RaisedButton build");
  8. return RaisedButton(
  9. child: Text("添加商品"),
  10. onPressed: () {
  11. // listen 設為false,不建立依賴關系
  12. ChangeNotifierProvider.of<CartModel>(context, listen: false)
  13. .add(Item(20.0, 1));
  14. },
  15. );
  16. })
  17. ],
  18. )

修改后再次運行上面的示例,我們會發(fā)現(xiàn)點擊”添加商品“按鈕后,控制臺不會再輸出"RaisedButton build"了,即按鈕不會被重新構建了。而總價仍然會更新,這是因為Consumer中調用ChangeNotifierProvider.oflisten值為默認值 true,所以還是會建立依賴關系。

至此我們便實現(xiàn)了一個迷你的 Provider,它具備 Pub上Provider Package 中的核心功能;但是我們的迷你版功能并不全面,如只實現(xiàn)了一個可監(jiān)聽的 ChangeNotifierProvider,并沒有實現(xiàn)只用于數(shù)據共享的 Provider;另外,我們的實現(xiàn)有些邊界也沒有考慮的到,比如如何保證在 Widget 樹重新 build 時 Model 始終是單例等。所以建議讀者在實戰(zhàn)中還是使用 Provider Package,而本節(jié)實現(xiàn)這個迷你 Provider 的主要目的主要是為了幫助讀者了解 Provider Package 底層的原理。

#其它狀態(tài)管理包

現(xiàn)在 Flutter 社區(qū)已經有很多專門用于狀態(tài)管理的包了,在此我們列出幾個相對評分比較高的:

包名 介紹
Provider (opens new window)Scoped Model(opens new window) 這兩個包都是基于InheritedWidget的,原理相似
Redux(opens new window) 是 Web 開發(fā)中 React 生態(tài)鏈中 Redux 包的 Flutter 實現(xiàn)
MobX(opens new window) 是 Web 開發(fā)中 React 生態(tài)鏈中 MobX 包的 Flutter 實現(xiàn)
BLoC(opens new window) 是 BLoC 模式的 Flutter 實現(xiàn)

在此筆者不對這些包做推薦,讀者有興趣都可以研究一下,了解它們各自的思想。

#總結

本節(jié)通過介紹事件總線在跨組件共享中的一些缺點引出了通過InheritedWidget來實現(xiàn)狀態(tài)的共享的思想,然后基于該思想實現(xiàn)了一個簡單的 Provider,在實現(xiàn)的過程中也更深入的探索了InheritedWidget與其依賴項的注冊機制和更新機制。通過本節(jié)的學習,讀者應該達到兩個目標,首先是對InheritedWidget徹底吃透,其次是 Provider 的設計思想。

InheritedWidget是 Flutter 中非常重要的一個 Widget,像國際化、主題等都是通過它來實現(xiàn),所以我們也不惜篇幅,通過好幾節(jié)來介紹它的,在下一節(jié)中,我們將介紹另一個基于InheritedWidget的組件 Theme(主題)。

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號