布局系统:约束、Flex 与溢出排查
Flutter 布局只有一条规则:父级给约束,子级定尺寸,父级再定位。所有"为什么 SizedBox 不生效""为什么 Column 里放 Expanded 就报错""为什么出现黄黑条纹"的问题,都是这条规则被违反的结果。本章先讲约束模型,再讲 Flex 与 Expanded/Flexible,最后给出溢出排查顺序。
约束模型
每个 RenderObject 布局时收到一个 BoxConstraints(minWidth/maxWidth/minHeight/maxHeight),子级必须在范围内挑一个尺寸,父级再决定放到哪里。
| 约束类型 | 含义 | 典型案例 |
|---|---|---|
| 紧约束(tight) | min 与 max 相等,尺寸被写死 | Expanded、SizedBox(width: 100) |
| 松约束(loose) | min 为 0,max 有限 | Container 的默认情况 |
| 无界约束(unbounded) | max 为 infinity | 放进 SingleChildScrollView 的 Column |
// 打印约束,是排查布局问题最快的手段
class DebugConstraints extends StatelessWidget {
const DebugConstraints({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
debugPrint('可用空间:$constraints'); // 看到父级到底给了什么
return child;
},
);
}
Row / Column / Flex 的对齐
Row 与 Column 都是 Flex 的语法糖,主轴分别是水平与垂直。
| 参数 | 作用对象 | 常用值 |
|---|---|---|
mainAxisAlignment | 主轴排布 | start/center/spaceBetween/spaceEvenly |
crossAxisAlignment | 交叉轴对齐 | start/center/end/stretch |
mainAxisSize | 主轴占多少空间 | max(默认铺满)/ min(包裹内容) |
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text('订单号 20240101'),
TextButton(onPressed: () {}, child: const Text('复制')),
],
)
Expanded 与 Flexible 的区别
| 组件 | 语义 | 效果 |
|---|---|---|
Expanded | 强制填满分配到的空间(紧约束) | 子级被拉满 |
Flexible | 允许占据剩余空间(松约束) | 空间富裕时子级保持自身尺寸 |
Row(
children: [
const Icon(Icons.image),
const SizedBox(width: 8),
// 标题过长时省略而不是溢出
Expanded(
child: Text('很长的标题'.padRight(60, '长'), maxLines: 1, overflow: TextOverflow.ellipsis),
),
Flexible(child: Text('次要信息', maxLines: 1, overflow: TextOverflow.ellipsis)),
],
)
Container 的 width/height 与约束的关系
Container 只是 Padding + DecoratedBox + ConstrainedBox + Align 的组合。它的 width/height 会转成紧约束,但父级上限更小时仍会被压缩。
// 误区:以为写了 400 宽就一定 400 宽
SizedBox(width: 200, child: Container(width: 400, color: Colors.amber)); // 实际 200
// 想真正固定尺寸,用 ConstrainedBox 明确约束来源
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 120, maxWidth: 400),
child: const Card(child: Padding(padding: EdgeInsets.all(16), child: Text('卡片'))),
)
溢出(黄黑条纹)的三种修法
出现 A RenderFlex overflowed by XX pixels 意味着子级总尺寸超过主轴可用空间。
| 修法 | 适用情况 | 代码要点 |
|---|---|---|
| 让子级可伸缩 | 长文本、弹性区块 | 用 Expanded/Flexible 包住最该被压缩的那个 |
| 让容器可滚动 | 内容天然超过一屏 | SingleChildScrollView + Column,并设 mainAxisSize: MainAxisSize.min |
| 换行或降级 | 标签、按钮太多 | 用 Wrap 代替 Row,或缩短文案 |
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min, // 不再要求铺满,避免无界约束下报错
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('表单区域', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [Chip(label: Text('标签一')), Chip(label: Text('标签二'))],
),
const SizedBox(height: 400),
const Text('底部内容也能正常滚动出来'),
],
),
)
排查顺序固定为:先从报错定位到具体 Row/Column,再用 LayoutBuilder 或 DevTools 的 Layout Explorer 确认可用空间,最后决定压缩、滚动还是换行。不要盲目加 SizedBox 掩盖问题,换个屏幕尺寸还会复发。
其他常用布局组件
| 组件 | 用途 | 关键参数 |
|---|---|---|
AspectRatio | 固定宽高比(封面 16:9) | aspectRatio |
Wrap | 自动换行的流式布局 | spacing、runSpacing |
Stack + Positioned | 层叠定位(角标、悬浮元素) | top/right/left/bottom |
SafeArea | 避开刘海与手势条 | top/bottom 可单独关闭 |
MediaQuery | 读取屏幕尺寸与安全区 | MediaQuery.sizeOf(context) |
Stack(
children: [
AspectRatio(aspectRatio: 16 / 9, // 固定宽高比,避免封面图变形
child: Image.network('https://picsum.photos/800/450', fit: BoxFit.cover)),
Positioned(right: 8, top: 8, child: Chip(label: const Text('热销'))),
],
)
小结:把"父给约束、子定尺寸、父做定位"练成本能;Flex 里 mainAxisAlignment 管主轴、crossAxisAlignment 管交叉轴,Expanded 强制填满而 Flexible 允许留白;溢出时先定位再选择压缩、滚动或换行,不要用固定尺寸硬盖。