Flutter 小技巧之 MediaQuery 和 build 優化你不知道的祕密

語言: CN / TW / HK

theme: smartblue

今天這篇文章的目的是補全大家對於 MediaQuery 和對應 rebuild 機制的基礎認知,相信本篇內容對你優化效能和除錯 bug 會很有幫助

Flutter 裡大家應該都離不開 MediaQuery ,比如通過 MediaQuery.of(context).size 獲取螢幕大小 ,或者通過 MediaQuery.of(context).padding.top 獲取狀態列高度,那隨便使用 MediaQuery.of(context) 會有什麼問題嗎?

首先我們需要簡單解釋一下,通過 MediaQuery.of 獲取到的 MediaQueryData 裡有幾個很類似的引數:

  • viewInsets被系統使用者介面完全遮擋的部分大小,簡單來說就是鍵盤高度
  • padding簡單來說就是狀態列和底部安全區域,但是 bottom 會因為鍵盤彈出變成 0
  • viewPaddingpadding 一樣,但是 bottom 部分不會發生改變

舉個例子,在 iOS 上,如下圖所示,在彈出鍵盤和未彈出鍵盤的情況下,可以看到 MediaQueryData 裡一些引數的變化:

  • viewInsets 在沒有彈出鍵盤時是 0,彈出鍵盤之後 bottom 變成 336
  • padding 在彈出鍵盤的前後區別, bottom 從 34 變成了 0
  • viewPadding 在鍵盤彈出前後資料沒有發生變化

image-20220624115935998

可以看到 MediaQueryData 裡的資料是會根據鍵盤狀態發生變化,又因為 MediaQuery 是一個 InheritedWidget ,所以我們可以通過 MediaQuery.of(context) 獲取到頂層共享的 MediaQueryData

那麼問題來了,InheritedWidget 的更新邏輯,是通過登記的 context 來繫結的,也就是 MediaQuery.of(context) 本身就是一個繫結行為,然後 MediaQueryData 又和鍵盤狀態有關係,所以:鍵盤的彈出可能會導致使用 MediaQuery.of(context) 的地方觸發 rebuild,舉個例子:

如下程式碼所示,我們在 MyHomePage 裡使用了 MediaQuery.of(context).size 並列印輸出,然後跳轉到 EditPage 頁面,彈出鍵盤 ,這時候會發生什麼情況?

```dart

class MyHomePage extends StatelessWidget { @override Widget build(BuildContext context) { print("######### MyHomePage ${MediaQuery.of(context).size}"); return Scaffold( body: Container( alignment: Alignment.center, child: InkWell( onTap: () { Navigator.of(context).push(CupertinoPageRoute(builder: (context) { return EditPage(); })); }, child: new Text( "Click", style: TextStyle(fontSize: 50), ), ), ), ); } }

class EditPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: new Text("ControllerDemoPage"), ), extendBody: true, body: Column( children: [ new Spacer(), new Container( margin: EdgeInsets.all(10), child: new Center( child: new TextField(), ), ), new Spacer(), ], ), ); } } ```

如下圖 log 所示 , 可以看到在鍵盤彈起來的過程,因為 bottom 發生改變,所以 MediaQueryData 發生了改變,從而導致上一級的 MyHomePage 雖然不可見,但是在鍵盤彈起的過程裡也被不斷 build 。

image-20220624121917686

試想一下,如果你在每個頁面開始的位置都是用了 MediaQuery.of(context) ,然後打開了 5 個頁面,這時候你在第 5 個頁面彈出鍵盤時,也觸發了前面 4 個頁面 rebuild,自然而然可能就會出現卡頓。

那麼如果我不在 MyHomePage 的 build 方法直接使用 MediaQuery.of(context) ,那在 EditPage 裡彈出鍵盤是不是就不會導致上一級的 MyHomePage 觸發 build

答案是肯定的,沒有了 MediaQuery.of(context).size 之後, MyHomePage 就不會因為 EditPage 裡的鍵盤彈出而導致 rebuild。

所以小技巧一:要慎重在 Scaffold 之外使用 MediaQuery.of(context) ,可能你現在會覺得奇怪什麼是 Scaffold 之外,沒事後面繼續解釋。

那到這裡有人可能就要說了:我們通過 MediaQuery.of(context) 獲取到的 MediaQueryData ,不就是對應在 MaterialApp 裡的 MediaQuery 嗎?那它發生改變,不應該都會觸發下面的 child 都 rebuild 嗎?

這其實和頁面路由有關係,也就是我們常說的 PageRoute 的實現

如下圖所示,因為巢狀結構的原因,事實上彈出鍵盤確實會導致 MaterialApp 下的 child 都觸發 rebuild ,因為設計上 MediaQuery 就是在 Navigator 上面,所以彈出鍵盤自然也就觸發 Navigator 的 rebuild

image-20220624141749056

那正常情況下 Navigator 都觸發 rebuild 了,為什麼頁面不會都被 rebuild 呢

這就和路由物件的基類 ModalRoute 有關係,因為在它的內部會通過一個 _modalScopeCache 引數把 Widget 快取起來,正如註釋所說:

快取區域不隨幀變化,以便得到最小化的構建

舉個例子,如下程式碼所示:

  • 首先定義了一個 TextGlobal ,在 build 方法裡輸出 "######## TextGlobal"
  • 然後在 MyHomePage 裡定義一個全域性的 TextGlobal globalText = TextGlobal();
  • 接著在 MyHomePage 裡新增 3 個 globalText
  • 最後點選 FloatingActionButton 觸發 setState(() {});

```dart class TextGlobal extends StatelessWidget { const TextGlobal({Key? key}) : super(key: key);

@override Widget build(BuildContext context) { print("######## TextGlobal"); return Container( child: new Text( "測試", style: new TextStyle(fontSize: 40, color: Colors.redAccent), textAlign: TextAlign.center, ), ); } } class MyHomePage extends StatefulWidget { final String? title; MyHomePage({Key? key, this.title}) : super(key: key); @override _MyHomePageState createState() => _MyHomePageState(); }

class _MyHomePageState extends State { TextGlobal globalText = TextGlobal(); @override Widget build(BuildContext context) { print("######## MyHomePage"); return Scaffold( appBar: AppBar(), body: new Container( alignment: Alignment.center, child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ globalText, globalText, globalText, ], ), ), floatingActionButton: FloatingActionButton( onPressed: () { setState(() {}); }, ), ); } } ```

那麼有趣的來了,如下圖 log 所示,"######## TextGlobal" 除了在一開始構建時有輸出之外,剩下 setState(() {}); 的時候都沒有在觸發,也就是沒有 rebuild ,這其實就是上面 ModalRoute 的類似行為:彈出鍵盤導致了 MediaQuery 觸發 Navigator 執行 rebuild,但是 rebuild 到了 ModalRoute 就不往下影響

其實這個行為也體現在了 Scaffold 裡,如果你去看 Scaffold 的原始碼,你就會發現 Scaffold 裡大量使用了 MediaQuery.of(context)

比如上面的程式碼,如果你給 MyHomePageScaffold 配置一個 3333 的 ValueKey ,那麼在 EditPage 彈出鍵盤時,其實 MyHomePageScaffold 是會觸發 rebuild ,但是因為其使用的是 widget.body ,所以並不會導致 body 內物件重構。

如果是 MyHomePage 如果 rebuild ,就會對 build 方法裡所有的配置的 new 物件進行 rebuild;但是如果只是 MyHomePage 裡的 Scaffold 內部觸發了 rebuild ,是不會導致 MyHomePage 裡的 body 引數對應的 child 執行 rebuild 。

是不是太抽象?舉個簡單的例子,如下程式碼所示:

  • 我們定義了一個 LikeScaffold 控制元件,在控制元件內通過 widget.body 傳遞物件
  • LikeScaffold 內部我們使用了 MediaQuery.of(context).viewInsets.bottom ,模仿 Scaffold 裡使用 MediaQuery
  • MyHomePage 裡使用 LikeScaffold ,並給 LikeScaffold 的 body 配置一個 Builder ,輸出 "############ HomePage Builder Text " 用於觀察
  • 跳到 EditPage 頁面開啟鍵盤

```dart class LikeScaffold extends StatefulWidget { final Widget body;

const LikeScaffold({Key? key, required this.body}) : super(key: key);

@override State createState() => _LikeScaffoldState(); }

class LikeScaffoldState extends State { @override Widget build(BuildContext context) { print("####### LikeScaffold build ${MediaQuery.of(context).viewInsets.bottom}"); return Material( child: new Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [widget.body], ), ); } } ···· class _MyHomePageState extends State { @override Widget build(BuildContext context) { var routeLists = routers.keys.toList(); return new LikeScaffold( body: Builder( builder: () { print("############ HomePage Builder Text "); return InkWell( onTap: () { Navigator.of(context).push(CupertinoPageRoute(builder: (context) { return EditPage(); })); }, child: Text( "FFFFFFF", style: TextStyle(fontSize: 50), ), ); }, ), ); } } ```

可以看到,最開始 "####### LikeScaffold build 0.0############ HomePage Builder Text 都正常執行,然後在鍵盤彈出之後,"####### LikeScaffold build 跟隨鍵盤動畫不斷輸出 bottom 的 大小,但是 "############ HomePage Builder Text ") 沒有輸出,因為它是 widget.body 例項。

所以通過這個最小例子,可以看到雖然 Scaffold 裡大量使用 MediaQuery.of(context) ,但是影響範圍是約束在 Scaffold 內部

接著我們繼續看修改這個例子,如果在 LikeScaffold 上巢狀多一個 Scaffold ,那輸出結果會是怎麼樣?

```dart

class _MyHomePageState extends State { @override Widget build(BuildContext context) { var routeLists = routers.keys.toList(); ///多加了個 Scaffold return Scaffold( body: new LikeScaffold( body: Builder( ····· ), ), ); } ```

答案是 LikeScaffold 內的 "####### LikeScaffold build 也不會因為鍵盤的彈起而輸出,也就是: LikeScaffold 雖然使用了 MediaQuery.of(context) ,但是它不再因為鍵盤的彈起而導致 rebuild

因為此時 LikeScaffoldScaffold 的 child ,所以在 LikeScaffold 內通過 MediaQuery.of(context) 指向的,其實是 Scaffold 內部經過處理的 MediaQueryData

image-20220624150712453

Scaffold 內部有很多類似的處理,例如 body 裡會根據是否有 AppbarBottomNavigationBar 來決定是否移除該區域內的 paddingTop 和 paddingBottom 。

所以,看到這裡有沒有想到什麼?為什麼時不時通過 MediaQuery.of(context) 獲取的 padding ,有的 top 為 0 ,有的不為 0 ,原因就在於你獲取的 context 來自哪裡

舉個例子,如下程式碼所示, ScaffoldChildPage 作為 Scaffold 的 child ,我們分別在 MyHomePageScaffoldChildPage 裡列印 MediaQuery.of(context).padding

dart class MyHomePage extends StatelessWidget { @override Widget build(BuildContext context) { print("MyHomePage MediaQuery padding: ${MediaQuery.of(context).padding}"); return Scaffold( appBar: AppBar( title: new Text(""), ), extendBody: true, body: Column( children: [ new Spacer(), ScaffoldChildPage(), new Spacer(), ], ), ); } } class ScaffoldChildPage extends StatelessWidget { @override Widget build(BuildContext context) { print("ScaffoldChildPage MediaQuery padding: ${MediaQuery.of(context).padding}"); return Container(); } }

如下圖所示,可以看到,因為此時 MyHomePageAppbar ,所以 ScaffoldChildPage 裡獲取到 paddingTop 是 0 ,因為此時 ScaffoldChildPage 獲取到的 MediaQueryData 已經被 MyHomePage 裡的 Scaffold 改寫了。

image-20220624151522429

如果此時你給 MyHomePage 增加了 BottomNavigationBar ,可以看到 ScaffoldChildPage 的 bottom 會從原本的 34 變成 90 。

image-20220624152008795

到這裡可以看到 MediaQuery.of 裡的 context 物件很重要:

  • 如果頁面 MediaQuery.of 用的是 Scaffold 外的 context ,獲取到的是頂層的 MediaQueryData ,那麼彈出鍵盤時就會導致頁面 rebuild
  • MediaQuery.of 用的是 Scaffold 內的 context ,那麼獲取到的是 Scaffold 對於區域內的 MediaQueryData ,比如前面介紹過的 body ,同時獲取到的 MediaQueryData 也會因為 Scaffold 的配置不同而發生改變

所以,如下動圖所示,其實部分人會在 push 對應路由地方,通過巢狀 MediaQuery 來做一些攔截處理,比如設定文字不可縮放,但是其實這樣會導致鍵盤在彈出和收起時,觸發各個頁面不停 rebuild ,比如在 Page 2 彈出鍵盤的過程,Page 1 也在不停 rebuild。

1111333

所以,如果需要做一些全域性攔截,推薦通過 useInheritedMediaQuery 這種方式來做全域性處理。

dart return MediaQuery( data: MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(boldText: false), child: MaterialApp( useInheritedMediaQuery: true, ), );

所以最後做個總結,本篇主要理清了:

  • MediaQueryDataviewInsets \ padding \ viewPadding 的區別
  • MediaQuery 和鍵盤狀態的關係
  • MediaQuery.of 使用不同 context 對效能的影響
  • 通過 Scaffold 內的 context 獲取到的 MediaQueryData 受到 Scaffold 的影響

那麼,如果看完本篇你還有什麼疑惑,歡迎留言評論交流。