Flutter 小技巧之 MediaQuery 和 build 優化你不知道的祕密
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
會因為鍵盤彈出變成 0viewPadding
:和padding
一樣,但是bottom
部分不會發生改變
舉個例子,在 iOS 上,如下圖所示,在彈出鍵盤和未彈出鍵盤的情況下,可以看到 MediaQueryData
裡一些引數的變化:
viewInsets
在沒有彈出鍵盤時是 0,彈出鍵盤之後bottom
變成 336padding
在彈出鍵盤的前後區別,bottom
從 34 變成了 0viewPadding
在鍵盤彈出前後資料沒有發生變化
可以看到
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 。
試想一下,如果你在每個頁面開始的位置都是用了
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。
那正常情況下 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
那麼有趣的來了,如下圖 log 所示,"######## TextGlobal"
除了在一開始構建時有輸出之外,剩下 setState(() {});
的時候都沒有在觸發,也就是沒有 rebuild ,這其實就是上面 ModalRoute
的類似行為:彈出鍵盤導致了 MediaQuery
觸發 Navigator
執行 rebuild,但是 rebuild 到了 ModalRoute
就不往下影響。
其實這個行為也體現在了 Scaffold
裡,如果你去看 Scaffold
的原始碼,你就會發現 Scaffold
裡大量使用了 MediaQuery.of(context)
。
比如上面的程式碼,如果你給 MyHomePage
的 Scaffold
配置一個 3333 的 ValueKey
,那麼在 EditPage
彈出鍵盤時,其實 MyHomePage
的 Scaffold
是會觸發 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
class LikeScaffoldState extends State
可以看到,最開始 "####### 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
答案是 LikeScaffold
內的 "####### LikeScaffold build
也不會因為鍵盤的彈起而輸出,也就是: LikeScaffold
雖然使用了 MediaQuery.of(context)
,但是它不再因為鍵盤的彈起而導致 rebuild 。
因為此時 LikeScaffold
是 Scaffold
的 child ,所以在 LikeScaffold
內通過 MediaQuery.of(context)
指向的,其實是 Scaffold
內部經過處理的 MediaQueryData
。
在
Scaffold
內部有很多類似的處理,例如body
裡會根據是否有Appbar
和BottomNavigationBar
來決定是否移除該區域內的 paddingTop 和 paddingBottom 。
所以,看到這裡有沒有想到什麼?為什麼時不時通過 MediaQuery.of(context)
獲取的 padding ,有的 top 為 0 ,有的不為 0 ,原因就在於你獲取的 context 來自哪裡。
舉個例子,如下程式碼所示, ScaffoldChildPage
作為 Scaffold
的 child ,我們分別在 MyHomePage
和 ScaffoldChildPage
裡列印 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();
}
}
如下圖所示,可以看到,因為此時 MyHomePage
有 Appbar
,所以 ScaffoldChildPage
裡獲取到 paddingTop 是 0 ,因為此時 ScaffoldChildPage
獲取到的 MediaQueryData
已經被 MyHomePage
裡的 Scaffold
改寫了。
如果此時你給 MyHomePage
增加了 BottomNavigationBar
,可以看到 ScaffoldChildPage
的 bottom 會從原本的 34 變成 90 。
到這裡可以看到 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。
所以,如果需要做一些全域性攔截,推薦通過 useInheritedMediaQuery
這種方式來做全域性處理。
dart
return MediaQuery(
data: MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(boldText: false),
child: MaterialApp(
useInheritedMediaQuery: true,
),
);
所以最後做個總結,本篇主要理清了:
MediaQueryData
裡viewInsets
\padding
\viewPadding
的區別MediaQuery
和鍵盤狀態的關係MediaQuery.of
使用不同 context 對效能的影響- 通過
Scaffold
內的context
獲取到的MediaQueryData
受到Scaffold
的影響
那麼,如果看完本篇你還有什麼疑惑,歡迎留言評論交流。
- Flutter 小技巧之優化使用的 BuildContext
- 維護高 Star Github 專案,會遇到什麼有趣的問題 2022 版
- Flutter 小技巧之 ListView 和 PageView 的各種花式巢狀
- Flutter 小技巧之 MediaQuery 和 build 優化你不知道的祕密
- 掘金x得物公開課 - Flutter 3.0下的混合開發演進
- Flutter 小技巧之有趣的動畫技巧
- Flutter 小技巧之 Dart 裡的 List 和 Iterable 你真的搞懂了嗎?
- Flutter 小技巧之玩轉字型渲染和問題修復
- 蘋果 WWDC22 亮點一文彙總解讀,驚喜不停
- Flutter 小技巧之 Flutter 3 下的 ThemeExtensions 和 Material3
- Flutter 小技巧之 ButtonStyle 和 MaterialStateProperty
- Google I/O Extended | Flutter 遊戲和全平臺正式版支援下 Flutter 的現狀
- 從臺下到臺上,我成為 GDE(谷歌開發者專家) 的經驗分享
- Android 13 適配指南
- Flutter 3.0 之 PlatformView :告別 VirtualDisplay ,擁抱 TextureLayer
- 一文帶你瞭解 Google I/O 2022 精彩彙總與個人感想
- Google I/O 2022:Jetpack 的新功能
- Jetpack Compose 的新功能-谷歌 I/O 2022
- Flutter 3.0 釋出啦~快來看看有什麼新功能-2022 Google I/O
- 基於 Flutter 和 Firebase 實現的小遊戲 I/O Pinball - 谷歌 I / O 2022