Flutter: MediaQuery.of(context) 为什么可能拖慢页面?

MediaQuery.of(context) 为什么可能拖慢页面,以及该用什么替代。

打开聊天页面,点一下输入框,键盘从底部滑出来。看上去挺顺畅,对吧?结果测试同事提了个问题:"键盘弹出时聊天列表有点卡,消息气泡好像还会闪一下。"

你检查了聊天气泡的 Widget,代码很简单,也没什么耗时操作。那问题到底出在哪儿?

先来看代码。

一个简单的聊天气泡

为了让消息看起来像聊天气泡,我们把它的最大宽度限制为屏幕宽度的 75%。

dart 复制代码
class MessageBubble extends StatelessWidget {
  final String text;
  final bool isMine;

  const MessageBubble({super.key, required this.text, required this.isMine});

  @override
  Widget build(BuildContext context) {
    final maxWidth = MediaQuery.of(context).size.width * 0.75;

    return Align(
      alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        constraints: BoxConstraints(maxWidth: maxWidth),
        margin: EdgeInsets.symmetric(vertical: 4, horizontal: 12),
        padding: EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: isMine ? Colors.blue : Colors.grey[200],
          borderRadius: BorderRadius.circular(16),
          boxShadow: [BoxShadow(blurRadius: 4, color: Colors.black12)],
        ),
        child: Text(
          text,
          style: TextStyle(color: isMine ? Colors.white : Colors.black),
        ),
      ),
    );
  }
}

乍看没什么问题,但这里有几处值得改进。我们逐个来看。

问题一:MediaQuery.of 依赖的是整份数据

这是本文重点讨论的问题。

MediaQuery.of(context) 返回完整的 MediaQueryData,其中包含尺寸、paddingviewInsets、文字缩放、亮度、方向等信息。

调用 .of() 后,当前 Widget 就对整份数据建立了依赖。只要其中任意一项发生变化,就会触发它重建,即使代码只读取了一个字段。

再想想键盘弹出时发生了什么。键盘对界面的遮挡通常体现在 viewInsets.bottom 中。原文描述的场景是:键盘在约 250ms 的动画过程中逐渐升起,这个值也随之频繁变化。

于是,所有通过 MediaQuery.of(context) 读取数据的可见气泡,都可能在键盘动画期间反复执行 build()。屏幕上如果有十五到二十个气泡,每个又带着阴影和圆角,额外工作就可能累积起来。

但气泡真正需要的只是宽度。键盘弹出时,宽度通常并没有变化,这些由无关字段变化引起的重建就没有必要。

注:键盘动画的时长,以及 viewInsets 更新的频率,会受平台和运行环境影响,不能一概认为每一帧都会重建。Widget 重建也不等于一定会重新布局或重绘,更不能仅凭这段代码就断定闪烁的原因;是否造成卡顿,需要通过性能工具确认。这里主要讨论的是 MediaQuery.of 依赖的是整份数据,而不是只依赖尺寸。

解决办法:改用 MediaQuery.sizeOf

原文以 Flutter 3.10 为版本背景介绍了这项能力:MediaQuery 基于 InheritedModel,可以只依赖自己需要的那部分数据。

如果只需要尺寸,可以这样写:

dart 复制代码
final maxWidth = MediaQuery.sizeOf(context).width * 0.75;

这样,只有 size 变化时,才会因为这项 MediaQuery 依赖触发重建,例如旋转设备或调整窗口大小。单独的 viewInsets 变化就不会再通过这项依赖影响气泡。

大多数常用字段都有对应的方法:

dart 复制代码
MediaQuery.paddingOf(context)
MediaQuery.viewInsetsOf(context)
MediaQuery.orientationOf(context)
MediaQuery.textScalerOf(context)
MediaQuery.platformBrightnessOf(context)

可以记住一个实用的习惯:写出 MediaQuery.of(context).something 时,先看看有没有对应的 somethingOf(context),按需建立依赖。

译注:sizeOf 依赖的是完整的 Size,而不只是你最终读取的 width。另外,父级更新等其他原因仍然可能触发 Widget 重建。这里减少的是无关 MediaQuery 字段变化所带来的重建。MediaQuery.sizeOf 官方说明

问题二:按屏幕宽度计算,而不是按父级可用空间计算

即使换成 sizeOf,我们问的仍然是:"整个界面有多宽?"

但气泡实际放在列表里,列表外面可能还有其他布局容器。

想象一下平板上的左右分栏:左边是会话列表,右边是聊天内容。聊天区域可能只占整个窗口宽度的 60%,气泡却仍然按整个窗口宽度的 75% 计算最大宽度。

这样得到的尺寸就不符合所在面板的设计预期,在某些布局中还可能引发显示问题。

解决办法:使用父级传入的布局约束

LayoutBuilder 可以拿到父级实际传给当前 Widget 的约束:

dart 复制代码
LayoutBuilder(
  builder: (context, constraints) {
    final maxWidth = constraints.maxWidth * 0.75;
    // 其余布局代码......
  },
)

气泡根据所在区域的可用宽度计算尺寸,就能更好地适配手机、平板、分栏界面和桌面窗口,而不必知道设备屏幕有多大。

在常见的纵向 ListView 中,键盘弹出通常不会改变列表项的宽度约束。如果传给 LayoutBuilder 的完整约束保持不变,而且没有其他更新,它也不会仅仅因为再次布局就重新调用 builder。

译注:LayoutBuilder 判断的是完整约束,不只比较宽度。父级更新该 Widget,或 builder 依赖的数据变化,也可能让 builder 再次执行。因此,"宽度没变就绝不重建"并不准确。LayoutBuilder 官方说明

这两种方式该怎么选?

  • 真正需要整个应用窗口的尺寸时,用 MediaQuery.sizeOf,例如在顶层决定采用手机布局还是平板布局。
  • Widget 需要根据所在区域调整尺寸时,用 LayoutBuilder。对于可复用组件,这通常更符合布局需求。

这里说的"屏幕尺寸",更准确地说是最近的 MediaQuery 提供的尺寸。在常见的应用顶层场景中,它通常对应应用窗口或视图的尺寸,并不是设备面板的物理像素尺寸。

问题三:能用 const 的地方没有用

EdgeInsets.symmetric(...)EdgeInsets.all(12),以及 BoxShadow 列表中的内容都不会变化,却在每次 build 时重新创建。

这些地方可以加上 const。单次收益不大,但在有大量列表项的页面中,减少不必要的对象创建仍然是个好习惯。启用相应的 lint 规则后,分析器也会提醒你。

dart 复制代码
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
padding: const EdgeInsets.all(12),
boxShadow: const [BoxShadow(blurRadius: 4, color: Colors.black12)],

问题四:颜色写死了

Colors.blueColors.grey[200]Colors.whiteColors.black 都没有使用应用主题。

切到深色模式后,收到的消息可能仍然是浅灰色气泡配黑色文字,和周围的深色界面显得不太协调。

可以改为从主题读取颜色:

dart 复制代码
final colors = Theme.of(context).colorScheme;
color: isMine ? colors.primary : colors.surfaceContainerHighest,
// 文字颜色
color: isMine ? colors.onPrimary : colors.onSurface,

这样,气泡就能跟随应用的主题色,并使用 ColorScheme 为浅色和深色模式配置的颜色。

调整后的完整版本

dart 复制代码
class MessageBubble extends StatelessWidget {
  const MessageBubble({
    super.key,
    required this.text,
    required this.isMine,
  });
  final String text;
  final bool isMine;

  static const double _widthFactor = 0.75;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return LayoutBuilder(
      builder: (context, constraints) {
        return Align(
          alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
          child: Container(
            constraints: BoxConstraints(
              maxWidth: constraints.maxWidth * _widthFactor,
            ),
            margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: isMine ? colors.primary : colors.surfaceContainerHighest,
              borderRadius: BorderRadius.circular(16),
              boxShadow: const [
                BoxShadow(blurRadius: 4, color: Colors.black12),
              ],
            ),
            child: Text(
              text,
              style: TextStyle(
                color: isMine ? colors.onPrimary : colors.onSurface,
              ),
            ),
          ),
        );
      },
    );
  }
}

这版主要改了什么?

  • 不再直接依赖 MediaQuery,避免由键盘遮挡信息变化直接触发气泡重建。
  • 根据父级约束计算宽度,更适合不同布局容器。
  • 固定不变的值尽量使用 const
  • 从主题获取颜色,适配浅色和深色模式。
  • 0.75 提取为有名称的 _widthFactor

注:此例适用于父级提供有限最大宽度的场景,例如常见的纵向聊天列表。如果放进横向宽度无界的容器,需要先提供合适的宽度约束。另外,代码仍通过 Theme.of(context) 依赖主题;主题改变时,重建是正常且必要的。

怎么自己验证?

不用只听我的结论,直接打开 Flutter DevTools 验证。

进入 Performance 页面,开启 Track Widget Builds,记录旧版本代码在点击输入框、弹出键盘时的表现,观察时间线中聊天气泡的 build 事件。再换成新版本,重复同样的操作,对比是否减少了无关重建。

注:本文预期的结果是:旧版本中气泡会反复重建,新版本中这部分重建明显减少。但实际结果还取决于父级更新、状态管理和布局结构,不能保证所有项目的重建次数都完全不变。

判断实际卡顿时,建议在真机上使用 profile 模式 ,同时查看帧耗时,而不要只凭 debug 模式下的表现或重建次数下结论。追踪功能本身也会带来额外开销。Flutter DevTools Performance 官方文档

几个值得记住的要点

  • MediaQuery.of(context) 会让当前 Widget 依赖整份 MediaQueryData,包括键盘遮挡信息在内的任何字段变化,都可能触发重建。
  • 只需要某项数据时,优先用 MediaQuery.sizeOfpaddingOfviewInsetsOf 等方法。
  • 可复用组件通常应该根据所在区域的约束布局,可以优先考虑 LayoutBuilder
  • 固定值尽量使用 const,颜色尽量从主题读取。

改动不大,却有机会减少页面中的无效工作。下次看到 MediaQuery.of(context).size,可以先想想:这里究竟需要依赖整份数据、窗口尺寸,还是父级给出的布局约束?

相关推荐
事圆则缓2 小时前
Android 图片内存到底怎么算
android
Godikov2 小时前
弱网环境离线优先:Android 终端数据可靠同步架构实战
android
用户38034165882973 小时前
Broadcast Extension 里的 Live Activity:一次跨进程妥协
ios·音视频开发
匠测AI说3 小时前
XCUITest 自动化全解:同进程白盒、XCTest 架构、自动同步机制与 iOS 端选型指南
测试工具·ios·自动化
爱编程的小新☆5 小时前
Fake GPS 虚拟定位保姆级使用教学
android·移动开发·gps·虚拟定位
执明wa6 小时前
Android RecyclerView 多类型, 多种 Item
android·xml·开发语言·设计模式·android studio
silianpan6 小时前
CAD 文档预览 UTS 插件
android·ios·harmonyos
小麦在野6 小时前
独立 App 开发系列:用 GitHub Pages 托管隐私政策和用户协议
android·github