当前位置:   article > 正文

Flutter中 MediaQuery 和 build 优化你不知道的秘密_flutter useinheritedmediaquery 属性

flutter useinheritedmediaquery 属性

今天这篇文章的目的是补全大家对于 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 在键盘弹出前后数据没有发生变化

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 页面,弹出键盘 ,这时候会发生什么情况?

  1. class MyHomePage extends StatelessWidget {
  2.   @override
  3.   Widget build(BuildContext context) {
  4.     print("######### MyHomePage ${MediaQuery.of(context).size}");
  5.     return Scaffold(
  6.       body: Container(
  7.         alignment: Alignment.center,
  8.         child: InkWell(
  9.           onTap: () {
  10.             Navigator.of(context).push(CupertinoPageRoute(builder: (context) {
  11.               return EditPage();
  12.             }));
  13.           },
  14.           child: new Text(
  15.             "Click",
  16.             style: TextStyle(fontSize: 50),
  17.           ),
  18.         ),
  19.       ),
  20.     );
  21.   }
  22. }
  23. class EditPage extends StatelessWidget {
  24.   @override
  25.   Widget build(BuildContext context) {
  26.     return Scaffold(
  27.       appBar: AppBar(
  28.         title: new Text("ControllerDemoPage"),
  29.       ),
  30.       extendBody: true,
  31.       body: Column(
  32.         children: [
  33.           new Spacer(),
  34.           new Container(
  35.             margin: EdgeInsets.all(10),
  36.             child: new Center(
  37.               child: new TextField(),
  38.             ),
  39.           ),
  40.           new Spacer(),
  41.         ],
  42.       ),
  43.     );
  44.   }
  45. }

如下图 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方法;
  1. class TextGlobal extends StatelessWidget {
  2. const TextGlobal({Key? key}) : super(key: key);
  3. @override
  4. Widget build(BuildContext context) {
  5. print("######## TextGlobal");
  6. return Container(
  7. child: new Text(
  8. "测试",
  9. style: new TextStyle(fontSize: 40, color: Colors.redAccent),
  10. textAlign: TextAlign.center,
  11. ),
  12. );
  13. }
  14. }
  15. class MyHomePage extends StatefulWidget {
  16. final String? title;
  17. MyHomePage({Key? key, this.title}) : super(key: key);
  18. @override
  19. _MyHomePageState createState() => _MyHomePageState();
  20. }
  21. class _MyHomePageState extends State<MyHomePage> {
  22. TextGlobal globalText = TextGlobal();
  23. @override
  24. Widget build(BuildContext context) {
  25. print("######## MyHomePage");
  26. return Scaffold(
  27. appBar: AppBar(),
  28. body: new Container(
  29. alignment: Alignment.center,
  30. child: Column(
  31. crossAxisAlignment: CrossAxisAlignment.center,
  32. mainAxisAlignment: MainAxisAlignment.center,
  33. children: [
  34. globalText,
  35. globalText,
  36. globalText,
  37. ],
  38. ),
  39. ),
  40. floatingActionButton: FloatingActionButton(
  41. onPressed: () {
  42. setState(() {});
  43. },
  44. ),
  45. );
  46. }
  47. }

那么有趣的来了,如下图 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 页面打开键盘
  1. class LikeScaffold extends StatefulWidget {
  2. final Widget body;
  3. const LikeScaffold({Key? key, required this.body}) : super(key: key);
  4. @override
  5. State<LikeScaffold> createState() => _LikeScaffoldState();
  6. }
  7. class _LikeScaffoldState extends State<LikeScaffold> {
  8. @override
  9. Widget build(BuildContext context) {
  10. print("####### LikeScaffold build ${MediaQuery.of(context).viewInsets.bottom}");
  11. return Material(
  12. child: new Column(
  13. crossAxisAlignment: CrossAxisAlignment.center,
  14. mainAxisAlignment: MainAxisAlignment.center,
  15. children: [widget.body],
  16. ),
  17. );
  18. }
  19. }
  20. ····
  21. class _MyHomePageState extends State<MyHomePage> {
  22. @override
  23. Widget build(BuildContext context) {
  24. var routeLists = routers.keys.toList();
  25. return new LikeScaffold(
  26. body: Builder(
  27. builder: (_) {
  28. print("############ HomePage Builder Text ");
  29. return InkWell(
  30. onTap: () {
  31. Navigator.of(context).push(CupertinoPageRoute(builder: (context) {
  32. return EditPage();
  33. }));
  34. },
  35. child: Text(
  36. "FFFFFFF",
  37. style: TextStyle(fontSize: 50),
  38. ),
  39. );
  40. },
  41. ),
  42. );
  43. }
  44. }

可以看到,最开始 "####### LikeScaffold build 0.0 和 ############ HomePage Builder Text 都正常执行,然后在键盘弹出之后,"####### LikeScaffold build 跟随键盘动画不断输出 bottom 的 大小,但是 "############ HomePage Builder Text ") 没有输出,因为它是 widget.body 实例。

 所以通过这个最小例子,可以看到虽然 Scaffold 里大量使用 MediaQuery.of(context) ,但是影响范围是约束在 Scaffold 内部。

接着我们继续看修改这个例子,如果在 LikeScaffold 上嵌套多一个 Scaffold ,那输出结果会是怎么样?

  1. class _MyHomePageState extends State<MyHomePage> {
  2. @override
  3. Widget build(BuildContext context) {
  4. var routeLists = routers.keys.toList();
  5. ///多加了个 Scaffold
  6. return Scaffold(
  7. body: new LikeScaffold(
  8. body: Builder(
  9. ·····
  10. ),
  11. ),
  12. );
  13. }

答案是 LikeScaffold 内的 "####### LikeScaffold build 也不会因为键盘的弹起而输出,也就是: LikeScaffold 虽然使用了 MediaQuery.of(context) ,但是它不再因为键盘的弹起而导致 rebuild 。

因为此时 LikeScaffold 是 Scaffold 的 child ,所以在 LikeScaffold 内通过 MediaQuery.of(context) 指向的,其实是 Scaffold 内部经过处理的 MediaQueryData。

image-20220624150712453

 在 Scaffold 内部有很多类似的处理,例如 body 里会根据是否有 Appbar 和 BottomNavigationBar 来决定是否移除该区域内的 paddingTop 和 paddingBottom 。

所以,看到这里有没有想到什么?为什么时不时通过 MediaQuery.of(context) 获取的 padding ,有的 top 为 0 ,有的不为 0 ,原因就在于你获取的 context 来自哪里。

举个例子,如下代码所示, ScaffoldChildPage 作为 Scaffold 的 child ,我们分别在 MyHomePage和 ScaffoldChildPage 里打印 MediaQuery.of(context).padding :

  1. class MyHomePage extends StatelessWidget {
  2. @override
  3. Widget build(BuildContext context) {
  4. print("MyHomePage MediaQuery padding: ${MediaQuery.of(context).padding}");
  5. return Scaffold(
  6. appBar: AppBar(
  7. title: new Text(""),
  8. ),
  9. extendBody: true,
  10. body: Column(
  11. children: [
  12. new Spacer(),
  13. ScaffoldChildPage(),
  14. new Spacer(),
  15. ],
  16. ),
  17. );
  18. }
  19. }
  20. class ScaffoldChildPage extends StatelessWidget {
  21. @override
  22. Widget build(BuildContext context) {
  23. print("ScaffoldChildPage MediaQuery padding: ${MediaQuery.of(context).padding}");
  24. return Container();
  25. }
  26. }

如下图所示,可以看到,因为此时 MyHomePage 有 Appbar ,所以 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 这种方式来做全局处理。

  1. return MediaQuery(
  2.   data: MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(boldText: false),
  3.   child: MaterialApp(
  4.     useInheritedMediaQuery: true,
  5.   ),
  6. );


所以最后做个总结,本篇主要理清了:

  • 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

为应用中的文本使用的默认样式

使用

  1. //该段代码源自flutter/material/app.dart
  2. //因为MaterialApp都是使用Theme里面的主题色,并且一般部件使用的是MaterialApp部件,所以该textStyle为报错文字的颜色
  3. const TextStyle _errorTextStyle= const TextStyle(
  4. color: const Color(0xD0FF0000),
  5. fontFamily: 'monospace',
  6. fontSize: 48.0,
  7. fontWeight: FontWeight.w900,
  8. decoration: TextDecoration.underline,
  9. decorationColor: const Color(0xFFFFFF00),
  10. decorationStyle: TextDecorationStyle.double,
  11. debugLabel: 'fallback style; consider putting your text in a Material',
  12. );
  13. new WidgetsApp(
  14. color: Colors.grey,
  15. textStyle: _myTextStyle ,
  16. );

2. debugShowWidgetInspector

当为true时,打开检查覆盖,该字段只能在检查模式下可用

3. inspectorSelectButtonBuilder

构建一个视图与视图切换的小部件,可以通过该小部件或按钮切换到检查模式(debugShowWidgetInspector==true时才有效,点击该按钮之后再点击你要检查的视图)

  1. new WidgetsApp(
  2. debugShowWidgetInspector: true,
  3. inspectorSelectButtonBuilder: (BuildContext context, VoidCallback onPressed) {
  4. return new FloatingActionButton(
  5. child: const Icon(Icons.search),
  6. onPressed: onPressed,
  7. mini: true,
  8. );
  9. },
  10. );

现在介绍一下MaterialApp特有的字段究竟对WidgetsApp做了什么?

4. home

该字段在MaterialApp中调用的是WidgetsApp的onGenerateRoute
当参数setting.name为Navigator.defaultRouteName(即"/")时返回home的Widget
所以可以推测当程序启动时,会调用一个以"/"为路由名的Widget
下面来看一段源码

  1. Route<dynamic> _onGenerateRoute(RouteSettings settings) {
  2. final String name = settings.name;
  3. WidgetBuilder builder;
  4. //判断当前home字段不为空,而且name为Navigator.defaultRouteName
  5. //返回home字段的Widget
  6. if (name == Navigator.defaultRouteName && widget.home != null) {
  7. builder = (BuildContext context) => widget.home;
  8. } else {
  9. //这里查找路由对应的Widget,即为routes字段传入的map
  10. builder = widget.routes[name];
  11. }
  12. if (builder != null) {
  13. //可以看到默认是使用MaterialPageRoute的切换界面动画
  14. return new MaterialPageRoute<dynamic>(
  15. builder: builder,
  16. settings: settings,
  17. );
  18. }
  19. if (widget.onGenerateRoute != null)
  20. return widget.onGenerateRoute(settings);
  21. return null;
  22. }
  23. //下面这里有部分省略
  24. new WidgetsApp(
  25. onGenerateRoute: _haveNavigator ? _onGenerateRoute : null,
  26. )

5. routes

这个字段上面源码已经解释的很清楚
就是在 _onGenerateRoute方法里面查找合适的路由
查找不到才在自身字段onGenerateRoute里面查找

6. theme

该主题主要传入到AnimatedTheme这个部件中,最终传入Theme
用于作为MaterialAPP里面的Widget的主题
一般使用BottomNavigationBar、AppBar这些部件,会应用到这个主题

  1. //如果为空使用默认光亮主题
  2. final ThemeData theme = widget.theme ?? new ThemeData.fallback();
  3. //factory ThemeData.fallback() => new ThemeData.light();
  4. Widget result = new AnimatedTheme(
  5. data: theme,
  6. isMaterialAppTheme: true,
  7. child: new WidgetsApp(
  8. key: new GlobalObjectKey(this),
  9. //..........
  10. )
  11. );

7. debugShowMaterialGrid

该字段开启后,会在WidgetsApp外层包裹GridPaper,这个部件主要显示网格

  1. assert(() {
  2. if (widget.debugShowMaterialGrid) {
  3. result = new GridPaper(
  4. color: const Color(0xE0F9BBE0),
  5. interval: 8.0,
  6. divisions: 2,
  7. subdivisions: 1,
  8. child: result,
  9. );
  10. }
  11. return true;
  12. }());

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家小花儿/article/detail/268710
推荐阅读
相关标签
  

闽ICP备14008679号