响应式布局与多端适配

“一套代码多端运行”能否兑现,取决于布局是否跟着可用空间变化,而不是跟着某台设备的像素写死。手机竖屏、平板横屏、桌面窗口随时拖拽,本质都是同一个问题:同一棵 Widget 树在不同约束下的表现。本章先讲清尺寸与像素的区别,再给出以断点为核心的自适应套路,最后补上安全区、弹性布局与桌面端交互。

尺寸与像素:先分清三个 API

API返回使用建议
MediaQuery.sizeOf(context)逻辑像素尺寸 Size首选,只在尺寸变化时重建
MediaQuery.of(context)完整 MediaQueryData会订阅全部媒体查询字段,尺寸/键盘每次变化都重建,慎用
MediaQuery.devicePixelRatioOf(context)物理像素与逻辑像素之比需要计算图片真实解码尺寸时用
final size = MediaQuery.sizeOf(context);              // 只依赖 size,重建范围更小
final ratio = MediaQuery.devicePixelRatioOf(context);

// 同一张图,3x 屏需要按 3 倍解码才清晰,但解码过大又浪费内存
final decodeWidth = (120 * ratio).round();
Image.network(url, width: 120, cacheWidth: decodeWidth);

键盘弹出时 MediaQuery.of(context) 的使用者会因为 viewInsets 变化整页重建,这是“输入框一弹就掉帧”的常见原因。

断点策略

区间(逻辑像素宽)设备形态布局策略
< 600手机竖屏单列、底部 NavigationBar、列表占满宽度
600 ~ 1024手机横屏、小平板双列网格、NavigationRail、内容限宽 720
> 1024桌面、大平板NavigationRail 或抽屉、列表+详情双栏、内容居中限宽

断点取的是逻辑像素宽度而非设备型号。业界常见的 600/840/1200 与上面的 600/1024/1440 都是习惯值,团队内统一一套并写进常量即可,不要每个页面各定一套。

LayoutBuilder 与 OrientationBuilder

MediaQuery 给的是整屏尺寸,LayoutBuilder 给的是父级约束,做组件级自适应时必须用它:

LayoutBuilder(
  builder: (context, constraints) {
    // 按父级可用宽度决定列数,组件复用时不依赖屏幕宽度
    final columns = constraints.maxWidth > 900 ? 4 : (constraints.maxWidth > 600 ? 3 : 2);
    return GridView.count(
      crossAxisCount: columns,
      childAspectRatio: 0.8,
      children: [for (final p in products) ProductCard(product: p)],
    ); // 列数由父级宽度决定,组件本身不关心屏幕尺寸
  },
);

OrientationBuilder(
  builder: (context, orientation) => orientation == Orientation.landscape
      ? Row(children: [image, detail])    // 横屏并排
      : Column(children: [image, detail]), // 竖屏上下
);

口诀:整屏级别的判断用 MediaQuery.sizeOf,组件内部的判断用 LayoutBuilder

自适应导航:底部 ↔ 侧边

class AdaptiveScaffold extends StatelessWidget {
  const AdaptiveScaffold({super.key, required this.index, required this.onSelect});
  final int index;
  final ValueChanged<int> onSelect;

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.sizeOf(context).width;
    final wide = width >= 600; // 平板与桌面改用侧边导航

    return Scaffold(
      body: Row(
        children: [
          if (wide)
            NavigationRail(
              selectedIndex: index,
              onDestinationSelected: onSelect,
              labelType: width >= 1024 ? NavigationRailLabelType.all : NavigationRailLabelType.none,
              destinations: _railDestinations, // 与底部导航共用同一份数据源
            ),
          const Expanded(child: ContentArea()), // 用 Expanded 吃掉剩余宽度
        ],
      ),
      bottomNavigationBar: wide
          ? null
          : NavigationBar(selectedIndex: index, onDestinationSelected: onSelect, destinations: _barDestinations),
    );
  }
}

NavigationBar 是 Material 3 的底部导航,别再用旧的 BottomNavigationBar;侧边栏用 NavigationRail。两者共用同一份 index 与回调,切换时状态不丢。

安全区与手势区

  • SafeArea 保证内容不被刘海、灵动岛、底部手势条遮挡,但它只处理四边内边距,不会改变滚动区域的延伸。
  • 沉浸式背景用 SafeArea(top: false) 让图片铺到状态栏下,仅对内容加边距。
  • 键盘遮挡输入框时,Scaffold 默认会调整 resizeToAvoidBottomInset;表单页建议配合 SingleChildScrollView 防止溢出。

弹性布局三件套

组件解决的问题关键参数
Flexible / Expanded一行内按比例分配剩余空间flexExpanded 等价于 fit: FlexFit.tight
Wrap标签、筛选项自动换行spacingrunSpacing
FractionallySizedBox按父级比例取宽高widthFactorheightFactor

用法要点:一行内按比例分配用 Expanded(flex: 2)Expanded(flex: 1) 的组合;标签类内容用 WrapspacingrunSpacing,不要手写两行 Row;封面图用 AspectRatioFractionallySizedBox 锁比例,避免高度随宽度失控。

桌面端:鼠标与快捷键

// 悬停反馈:桌面端鼠标经过要有变化,用 MouseRegion 配 SystemMouseCursors.click
MouseRegion(cursor: SystemMouseCursors.click, onEnter: (_) => setHover(true), child: card);

// 快捷键:Ctrl/Cmd + F 聚焦搜索框
Shortcuts(
  shortcuts: const {SingleActivator(LogicalKeyboardKey.keyF, control: true): _FocusSearchIntent()},
  child: Actions(
    actions: {
      _FocusSearchIntent: CallbackAction<_FocusSearchIntent>(
        onInvoke: (_) => FocusScope.of(context).requestFocus(_searchFocus),
      ),
    },
    child: child,
  ),
);

Shortcuts 负责把按键映射成 Intent,Actions 负责执行,两者解耦后同一套快捷键可以绑定到不同页面。桌面端还应检查触控与鼠标的点击热区差异:桌面按钮可以更小,触摸目标则建议不小于 48×48。

常见坑

现象正确做法
到处用 MediaQuery.of(context)键盘弹出或旋转时整页重建、掉帧按需改用 sizeOf / viewInsetsOf
只用手机尺寸验证平板、桌面窗口下溢出或拉伸LayoutBuilder 加多尺寸真机/窗口回归
未包 SafeArea刘海与手势条遮挡内容页面内容外包 SafeArea,背景不入内
Column 包长内容报 RenderFlex overflow换成 SingleChildScrollView + Column

小结:自适应布局的判断标准是“可用空间”而不是设备型号,整屏判断用 MediaQuery.sizeOf、组件内判断用 LayoutBuilder;按 600/1024/1440 三档断点在 NavigationBarNavigationRail 之间切换;安全区、Expanded/Wrap 负责不溢出,桌面端补上悬停与 Shortcuts/Actions 快捷键,最后一定要在真机上把各尺寸过一遍。

笔记加载中…