Skip to content

02 · Flutter 核心基础

目标:读完后能独立搭出一个页面的静态骨架,并理解 90% 的布局报错是怎么来的。

本文聚焦「Flutter 本身」,不涉及 Riverpod / 路由等项目框架(那些在 07 章之后)。


开篇:Flutter 与前端 DOM 的对照

前端概念Flutter关键差异
DOM 树Widget 树Flutter 有三棵树(见下文)
HTML 元素WidgetWidget 是配置,不是真实节点
CSS 盒模型约束盒模型规则完全不同(见「布局铁律」)
display: flexRow / Column概念接近
position: absoluteStack + Positioned
overflow: scrollListView / SingleChildScrollView
CSS 样式表嵌套在 Widget 构造参数里没有样式表,样式即 Widget
divContainer / SizedBox / Padding
span / 文本Text
React 组件StatelessWidget / StatefulWidget
React 受控组件TextEditingController
document.querySelector没有。靠 BuildContext 向上查找

最重要的思维转变

前端是「写 HTML + 用 CSS 装饰」;Flutter 是用 Widget 组合 Widget—— 间距是 Padding(一个 Widget),居中是 Center(一个 Widget), 点击手势是 GestureDetector(一个 Widget)。一切皆 Widget。


一、三棵树:Widget / Element / RenderObject

这是 Flutter 面试必问,也是理解重建机制的基础。

Widget 树(配置,不可变,频繁重建,很 cheap)
   ↕ Element 持有
Element 树(中间层,负责 diff 与复用,稳定)
   ↕ Element 持有
RenderObject 树(真实布局与绘制,稳定,很贵)
是什么前端类比生命周期
Widget一坨配置数据,不可变React 的 JSX 描述每帧都可能重建
ElementWidget 的实例化,持有 StateReact 内部的 Fiber 节点尽量复用
RenderObject真正的布局、绘制、命中测试DOM 节点尽量复用

为什么需要三棵

性能。Widget 是不可变的,改一个配置就得新建一个 Widget 对象——这非常频繁(每帧、每次状态变化)。但真正做布局绘制的 RenderObject 很贵,不能频繁重建。

Element 在中间做 diff:新的 Widget 来了,如果 runtimeTypekey 都和旧的相同,就复用已有的 Element 和 RenderObject,只更新配置。

dart
// 每秒执行 60 次的 build(),重建的是 Widget(cheap)
Widget build(context) => Text('${DateTime.now()}');
// 但底层的 RenderParagraph 被复用了,没有重新布局

对新手的实际意义

  1. build() 会被频繁调用,所以里面不要做耗时操作、不要 new 大对象
  2. Widget 重建 ≠ 重新布局绘制,中间有 diff,没那么可怕
  3. const 构造能跳过重建(见 03 章)

详细机制见 03-Flutter渲染原理与生命周期


二、四种 Widget 基类怎么选

项目里你会看到四种,选错会写得很别扭:

基类用途能否用 ref(Riverpod)前端类比
StatelessWidget纯展示,无状态纯函数组件
StatefulWidget有本地 UI 状态(动画、控制器)useState 的组件
ConsumerWidget需要读 Riverpod 状态用了 store 的组件
ConsumerStatefulWidget既要本地状态又要 Riverpod两者都用

判断流程

需要读 Riverpod provider 吗?
├─ 否 → 需要本地可变状态(动画控制器/TextEditingController/展开收起)吗?
│        ├─ 否 → StatelessWidget
│        └─ 是 → StatefulWidget
└─ 是 → 需要本地可变状态吗?
         ├─ 否 → ConsumerWidget      ← 项目里最多
         └─ 是 → ConsumerStatefulWidget

项目实例

ConsumerWidget(最常见)—— lib/ui/core/widgets/paged_list_body.dart:30

dart
abstract class PagedListBody<T> extends ConsumerWidget {
  const PagedListBody({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final l10n = RuntimeI18n.ofNonNull(context);   // 取文案
    //          ↑ 注意第二个参数 WidgetRef ref
    ...
  }
}

ConsumerStatefulWidget —— lib/main.dart:403

dart
class _PrivacyGateHome extends ConsumerStatefulWidget {
  const _PrivacyGateHome({required this.onAgreed});
  final Future<void> Function() onAgreed;

  @override
  ConsumerState<_PrivacyGateHome> createState() => _PrivacyGateHomeState();
}

class _PrivacyGateHomeState extends ConsumerState<_PrivacyGateHome> {
  bool _dialogShown = false;     // 本地状态

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) => _showDialog());
  }
  ...
}

注意 _PrivacyGateHomeState 里可以直接用 ref(不需要额外声明),这是 ConsumerState 提供的。

前端对照:StatelessWidget 的 props

dart
class DeviceCard extends StatelessWidget {
  const DeviceCard({super.key, required this.name, this.status = '在线'});

  final String name;
  final String status;

  @override
  Widget build(BuildContext context) { ... }
}

// 使用
const DeviceCard(name: '逆变器 01', status: '离线')

对应 React:

jsx
function DeviceCard({ name, status = '在线' }) { ... }
<DeviceCard name="逆变器 01" status="离线" />

注意:Widget 的字段都是 final(Widget 不可变)。


三、布局铁律:Constraints go down. Sizes go up. Parent sets position.

这一节是整个 Flutter 布局的核心,也是 90% 报错的根源。

三句话

  1. 约束向下(Constraints go down):父 Widget 给子 Widget 一个约束(最大/最小宽高)
  2. 尺寸向上(Sizes go up):子 Widget 在自己被给的约束内决定自己的尺寸,回报给父
  3. 父定位置(Parent sets position):子 Widget 的位置由父决定,子说了不算

前端对照:和 CSS 的根本差异

CSSFlutter
子元素通常可以「撑开」父元素子不能决定自己在哪,也不能超出父给的最大约束
width: 100% 撑满double.infinityExpanded
默认内容决定尺寸取决于 Widget(有些撑满,有些包裹内容)
溢出是 overflow: scroll溢出是黄黑条纹报错

约束的类型

dart
BoxConstraints(
  minWidth: 0, maxWidth: 300,
  minHeight: 0, maxHeight: double.infinity,   // 无限高
)

常见的三种形态:

名称含义谁会给
tight(紧约束)min == max,尺寸被完全定死Container(width: 100)
loose(松约束)min = 0,子可以在 0~max 之间选CenterPadding
unbounded(无限)max = double.infinityRow/Column 的主轴、ListView 的滚动方向

unbounded 是报错的常见根源——子 Widget 想在「无限」空间里延展,就崩了。

图解

父 Container(width: 300)

  ├─ 向下传:BoxConstraints(maxWidth: 300)

  └─ 子 Text('很长的文字...')

       ├─ 在 0~300 内选自己的尺寸 → 比如 280

       └─ 向上报:size = (280, 20)

           └─ 父决定把子放在 (0, 0)

项目实例

lib/ui/core/widgets/paged_list_body.dart:102-107 —— Expanded 给无限高加约束:

dart
return Column(
  children: [
    header,                        // 固定高度
    Expanded(child: list),         // 占满剩余空间(约束了 ListView 的高度)
  ],
);

这里的 Expanded 就是告诉 ListView:「你的高度 = Column 剩下的一切」,避免 unbounded height 报错。


四、常用布局 Widget

4.1 Row / Column(≈ display: flex

CSS flexFlutter
flex-direction: rowRow
flex-direction: columnColumn
justify-contentmainAxisAlignment
align-itemscrossAxisAlignment
flex: 1Expanded
gapspacing 参数(Flutter 3.27+)或手动 SizedBox
dart
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,   // 主轴(水平)分布
  crossAxisAlignment: CrossAxisAlignment.center,        // 交叉轴(垂直)对齐
  children: [
    Text('左'),
    Text('右'),
  ],
)

主轴与交叉轴

  • Row 的主轴是水平方向(children 横着排)
  • Column 的主轴是垂直方向(children 竖着排)

4.2 Expanded / Flexible(≈ flex: 1

dart
Row(
  children: [
    const Text('标签:'),
    Expanded(                    // 占满剩余宽度
      child: Text(longText, overflow: TextOverflow.ellipsis),
    ),
  ],
)
Widget行为
Expanded强制填满剩余空间(fit: FlexFit.tight
Flexible可以小于剩余空间,按内容来(fit: FlexFit.loose

前端类比Expandedflex: 1Flexibleflex: 0 1 auto

⚠️ Expanded 必须是 Row/Column/Flex 的直系子节点,中间隔一层 Padding 就报错(见「常见报错」)。

4.3 Stack + Positioned(≈ position: absolute

dart
Stack(
  children: [
    Image.network(url),                      // 底层
    const Positioned(                        // 定位层
      top: 8,
      right: 8,
      child: Badge('新'),
    ),
  ],
)

alignment 控制未定位子节点的对齐(默认 topLeft,与 CSS 不同)。

4.4 ListView(≈ overflow: scroll

dart
// 少量固定子项
ListView(
  padding: EdgeInsets.all(AppTokens.spacingMd),
  children: [item1, item2, item3],
)

// 大量/动态子项 —— 必须用 builder(懒加载)
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => DeviceTile(items[index]),
)

// 带分隔线
ListView.separated(
  itemCount: items.length,
  separatorBuilder: (_, __) => SizedBox(height: AppTokens.spacingSm),
  itemBuilder: (context, index) => DeviceTile(items[index]),
)

前端类比ListView.builder ≈ 虚拟滚动列表。它只渲染可见区域的 item,所以用 builder 而不是 children: [...1000个]

4.5 其他高频 Widget

场景Widget
包一层内边距Padding(padding: ..., child: ...)
居中Center(child: ...)
固定宽高SizedBox(width:, height:)
占位空隙SizedBox(height: AppTokens.spacingMd)
加装饰(背景色/圆角/边框)Container(decoration: BoxDecoration(...))
加点击事件GestureDetector(onTap: ..., child: ...)
加点击水波纹InkWell(onTap: ..., child: ...)
内容超出可滚动SingleChildScrollView(child: ...)
撑满父容器SizedBox.expand(child: ...)

项目实例:一个真实的列表项

lib/ui/core/widgets/paged_list_body.dart:190-199

dart
Widget pagedNoMoreTail(BuildContext context) {
  final l10n = RuntimeI18n.ofNonNull(context);
  final tokens = AppTokens.of(context);
  return Padding(
    padding: EdgeInsets.symmetric(vertical: AppTokens.spacingMd),
    child: Center(
      child: Text(
        l10n.t('bTerminal.nomore'),
        style: AppTokens.bodyMdRegular.copyWith(color: tokens.textOnSurfaceHeading),
      ),
    ),
  );
}

这个片段展示了本项目的标准写法:Padding + Center + Text,尺寸和颜色全部走 AppTokens,文案走 RuntimeI18n


五、常见布局报错速查

更完整的排查流程见 17-常见陷阱与速查表。这里先建立基本认知。

报错签名对照表

控制台错误触发场景主因
Vertical viewport was given unbounded heightListViewColumn父给无限高,子想无限延展
An InputDecorator…cannot have an unbounded widthTextFieldRow文本框想按无限宽决定自己宽
RenderFlex overflowed by X pixelsRow/Column 子节点超出父约束子请求尺寸大于父分配(黄黑条纹)
Incorrect use of ParentData widgetExpanded 不在 Row/Column 直系ParentDataWidget 找不到合适祖先
RenderBox was not laid out上述错误的级联副作用忽略,往上找主错误

⚠️ 排查第一原则

看到 RenderBox was not laid out 不要直接修它。它是上游约束失败的级联结果。

从堆栈栈顶往栈底扫,找第一条含以下关键词的错误

  • unbounded
  • overflowed
  • cannot have an
  • Incorrect use of ParentData

修复套路

unbounded heightListView 放进 Column

dart
// ❌
Column(children: [Text('标题'), ListView(children: [...])])

// ✅ 方案一:Expanded(推荐)
Column(children: [
  Text('标题'),
  Expanded(child: ListView(children: [...])),
])

// ✅ 方案二:给固定高(高度必须走 AppTokens)
Column(children: [
  Text('标题'),
  SizedBox(height: AppTokens.spacingXxxxl, child: ListView(...)),
])

// ✅ 方案三:不需要滚动时改用 shrinkWrap
Column(children: [
  Text('标题'),
  ListView(shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), ...),
])

overflowed:内容撑爆

dart
// ❌ 长文本撑爆 Row
Row(children: [AppIcon(name: 'icon_info'), Text(longText)])

// ✅ Expanded 约束 + 截断
Row(children: [
  AppIcon(name: 'icon_info'),
  Expanded(
    child: Text(longText,
      overflow: TextOverflow.ellipsis,   // 单行省略号
      maxLines: 1,
    ),
  ),
])

Incorrect use of ParentData:Expanded 层级错了

dart
// ❌ Expanded 被 Padding 包着,不是 Column 直系
Column(children: [
  Padding(
    padding: EdgeInsets.all(AppTokens.spacingMd),
    child: Expanded(child: Text('...')),   // 报错
  ),
])

// ✅ Expanded 在外,Padding 在内
Column(children: [
  Expanded(
    child: Padding(
      padding: EdgeInsets.all(AppTokens.spacingMd),
      child: Text('...'),
    ),
  ),
])

项目特有:TDesign 组件的「假溢出」

报错堆栈里如果出现 package:tdesign_flutter/...

不要用 ScreenUtil 包装它。TDesign 内部是固定 dp,在 ScreenUtil 等比缩放环境下看起来「溢出 1-2px」通常不是真 overflow

正确做法:

  1. 查 TDesign 组件的 API 参数(maxWidth / width 等)
  2. 业务侧通过参数传约束,而不是外层包装
  3. 需要自定义封装时,走 he_components 封装流程

项目特有:const 与 token 不兼容

dart
const SizedBox(height: AppTokens.spacingMd)   // ❌ 编译失败
SizedBox(height: AppTokens.spacingMd)         // ✅

AppTokens.* 是 getter(内部走 ScreenUtil 缩放),不是编译期常量。


六、BuildContextof(context)

是什么

BuildContext当前 Widget 在 Element 树中的位置句柄。所有 Xxx.of(context) 都是从这个位置向上查找最近的祖先。

前端类比

最接近的是 React Context:

jsx
const theme = useContext(ThemeContext);   // 向上找最近的 Provider
dart
final tokens = AppTokens.of(context);     // 向上找最近的 AppTokens

常见的 of(context)

写法拿到什么定义位置
Theme.of(context)当前 ThemeDataFlutter 内置
MediaQuery.of(context)屏幕尺寸、padding、亮度Flutter 内置
AppTokens.of(context)本项目设计 tokenlib/he_components/src/themes/app_tokens.dart:22
context.appTokens同上(extension 简写)app_tokens.dart:545
RuntimeI18n.ofNonNull(context)多语言实例lib/core/i18n/runtime_i18n.dart:26

项目实例:两种等价写法

lib/he_components/src/themes/app_tokens.dart:22-23:544-545

dart
class AppTokens extends ThemeExtension<AppTokens> {
  static AppTokens of(BuildContext context) =>
      Theme.of(context).extension<AppTokens>()!;
}

extension AppTokensContextExtension on BuildContext {
  AppTokens get appTokens => AppTokens.of(this);
}

所以这两种等价,推荐后者(更短):

dart
final tokens = AppTokens.of(context);
final tokens = context.appTokens;

⚠️ 陷阱:跨 BuildContext 失效

of(context)调用处的 context 向上找。如果 context 位置不对,会找不到或找到错的。

典型报错场景:在 Scaffold 的同级 build 里调用 Scaffold.of(context)——找不到,因为 Scaffold 是当前 Widget 的,不是祖先。

解法:用 Builder 造一个子 context,或用 ScaffoldMessenger


七、文本与图片的本项目规范

文本:禁止字面量

dart
// ❌ 禁止:硬编码文案,无法国际化
Text('没有更多了')

// ✅ 正确
Text(l10n.t('bTerminal.nomore'))

图片:禁止字面量路径

dart
// ❌ 禁止
Image.asset('assets/images/auth/logo.png')

// ✅ 正确:flutter_gen 生成的强类型引用
Assets.images.auth.logo.image()

详见 11-组件与主题

图标:用 AppIcon

dart
// ❌ 不推荐:直接用 Material Icons
Icon(Icons.search)

// ✅ 项目规范:用 iconfont 图标
AppIcon(name: 'icon_search', size: ..., color: ...)

AppIcon 定义于 lib/core/iconfont/app_icon_widget.dart:11,是 ConsumerWidget(内部监听远程字体加载状态)。


八、动手练习

全部在 lib/main_playground.dart 里做(记得替换 PlaygroundPage 的 body)。 运行:fvm flutter run -t lib/main_playground.dart

练习 2.1:布局约束感知

dart
// 目标:理解「约束向下」
// 1. 写一个 Column,里面放两个 Container(一个高 100,一个高 100)
// 2. 再包一层 SizedBox(height: 150),观察是否报错、报什么错
// 3. 给第二个 Container 包上 Expanded,再观察

练习 2.2:复现并修复三种报错

依次写出下面三种报错,然后修好:

  1. unbounded heightColumn 里放 ListView
  2. overflowedRow 里放一段超长文本
  3. ParentDataExpandedPadding 包住

每复现一次,读一遍报错信息,确认自己能认出主错误(而不是被 RenderBox was not laid out 带偏)。

练习 2.3:照抄一个真实列表项

lib/ui/core/widgets/paged_list_body.dart:190-199pagedNoMoreTail,写一个「设备卡片」:

  • 左侧图标
  • 中间:设备名 + 状态标签
  • 右侧:箭头

要求:用 Row + Expanded,长设备名截断。


九、自检清单

  • [ ] 能说出三棵树各自的作用,以及为什么需要三棵
  • [ ] 知道 ConsumerWidgetStatelessWidget 的选型依据
  • [ ] 能背出布局铁律:约束向下、尺寸向上、父定位置
  • [ ] 看到 unbounded height 知道是 ListViewColumn 里,用 Expanded
  • [ ] 看到 RenderBox was not laid out 知道要往上找主错误
  • [ ] 知道 Expanded 必须是 Row/Column 直系子节点
  • [ ] 知道 AppTokens 不能放进 const
  • [ ] 能用 Row + Expanded 写出一个左中右布局

下一步

03 · Flutter 渲染原理与生命周期

03~06 是项目里少见但必须懂的通用知识。如果你急着上手写页面, 也可以先跳到 07-项目架构总览,回头再补。