天天看點

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:

今天這篇文章的目的是補全大家對于 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 在鍵盤彈出前後資料沒有發生變化
Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:
可以看到 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 。

Flutter中 MediaQuery 和 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。

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:

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

這就和路由對象的基類 ModalRoute 有關系,因為在它的内部會通過一個 _modalScopeCache 參數把 Widget 緩存起來,正如注釋所說:

緩存區域不随幀變化,以便得到最小化的建構。
Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:

 舉個例子,如下代碼所示:

  • 首先定義了一個 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 就不往下影響。

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:

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

比如上面的代碼,如果你給 MyHomePage 的 Scaffold 配置一個 3333 的 ValueKey ,那麼在 EditPage 彈出鍵盤時,其實 MyHomePage 的 Scaffold 是會觸發 rebuild ,但是因為其使用的是 widget.body ,是以并不會導緻 body 内對象重構。

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:
 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 執行個體。

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:

 是以通過這個最小例子,可以看到雖然 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。

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:
 在 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 改寫了。

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:

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

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:

 到這裡可以看到 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。

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:

 是以,如果需要做一些全局攔截,推薦通過 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。

Flutter中 MediaQuery 和 build 優化你不知道的秘密擴充:

 為什麼不使用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;
    }());