善用 Provider 榨乾 Flutter 最後一點效能

ZacJi發表於2020-02-05

Provider 作為 Google 欽定的狀態管理框架,以其簡單易上手的特點,成為大部分中小 App 的首選。Provider 的使用非常簡單,官方文件也不長,基本上半個小時就能上手操作。但要用好 Provider 卻不簡單,這可關係到 App 的執行效率和流暢度。 下面我就總結了一些 Provider 在使用過程中需要注意的 Tips,幫助你榨乾 Flutter 的最後一點效能!

⚠️ 提示:本文不是 Provider 入門教程,需要你對 Provider 有一個基本對了解。初學者建議跳轉到問末首先閱讀官方文件 & 例項教學。

更新到最新版本

毫無疑問 Flutter 連帶整個第三方外掛社群都在高密度的迭代,Provider 作為一個釋出才1年多的庫如今已經迭代到 4.0 了。每一次更新不僅僅是 Bug 的修復,還有大量功能的提升和效能的優化。比如 3.1 推出的 Selector,以及後期加入的針對效能的提示等。

正確地初始化 Provider

所有的 Provider 都有兩個構造方法,分別為預設構造方法和便利構造方法。很多人簡單地認為便利構造方法只是一種更加簡便的構造方法,它們接收的引數是一樣的。其實不對。 我們以 ChangeNotifierProvider 為例:

// ✅ 預設構造方法
ChangeNotifierProvider(
  create: (_) => MyModel(),
  child: ...
)
複製程式碼
// ❌ 預設構造方法
MyModel myModel;
ChangeNotifierProvider(
  create: (_) => myModel,
  child: ...
)
複製程式碼
// ✅ 便利構造方法
MyModel myModel;
ChangeNotifierProvider.value(
  value: myModel,
  child: ...
)
複製程式碼
// ❌ 便利構造方法
ChangeNotifierProvider.value(
  value: MyModel(),
  child: ...
)
複製程式碼

簡單的說就是,如果你需要初始化一個新的 Value ,就使用預設構造方法,通過 create 方法的返回值傳遞。而如果你已經有了這個 Value 的例項,則使用便利構造方法,直接賦值給 value 引數。具體的原因可以參考這個解答

儘量使用 StatelessWidget 替代 StatefulWidget

由於引入了 Provider 進行統一的狀態管理,因此大部分 Widget 不再需要繼承自 StatefulWidget 來更新資料了。StatelessWidget 的維護成本比 StatefulWidget 要低,構建效率更高。同時更少的程式碼量會讓我們更容易地控制重建範圍,提高渲染效率。

當然,對於部分需要依附於 Widget 生命週期的邏輯(比如首次進入頁面進行 HTTP 請求),還是得繼續使用 StatefulWidget 。

儘量使用 Consumer 替代 Provider.of(context)

Provider 取值有兩種方式,一種是 Provider.of(context) ,直接返回 Value。

由於它是一個方法,無法直接在 Widget 樹中呼叫,一般我們放在 build 方法中,return 方法之前。

Widget build(BuildContext context) {
  final text = Provider.of<String>(context);
  return Container(child: Text(text));
}
複製程式碼

但是,由於 Provider 會監聽 Value 的變化而更新整個 context 上下文,因此如果 build 方法返回的 Widget 過大過於複雜的話,重新整理的成本是非常高的。那麼我們該如何進一步控制 Widget 的更新範圍呢?

一個辦法是將真正需要更新的 Widget 封裝成一個獨立的 Widget,將取值方法放到該 Widget 內部。

Widget build(BuildContext context) {
  return Container(child: MyText());
}

class MyText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final text = Provider.of<String>(context);
    return Text(text);
  }
}
複製程式碼

另一個相對好一點的辦法是使用 Builder 方法創造一個範圍更小的 context。

Widget build(BuildContext context) {
  return Container(child: Builder(builder: (context) {
    final text = Provider.of<String>(context);
    return Text(text);
  }));
}
複製程式碼

這兩種方法都能夠在重新整理 Widget 時跳過 Container 直接重建 Text 。無論哪種方法,其根本目的就是縮小 Provider.of(context) 中 context 的範圍,減少 Widget 重建數量。但這兩個方法都太過繁瑣。

Consumer 是 Provier 的另一種取值方式,不同的是它是一個 Widget ,能夠方便的嵌入到 Widget 樹中呼叫,類似於上面的 Builder 方案。

Widget build(BuildContext context) {
  return Container(child: Consumer<String>(
    builder: (context, text, child) => Text(text),
  ));
}
複製程式碼

Consumer 可以直接拿到 context 連帶 Value 一併傳作為引數傳遞給 builder ,使用無疑更加方便和直觀,大大降低了開發人員對於控制重新整理範圍的工作成本。

Container 的 builder 方法有一個 child 屬性,我們可以將 Container 層級下不受 Value 影響的 Widget 寫到 child 中,這樣當 Value 更新時不會重新構建 child 中的 Widget ,進一步提高效率。

Widget build(BuildContext context) {
 return Container(child: Consumer<String>(
   builder: (context, text, child) => Row(
     children: <Widget>[
       Text(text),
       child
     ],
   ),
   child: Text("不變的內容"),
 ));
}
複製程式碼

上面程式碼中將不受 text 控制的 Text 放入 child 中並帶入 builder 方法,這樣當 text 改變時不會重新構建 child 中的 Text。

儘量使用 Selector 替代 Consumer

Selector 是 3.1 推出的功能,目的是更近一步的控制 Widget 的更新範圍,將監聽重新整理的範圍控制到最小。 實際專案中我們往往會根據業務場景或者頁面元素來設計 Provider 的 Value,此時的 Value 其實就是 ViewModel。大量的資料都放入 Value 的後果就是,只要一個值的改動,就會觸發整個 ViewModel 的 notifyListeners ,進而引發整個 ViewModel 關聯 Widget 的重新整理。

因此,我們需要一個能力,在執行重新整理之前給我們一次機會,判斷是否需要重新整理,來避免不需要的重新整理。這個能力,就是由 Selector 來實現的。

Selector<ViewModel, String>( 
  selector: (context, viewModel) => viewModel.title,
  shouldRebuild: (pre, next) => pre != next,
  builder: (context, title, child) => Text(title)
);
複製程式碼

Selector 有兩個範型引數,分別是 Provider 的 Value 型別以及 Value 中具體用到的引數型別。它有三個引數:

  • selector:是一個 Function,傳入 Value ,要求我們返回 Value 中具體使用到的屬性。
  • shouldRebuild:這個 Function 會傳入兩個值,其中一個為之前保持的舊值,以及此次由 selector 返回的新值,我們就是通過這個引數控制是否需要重新整理 builder 內的 Widget。如果不實現 shouldRebuild ,預設會對 pre 和 next 進行深比較(deeply compares)。如果不相同,則返回 true。
  • builder:返回 Widget 的地方,第二個引數 title,就是我們剛才 selector 中返回的 String。

有了 Selector ,我們就可以避免 ViewModel 中一人改動全家更新的尷尬了。但 Selector 的使用場景遠遠不限於 ViewModel 這種重 Value ,即便是用在單一資料上,Selector 也能盡最大限度榨乾效能。

比如一個資料列表 List ,如果修改其中一項資料,我們往往會更新整個 ListView 中的 ListTile 。

return ListView.builder(itemBuilder: (context, index) {
    final foo = Provider.of<ViewModel>(context).foos[index]
    return ListTile(title: Text(foo.didSelected),);
});
複製程式碼

如果通過 Performance 或者 Log 我們會發現,只修改 foos 中的某一個 foo 的 didSelected 屬性,會將所有的 ListTile 都重新構建一遍。這無疑是沒有必要的。

return ListView.builder(itemBuilder: (context, index) {
  return Selector< ViewModel, Foo>(
    selector: (context, viewModel) => viewModel.foos[index],
    shouldRebuild: (pre, next) => pre != next, // 此行可以省略
    builder: (context, foo, child) {
      return ListTile(
        title: Text(foo.didSelected),
      );
    },
  );
});
複製程式碼

通過 Selector 不僅能在構建 Widget 的過程中方便的獲取 Value ,還能在構建子 Widget 之前留給我們一個額外的機會讓我們決定是否需要重新構建子 Widget 。這樣,ListView 每次就只會重構被修改的那個 ListTile 了。

善用 Provider.of(context) 的隱藏屬性 listen

前面的 Consumer 似乎可以替代 Provider.of 的所有場景,那我們還需要 Provider.of 嗎? 我們常常有這樣的需求,就是隻需要取得上層 Provider 的 Value,不需要監聽並重新整理資料,比如呼叫 Value 的方法。

Button(
  onPressed: () =>
      Provider.of<ViewModel>(context).run(),
)
複製程式碼

上面這樣的寫法會報錯,因為 onPressed 方法只需要拿到 ViewModel 來呼叫 run 方法,它的內部不關心 ViewModel 是否有變化需不需要重新整理。而 Provider.of 預設會監聽 ViewModel 的改變並影響執行效率。 其實 Provider.of(context) 方法有一個隱藏屬性 listen ,對於這種不關心 Value 是否變化只需要取值的情況,只需要將 listen 設定為 false(預設為 true ),Provider.of 返回的 Value 就不會觸發監聽重新整理啦。

Button(
  onPressed: () =>
      Provider.of<ViewModel>(context, listen: false).run(),
)
複製程式碼

避免在錯誤的地方獲取 Value

前面提到了,有些邏輯必須依賴 Widget 的生命週期,比如在進入頁面時訪問 Provider 。因此很多人會將邏輯放到 StatefulWidget 的 initState 或 didChangeDependencies 中。

initState() {
  super.initState();
  print(Provider.of<Foo>(context).value);
}
複製程式碼

但是這麼做是有矛盾的,而且也會報錯。既然將 load 方法放到了 initState 回撥中,就意味著你希望該方法在 Widget 生命週期內只走一次,也就就意味著此處的 Value 並不關心值會不會改變。

因此,如果你只是想要拿到 Value 而不需要監聽,直接使用上面的 listen 引數關閉監聽即可。

initState() {
  super.initState();
  print(Provider.of<Foo>(context, listen: false).value);
}
複製程式碼

而如果你需要持續監聽 Value 並作出反應,則不應該將邏輯放入 initState 中,didChangeDependencies 更適合這樣的邏輯。但是由於 didChangeDependencies 會頻繁呼叫多次,獲取 Value 之後需要判斷一下 Value 是否有改變,避免 didChangeDependencies 方法死迴圈。

Value value;

didChangeDependencies() {
  super.didChangeDependencies();
  final value = Provider.of<Foo>(context).value;
  if (value != this.value) {
    this.value = value;
    print(value);
  }
}
複製程式碼

但是!

以上方案只使用於訪問 Value ,如果需要修改 Value 並觸發更新(例如訪問網路),則會報錯。因為 initState didChangeDependencies 中是不能觸發狀態更新的(包括呼叫 setState ),這樣可能會導致 Widgets 在上次構建還沒完成之前狀態就又被更新,最終導致狀態不統一。

因此,官方的建議是,如果 Provider Value 的方法不依賴外部引數,直接在 Value 初始化的時候執行方法。

class MyApi with ChangeNotifier {
  MyApi() {
    load();
  }

  Future<void> load() async {}
}
複製程式碼

如果 Provider Value 的方法必須依賴 Widgets 提供的外部引數,可以用 Future.microtask 將呼叫過程包在一個非同步方法中。非同步方法由於 event loop 的緣故會推遲到下一個週期執行,避開了衝突。

initState() {
  super.initState();
  Future.microtask(() =>
    Provider.of<MyApi>(context, listen: false).load(page: page);
  );
}
複製程式碼

及時釋放資源

及時釋放不再使用的資源是優化的重點。Provider 提供了兩套方案方便我們及時釋放資源。

  1. Provider 的預設構造方法中有一個 dispose 回撥,會在 Provider 銷燬時觸發。我們只需要在這個回撥中釋放我們的資源即可。
Provider(
    create:(_) => Model(),
    dispose:(context, value) {
        // 釋放資源
    }
)
複製程式碼
  1. 重寫 ChangeNotifier 的 dispose 方法。細心的同學可能會發現,ChangeNotifierProvider 的初始化方法中是沒有 dispose 這個引數的,這是因為 ChangeNotifierProvider 會在銷燬時自動幫我們呼叫 Value 的 dispose 方法。我們所需要做的,僅僅是重寫 Value 的 dispose 方法罷了。
class Model with ChangeNotifier { 
  @override
  void dispose() {
    // 釋放資源
    super.dispose();
  }
}
複製程式碼

其實,這恰恰也是 ChangeNotifierProvider 和 ListenableProvider 的最大區別。ChangeNotifierProvider 繼承自 ListenableProvider ,只不過 ChangeNotifierProvider 對 Value 的型別要求更高,必須實現 ChangeNotifier ,而 dispose 是 ChangeNotifier 的一個方法。

除此之外,我們還應該避免將所有 Provider 狀態都放置到頂層。雖然取用起來比較方便,但全域性的 Provider 資源都無法釋放,對效能的影響會越來越大。我們應該在構建新頁面和新功能的時候就理清業務,讓 Provider 只覆蓋它所負責的範圍,並在退出該功能頁面後及時釋放資源。

多打 Log 多跑 Performance

最簡單最無腦的方式就是在 Widget 之間插入 Log 來觀察 Widget 的重新整理範圍,一旦發現重新整理範圍過大,和實際邏輯不符就應該嘗試查詢優化點。這種排查方式雖然相對粗曠,但對於尚未怎麼優化的專案而言效果顯著。

對於已經做過初步優化的專案而言,如果還想近一步榨乾 Flutter 的效能,就只能通過跑 Performance 搭配工具來分析出效能瓶頸。

總結

其實上面所有的 Tips ,背後其實都在做一件事情:減少 Widget 的重建。

雖然我們知道 Flutter 在內部做了大量高效的演算法和策略來避免無效的重建和渲染,但再高效的演算法也是有成本的,更何況演算法對我們來說是一個黑盒子,我們無法保證它能一直有效,因此我們需要在源頭就掐斷無用的 Widget 重建。

最後是濃縮版的建議:

  1. 每一次通過 Provider 取值的時候都問自己一遍,我是否需要監聽資料,還是隻是單純訪問 Value 。
  2. 每一次通過 Provider 取值的時候都問自己一遍,是否可以用 Selector 替代 Consumer,不行的話是否可以用 Consumer 替代 Provider.of(context)。

provider

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

相關文章