Flutter 基础体系 · 第 41/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 响应式与自适应:约束、断点、平台和窗口尺寸

Flutter 中的“响应式”和“自适应”经常被混用,但它们解决的问题不同:

  • 响应式(responsive):界面根据可用空间、窗口尺寸、文字缩放、键盘和系统安全区域等运行时条件变化。
  • 自适应(adaptive):界面根据平台能力、输入方式、导航习惯和交互模型选择不同的呈现或行为。
  • 约束(constraints):父组件传给子组件的尺寸范围,是 Flutter 布局系统的基础。
  • 断点(breakpoint):把连续变化的可用空间划分为几个布局区间,在区间边界切换布局策略。
  • 平台(platform):Android、iOS、桌面、Web 等运行环境及其输入、窗口、系统控件和交互差异。
  • 窗口尺寸(window size):当前 Flutter 视图实际可用的逻辑像素大小;它不一定等于设备物理屏幕大小。

如果忽略约束,断点会变成随意的数字;如果只判断平台,桌面窗口缩窄或 Web 浏览器调整大小时仍可能溢出;如果只读取一次屏幕尺寸,旋转设备、分屏、键盘弹出和桌面窗口调整也会产生错误。


一、先建立 Flutter 布局的基本模型

Flutter 的布局过程可以抽象为:

  1. 父组件向子组件传递约束;
  2. 子组件在约束允许的范围内确定自己的大小;
  3. 父组件根据子组件的大小决定其位置;
  4. 渲染对象绘制内容。

约束通常表示为:

C=(wmin,wmax,hmin,hmax)C = (w_{\min}, w_{\max}, h_{\min}, h_{\max})

其中:

  • wminw_{\min}wmaxw_{\max}:子组件允许的最小和最大宽度;
  • hminh_{\min}hmaxh_{\max}:子组件允许的最小和最大高度。

子组件选择的尺寸 S=(w,h)S=(w,h) 必须满足:

wminwwmaxw_{\min} \leq w \leq w_{\max}

hminhhmaxh_{\min} \leq h \leq h_{\max}

这不是建议,而是布局协议。子组件不能返回超出约束的尺寸。

1. 有限约束、紧约束和无界约束

以下三类约束非常重要。

有限约束

例如:

0 <= width <= 360
0 <= height <= 800

子组件可以选择不超过上限的大小。

紧约束

当最小值和最大值相等时,约束是紧的:

width = 360
height = 800

典型来源是:

SizedBox.expand(
  child: child,
)

或者父组件本身把精确尺寸传给子组件。

无界约束

例如:

0 <= height <= infinity

这意味着父组件没有规定子组件的最大高度。ListViewColumn 等可扩展组件在某些嵌套场景中会遇到这种情况。

无界约束本身不一定错误,但需要子组件能够处理它。下面的代码很容易失败:

SingleChildScrollView(
  child: Column(
    children: [
      Expanded(
        child: Container(color: Colors.blue),
      ),
    ],
  ),
)

SingleChildScrollView 在滚动方向上通常给子组件无界高度,而 Expanded 需要一个有限的剩余空间来分配,因此 Flutter 可能抛出类似以下错误:

RenderFlex children have non-zero flex but incoming height constraints are unbounded.

修复方式不是简单增加 MediaQuery,而是改变约束关系。例如让滚动区域内部使用明确的最小高度,或避免在无界方向上使用 Expanded

SingleChildScrollView(
  child: Column(
    children: [
      Container(
        height: 400,
        color: Colors.blue,
      ),
    ],
  ),
)

如果确实需要“至少填满视口,内容多时仍可滚动”,可以使用 LayoutBuilderConstrainedBox

LayoutBuilder(
  builder: (context, constraints) {
    return SingleChildScrollView(
      child: ConstrainedBox(
        constraints: BoxConstraints(minHeight: constraints.maxHeight),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text('内容'),
            const SizedBox(height: 600),
          ],
        ),
      ),
    );
  },
)

这里的因果关系是:

  1. LayoutBuilder 取得父组件给出的有限高度;
  2. ConstrainedBox 将这个高度转成子树的最小高度;
  3. Column 至少占满视口;
  4. 如果内容超过视口,SingleChildScrollView 负责滚动;
  5. 没有在无界高度上要求 Expanded 分配“剩余空间”。

二、为什么“把屏幕宽度传给所有组件”不是响应式布局

很多代码会这样写:

final width = MediaQuery.of(context).size.width;

return SizedBox(
  width: width,
  child: ...
);

这通常是多余的,甚至可能破坏布局。因为父组件本来就会通过约束告诉子组件可用宽度。

假设父组件传入:

0 <= width <= 600

子组件只需要返回不超过 600 的宽度。若它强行使用屏幕宽度 800,就会违反局部约束。窗口的总宽度和当前组件的可用宽度不是同一个概念:

  • 应用窗口宽度可能是 1200;
  • 页面外层有 24 像素边距;
  • Row 左侧导航占用 280;
  • 当前卡片实际只剩 896;
  • 某个卡片内部又可能只有 400。

因此,布局组件应优先使用局部约束:

LayoutBuilder(
  builder: (context, constraints) {
    final width = constraints.maxWidth;

    if (width < 400) {
      return const CompactPanel();
    }

    return const WidePanel();
  },
)

这里的 constraints.maxWidth 表示“这个组件此刻可以使用的最大宽度”,比全局窗口宽度更接近实际布局问题。

MediaQueryLayoutBuilder 的职责

可以这样区分:

工具 主要回答的问题
LayoutBuilder 当前组件从父组件那里获得了什么约束?
MediaQuery 当前 Flutter 视图、系统内嵌区域、文字缩放和显示特征是什么?
SafeArea 如何避免系统状态栏、刘海、导航栏或键盘遮挡?
OrientationBuilder 当前可用布局方向是什么?

例如,页面主体是否使用双栏,通常应该用 LayoutBuilder;页面是否需要避开刘海,应该用 SafeAreaMediaQuery 的内嵌数据。


三、窗口尺寸、屏幕尺寸和物理像素不是一回事

Flutter 布局使用的是逻辑像素,而不是设备物理像素。

设设备像素比为 dd,物理像素尺寸为 PP,逻辑尺寸约为:

L=PdL = \frac{P}{d}

例如:

物理宽度 = 1080 px
devicePixelRatio = 3.0
逻辑宽度 ≈ 360

Flutter 的 SizedBox(width: 360) 使用的是逻辑像素,不是 360 个物理像素。

1. 当前视图大小

在已有 MediaQuery 的 widget 树中,可以使用:

final size = MediaQuery.sizeOf(context);
final width = size.width;
final height = size.height;

MediaQuery.sizeOf(context) 表示当前 BuildContext 所属 Flutter 视图的逻辑尺寸。它适合回答:

当前应用视图整体有多大?

使用 sizeOf 比直接使用 MediaQuery.of(context) 更能表达依赖关系:组件只依赖尺寸时,尺寸变化才是主要触发因素,而不是依赖整个 MediaQueryData

也可以使用:

final view = View.of(context);
final logicalSize = view.physicalSize / view.devicePixelRatio;

但在普通页面布局中不应优先绕过 MediaQueryMediaQuery 还包含系统内嵌区域、文字缩放、显示特征等经过框架组织的数据。

2. 为什么窗口尺寸会动态变化

窗口尺寸不是初始化常量,以下情况都会改变它:

  • 手机旋转;
  • Android 分屏或自由窗口;
  • iPad 多任务布局;
  • 桌面窗口拖拽调整大小;
  • Web 浏览器调整大小;
  • 外接显示器或窗口迁移;
  • 某些系统中的键盘、系统栏或显示区域变化。

使用 MediaQuery.sizeOf(context)LayoutBuilder 的 widget 会随依赖变化而重建。例如:

class WindowInfo extends StatelessWidget {
  const WindowInfo({super.key});

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.sizeOf(context);

    return Text(
      '逻辑尺寸:${size.width.toStringAsFixed(0)} × '
      '${size.height.toStringAsFixed(0)}',
    );
  }
}

这个组件的输入是当前 BuildContext 对应的 MediaQuery 尺寸。窗口调整后,框架更新媒体数据,组件重新构建,文本随之变化。

3. 多窗口和 FlutterView

在桌面、多窗口或嵌入场景中,不应把某个全局窗口对象当成所有界面的尺寸来源。一个 BuildContext 关联的是特定视图,View.of(context) 能够获取该上下文对应的 FlutterView

因此:

final view = View.of(context);

比在全局保存一个“当前窗口宽度”更可靠。需要在应用级监听窗口指标时,可以实现 WidgetsBindingObserver

class MetricsObserver with WidgetsBindingObserver {
  @override
  void didChangeMetrics() {
    // 视图尺寸、设备像素比或系统内嵌区域可能发生变化。
  }
}

不过,页面布局通常不需要手动监听。手动监听适用于:

  • 非 widget 的窗口服务;
  • 需要向外部系统同步窗口尺寸;
  • 需要在 widget 树之外执行副作用。

如果只是为了切换页面布局,优先使用 LayoutBuilderMediaQuery。手动监听容易产生生命周期问题:注册后忘记移除、保存旧尺寸、在组件销毁后仍更新状态。


四、响应式布局的核心:连续尺寸输入,离散布局策略

窗口宽度是连续变量,但布局策略通常是离散的。例如:

  • 小宽度:底部导航或抽屉;
  • 中等宽度:侧边导航;
  • 大宽度:展开侧边导航,并显示更宽内容区域。

可以定义布局策略函数:

L(w)={compact,w<b1medium,b1w<b2expanded,wb2L(w)= \begin{cases} \text{compact}, & w < b_1 \\ \text{medium}, & b_1 \leq w < b_2 \\ \text{expanded}, & w \geq b_2 \end{cases}

其中:

  • ww 是当前局部可用宽度;
  • b1b_1b2b_2 是断点;
  • L(w)L(w) 是布局策略,而不是单个组件的像素宽度。

断点的含义不是“设备类型”。例如,一个 700 逻辑像素宽的桌面窗口和一个 700 逻辑像素宽的平板分屏,可能应该采用相同的布局,因为它们真正能提供给页面的空间相同。

断点不应脱离内容推导

假设一个导航栏需要:

  • 图标宽度:56;
  • 文字和内边距:140;
  • 主内容最小可用宽度:480;
  • 两者之间间距:24。

则双栏布局的最低宽度可近似计算为:

Wmin=56+140+480+24=700W_{\min}=56+140+480+24=700

如果还需要外边距 2×242 \times 24,则窗口至少需要:

Wwindow=700+48=748W_{\text{window}}=700+48=748

因此,700768 之类的值只能是策略结果,不能被解释为 Flutter 的 universal breakpoint。Flutter 并没有为所有应用规定一组唯一断点。断点应由内容的最小可用尺寸、交互目标和设计系统共同决定。


五、一个可运行的自适应页面

下面是一个可以放入新建 Flutter 项目的 lib/main.dart 中运行的示例。它根据当前页面获得的宽度选择导航结构:

  • 小于 600:底部导航;
  • 600 到 839:紧凑侧边导航;
  • 840 及以上:展开侧边导航。
import 'package:flutter/material.dart';

void main() {
  runApp(const AdaptiveApp());
}

class AdaptiveApp extends StatelessWidget {
  const AdaptiveApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Adaptive Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const AdaptiveHomePage(),
    );
  }
}

class AdaptiveHomePage extends StatefulWidget {
  const AdaptiveHomePage({super.key});

  @override
  State<AdaptiveHomePage> createState() => _AdaptiveHomePageState();
}

class _AdaptiveHomePageState extends State<AdaptiveHomePage> {
  int _selectedIndex = 0;

  static const _destinations = <NavigationDestination>[
    NavigationDestination(
      icon: Icon(Icons.home_outlined),
      selectedIcon: Icon(Icons.home),
      label: '首页',
    ),
    NavigationDestination(
      icon: Icon(Icons.settings_outlined),
      selectedIcon: Icon(Icons.settings),
      label: '设置',
    ),
  ];

  static const _railDestinations = <NavigationRailDestination>[
    NavigationRailDestination(
      icon: Icon(Icons.home_outlined),
      selectedIcon: Icon(Icons.home),
      label: Text('首页'),
    ),
    NavigationRailDestination(
      icon: Icon(Icons.settings_outlined),
      selectedIcon: Icon(Icons.settings),
      label: Text('设置'),
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('响应式页面'),
      ),
      body: LayoutBuilder(
        builder: (context, constraints) {
          final width = constraints.maxWidth;

          if (width < 600) {
            return _buildCompactLayout();
          }

          final extended = width >= 840;

          return Row(
            children: [
              NavigationRail(
                extended: extended,
                selectedIndex: _selectedIndex,
                onDestinationSelected: _selectDestination,
                destinations: _railDestinations,
              ),
              const VerticalDivider(width: 1),
              Expanded(
                child: _PageContent(index: _selectedIndex),
              ),
            ],
          );
        },
      ),
      bottomNavigationBar: LayoutBuilder(
        builder: (context, constraints) {
          // 这里的约束来自 Scaffold 的底部区域,而不是页面主体。
          // 为避免主体和底部导航重复显示,只在窄窗口返回导航栏。
          if (constraints.maxWidth >= 600) {
            return const SizedBox.shrink();
          }

          return NavigationBar(
            selectedIndex: _selectedIndex,
            onDestinationSelected: _selectDestination,
            destinations: _destinations,
          );
        },
      ),
    );
  }

  Widget _buildCompactLayout() {
    return _PageContent(index: _selectedIndex);
  }

  void _selectDestination(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }
}

class _PageContent extends StatelessWidget {
  const _PageContent({required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 960),
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: Card(
              child: Padding(
                padding: const EdgeInsets.all(24),
                child: Text(
                  index == 0 ? '首页内容' : '设置内容',
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

这个示例的约束链

以宽窗口为例,约束大致经过以下过程:

  1. Scaffoldbody 一个页面主体的宽高约束;
  2. LayoutBuilder 读取这个约束;
  3. Row 横向分配空间;
  4. NavigationRail 根据 extended 选择自身宽度;
  5. Expanded 把剩余宽度传给 _PageContent
  6. ConstrainedBox 将内容最大宽度限制为 960;
  7. Padding 再从内容区域两侧扣除 24;
  8. Card 和内部文本在剩余约束中布局。

ConstrainedBox(maxWidth: 960) 的作用是防止超宽桌面窗口导致文本行过长、卡片内容难以阅读。它不是为了适配某个设备,而是根据内容可读性增加上限。

一个需要注意的状态问题

窗口从 500 调整到 700 时,导航从 NavigationBar 切换到 NavigationRail。但 _selectedIndex 位于页面状态中,因此切换布局不会丢失当前选中项。

这说明响应式布局通常应遵循:

窗口条件变化
    ↓
布局树变化
    ↓
共享的页面状态继续存在

如果把选中状态放在会被条件分支反复销毁的子 widget 中,就可能在布局切换时丢失状态。需要持久化的状态应放在更稳定的祖先、状态管理层或路由状态中。


六、SafeArea、内嵌区域和键盘

窗口尺寸描述的是视图大小,但并不表示所有区域都可以直接绘制交互内容。

系统可能占用:

  • 状态栏;
  • 刘海或挖孔区域;
  • 底部手势区域;
  • Android 导航栏;
  • 软件键盘;
  • 桌面窗口装饰或嵌入容器提供的安全区域。

SafeArea 会根据 MediaQuery 的内嵌数据给子组件增加必要的边距:

SafeArea(
  child: ListView(
    padding: const EdgeInsets.all(16),
    children: const [
      TextField(),
    ],
  ),
)

它的因果关系是:

  1. Flutter 从平台视图获得内嵌区域;
  2. MediaQuery 提供这些区域数据;
  3. SafeArea 将需要避开的边缘转换为 padding;
  4. 子组件在缩小后的安全区域中布局。

键盘出现时的差异

键盘通常会影响 MediaQuery.viewInsets,例如底部出现非零 inset。Scaffold 默认可能调整 body 的可用区域,但具体效果受 resizeToAvoidBottomInset 和页面结构影响。

表单页面中,直接固定高度通常容易失败:

SizedBox(
  height: 700,
  child: Form(...),
)

在键盘出现后,剩余高度可能小于 700,导致溢出。更稳妥的结构是让内容可滚动:

Scaffold(
  body: SafeArea(
    child: SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Form(
        child: Column(
          children: const [
            TextField(),
            SizedBox(height: 16),
            TextField(),
          ],
        ),
      ),
    ),
  ),
)

这里不应简单把 MediaQuery.viewInsets.bottom 加到所有 padding 上,否则可能与 ScaffoldSafeArea 的处理重复。应先确认当前页面是谁在处理键盘 inset,再补充页面自身需要的间距。


七、方向不是尺寸的替代品

OrientationBuilder 可以根据宽高关系判断方向:

OrientationBuilder(
  builder: (context, orientation) {
    if (orientation == Orientation.landscape) {
      return const WideLayout();
    }

    return const TallLayout();
  },
)

但方向只提供一个二值信息:

orientation={portrait,heightwidthlandscape,width>height\text{orientation} = \begin{cases} \text{portrait}, & height \geq width \\ \text{landscape}, & width > height \end{cases}

它无法表达:

  • 竖屏平板和竖屏手机的宽度差异;
  • 横屏手机和窄桌面窗口的差异;
  • 页面局部区域是否足够容纳双栏。

因此,如果真正关心的是可用宽度,应使用:

LayoutBuilder(
  builder: (context, constraints) {
    return constraints.maxWidth >= 600
        ? const WideLayout()
        : const CompactLayout();
  },
)

方向适合表达“横向布局还是纵向布局”的策略;断点适合表达“空间是否足够”的策略。两者可以同时存在,但不能相互替代。


八、RowColumnWrap 和网格中的响应式差异

响应式布局不只是“在两个 widget 之间切换”,还包括在约束变化时让同一组内容重新分配空间。

1. Expanded:分配剩余有限空间

Row(
  children: [
    const SizedBox(width: 120),
    Expanded(
      child: Container(color: Colors.blue),
    ),
  ],
)

如果 Row 获得宽度 WW,固定子项占用 FF,则 Expanded 大致获得:

Wexpanded=WFW_{\text{expanded}} = W-F

前提是 Row 在横向获得有限约束。若横向无界,Expanded 就无法计算剩余空间。

2. Flexible:允许子项不填满剩余空间

Flexible 也参与 Flex 分配,但子组件可以选择小于分配值的尺寸。适合内容具有自然宽度、但在空间不足时允许压缩的情况。

3. Wrap:换行而不是压缩

Wrap(
  spacing: 8,
  runSpacing: 8,
  children: tags.map((tag) {
    return Chip(label: Text(tag));
  }).toList(),
)

当一行无法容纳下一个子项时,Wrap 将其放到下一行。它与 Row 的根本区别是:

  • Row 不会自动换行;
  • Wrap 不会把所有子项强行拉伸到相同宽度;
  • Wrap 的总高度会随换行数量变化。

如果标签文本长度来自网络数据,Wrap 往往比固定列宽更能处理真实内容。

4. 网格列数的推导

设:

  • 可用宽度为 WW
  • 每个卡片最小宽度为 mm
  • 列间距为 gg
  • 列数为 nn

要放下 nn 列,需要:

nm+(n1)gWn \cdot m + (n-1)\cdot g \leq W

因此最大列数为满足该不等式的最大整数。可以据此计算,而不是把列数和设备名称绑定:

LayoutBuilder(
  builder: (context, constraints) {
    const minTileWidth = 220.0;
    const spacing = 16.0;

    final columns = ((constraints.maxWidth + spacing) /
            (minTileWidth + spacing))
        .floor()
        .clamp(1, 6);

    return GridView.builder(
      padding: const EdgeInsets.all(16),
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: columns,
        crossAxisSpacing: spacing,
        mainAxisSpacing: spacing,
        childAspectRatio: 1.3,
      ),
      itemCount: 20,
      itemBuilder: (context, index) {
        return Card(
          child: Center(child: Text('项目 $index')),
        );
      },
    );
  },
)

这里的 columns 是由局部宽度推导出的结果。clamp(1, 6) 保证不会出现零列,也避免超宽窗口产生过多窄卡片。


九、文本、缩放和“看似足够宽”的失败布局

响应式布局不能只考虑容器宽度,还必须考虑文字缩放和实际内容长度。

用户可能启用较大的系统字体。Flutter 会通过 MediaQuery.textScaler 向文本组件提供文字缩放信息。过去常见的 textScaleFactor 仍可能出现在旧代码中,但新代码应优先使用当前文本缩放 API 体系,并避免假设文字永远按固定比例增长。

一个常见失败例子是:

Row(
  children: [
    const Icon(Icons.info),
    const SizedBox(width: 8),
    Text('一段可能很长的说明文字'),
    const Icon(Icons.arrow_forward),
  ],
)

当文字变长或缩放后,Row 可能溢出。通常应让文本占据可压缩空间:

Row(
  children: [
    const Icon(Icons.info),
    const SizedBox(width: 8),
    Expanded(
      child: Text(
        '一段可能很长的说明文字',
        softWrap: true,
      ),
    ),
    const SizedBox(width: 8),
    const Icon(Icons.arrow_forward),
  ],
)

但这也有边界:如果最后的箭头不能被压缩,而文字必须至少显示完整内容,那么空间不足时应改为纵向布局、允许滚动,或设计明确的截断策略。TextOverflow.ellipsis 只能隐藏超出内容,不能解决信息必须完整呈现的问题。


十、平台适配和尺寸适配是两个维度

平台适配不是把 Android、iOS、macOS、Windows、Linux、Web 写成一个条件判断就结束了。平台会影响:

  • 默认字体和字形;
  • 滚动行为;
  • 鼠标、触摸、键盘和手写笔;
  • hover、右键和快捷键;
  • 系统返回行为;
  • 窗口是否可调整;
  • 系统导航和页面转场习惯;
  • Material 和 Cupertino 的视觉、交互约定。

但平台不等于布局宽度。例如:

  • Android 平板可能需要桌面式侧栏;
  • iPad 分屏后可能只有手机级宽度;
  • macOS 小窗口可能不能显示双栏;
  • Web 在手机浏览器中也可能使用触摸输入;
  • Windows 触摸屏也可能没有鼠标 hover。

因此常见优先级是:

先根据可用空间决定布局
再根据输入能力和平台决定交互细节

1. defaultTargetPlatform

final platform = Theme.of(context).platform;

switch (platform) {
  case TargetPlatform.iOS:
  case TargetPlatform.macOS:
    return const Text('Apple 风格行为');
  default:
    return const Text('其他平台行为');
}

Theme.of(context).platform 表示当前主题使用的平台值。它可能被主题覆盖,因此它描述的是 Flutter 主题语义,不一定等同于底层操作系统探测结果。

defaultTargetPlatform 适合在 Flutter 层判断默认目标平台,但同样不代表设备一定具备某种输入能力。

2. kIsWebdart:io

判断 Web 可以使用:

import 'package:flutter/foundation.dart';

if (kIsWeb) {
  // Web
}

不要在包含 Web 编译目标的公共代码中无条件导入 dart:io 并使用:

import 'dart:io';

if (Platform.isWindows) {
  ...
}

dart:io 不适用于 Web。若项目确实需要文件系统、进程或原生平台能力,应通过条件导入、平台接口或插件隔离实现,而不是让页面 widget 直接依赖不可移植 API。

3. 不要用平台判断替代输入能力判断

例如,hover 行为更应该依赖指针设备和组件事件,而不是写成:

if (Platform.isWindows) {
  showHoverTooltip();
}

因为 Web、macOS、Linux、带鼠标的平板同样可能支持指针。组件可以使用 MouseRegionFocusableActionDetector、快捷键和语义系统表达能力,而不是把能力硬编码成平台名称。


十一、同一信息,不同平台可以采用不同交互结构

Material 和 Cupertino 不是简单的颜色主题差异。它们包含不同的组件语义和交互习惯。

例如页面根结构可以根据主题平台选择:

Widget buildPage(BuildContext context) {
  switch (Theme.of(context).platform) {
    case TargetPlatform.iOS:
    case TargetPlatform.macOS:
      return const CupertinoLikePage();
    default:
      return const MaterialLikePage();
  }
}

但生产代码中通常应进一步考虑:

  1. 平台默认行为是否真的需要不同;
  2. 应用是否要求跨平台品牌一致;
  3. 无障碍语义是否在两个组件树中都完整;
  4. 路由、返回手势和键盘行为是否一致;
  5. 是否会因为两套组件导致状态和测试重复。

平台自适应的合理边界是“交互模型确实不同”。如果只是为了改变颜色或间距,应使用主题、设计令牌和布局约束,而不是复制整棵页面树。


十二、显示特征:折叠屏、铰链和非连续区域

窗口可能不是一个简单的连续矩形。折叠设备、铰链或分屏显示可能通过 MediaQueryData.displayFeatures 暴露显示特征。

概念上,布局需要处理:

完整视图
 ├── 可用区域 A
 ├── 铰链或遮挡区域
 └── 可用区域 B

如果一个双栏页面把重要按钮正好放在铰链区域,就算总宽度足够,也可能无法正常使用。

示意代码:

final features = MediaQuery.displayFeaturesOf(context);

for (final feature in features) {
  // 根据 feature.bounds、feature.type、
  // feature.state 等信息划分内容区域。
}

实际字段和枚举应以当前稳定 Flutter API Reference 为准。这里的关键不是为所有设备写死分支,而是:

  1. 读取视图提供的显示特征;
  2. 将遮挡或折叠区域视为不可用区域;
  3. 把内容放入可见的子区域;
  4. 对横向、纵向和不同折叠状态分别验证。

十三、响应式状态流和生命周期

响应式布局通常不是独立的计算函数,而是 widget 树的一部分。基本数据流如下:

flowchart TD
    A[平台窗口或视图指标变化] --> B[FlutterView 更新]
    B --> C[MediaQuery 更新]
    C --> D[依赖尺寸的 Widget 重建]
    D --> E[LayoutBuilder 获得新约束]
    E --> F[选择布局策略]
    F --> G[保留或迁移页面状态]

关键路径是:

  1. 平台通知 Flutter 视图指标发生变化;
  2. FlutterView 的尺寸、像素比或内嵌数据更新;
  3. MediaQuery 向子树提供新数据;
  4. 使用 MediaQuery 的 widget 可能重建;
  5. LayoutBuilder 得到新的局部约束;
  6. 页面选择新的布局分支;
  7. 状态对象是否保留,取决于 widget 的身份和状态存放位置。

条件分支中的状态保留

下面这种写法会根据宽度创建不同子树:

if (width < 600) {
  return const CompactPage();
}
return const WidePage();

如果 CompactPageWidePage 内部各自保存了表单状态,那么窗口切换时其中一棵子树可能被移除,状态也会消失。

解决方案取决于状态语义:

  • 页面选择状态:放在共同祖先;
  • 表单草稿:放在表单模型或状态管理层;
  • 需要同时保留的两个分支:使用稳定 key、IndexedStack 或显式状态迁移;
  • 临时动画状态:允许随布局切换重建。

不能仅依赖 GlobalKey 解决所有问题。GlobalKey 会增加元素树管理复杂度,也不能自动解决不兼容布局之间的状态语义差异。


十四、常见失败表现和诊断路径

1. RenderFlex overflowed

表现通常是黄色黑色斜纹区域,并伴随类似:

A RenderFlex overflowed by 24 pixels on the right.

诊断步骤:

  1. 找到错误中的方向和溢出边;
  2. 检查 RowColumn 是否包含固定尺寸;
  3. 检查文本是否应该放入 ExpandedFlexible
  4. 检查是否应使用 Wrap 或滚动容器;
  5. 检查是否把窗口宽度硬编码给了局部组件;
  6. 在不同文字缩放、窗口宽度和平台下重现。

2. BoxConstraints forces an infinite width/height

通常表示某个组件收到了无界约束,却要求一个有限或无限扩张的尺寸。常见原因包括:

  • ListView 中嵌套未约束的 Column
  • Row 中放置依赖宽度的组件但横向无界;
  • Expanded 位于无界方向;
  • double.infinity 被传入没有有限上限的父组件。

诊断时不要先增加任意 SizedBox。应沿组件树回溯约束来源:

LayoutBuilder(
  builder: (context, constraints) {
    debugPrint('$constraints');
    return child;
  },
)

要回答的是:

这个组件的最小/最大宽高是谁提供的?
当前方向是否有界?
子组件为什么需要一个无法由父组件提供的尺寸?

3. MediaQuery.of(context) 取得了错误尺寸

可能原因包括:

  • context 位于不同的 MediaQuery 子树;
  • 组件在嵌套导航、对话框或分屏容器中;
  • 使用了全局保存的旧窗口尺寸;
  • 把完整窗口宽度误当成局部可用宽度;
  • MediaQuery 之前使用了该 context

对于局部布局,改用 LayoutBuilder;对于当前视图数据,使用正确位置的 MediaQuery;对于多视图场景,避免全局单例保存窗口对象。

4. 桌面和 Web 上页面“看起来能用但不好用”

典型原因是只按移动端设计:

  • 过大的横向空白;
  • 内容最大宽度没有限制;
  • 鼠标无法 hover 或聚焦;
  • 键盘操作顺序不合理;
  • 右键、滚轮和拖拽行为缺失;
  • 窗口缩窄时固定双栏溢出。

这不是单纯的断点问题。桌面和 Web 需要同时验证:

  • 可用宽度;
  • 指针和键盘输入;
  • 焦点与语义;
  • 滚动方向;
  • 窗口动态调整;
  • 浏览器缩放和文字可读性。

十五、测试响应式布局不能只测一台手机

响应式界面的输入变量至少包括:

I=(W,H,D,P,T,F)I=(W,H,D,P,T,F)

其中:

  • WW:逻辑宽度;
  • HH:逻辑高度;
  • DD:设备像素比;
  • PP:平台;
  • TT:文字缩放;
  • FF:输入能力和显示特征。

单元测试或 widget 测试可以直接设置测试窗口尺寸:

testWidgets('宽窗口显示侧边导航', (tester) async {
  tester.view.physicalSize = const Size(1200, 800);
  tester.view.devicePixelRatio = 1.0;

  addTearDown(() {
    tester.view.resetPhysicalSize();
    tester.view.resetDevicePixelRatio();
  });

  await tester.pumpWidget(const AdaptiveApp());

  expect(find.byType(NavigationRail), findsOneWidget);
  expect(find.byType(NavigationBar), findsNothing);
});

这里物理尺寸除以 devicePixelRatio 后得到逻辑尺寸,因此该测试的逻辑窗口约为:

1200 × 800

窄窗口测试:

testWidgets('窄窗口显示底部导航', (tester) async {
  tester.view.physicalSize = const Size(390, 844);
  tester.view.devicePixelRatio = 1.0;

  addTearDown(() {
    tester.view.resetPhysicalSize();
    tester.view.resetDevicePixelRatio();
  });

  await tester.pumpWidget(const AdaptiveApp());

  expect(find.byType(NavigationBar), findsOneWidget);
  expect(find.byType(NavigationRail), findsNothing);
});

测试时还应覆盖断点附近的值:

599、600、601
839、840、841

因为断点错误常常不是在 390 宽时暴露,而是在边界切换时暴露。还要验证:

  • 窗口调整后选中状态是否保留;
  • 键盘弹出后表单是否溢出;
  • 大字号下文本是否可读;
  • Web 和桌面上的鼠标、键盘焦点是否可操作;
  • SafeArea 是否没有重复增加间距;
  • 折叠或遮挡区域是否覆盖关键内容。

十六、几个需要明确纠正的误解

误解一:响应式就是使用 MediaQuery

MediaQuery 只能提供视图环境数据。真正的布局仍由约束系统完成。局部组件应优先响应 LayoutBuilder 获得的约束,而不是读取全局宽度后强行指定尺寸。

误解二:断点就是手机、平板、桌面的设备分类

断点描述的是布局空间是否足够,不是设备名称。桌面小窗口可以落入紧凑布局,平板分屏也可能落入紧凑布局。

误解三:横屏一定适合双栏

横屏手机可能只有有限逻辑宽度;竖屏平板也可能足够容纳双栏。应测量实际可用宽度,并根据内容最小尺寸推导策略。

误解四:double.infinity 能让组件自动适配

double.infinity 的意思是“尽可能使用父组件允许的最大尺寸”。如果父组件在该方向无界,就可能造成异常;如果局部区域比窗口小,也不会得到窗口的完整宽度。

误解五:平台判断越多,适配越完整

大量平台分支会让布局、状态和测试重复。首先适配空间和输入能力,只有当系统交互语义确实不同,才增加平台分支。

误解六:SafeArea 可以解决所有遮挡问题

SafeArea 主要处理 Flutter 能从媒体数据中获知的安全区域。它不能替代滚动布局、键盘交互、折叠屏区域划分,也不能自动修复固定高度造成的溢出。


十七、实际设计顺序

一个可靠的响应式页面通常按以下因果顺序设计:

  1. 先确定内容的最小可用尺寸:文字、按钮、列表项和主要操作分别需要多少空间;
  2. 让父约束驱动局部布局:优先使用 LayoutBuilder
  3. 为连续尺寸选择少量策略区间:窄、中、宽,而不是为每个设备写分支;
  4. 使用 ExpandedFlexibleWrap 和滚动组件处理剩余空间
  5. 使用 MediaQuerySafeArea 处理视图环境
  6. 使用平台和输入能力处理交互差异
  7. 把需要持久化的状态放在不会因布局分支切换而销毁的位置
  8. 验证窗口动态变化、字体缩放、键盘、Web、桌面和边界宽度

最终可以用一个简化模型理解 Flutter 的自适应过程:

布局结果=f(局部约束,视图尺寸,安全区域,文字缩放,显示特征,平台能力,页面状态)\text{布局结果} = f( \text{局部约束}, \text{视图尺寸}, \text{安全区域}, \text{文字缩放}, \text{显示特征}, \text{平台能力}, \text{页面状态} )

其中最基础的输入仍然是约束。窗口尺寸告诉你外部视图有多大,断点把连续空间映射为布局策略,平台提供交互环境,而状态决定布局切换后用户是否继续处于原来的工作上下文。只有这些因素共同参与,Flutter 界面才真正具备跨尺寸、跨平台和动态窗口环境下的适应能力。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。