响应式布局与多端适配
“一套代码多端运行”能否兑现,取决于布局是否跟着可用空间变化,而不是跟着某台设备的像素写死。手机竖屏、平板横屏、桌面窗口随时拖拽,本质都是同一个问题:同一棵 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 | 一行内按比例分配剩余空间 | flex,Expanded 等价于 fit: FlexFit.tight |
Wrap | 标签、筛选项自动换行 | spacing、runSpacing |
FractionallySizedBox | 按父级比例取宽高 | widthFactor、heightFactor |
用法要点:一行内按比例分配用 Expanded(flex: 2) 与 Expanded(flex: 1) 的组合;标签类内容用 Wrap 加 spacing 与 runSpacing,不要手写两行 Row;封面图用 AspectRatio 或 FractionallySizedBox 锁比例,避免高度随宽度失控。
桌面端:鼠标与快捷键
// 悬停反馈:桌面端鼠标经过要有变化,用 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 三档断点在 NavigationBar 与 NavigationRail 之间切换;安全区、Expanded/Wrap 负责不溢出,桌面端补上悬停与 Shortcuts/Actions 快捷键,最后一定要在真机上把各尺寸过一遍。