用脚步丈量人生宽度

Flutter 从零开始(四):布局这件事,其实是父级给约束、子级定尺寸

我刚开始写 Flutter 的时候,调布局的手法跟当年写网页差不多:宽度不对就套一层 SizedBox,挤了就减 padding,改一轮跑一轮看效果。字体放大一档、或者换到平板,之前调好的间距又开始溢出。真正让我停下来的是一次 RenderFlex overflowed by 21 pixels on the right,我没像往常那样直接减 padding,而是用 LayoutBuilder 把当时的约束打印出来看了一眼。那次我才确认,自己一直是靠猜的。

父级给约束,子级定尺寸,父级再定位

Flutter 的一次布局是三段式的:父级先构造一个约束对象递给子级,子级在这个约束允许的范围内挑一个尺寸返回给父级,父级再决定把子级摆在自己的哪个坐标上。子级没有"我要多宽就多宽"的权力,它只能在别人给定的区间里选;父级也没有"我说你多宽你就多宽"的权力,它只能把区间收窄,而子级有权在里面选一个更小的值。

这跟 CSS 盒模型的差别,我体会在这里:CSS 里一个块级元素默认吃掉父级的内容宽度、高度由内容撑开,这个"默认"是浏览器按规范里一整套 sizing rules 替开发者算出来的,元素本身看不到"我被允许多宽"这件事。Flutter 把那套隐藏的规则显式化成了一个 BoxConstraints 对象,它一路往下传,任何一层想知道自己的处境都可以直接读它。所以我把注意力从"这个组件该写多大"换成了"传到我这里的区间是多少"。

这里我把两条轴分开说:主轴是一个 flex 容器自己的排列方向(Row 横向,Column 纵向),交叉轴与它垂直。约束在这两条轴上是分开谈的:Column 给子级的纵向区间可以是 0..∞,而横向区间由 crossAxisAlignment 决定——stretch 时收成 tight,其余情况保持 0..父宽。我一开始的很多混淆都来自把两条轴当成一件事看:同一段代码在 Column 里成立、放进 Row 就崩,差别基本就是主轴换了方向。

代价是显式约束要自己承担后果:一个没人给它尺寸的组件,尺寸往往是 0,而不是像网页里那样自然撑开。我早期很多"背景色不见了""这一层高度莫名是 0"的困惑都出在这里。

BoxConstraints 的四个字段怎么读

它只有四个字段:minWidth、maxWidth、minHeight、maxHeight。读的时候我关心两件事:某条轴上的下界和上界是否相等,以及上界是不是无穷。

形态字段特征子级的自由度我这边常见的来源
tightmin == max(单轴或双轴)无,必须接受这个尺寸SizedBox(width: 56)、Expanded 分到的那一段、Column 在 crossAxisAlignment: stretch 下的宽度
loosemin == 0,max 有限在 0 到 max 之间自选Center(它会把 min 归零)、Row 里非 stretch 的交叉轴
unboundedmax == double.infinity必须自己给出有限值,否则传到 viewport 那一层就崩Row 子级的主轴、Column 子级的主轴、SingleChildScrollView 的滚动轴

double.infinity 在这里不是"很大的数",它是被当真处理的:一旦某个组件把它当成自己的尺寸往下传,检查就会拦下来。

下面这段是我打印约束的小工具,套在可疑的那一层外面就能看到真实区间:

class ConstraintsProbe extends StatelessWidget {
  const ConstraintsProbe({super.key, required this.child});

  final Widget child;

  static String _fmt(double v) =>
      v == double.infinity ? '∞' : v.toStringAsFixed(1);

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      // builder 收到的 constraints 就是父级真正递过来的那个对象,
      // 不是我以为的屏幕宽度——这一点是它最有价值的地方。
      builder: (context, constraints) {
        final text = 'minW ${_fmt(constraints.minWidth)} '
            'maxW ${_fmt(constraints.maxWidth)} '
            'minH ${_fmt(constraints.minHeight)} '
            'maxH ${_fmt(constraints.maxHeight)}';
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(text,
                style: const TextStyle(fontFamily: 'monospace', fontSize: 11)),
            child,
          ],
        );
      },
    );
  }
}

LayoutBuilder 的 builder 只在收到的约束和上次不同才会重跑,所以它不是每帧都贵的东西。我把它当调试期的探针,定位完就撤;留在生产代码里的场景,通常是那一层确实要按父级宽度切换布局(比如宽屏分两栏、窄屏叠成一列)。

四个在我这边反复出现的报错

RenderFlex overflowed by 21 pixels on the right

现场是一个 Row,左边标题 Text,右边一个时间戳。短标题时一切正常,标题一长就红黑条纹加这条 assert。

原因是 Row 递给非 Flexible 子级的主轴约束是 maxWidth: infinity(横向 flex 的主轴无界),于是每个子级都按自己想要的宽度回报尺寸,标题按整段文字的自然宽度算,加起来超过了 Row 可用的宽度。报错里的数字是"子级主轴尺寸之和 − 可用宽度",是超出的量,不是还差多少。

我的处理取决于想要什么效果:

Row(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Expanded(
      // Expanded 把主轴剩余空间按 flex 权重分掉,
      // 然后给这个子级一条 tight 约束:minWidth == maxWidth == 分到的那一段。
      // 标题因此才知道自己确切能占多宽,overflow: ellipsis 才有意义。
      child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
    ),
    const SizedBox(width: 8),
    // 不加 Expanded 的这一项仍按自然宽度算,
    // 它是先从"可用空间"里被扣掉的那部分。
    Text(time, style: Theme.of(context).textTheme.bodySmall),
  ],
)

Flexible(fit: StackFit.loose) 和 Expanded 的差别在这:前者给的是 minWidth: 0, maxWidth: 分到的额度,文本短时它只占自己需要的宽度,右侧时间戳因而贴得更近;后者强制铺满那一段。我一般先试 Flexible,只有在需要整行都可点时才换成 Expanded。

还有一个关联的坑:Expanded 依赖"剩余空间"这个概念,而剩余空间只在父级那一轴有界时才存在。把 Expanded 放进被 SingleChildScrollView 包住的 Column,得到的是 RenderFlex children have non-zero flex but incoming height constraints are unbounded,因为要分摊的"剩余"是无穷。我这边分成两种收场:想让这一块固定占满剩余,就得先让外层给出有限高度(SizedBox、ConstrainedBox,或者让这个 Column 自己待在一个有界的 Expanded 里);想让这一块跟着内容长短走,就把 Expanded 去掉,承认它内容多高就多高。两种的对错取决于这一屏有没有"必须占满剩余视口"的需求。

BoxConstraints forces an infinite width

这条我最早是在 Row 里写 SizedBox(width: double.infinity) 时撞上的。Row 给的横向 maxWidth 是无穷,infinity 被当成期望尺寸去 enforce 一条无穷约束,算出来的尺寸还是无穷,assert 当场拦下。

有意思的是同一段代码换个父级就正常:放在 Column 里、或者直接作为 Scaffold 的 body 时,那一层的 maxWidth 是屏幕宽度,double.infinity 被收窄成屏幕宽度,铺满就生效了。我的判断方式是往上找第一个在报错那条轴上给出有限 max 的祖先,中间只要隔着一个 Row(横向无界)或者一个滚动容器(滚动轴无界),infinity 就落不了地。

想要"吃掉这一行剩下的宽度",语义上对应的是 Expanded 而不是 double.infinity;想要"在整个页面宽度内铺满",前提是祖先链上已经有人把宽度收成有限值。这两种诉求在页面上看着像同一件事,代价完全不同:前者会跟同一行的其他项抢空间、并且要求其他项先算完,后者不依赖兄弟节点,但要求祖先链上有人把宽度定死。

RenderBox was not laid out: RenderFlex#... 'I have no size'

这条我第一次看到时完全找错方向,因为报错栈里指向我写的一个 Text,而 Text 本身没有问题。

现场是 Row 里放一个 Column,并且给这个 Column 写了 crossAxisAlignment: CrossAxisAlignment.stretch。Column 的交叉轴是横向,而 stretch 的含义是"把交叉轴约束变成 tight"——它需要知道一个有限的横向 maxWidth 才能把子级拉满;可 Row 递给非 flexible 子级的横向恰恰是 infinity。于是这个 Column 算不出自己的尺寸,回报了一个空值,父级 Row 在后续排版它时报的就是这条 'I have no size'。

// 复现:这一段的 Column 会得到 maxWidth: infinity,
// stretch 想把它变成 tight 却没有可用的有限值,layout 直接失败。
Row(
  children: [
    Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Text(title),
        Text(excerpt),
      ],
    ),
    Icon(Icons.chevron_right),
  ],
)
// 我后来的写法:先让 Column 处在一个宽度已知的容器里。
// Expanded 递过来的是 tight 宽度(minW == maxW == 分到的额度),
// stretch 这时才有可依据的数字。
Row(
  children: [
    Expanded(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        mainAxisSize: MainAxisSize.min,
        // mainAxisSize.min 管另一件事:让 Column 的高度收到两行文字之和,
        // 而不是顶满 Row 的交叉轴。
        children: [
          Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
          Text(excerpt, style: theme.textTheme.bodySmall),
        ],
      ),
    ),
    const Icon(Icons.chevron_right),
  ],
)

原理上值得注意的是:这条报错经常是余波而不是根因。Flutter 的 layout 遇到 assert 失败后,那个 RenderObject 的尺寸是未定义的,父级接着排它时又抛第二条。控制台的输出里两条都会出现,而栈信息指向的组件往往是受害的那一个。我的做法是从上往下读,只看第一条 assert——它带的组件名(RenderFlex、RenderConstrainedBox、Viewport)就是出问题的那一层,后面几条基本可以忽略。反过来说,如果屏幕上只剩红屏却没有第一条,我一般会重启一次热重载,因为热重载偶尔会把已经修好的错误留在旧的日志里。

Vertical viewport was given unbounded height

这条是 Column 里直接放 ListView 的结果。Column 给子级的高度是 minHeight: 0, maxHeight: infinity,而 viewport(ListView、GridView、SingleChildScrollView 背后的那一层)需要一个有限高度来计算可视区域和 cacheExtent,拿到无穷就只能抛错。

两种解法我都用过,取舍看场景:

// 解法一:把高度约束补齐,ListView 自己负责滚动
Column(
  children: [
    const Header(),
    Expanded(
      // Expanded 给子级 tight 高度:minH == maxH == Column 剩下的那段。
      // ListView 因此有了有限的 maxHeight,滚动、复用、cacheExtent 全部正常。
      child: ListView.builder(
        itemCount: items.length,
        itemBuilder: (context, i) => ItemRow(items[i]),
      ),
    ),
    const Footer(),
  ],
)

// 解法二:让 ListView 自己把测量扛下来
ListView(
  shrinkWrap: true,
  physics: const NeverScrollableScrollPhysics(),
  children: const [RowA(), RowB(), RowC()],
)

shrinkWrap: true 的含义是 viewport 反过来按子级的总高度定自己的尺寸,这要求它把所有子级都 layout 一遍——itemCount 很大时这跟"列表复用"的初衷是冲突的,长列表上会明显变慢;NeverScrollableScrollPhysics 是把滚动权交还给外层,否则两层滚动嵌在一起手势会互相抢。我一般只在条数固定且个位数、且真的想让它当"一块内容"而不是"一个视口"时用解法二。一个 Column 里同时有多个 Expanded 时它们按 flex 权重分摊剩余高度,这一点在决定用解法一之前要先想清楚。

常用包裹层各自的约束行为

同样一层"包一下",递给子级的约束差别不小,这也是我把它们列在一起的原因:

组件递给子级的约束我自己回报的尺寸由此产生的现象
SizedBox(w, h)对应轴 tight 到 w/h,另一轴原样透传就是 w/h唯一能"命令"子级尺寸的方式
ConstrainedBox父约束 ∩ 自己那份(enforce 取交集)收窄后的区间谁更严谁生效,加 maxWidth 收窄不会突破父级上界
Padding上下左右各扣掉自己的量(负数归 0)子级尺寸 + padding它是在"传递之前"就扣掉,所以子级看到的区间已经变小
Align原样透传子级尺寸父级已经 tight 时它无事可做,因为子级本来就没有别的可选
Centerloosen 后透传(min 归 0,max 不变)有子级则同子级;无子级则 biggest这是"套了 Center 之后 Text 就缩到自身宽度"的原因
Container(带 alignment)等价于在里面放一个 Align会先撑到 biggest 再对齐主轴无界时撞上 forces an infinite …;只给 color 不给尺寸,高度是 0
SafeArea父约束减去安全区那圈 padding同子级它减的是 viewPadding/viewInsets,键盘弹起时 viewInsets.bottom 会参与计算

Center 把 min 归零这个细节我花了挺久才注意到:父级给它一条 tight 约束(比如整个屏幕宽度),它转手给子级变成 loose,于是子级只按自己要的大小出现,父级那条 tight 就被"松开"了。这既是 Center 有用的地方(内容居中而不拉伸),也是它容易让背景色消失的地方。Container 没有 child 又有 color 时那一层高度算出来是 0,我最初的排错方向是"颜色不对",其实尺寸就没成立。

Stack 与 Positioned:StackFit 三种取值

Stack 的尺寸由非 Positioned 的子级决定(在 fit 给它的约束范围内取最大),Positioned 的子级只是参照这个尺寸定位,不参与撑开。fit 控制的是前者拿到的约束:

StackFit非 Positioned 子级拿到的约束典型用法
loose(默认)0..stack 尺寸,即 loose子级按自身大小出现,角标贴边
expandtight 到 stack 尺寸底图 + 蒙层,需要一层严丝合缝铺满
passthrough原样透传父级约束想让 Stack 完全交出尺寸决定权,父级无界时风险也一并传下去
SizedBox(
  // 这一层的作用是把 Stack 的尺寸定死:
  // Stack 若全部子级都是 Positioned,它会去撑到 constraints.biggest,
  // 而无界的 biggest 就是那条 forces an infinite 的来源。
  width: 56,
  height: 56,
  child: Stack(
    clipBehavior: Clip.none, // 默认是 hardEdge,角标超出会被裁掉
    children: [
      const Positioned.fill(
        // fill 等价于 left/top/right/bottom 全 0,
        // 它只在这条轴已经有界时才有确定含义。
        child: Avatar(),
      ),
      Positioned(
        right: -2,
        top: -2,
        child: Badge(count: unread),
      ),
    ],
  ),
)

这段里 clipBehavior: Clip.none 是我第二次才发现的:默认裁切会让负偏移的角标直接不见,症状是"代码写了但屏幕上没有",很容易被误判成角标本身的问题。

综合例子:带底部安全区的卡片列表项

提纲里的这个例子我拆成两层——列表项内部管宽度,页面级管底部安全区。

class NoteCard extends StatelessWidget {
  const NoteCard({super.key, required this.note});

  final Note note;

  @override
  Widget build(BuildContext context) {
    return Padding(
      // 1. 外层 padding 在 ListView 给的 tight 宽度里先扣掉 32,
      //    于是里面的卡片知道自己可用宽度是「屏宽 - 32」。
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      child: Container(
        decoration: BoxDecoration(
          color: Theme.of(context).colorScheme.surfaceContainerLowest,
          borderRadius: BorderRadius.circular(14),
          boxShadow: const [BoxShadow(blurRadius: 12, offset: Offset(0, 2))],
        ),
        // 2. 卡片内边距同样在传递前扣掉,第 3 步拿到的区间已经减过两轮。
        padding: const EdgeInsets.all(14),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            SizedBox(
              width: 44,
              height: 44,
              // 3. 缩略图给 tight 约束,是这一行里唯一确定的数字,
              //    Row 会先把它从可用空间扣掉。
              child: ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Image.network(note.coverUrl, fit: BoxFit.cover),
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              // 4. 标题 + 摘要这两行必须知道"还剩多宽"才能换行或截断,
              //    所以这里用 Expanded 而不是让它自然撑开。
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                // 5. Column 的高度在 Row 的交叉轴上是 0..行高,
                //    min 让它按两行文字的实际高度收住,不会顶到卡片底部。
                children: [
                  Text(note.title,
                      maxLines: 1, overflow: TextOverflow.ellipsis),
                  Text(note.excerpt,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                      style: Theme.of(context).textTheme.bodySmall),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

页面那一层我是这么收尾的:

Column(
  children: [
    Expanded(
      // 6. 先给列表一条有限高度,滚动发生在它内部;
      //    卡片本身不需要关心安全区。
      child: ListView.builder(
        padding: const EdgeInsets.only(bottom: 12),
        itemCount: notes.length,
        itemBuilder: (context, i) => NoteCard(note: notes[i]),
      ),
    ),
    SafeArea(
      top: false,
      // 7. 底部安全区放在列表之外的这一层,只加一次;
      //    页脚因此永远露在手势条上方。
      child: Padding(
        padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
        child: Text('共 ${notes.length} 条',
            style: Theme.of(context).textTheme.labelSmall),
      ),
    ),
  ],
)

第 7 步的位置我改过一次。最早我把 SafeArea 写进 itemBuilder 里的每张卡片,本意是"最后一张贴着安全区",实际效果是每张卡片底部都多出一截间距,列表越长越明显。原因是 SafeArea 在它自己那一层减 padding,放进 itemBuilder 就等于按条数叠加。后来我把它提到列表外面、作为固定的页脚层,间距只加一次,滚动内容也不会被它干扰。要不要固定一个页脚、还是让最后一条留出等高的空白,取决于这个列表有没有"总数"这类需要一直可见的信息。

学习时要特别注意的几处

  • 症状:Row 里的标题一长就溢出,加了 maxLines: 1 也不截断。原因:那一层的 maxWidth 还是 infinity,Text 认为自己可以无限宽,截断逻辑没有触发条件。我怎么确认:我把 ConstraintsProbe 套在 Text 外面看 maxW,如果是 ∞,就说明这一轴上还没有收口的那一层,Expanded/Flexible 承担的正是这件事。
  • 症状:写了背景色却整块空白,第一反应是颜色值不对。原因:容器在主轴上拿到的是无界约束,算出来的尺寸是 0,装饰画在 0 面积上。我怎么确认:我临时在那一层加一个 child 或固定 height,颜色立刻出现就说明跟颜色无关,是尺寸没成立。
  • 症状:width: double.infinity 在这个页面正常,换到另一个布局里就报 BoxConstraints forces an infinite width。原因:前者祖先链上有有限宽度,后者的 maxW 是无穷。我怎么确认:我从报错组件往上找第一个给主轴有限尺寸的祖先,中间只要隔着 Row(横向)或滚动容器(滚动轴),基本就能定位到断在哪一层。
  • 症状:列表底部内容被手势条压住,加了 SafeArea 却没有改善。原因:SafeArea 被放在滚动项内部,它减的那圈 padding 随每条重复计算,或者它所在那一层本来就不接触底部。我怎么确认:我把 SafeArea 移到列表之外试一次,间距变化明显就说明原来是被条数摊掉了。
  • 症状:宽屏(折叠屏展开、平板分屏)上突然大面积溢出。原因:Row 的主轴宽度跟着父级变,而里面某个子级写的是"自然撑开",它此前从未在真正紧的约束下试过自己。我怎么确认:我在测试里用 ConstrainedBox(或改 MediaQuery 的 size)把宽度压到最窄、拉到最宽各跑一遍,这类问题会提前暴露。我后来对横向并排的东西都会试一下最窄档,因为手机竖屏往往是约束最松的时候,很多问题在这种宽度下根本不出现。

评论

暂未收到

写下评论

支持 Markdown 语法