今天這篇文章的目的是補全大家對于 MediaQuery 和對應 rebuild 機制的基礎認知,相信本篇内容對你優化性能和調試 bug 會很有幫助。
Flutter 裡大家應該都離不開 MediaQuery ,比如通過 MediaQuery.of(context).size 擷取螢幕大小 ,或者通過 MediaQuery.of(context).padding.top 擷取狀态欄高度,那随便使用 MediaQuery.of(context) 會有什麼問題嗎?
首先我們需要簡單解釋一下,通過 MediaQuery.of 擷取到的 MediaQueryData 裡有幾個很類似的參數:
- viewInsets : 被系統使用者界面完全遮擋的部分大小,簡單來說就是鍵盤高度
- padding : 簡單來說就是狀态欄和底部安全區域,但是 bottom 會因為鍵盤彈出變成 0
- viewPadding :和 padding 一樣,但是 bottom 部分不會發生改變
舉個例子,在 iOS 上,如下圖所示,在彈出鍵盤和未彈出鍵盤的情況下,可以看到 MediaQueryData 裡一些參數的變化:
- viewInsets 在沒有彈出鍵盤時是 0,彈出鍵盤之後 bottom 變成 336
- padding 在彈出鍵盤的前後差別, bottom 從 34 變成了 0
- viewPadding 在鍵盤彈出前後資料沒有發生變化

可以看到 MediaQueryData 裡的資料是會根據鍵盤狀态發生變化,又因為 MediaQuery 是一個 InheritedWidget ,是以我們可以通過 MediaQuery.of(context) 擷取到頂層共享的 MediaQueryData 。
那麼問題來了,InheritedWidget 的更新邏輯,是通過登記的 context 來綁定的,也就是 MediaQuery.of(context) 本身就是一個綁定行為,然後 MediaQueryData 又和鍵盤狀态有關系,是以:鍵盤的彈出可能會導緻使用 MediaQuery.of(context) 的地方觸發 rebuild,舉個例子:
如下代碼所示,我們在 MyHomePage 裡使用了 MediaQuery.of(context).size 并列印輸出,然後跳轉到 EditPage 頁面,彈出鍵盤 ,這時候會發生什麼情況?
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方法;
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<MyHomePage> {
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) 。
比如上面的代碼,如果你給 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 頁面打開鍵盤
class LikeScaffold extends StatefulWidget {
final Widget body;
const LikeScaffold({Key? key, required this.body}) : super(key: key);
@override
State<LikeScaffold> createState() => _LikeScaffoldState();
}
class _LikeScaffoldState extends State<LikeScaffold> {
@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<MyHomePage> {
@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 ,那輸出結果會是怎麼樣?
class _MyHomePageState extends State<MyHomePage> {
@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 。
因為此時 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 :
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 這種方式來做全局處理。
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 的影響
擴充:
問題一:
MediaQuery.of() called with a context that does not contain a MediaQuery.
MediaQuery.of() called with a context that does not contain a MediaQuery.\n'
'No MediaQuery ancestor could be found starting from the context that was passed '
'to MediaQuery.of(). This can happen because you do not have a WidgetsApp or '
'MaterialApp widget (those widgets introduce a MediaQuery), or it can happen '
'if the context you use comes from a widget above those widgets.\n'
'The context used was:
在開發中經常遇到此問題,根據字面意思就是無法從上下文擷取到MediaQuery,需要在MaterialApp或者WidgetsApp裡面使用才行。
解決問題的辦法就是在使用MediaQuery.of()的widget需要是MaterialApp或者外層包括了MaterialApp,換個說法就是頂層widget需要是MaterialApp。
為什麼不使用WidgetsApp?
一般情況下,不會直接使用WidgetsApp,而是使用
MaterialApp
或者
CupertinoApp。
WidgetsApp提供了最基本的元素,MaterialApp在WidgetsApp的基礎上進行了修飾,是修改過的WidgetsApp。
WidgetsApp元件中有18個參數屬性和
MaterialApp
一樣,詳細可以參考下面連結:
WidgetsApp | Flutter | 老孟
Flutter之WidgetsApp與MaterialApp的不同
未經過改裝的MaterialApp
可以說MaterialApp基于WidgetsApp
如果對MaterialApp不熟悉,可先看我上一篇文章:
Flutter之MaterialApp使用詳解
與MaterialApp相比
18個相同字段:
字段 | 類型 |
navigatorKey(導航鍵) | GlobalKey<NavigatorState> |
onGenerateRoute(生成路由) | RouteFactory |
onUnknownRoute(未知路由) | RouteFactory |
navigatorObservers(導航觀察器) | List<NavigatorObserver> |
initialRoute(初始路由) | String |
builder(建造者) | TransitionBuilder |
title(标題) | String |
onGenerateTitle(生成标題) | GenerateAppTitle |
color(顔色) | Color |
locale(地點) | Locale |
localizationsDelegates(本地化委托) | Iterable<LocalizationsDelegate<dynamic>> |
localeResolutionCallback(區域分辨回調) | LocaleResolutionCallback |
supportedLocales(支援區域) | Iterable<Locale> |
showPerformanceOverlay(顯示性能疊加) | bool |
checkerboardRasterCacheImages(棋盤格光栅緩存圖像) | bool |
checkerboardOffscreenLayers(棋盤格層) | bool |
showSemanticsDebugger(顯示語義調試器) | bool |
debugShowCheckedModeBanner(調試顯示檢查模式橫幅) | bool |
WidgetsApp特有的字段:
字段 | 類型 |
textStyle(文字樣式) | TextStyle |
debugShowWidgetInspector(調試小部件檢測) | bool |
inspectorSelectButtonBuilder(審查員選擇按鈕生成器) | InspectorSelectButtonBuilder |
MaterialApp特有的字段:
字段 | 類型 |
home(首頁) | Widget |
routes(路由) | Map<String, WidgetBuilder> |
theme(主題) | ThemeData |
debugShowMaterialGrid(調試顯示材質網格) | bool |
先來介紹WidgetsApp特有的字段吧!
1. textStyle
為應用中的文本使用的預設樣式
使用
//該段代碼源自flutter/material/app.dart
//因為MaterialApp都是使用Theme裡面的主題色,并且一般部件使用的是MaterialApp部件,是以該textStyle為報錯文字的顔色
const TextStyle _errorTextStyle= const TextStyle(
color: const Color(0xD0FF0000),
fontFamily: 'monospace',
fontSize: 48.0,
fontWeight: FontWeight.w900,
decoration: TextDecoration.underline,
decorationColor: const Color(0xFFFFFF00),
decorationStyle: TextDecorationStyle.double,
debugLabel: 'fallback style; consider putting your text in a Material',
);
new WidgetsApp(
color: Colors.grey,
textStyle: _myTextStyle ,
);
2. debugShowWidgetInspector
當為true時,打開檢查覆寫,該字段隻能在檢查模式下可用
3. inspectorSelectButtonBuilder
建構一個視圖與視圖切換的小部件,可以通過該小部件或按鈕切換到檢查模式(debugShowWidgetInspector==true時才有效,點選該按鈕之後再點選你要檢查的視圖)
new WidgetsApp(
debugShowWidgetInspector: true,
inspectorSelectButtonBuilder: (BuildContext context, VoidCallback onPressed) {
return new FloatingActionButton(
child: const Icon(Icons.search),
onPressed: onPressed,
mini: true,
);
},
);
現在介紹一下MaterialApp特有的字段究竟對WidgetsApp做了什麼?
4. home
該字段在MaterialApp中調用的是WidgetsApp的onGenerateRoute
當參數setting.name為Navigator.defaultRouteName(即"/")時傳回home的Widget
是以可以推測當程式啟動時,會調用一個以"/"為路由名的Widget
下面來看一段源碼
Route<dynamic> _onGenerateRoute(RouteSettings settings) {
final String name = settings.name;
WidgetBuilder builder;
//判斷目前home字段不為空,而且name為Navigator.defaultRouteName
//傳回home字段的Widget
if (name == Navigator.defaultRouteName && widget.home != null) {
builder = (BuildContext context) => widget.home;
} else {
//這裡查找路由對應的Widget,即為routes字段傳入的map
builder = widget.routes[name];
}
if (builder != null) {
//可以看到預設是使用MaterialPageRoute的切換界面動畫
return new MaterialPageRoute<dynamic>(
builder: builder,
settings: settings,
);
}
if (widget.onGenerateRoute != null)
return widget.onGenerateRoute(settings);
return null;
}
//下面這裡有部分省略
new WidgetsApp(
onGenerateRoute: _haveNavigator ? _onGenerateRoute : null,
)
5. routes
6. theme
//如果為空使用預設光亮主題
final ThemeData theme = widget.theme ?? new ThemeData.fallback();
//factory ThemeData.fallback() => new ThemeData.light();
Widget result = new AnimatedTheme(
data: theme,
isMaterialAppTheme: true,
child: new WidgetsApp(
key: new GlobalObjectKey(this),
//..........
)
);
7. debugShowMaterialGrid
assert(() {
if (widget.debugShowMaterialGrid) {
result = new GridPaper(
color: const Color(0xE0F9BBE0),
interval: 8.0,
divisions: 2,
subdivisions: 1,
child: result,
);
}
return true;
}());