Flutter | 狀態管理指南篇——Provider

Vadaski發表於2019-06-13

前言

2019 Google I/O 大會,官方在 Pragmatic State Management in Flutter (Google I/O'19) 主題演講上正式介紹了 由社群作者 Remi Rousselet 與 Flutter Team 共同編寫的 Provider 代替 Provide 成為官方推薦的狀態管理方式之一。

讀者老朋友應該都知道,在之前的文章中我介紹了 Google 官方倉庫下的一個狀態管理 Provide。乍一看這倆玩意可能很容易就被認為是同一個東西,仔細一看,這不就差了一個字嗎,有什麼區別呢。?

首先,你要知道的最大的一個區別就是,Provide 被 Provider 幹掉了...假如你就是用了 Provide 的幸運鵝,你的內心應該已經開始 甘霖* 這不是坑爹嗎 ?‍♀️。我也在這先給這部分朋友說聲抱歉嗷,畢竟很多人是看了我之前那篇文章才入坑的。不過幸運的是,你要從 Provide 遷移到 Provider 並不是太難。

本文將基於最新 Provider v-3.0 進行介紹,除了講解其使用方式之外,我認為更重要的是 Provider 不同“提供”方式的適用場景及使用原則。以及在使用狀態管理時候需要遵守的原則,在編寫 Flutter App 的過程中減輕你的思考負擔。希望本文能給你帶來一些有價值的參考。(提前打個預防針,本文篇幅較長,建議馬住在看。)

推薦閱讀時間:1小時

What's the problem

在正式介紹 Provider 之前允許我再囉嗦兩句,為什麼我們需要狀態管理。如果你已經對此十分清楚,那麼建議直接跳過這一節。

如果我們的應用足夠簡單,Flutter 作為一個宣告式框架,你或許只需要將 資料 對映成 檢視 就可以了。你可能並不需要狀態管理,就像下面這樣。

Flutter | 狀態管理指南篇——Provider

但是隨著功能的增加,你的應用程式將會有幾十個甚至上百個狀態。這個時候你的應用應該會是這樣。

Flutter | 狀態管理指南篇——Provider

WTF,這是什麼鬼。我們很難再清楚的測試維護我們的狀態,因為它看上去實在是太複雜了!而且還會有多個頁面共享同一個狀態,例如當你進入一個文章點贊,退出到外部縮略展示的時候,外部也需要顯示點贊數,這時候就需要同步這兩個狀態。

Flutter 實際上在一開始就為我們提供了一種狀態管理方式,那就是 StatefulWidget。但是我們很快發現,它正是造成上述原因的罪魁禍首

在 State 屬於某一個特定的 Widget,在多個 Widget 之間進行交流的時候,雖然你可以使用 callback 解決,但是當巢狀足夠深的話,我們增加非常多可怕的垃圾程式碼。

這時候,我們便迫切的需要一個架構來幫助我們理清這些關係,狀態管理框架應運而生。

What is Provider

那麼我們該如何解決上面這種糟糕的情況呢。在上手這個庫之後我可以說 Provider 是一個相當不錯的解決方案。(你上次介紹 Provide 也這麼說?)我們先來簡單說一下 Provider 的基本作用。

Provider 從名字上就很容易理解,它就是用於提供資料,無論是在單個頁面還是在整個 app 都有它自己的解決方案,我們可以很方便的管理狀態。可以說,Provider 的目標就是完全替代 StatefulWidget。

說了很多還是很抽象,我們先一起做一個最簡單的例子。

How to do

這裡我們還是用這個 Counter App 為例,給大家介紹如何在兩個獨立的頁面中共享計數器(counter)的狀態應該怎麼做,具體長這樣。

Flutter | 狀態管理指南篇——Provider

兩個頁面中心字型共用了同一個字型大小。第二個頁面的按鈕將會讓數字增加,第一個頁面的數字將會同步增加。

第一步:新增依賴

在pubspec.yaml中新增Provider的依賴。

Flutter | 狀態管理指南篇——Provider

第二步:建立資料 Model

這裡的 Model 實際上就是我們的狀態,它不僅儲存了我們的資料模型,而且還包含了更改資料的方法,並暴露出它想要暴露出的資料。

import 'package:flutter/material.dart';

class CounterModel with ChangeNotifier {
  int _count = 0;
  int get value => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}
複製程式碼

這個類意圖非常清晰,我們的資料就是一個 int 型別的 _count,下劃線代表私有。通過 get value_count 值暴露出來。並提供 increment 方法用於更改資料。

這裡使用了 mixin 混入了 ChangeNotifier,這個類能夠幫駐我們自動管理所有聽眾。當呼叫 notifyListeners() 時,它會通知所有聽眾進行重新整理。

如果你對 mixin 這個概念還不是很清楚的話,可以看我之前翻譯的這篇 【譯】Dart | 什麼是Mixin

第三步:建立頂層共享資料

我們在 main 方法中初始化全域性資料。

void main() {
  final counter = CounterModel();
  final textSize = 48;

  runApp(
    Provider<int>.value(
      value: textSize,
      child: ChangeNotifierProvider.value(
        value: counter,
        child: MyApp(),
      ),
    ),
  );
}
複製程式碼

通過 Provider<T>.value 能夠管理一個恆定的資料,並提供給子孫節點使用。我們只需要將資料在其 value 屬性中宣告即可。在這裡我們將 textSize 傳入。

ChangeNotifierProvider<T>.value 不僅能夠提供資料供子孫節點使用,還可以在資料改變的時候通知所有聽眾重新整理。(通過之前我們說過的 notifyListeners)

此處的 <T> 範型可省略。但是我建議大家還是進行宣告,這會使你的應用更加健壯。

除了上述幾個屬性之外 Provider<T>.value 還提供了 UpdateShouldNotify Function,用於控制重新整理時機。

typedef UpdateShouldNotify<T> = bool Function(T previous, T current);

我們可以在這裡傳入一個方法 (T previous, T current){...} ,並獲得前後兩個 Model 的例項,然後通過比較兩個 Model 以自定義重新整理規則,返回 bool 表示是否需要重新整理。預設為 previous != current 則重新整理。

當然,key 屬性是肯定有的,常規操作。如果你還不太清楚的話,建議閱讀我之前的這篇文章 [Flutter | 深入淺出Key] (juejin.im/post/5ca215…)

為了讓各位思維連貫,我還是在這裡放上這個平淡無奇的 MyApp Widget 程式碼。?

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark(),
      home: FirstScreen(),
    );
  }
}
複製程式碼

第四步:在子頁面中獲取狀態

在這裡我們有兩個頁面,FirstScreen 和 SecondScreen。我們先來看 FirstScreen 的程式碼。

Provider.of(context)

class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final _counter = Provider.of<CounterModel>(context);
    final textSize = Provider.of<int>(context).toDouble();

    return Scaffold(
      appBar: AppBar(
        title: Text('FirstPage'),
      ),
      body: Center(
        child: Text(
          'Value: ${_counter.value}',
          style: TextStyle(fontSize: textSize),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Navigator.of(context)
            .push(MaterialPageRoute(builder: (context) => SecondPage())),
        child: Icon(Icons.navigate_next),
      ),
    );
  }
}
複製程式碼

獲取頂層資料最簡單的方法就是 Provider.of<T>(context); 這裡的範型 <T> 指定了獲取 FirstScreen 向上尋找最近的儲存了 T 的祖先節點的資料。

我們通過這個方法獲取了頂層的 CounterModel 及 textSize。並在 Text 元件中進行使用。

floatingActionButton 用來點選跳轉到 SecondScreen 頁面,和我們的主題無關。

Consumer

看到這裡你可能會想,兩個頁面都是獲取頂層狀態,程式碼不都一樣嗎,弄啥捏。? 別忙著跳到下一節,我們來看另外一種獲取狀態的方式,這將會影響你的 app performance。

class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Second Page'),
      ),
      body: Consumer2<CounterModel,int>(
        builder: (context, CounterModel counter, int textSize, _) => Center(
              child: Text(
                'Value: ${counter.value}',
                style: TextStyle(
                  fontSize: textSize.toDouble(),
                ),
              ),
            ),
      ),
      floatingActionButton: Consumer<CounterModel>(
        builder: (context, CounterModel counter, child) => FloatingActionButton(
              onPressed: counter.increment,
              child: child,
            ),
        child: Icon(Icons.add),
      ),
    );
  }
}
複製程式碼

這裡我們要介紹的是第二種方式,使用 Consumer 獲取祖先節點中的資料。

在這個頁面中,我們有兩處使用到了公共 Model。

  • 應用中心的文字:使用 CounterModel 在 Text 中展示文字,以及通過 textSize 定義自身的大小。一共使用到了兩個 Model。
  • 浮動按鈕:使用 CounterModel 的 increment 方法觸發計數器的值增加。使用到了一個 Model。

Single Model Consumer

我們先看 floatingActionButton,使用了一個 Consumer 的情況。

Consumer 使用了 Builder 模式,收到更新通知就會通過 builder 重新構建。Consumer<T> 代表了它要獲取哪一個祖先中的 Model。

Consumer 的 builder 實際上就是一個 Function,它接收三個引數 (BuildContext context, T model, Widget child)

  • context: context 就是 build 方法傳進來的 BuildContext 在這裡就不細說了,如果有興趣可以看我之前這篇文章 Flutter | 深入理解BuildContext
  • T:T也很簡單,就是獲取到的最近一個祖先節點中的資料模型。
  • child:它用來構建那些與 Model 無關的部分,在多次執行 builder 中,child 不會進行重建。

然後它會返回一個通過這三個引數對映的 Widget 用於構建自身。

在這個浮動按鈕的例子中,我們通過 Consumer 獲取到了頂層的 CounterModel 例項。並在浮動按鈕 onTap 的 callback 中呼叫其 increment 方法。

而且我們成功抽離出 Consumer 中不變的部分,也就是浮動按鈕中心的 Icon 並將其作為 child 引數傳入 builder 方法中。

Consumer2

現在我們再來看中心的文字部分。這時候你可能會有疑惑了,剛才我們講的 Consumer 獲取的只有一個 Model,而現在 Text 元件不僅需要 CounterModel 用以顯示計數器,而且還需要獲得 textSize 以調整字型大小,咋整捏。

遇到這種情況你可以使用 Consumer2<A,B>。使用方式基本上和 Consumer<T> 一致,只不過範型改為了兩個,並且 builder 方法也變成了 Function(BuildContext context, A value, B value2, Widget child)

我勒個去...假如我要獲得 100 個 Model,那豈不是得搞個 Consumer100 (???黑人問號.jpg)

然而並沒有 ?。

從原始碼裡面可以看到,作者只為我們搞到了 Consumer6。emmmmm.....還要要求更多就只有自力更生嘍。

順手幫作者修復了一個 clerical error。

Flutter | 狀態管理指南篇——Provider

區別

我們來看 Consumer 的內部實現。

@override
  Widget build(BuildContext context) {
    return builder(
      context,
      Provider.of<T>(context),
      child,
    );
  }
複製程式碼

可以發現,Consumer 就是通過 Provider.of<T>(context) 來實現的。但是從實現來講 Provider.of<T>(context)Consumer 簡單好用太多,為啥我要搞得那麼複雜捏。

實際上 Consumer 非常有用,它的經典之處在於能夠在複雜專案中,極大地縮小你的控制元件重新整理範圍Provider.of<T>(context) 將會把呼叫了該方法的 context 作為聽眾,並在 notifyListeners 的時候通知其重新整理。

舉個例子來說,我們的 FirstScreen 使用了 Provider.of<T>(context) 來獲取資料,SecondScreen 則沒有。

  • 你在 FirstScreen 中的 build 方法中新增一個 print('first screen rebuild');
  • 然後在 SecondScreen 中的 build 方法中新增一個 print('second screen rebuild');
  • 點選第二個頁面的浮動按鈕,那麼你會在控制檯看到這句輸出。

first screen rebuild

首先這證明了 Provider.of<T>(context) 會導致呼叫的 context 頁面範圍的重新整理。

那麼第二個頁面重新整理沒有呢? 重新整理了,但是隻重新整理了 Consumer 的部分,甚至連浮動按鈕中的 Icon 的不重新整理我們都給控制了。你可以在 Consumer 的 builder 方法中驗證,這裡不再囉嗦

假如你在你的應用的 頁面級別 的 Widget 中,使用了 Provider.of<T>(context)。會導致什麼後果已經顯而易見了,每當其狀態改變的時候,你都會重新重新整理整個頁面。雖然你有 Flutter 的自動優化演算法給你撐腰,但你肯定無法獲得最好的效能

所以在這裡我建議各位儘量使用 Consumer 而不是 Provider.of<T>(context) 獲取頂層資料。

以上便是一個最簡單的使用 Provider 的例子。

You also need to know

合理選擇使用 Provides 的構造方法

在上面這個例子中?,我們選擇了使用 XProvider<T>.value 的構造方法來建立祖先節點中的 提供者。除了這種方式,我們還可以使用預設構造方法。

Provider({
    Key key,
    @required ValueBuilder<T> builder,
    Disposer<T> dispose,
    Widget child,
  }) : this._(
          key: key,
          delegate: BuilderStateDelegate<T>(builder, dispose: dispose),
          updateShouldNotify: null,
          child: child,
        );
複製程式碼

常規的 key/child 屬性我們不在這裡囉嗦。我們先來看這個看上去相對教複雜一點的 builder。

ValueBuilder

相比起 .value 構造方式中直接傳入一個 value 就 ok,這裡的 builder 要求我們傳入一個 ValueBuilder。WTF?

typedef ValueBuilder<T> = T Function(BuildContext context);

其實很簡單,就是傳入一個 Function 返回一個資料而已。在上面這個例子中,你可以替換成這樣。

Provider(
    builder: (context) => textSize,
    ...
)
複製程式碼

由於是 Builder 模式,這裡預設需要傳入 context,實際上我們的 Model(textSize)與 context 並沒有關係,所以你完全可以這樣寫。

Provider(
    builder: (_) => textSize,
    ...
)
複製程式碼

Disposer

現在我們知道了 builder,那這個 dispose 方法又用來做什麼的呢。實際上這才是 Provider 的點睛之筆。

typedef Disposer<T> = void Function(BuildContext context, T value);

dispose 屬性需要一個 Disposer<T>,而這個其實也是一個回撥。

如果你之前使用過 BLoC 的話,相信你肯定遇到過一個頭疼的問題。我應該在什麼時候釋放資源呢? BloC 使用了觀察者模式,它旨在替代 StatefulWidget。然而大量的流使用完畢之後必須 close 掉,以釋放資源。

然而 Stateless Widget 並沒有給我們類似於 dispose 之類的方法,這便是 BLoC 的硬傷。你不得不為了釋放資源而使用 StatefulWidget,這與我們的本意相違。而 Provider 則為我們解決了這一點。

當 Provider 所在節點被移除的時候,它就會啟動 Disposer<T>,然後我們便可以在這裡釋放資源。

舉個例子,假如我們有這樣一個 BLoC。

class ValidatorBLoC {
  StreamController<String> _validator = StreamController<String>.broadcast();

  get validator => _validator.stream;

  validateAccount(String text) {
    //Processing verification text ...
  }

  dispose() {
    _validator.close();
  }
}
複製程式碼

這時候我們想要在某個頁面提供這個 BLoC 但是又不想使用 StatefulWidget。這時候我們可以在頁面頂層套上這個 Provider。

Provider(
    builder:(_) => ValidatorBLoC(),
    dispose:(_, ValidatorBLoC bloc) => bloc.dispose(),
    }
)
複製程式碼

這樣就完美解決了資料釋放的問題!?

現在我們可以放心的結合 BLoC 一起使用了,很贊有沒有。但是現在你可能又有疑問了,在使用 Provider 的時候,我應該選擇哪種構造方法呢。

我的推薦是,簡單模型就選擇 Provider<T>.value,好處是可以精確控制重新整理時機。而需要對資源進行釋放處理等複雜模型的時候,Provider() 預設構造方式絕對是你的最佳選擇。

其他幾種 Provider 也遵循該模式,需要的時候可以自行檢視原始碼。

我該使用哪種 Provider

如果你在 Provider 中提供了可監聽物件(Listenable 或者 Stream)及其子類的話,那麼你會得到下面這個異常警告。

Flutter | 狀態管理指南篇——Provider

你可以將本文中所使用到的 CounterModel 放入 Provider 進行提供(記得 hot restart 而不是 hot reload),那麼你就能看到上面這個 FlutterError 了。

你也可以在 main 方法中通過下面這行程式碼來禁用此提示。 Provider.debugCheckInvalidValueType = null;

這是由於 Provider 只能提供恆定的資料,不能通知依賴它的子部件重新整理。提示也說的很清楚了,假如你想使用一個會發生 change 的 Provider,請使用下面的 Provider。

  • ListenableProvider
  • ChangeNotifierProvider
  • ValueListenableProvider
  • StreamProvider

你可能會在這裡產生一個疑問,不是說(Listenable 或者 Stream)才不行嗎,為什麼我們的 CounterModel 混入的是 ChangeNotifier 但是還是出現了這個 FlutterError 呢。

class ChangeNotifier implements Listenable

我們再來看上面的這幾個 Provider 有什麼異同。先關注 ListenableProvider / ChangeNotifierProvider 這兩個類。

ListenableProvider 提供(provide)的物件是繼承了 Listenable 抽象類的子類。由於無法混入,所以通過繼承來獲得 Listenable 的能力,同時必須實現其 addListener / removeListener 方法,手動管理收聽者。顯然,這樣太過複雜,我們通常都不需要這樣做。

而混入了 ChangeNotifier 的類自動幫我們實現了聽眾管理,所以 ListenableProvider 同樣也可以接收混入了 ChangeNotifier 的類。

ChangeNotifierProvider 則更為簡單,它能夠對子節點提供一個 繼承 / 混入 / 實現 了 ChangeNotifier 的類。通常我們只需要在 Model 中 with ChangeNotifier ,然後在需要重新整理狀態的時候呼叫 notifyListeners 即可。

那麼 ChangeNotifierProviderListenableProvider 究竟區別在哪呢,ListenableProvider 不是也可以提供(provide)混入了 ChangeNotifier 的 Model 嗎。

還是那個你需要思考的問題。你在這裡的 Model 究竟是一個簡單模型還是複雜模型。這是因為 ChangeNotifierProvider 會在你需要的時候,自動呼叫其 _disposer 方法。

static void _disposer(BuildContext context, ChangeNotifier notifier) => notifier?.dispose();

我們可以在 Model 中重寫 ChangeNotifier 的 dispose 方法,來釋放其資源。這對於複雜 Model 的情況下十分有用。

現在你應該已經十分清楚 ListenableProvider / ChangeNotifierProvider 的區別了。下面我們來看 ValueListenableProvider。

ValueListenableProvider 用於提供實現了 繼承 / 混入 / 實現 了 ValueListenable 的 Model。它實際上是專門用於處理只有一個單一變化資料的 ChangeNotifier。

class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T>

通過 ValueListenable 處理的類不再需要資料更新的時候呼叫 notifyListeners

好了,終於只剩下最後一個 StreamProvider 了。

StreamProvider 專門用作提供(provide)一條 Single Stream。我在這裡僅對其核心屬性進行講解。

  • T initialData:你可以通過這個屬性宣告這條流的初始值。
  • ErrorBuilder<T> catchError:這個屬性用來捕獲流中的 error。在這條流 addError 了之後,你會能夠通過 T Function(BuildContext context, Object error) 回撥來處理這個異常資料。實際開發中它非常有用。
  • updateShouldNotify:和之前的回撥一樣,這裡不再贅述。

除了這三個構造方法都有的屬性以外,StreamProvider 還有三種不同的構造方法。

  • StreamProvider(...):預設構造方法用作建立一個 Stream 並收聽它。
  • StreamProvider.controller(...):通過 builder 方式建立一個 StreamController<T>。並且在 StreamProvider 被移除時,自動釋放 StreamController。
  • StreamProvider.value(...):監聽一個已有的 Stream 並將其 value 提供給子孫節點。

除了上面這五種已經提到過的 Provider,還有一種 FutureProvider,它提供了一個 Future 給其子孫節點,並在 Future 完成時,通知依賴的子孫節點進行重新整理,這裡不再詳細介紹,需要的話自行檢視 api 文件。

優雅地處理多個 Provider

在我們之前的例子中,我們使用了巢狀的方式來組合多個 Provider。這樣看上去有些傻瓜(我就是有一百個 Model ?)。

這時候我們就可以使用一個非常 sweet 的元件 —— MultiProvider

這時候我們剛才那個例子就可以改成這樣。

void main() {
  final counter = CounterModel();
  final textSize = 48;

  runApp(
    MultiProvider(
      providers: [
        Provider.value(value: textSize),
        ChangeNotifierProvider.value(value: counter)
      ],
      child: MyApp(),
    ),
  );
}
複製程式碼

我們的程式碼瞬間清晰很多,而且與剛才的巢狀做法完全等價。

Tips

保證 build 方法無副作用

build 無副作用也通常被人叫做,build 保持 pure,二者是一個意思。

通常我們經常會看到,為了獲取頂層資料我們會在 build 方法中呼叫 XXX.of(context) 方法。你必須非常小心,你的 build 函式不應該產生任何副作用,包括新的物件(Widget 以外),請求網路,或作出一個對映檢視以外的操作等。

這是因為,你的根本無法控制什麼時候你的 build 函式將會被呼叫。我可以說隨時。每當你的 build 函式被呼叫,那麼都會產生一個副作用。這將會發生非常恐怖的事情。?

我這樣說你肯定會感到比較抽象,我們來舉一個例子。

假如你有一個 ArticleModel 這個 Model 的作用是 通過網路 獲取一頁 List 資料,並用 ListView 顯示在頁面上。

這時候,我們假設你在 build 函式中做了下面這些事情。

@override
  Widget build(BuildContext context) {
      final articleModel = Provider.of<ArticleModel>(context);
      mainCategoryModel.getPage(); // By requesting data from the server
      return XWidget(...);
  }
複製程式碼

我們在 build 函式中獲得了祖先節點中的 articleModel,隨後呼叫了 getPage 方法。

這時候會發生什麼事情呢,當我們請求成功獲得了結果的時候,根據之前我們已經介紹過的,呼叫了 Provider.of<T>(context); 會重新執行其 build。這樣 getPage 就又被執行了一次。

而你的 Model 中每次請求 getPage 都會導致 Model 中儲存的當前請求頁自增(第一次請求第一頁的資料,第二次請求第二頁的資料以此類推),那麼每次 build 都會導致新的一次資料請求,並在新的資料 get 的時候請求下一頁的資料。你的伺服器掛掉那是遲早的事情。(come on baby!

所以你應該嚴格遵守這項原則,否則會導致一系列糟糕的後果。

那麼怎麼解決資料初始化這個問題呢,請看 Q&A 部分。

不要所有狀態都放在全域性

第二個小貼士是不要把你的所有狀態都放在頂層。開發者為了圖方便省事,再接觸了狀態管理之後經常喜歡把所有東西都放在頂層 MaterialApp 之上。這樣看上去就很方便共享資料了,我要資料就直接去獲取。

不要這麼做。嚴格區分你的全域性資料與區域性資料,資源不用了就要釋放!否則將會嚴重影響你的應用 performance。

儘量在 Model 中使用私有變數“_”

這可能是我們每個人在新手階段都會出現的疑問。為什麼要用私有變數呢,我在任何地方都能夠操作成員不是很方便嗎。

一個應用需要大量開發人員參與,你寫的程式碼也許在幾個月之後被另外一個開發看到了,這時候假如你的變數沒有被保護的話,也許同樣是讓 count++,他會用 countController.sink.add(++_count) 這種原始方法,而不是呼叫你已經封裝好了的 increment 方法。

雖然兩種方式的效果完全一樣,但是第二種方式將會讓我們的business logic零散的混入其他程式碼中。久而久之專案中就會大量充斥著這些垃圾程式碼增加專案程式碼耦合程度,非常不利於程式碼的維護以及閱讀。

所以,請務必使用私有變數保護你的 Model。

控制你的重新整理範圍

在 Flutter 中,組合大於繼承的特性隨處可見。常見的 Widget 實際上都是由更小的 Widget 組合而成,直到基本元件為止。為了使我們的應用擁有更高的效能,控制 Widget 的重新整理範圍便顯得至關重要。

我們已經通過前面的介紹瞭解到了,在 Provider 中獲取 Model 的方式會影響重新整理範圍。所有,請儘量使用 Consumer 來獲取祖先 Model,以維持最小重新整理範圍。

Q&A

在這裡對一些大家可能會有疑問的常見問題做一個回答,如果你還有這之外的疑問的話,歡迎在下方評論區一起討論。

Provider 是如何做到狀態共享的

這個問題實際上得分兩步。

獲取頂層資料

實際上在祖先節點中共享資料這件事我們已經在之前的文章中接觸過很多次了,都是通過系統的 InheritedWidget 進行實現的。

Provider 也不例外,在所有 Provider 的 build 方法中,返回了一個 InheritedProvider。

class InheritedProvider<T> extends InheritedWidget

Flutter 通過在每個 Element 上維護一個 InheritedWidget 雜湊表來向下傳遞 Element 樹中的資訊。通常情況下,多個 Element 引用相同的雜湊表,並且該表僅在 Element 引入新的 InheritedWidget 時改變。

所以尋找祖先節點的時間複雜度為 O(1) ?

通知重新整理

通知重新整理這一步實際上在講各種 Provider 的時候已經講過了,其實就是使用了 Listener 模式。Model 中維護了一堆聽眾,然後 notifiedListener 通知重新整理。(空間換時間?

為什麼全域性狀態需要放在頂層 MaterialApp 之上

這個問題需要結合 Navigator 以及 BuildContext 來回答,在之前的文章中 Flutter | 深入理解BuildContext 已經解釋過了,這裡不再贅述。

我應該在哪裡進行資料初始化

對於資料初始化這個問題,我們必須要分類討論。

全域性資料

當我們需要獲取全域性頂層資料(就像之前 CounterApp 例子一樣)並需要做一些會產生額外結果的時候,main 函式是一個很好的選擇。

我們可以在 main 方法中建立 Model 並進行初始化的工作,這樣就只會執行一次。

單頁面

如果我們的資料只是在這個頁面中需要使用,那麼你有這兩種方式可以選擇。

StatefulWidget

第一種是頁面級別還是使用 StatefulWidget,然後在其 State 的 didChangeDependence 生命週期中,做這些會產生額外結果的動作的事。由於 State 是長宣告週期物件,在其存在期間,didChangeDependence 只會在建立的時候執行一次。

class FirstScreen extends StatefulWidget {···}

class _FirstScreenState extends State<FirstScreen> {
  CounterModel _counter;
  double _textSize;
  
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _counter = Provider.of<CounterModel>(context);
    _textSize = Provider.of<int>(context).toDouble();
    _counter.increment();
  }
  
  ...
    
}
複製程式碼

cascade

你也可以在使用 dart 的級連語法 ..do() 直接在頁面的 StatelessWidget 成員變數宣告時進行初始化。

class FirstScreen extends StatelessWidget {
    CounterModel _counter = CounterModel()..increment();
    double _textSize = 48;
    ...
}
複製程式碼

使用這種方式需要注意,當這個 StatelessWidget 重新執行 build 的時候,狀態會丟失。這種情況在 TabBarView 中的子頁面切換過程中就可能會出現。

所以建議還是使用第一種,在 State 中初始化資料。

我需要擔心效能問題嗎

是的,無論 Flutter 再怎麼努力優化,Provider 考慮的情況再多,我們總是有辦法讓應用卡爆 ?(開個玩笑)

僅當我們不遵守其行為規範的時候,會出現這樣的情況。效能會因為你的各種不當操作而變得很糟糕。我的建議是:遵守其規範,做任何事情都考慮對效能的影響,要知道 Flutter 把更新演算法可是優化到了 O(N)。

Provider 僅僅是對 InheritedWidget 的一個升級,你不必擔心引入 Provider 會對應用造成效能問題。

為什麼選擇 Provider

Provider 不僅做到了提供資料,而且它擁有著一套完整的解決方案,覆蓋了你會遇到的絕大多數情況。就連 BLoC 未解決的那個棘手的 dispose 問題,和 ScopedModel 的侵入性問題,它也都解決了。

然而它就是完美的嗎,並不是,至少現在來說。Flutter Widget 構建模式很容易在 UI 層面上元件化,但是僅僅使用 Provider,Model 和 View 之間還是容易產生依賴。

我們只有通過手動將 Model 轉化為 ViewModel 這樣才能消除掉依賴關係,所以假如各位有元件化的需求,還需要另外處理。

不過對於大多數情況來說,Provider 足以優秀,它能夠讓你開發出簡單高效能層次清晰 的應用。

我應該如何選擇狀態管理

介紹了這麼多狀態管理,你可能會發現,一些狀態管理之間職責並不衝突。例如 BLoC 可以結合 RxDart 庫變得很強大,很好用。而 BLoC 也可以結合 Provider / ScopedModel 一起使用。那我應該選擇哪種狀態管理方式呢。

我的建議是遵守以下幾點:

  1. 使用狀態管理的目的是為了讓編寫程式碼變得更簡單,任何會增加你的應用複雜度的狀態管理,統統都不要用。
  2. 選擇自己能夠 hold 住的,BLoC / Rxdart / Redux / Fish-Redux 這些狀態管理方式都有一定上手難度,不要選自己無法理解的狀態管理方式。
  3. 在做最終決定之前,敲一敲 demo,真正感受各個狀態管理方式給你帶來的 好處/壞處 然後再做你的決定。

希望能夠幫助到你。

原始碼淺析

這裡在分享一點原始碼淺析(真的很淺?)

Flutter 中的 Builder 模式

在 Provider 中,各種 Provider 的原始構造方法都有一個 builder 引數,這裡一般就用 (_) => XXXModel() 就行了。感覺有點多次一舉,為什麼不能像 .value() 構造方法那樣簡潔呢。

實際上,Provider 為了幫我們管理 Model,使用到了 delegation pattern。

builder 宣告的 ValueBuilder 最終被傳入代理類 BuilderStateDelegate / SingleValueDelegate。 然後通過代理類才實現的 Model 生命週期管理。

class BuilderStateDelegate<T> extends ValueStateDelegate<T> {
  BuilderStateDelegate(this._builder, {Disposer<T> dispose})
      : assert(_builder != null),
        _dispose = dispose;
  
  final ValueBuilder<T> _builder;
  final Disposer<T> _dispose;
  
  T _value;
  @override
  T get value => _value;

  @override
  void initDelegate() {
    super.initDelegate();
    _value = _builder(context);
  }

  @override
  void didUpdateDelegate(BuilderStateDelegate<T> old) {
    super.didUpdateDelegate(old);
    _value = old.value;
  }

  @override
  void dispose() {
    _dispose?.call(context, value);
    super.dispose();
  }
}
複製程式碼

這裡就僅放 BuilderStateDelegate,其餘的請自行檢視原始碼。

如何實現 MultiProvider

Widget build(BuildContext context) {
    var tree = child;
    for (final provider in providers.reversed) {
      tree = provider.cloneWithChild(tree);
    }
    return tree;
  }
複製程式碼

MultiProvider 實際上就是通過每一個 provider 都實現了的 cloneWithChild 方法把自己一層一層包裹起來。

MultiProvider(
    providers:[
        AProvider,
        BProvider,
        CProvider,
    ],
    child: child,
)
複製程式碼

等價於

AProvider(
    child: BProvider(
        child: CProvider(
            child: child,
        ),
    ),
)
複製程式碼

寫在最後

這次寫的太順暢,不小心就寫得過多了。能看到這裡的朋友,都很強 ?。

與其說這次是 Provider 專場,更像是把狀態管理自己所遇到的心得都總結在這裡了。希望能夠給各位有參考價值。

後期的 Tips 和 Q&A 有一部分實際上對大多數狀態管理都適用,我後面會考慮把這些專門拉出來講一篇。不過下篇文章主題已經決定了,是關於 Flutter 中 DI / IOC 的。如果你感興趣的話一定不要錯過。

如果您對Provider還有任何疑問或者文章的建議,歡迎在下方評論區以及我的郵箱1652219550a@gmail.com與我聯絡,我會及時回覆!

相關文章