從零開始的Flutter之旅: Provider

午後一小憩發表於2020-06-22

從零開始的Flutter之旅: Provider

往期回顧

從零開始的Flutter之旅: StatelessWidget

從零開始的Flutter之旅: StatefulWidget

從零開始的Flutter之旅: InheritedWidget

在上篇文章中我們介紹了InheritedWidget,並在最後引發出一個問題。

雖然InheritedWidget可以提供共享資料,並且通過getElementForInheritedWidgetOfExactType來解除didChangeDependencies的呼叫,但還是沒有避免CountWidget的重新build,並沒有將build最小化。

我們今天就來解決如何避免不必要的build構建,將build縮小到最小的CountText。

分析

首先我們來分析下為什麼會導致父widget的重新build。

class CountWidget extends StatefulWidget {
  @override
  _CountState createState() {
    return _CountState();
  }
}
 
class _CountState extends State<CountWidget> {
  int count = 0;
 
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Count App',
      theme: new ThemeData(primarySwatch: Colors.blue),
      home: Scaffold(
        appBar: AppBar(
          title: Text("Count"),
        ),
        body: Center(
          child: CountInheritedWidget(
            count: count,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                CountText(),
                RaisedButton(
                  onPressed: () => setState(() => count++),
                  child: Text("Increment"),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }
}
複製程式碼

為了方便分析,我把之前的程式碼提到這裡來。

我們來看,在點選RaisedButton的時候,我們會通過setState將count進行更新。而此時的setState方法的提供者是_CountState,即CountWidget。而state的改變會導致build的重新構建,導致的效果是CountWidget的build被重新呼叫,繼而它的子widget也相繼被重新build。

既然已經知道了原因,那麼我們再來思考下解決方案。

  1. 最簡單的,我們縮小setState提供者的範圍。現在是CountWidget,我們將其縮小到Column。
  2. 雖然已經縮小到了Column,但還是無法避免自身的build與其CountText之外的子Widget(RaisedButton)的重新build。如果我們將Column全部快取下來呢?我們在Column外層套一個Widget,並將其進行快取,一旦外層的Widget重新build,我們都使用Column的快取,這樣不就避免了Column的重新build。不過使用快取以後會有個問題,既然是快取,Center裡面的CountText也將不會改變。為了解決這個問題,我們就要使用上篇文章中的InheritedWidget。將整個Column放到InheritedWidget中,雖然Column是快取,但是CountText中引用了InheritedWidget中的count資料,一旦count發生改變,將會通知其進行重新build。這樣就保證了只重新整理CountText。

如果你對InheritedWidget不熟悉,推薦閱讀從零開始的Flutter之旅: InheritedWidget

我們來總結一下,在Column外套一層Widget,並將Column進行快取,然後外層的Widget結合InheritedWidget來提供共享count的資料來源。一旦count更新將會呼叫外層Widget的setState,並且重新build,但我們使用的是Column快取,同時CountText通過依賴的方式引用了共享的count資料來源,從而會同步build更新。而RaisedButton使用的是未依賴的共享count資料來源,所以並不會重新build。這樣就保證了只重新整理CountText。

這種方式統一定義為Provider,其實Flutter內部已經有Provider的完整實現,不過我們為了學習這種解決方法的思想,自己來實現一個簡易版的Provider。之後再去看Flutter的Provider將會更加簡單。

方案已經有了,下面我們直接來看具體實現細節。

實現

  1. 定義共享資料的ProviderInheritedWidget
  2. 定義監聽重新整理的NotifyModel
  3. 提供快取Widget的ModelProviderWidget
  4. 組裝替換原有實現方案

ProviderInheritedWidget

實現一個自己的InheritedWidget,主要用來提供共享資料來源,並接受快取的child。

class ProviderInheritedWidget<T> extends InheritedWidget {
  final T data;
  final Widget child;
 
  ProviderInheritedWidget({@required this.data, this.child})
      : super(child: child);
 
  @override
  bool updateShouldNotify(ProviderInheritedWidget oldWidget) {
    // true -> 通知樹中依賴改共享資料的子widget
    return true;
  }
}
複製程式碼

NotifyModel

為了監聽共享資料count的變化,我們通過觀察者訂閱模式來實現。

class NotifyModel implements Listenable {
  List _listeners = [];
 
  @override
  void addListener(listener) {
    _listeners.add(listener);
  }
 
  @override
  void removeListener(listener) {
    _listeners.remove(listener);
  }
 
  void notifyDataSetChanged() {
    _listeners.forEach((item) => item());
  }
}
複製程式碼

Listenable提供一個簡單的監聽介面,通過add與remove來增加與移除監聽,然後提供一個notify方法來進行通知監聽者。

最後我們通過繼承NotifyModel來使count具有可監聽能力

class CountModel extends NotifyModel {
  int count = 0;
 
  CountModel({this.count});
 
  void increment() {
    count++;
    notifyDataSetChanged();
  }
}
複製程式碼

一旦count自增,就呼叫notifyDataSetChanged來通知訂閱的監聽者。

ModelProviderWidget

有了上面的Provider與Model,我們在提供一個外部Widget來統一管理它們,將它們結合起來。

class ModelProviderWidget<T extends NotifyModel> extends StatefulWidget {
  final T data;
 
  final Widget child;
 
  // context 必須為當前widget的context
  static T of<T>(BuildContext context, {bool listen = true}) {
    return (listen ? context.dependOnInheritedWidgetOfExactType<ProviderInheritedWidget<T>>()
            : (context.getElementForInheritedWidgetOfExactType<ProviderInheritedWidget<T>>()
        .widget as ProviderInheritedWidget<T>)).data;
  }
 
  ModelProviderWidget({Key key, @required this.data, @required this.child})
      : super(key: key);
 
  @override
  _ModelProviderState<T> createState() => _ModelProviderState<T>();
}
 
class _ModelProviderState<T extends NotifyModel>
    extends State<ModelProviderWidget> {
  void notify() {
    setState(() {
      print("notify");
    });
  }
 
  @override
  void initState() {
    // 新增監聽
    widget.data.addListener(notify);
    super.initState();
  }
 
  @override
  void dispose() {
    // 移除監聽
    widget.data.removeListener(notify);
    super.dispose();
  }
 
  @override
  void didUpdateWidget(ModelProviderWidget<T> oldWidget) {
    // data 更新時移除老的data監聽
    if (oldWidget.data != widget.data) {
      oldWidget.data.removeListener(notify);
      widget.data.addListener(notify);
    }
    super.didUpdateWidget(oldWidget);
  }
 
  @override
  Widget build(BuildContext context) {
    return ProviderInheritedWidget<T>(
      data: widget.data,
      child: widget.child,
    );
  }
}
複製程式碼

在這裡我們提供可監聽的data資料與需要快取的child,同時在state中對可監聽的data在合適的地方進行監聽訂閱與移除訂閱,並在收到data資料改變時呼叫notify進行setState操作,通知widget重新整理。

在build中引用了ProviderInheritedWidget,來實現對共享子widget的資料共享,同時在ModelProviderWidget中提供of方法來暴露ProviderInheritedWidget的統一獲取方式。

通過引數listen(預設true)來控制獲取共享資料的方式,來決定是否建立依賴關係,即共享資料改變時,引用共享資料的widget是否重新build。

這一幕是不是有點似曾相識,基本上都是上篇文章中提到的InheritedWidget使用的細節。

接下來就是最終的方案替換

組裝替換原有實現方案

我們通過ModelProviderWidget.of來獲取共享的資料,所以只要使用到了共享資料,將要呼叫該方法。為了避免不必要的重複書寫,我們將其單獨封裝到Consumer中,內部來實現對其的呼叫,並且將呼叫的結果暴露出來。

class Consumer<T> extends StatelessWidget {
  final Widget Function(BuildContext context, T value) builder;
 
  const Consumer({Key key, @required this.builder}) : super(key: key);
 
  @override
  Widget build(BuildContext context) {
    print("Consumer build");
    return builder(context, ModelProviderWidget.of<T>(context));
  }
}
複製程式碼

一切準備就緒,我們再對之前的程式碼進行優化。

class CountWidget extends StatefulWidget {
  @override
  _CountState createState() {
    return _CountState();
  }
}
 
class _CountState extends State<CountWidget> {
  @override
  Widget build(BuildContext context) {
    print("CountWidget build");
    return MaterialApp(
      title: 'Count App',
      theme: new ThemeData(primarySwatch: Colors.blue),
      home: Scaffold(
        appBar: AppBar(
          title: Text("Count"),
        ),
        body: Center(
          child: ModelProviderWidget<CountModel>(
            data: CountModel(count: 0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Consumer<CountModel>(
                    builder: (context, value) => Text("count: ${value.count}")),
                Builder(
                  builder: (context) {
                    print("RaiseButton build");
                    return RaisedButton(
                      onPressed: () => ModelProviderWidget.of<CountModel>(
                              context,
                              listen: false)
                          .increment(),
                      child: Text("Increment"),
                    );
                  },
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
複製程式碼

我們將Column快取到ModelProviderWidget中,同時對CountModel資料進行共享;通過Consumer進行Text的封裝,引用共享資料CountModel中的count。

對於RaisedButton,因為它只是提供點選,並且觸發count的自增操作、沒有發生ui上的任何變化。所以為了避免RaisedButton引用的共享資料進行自增時重新build,這裡將listen引數置為false。

最後我們執行上面的程式碼,我們點選Increment按鈕時,控制檯將會輸出如下日誌:

從零開始的Flutter之旅: Provider

I/flutter ( 3141): notify
I/flutter ( 3141): Consumer build
複製程式碼

說明只有Consumer重新呼叫了build,即Text進行了重新整理。其它的widget都沒有變化。

這樣就解決了開篇提到的疑問,達到了widget重新整理的最小化。

以上是一個簡單地Provider-Consumer的使用。Flutter對這一塊有更完善的實現方案。但是經過我們這一輪分析,你再去看Flutter中Provider的原始碼將會更加簡單易懂。

如果你想了解Flutter中Provider的使用,你可以通過flutter_github來了解它的具體實戰使用技巧。

想要檢視Provider實戰技巧,需要將分支切換到sample_provider

推薦專案

下面介紹一個完整的Flutter專案,對於新手來說是個不錯的入門。

flutter_github,這是一個基於Flutter的Github客戶端同時支援Android與IOS,支援賬戶密碼與認證登陸。使用dart語言進行開發,專案架構是基於Model/State/ViewModel的MSVM;使用Navigator進行頁面的跳轉;網路框架使用了dio。專案正在持續更新中,感興趣的可以關注一下。

從零開始的Flutter之旅: Provider

當然如果你想了解Android原生,相信flutter_github的純Android版本AwesomeGithub是一個不錯的選擇。

如果你喜歡我的文章模式,或者對我接下來的文章感興趣,建議您關注我的微信公眾號:【Android補給站】

或者掃描下方二維碼,與我建立有效的溝通,同時更快更準的收到我的更新推送。

從零開始的Flutter之旅: Provider

相關文章