02 · Flutter 核心基础
目标:读完后能独立搭出一个页面的静态骨架,并理解 90% 的布局报错是怎么来的。
本文聚焦「Flutter 本身」,不涉及 Riverpod / 路由等项目框架(那些在 07 章之后)。
开篇:Flutter 与前端 DOM 的对照
| 前端概念 | Flutter | 关键差异 |
|---|---|---|
| DOM 树 | Widget 树 | Flutter 有三棵树(见下文) |
| HTML 元素 | Widget | Widget 是配置,不是真实节点 |
| CSS 盒模型 | 约束盒模型 | 规则完全不同(见「布局铁律」) |
display: flex | Row / Column | 概念接近 |
position: absolute | Stack + Positioned | |
overflow: scroll | ListView / SingleChildScrollView | |
| CSS 样式表 | 嵌套在 Widget 构造参数里 | 没有样式表,样式即 Widget |
div | Container / 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 描述 | 每帧都可能重建 |
| Element | Widget 的实例化,持有 State | React 内部的 Fiber 节点 | 尽量复用 |
| RenderObject | 真正的布局、绘制、命中测试 | DOM 节点 | 尽量复用 |
为什么需要三棵
性能。Widget 是不可变的,改一个配置就得新建一个 Widget 对象——这非常频繁(每帧、每次状态变化)。但真正做布局绘制的 RenderObject 很贵,不能频繁重建。
Element 在中间做 diff:新的 Widget 来了,如果 runtimeType 和 key 都和旧的相同,就复用已有的 Element 和 RenderObject,只更新配置。
// 每秒执行 60 次的 build(),重建的是 Widget(cheap)
Widget build(context) => Text('${DateTime.now()}');
// 但底层的 RenderParagraph 被复用了,没有重新布局对新手的实际意义
build()会被频繁调用,所以里面不要做耗时操作、不要 new 大对象- Widget 重建 ≠ 重新布局绘制,中间有 diff,没那么可怕
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:
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:
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
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:
function DeviceCard({ name, status = '在线' }) { ... }
<DeviceCard name="逆变器 01" status="离线" />注意:Widget 的字段都是 final(Widget 不可变)。
三、布局铁律:Constraints go down. Sizes go up. Parent sets position.
这一节是整个 Flutter 布局的核心,也是 90% 报错的根源。
三句话
- 约束向下(Constraints go down):父 Widget 给子 Widget 一个约束(最大/最小宽高)
- 尺寸向上(Sizes go up):子 Widget 在自己被给的约束内决定自己的尺寸,回报给父
- 父定位置(Parent sets position):子 Widget 的位置由父决定,子说了不算
前端对照:和 CSS 的根本差异
| CSS | Flutter |
|---|---|
| 子元素通常可以「撑开」父元素 | 子不能决定自己在哪,也不能超出父给的最大约束 |
width: 100% 撑满 | double.infinity 或 Expanded |
| 默认内容决定尺寸 | 取决于 Widget(有些撑满,有些包裹内容) |
溢出是 overflow: scroll | 溢出是黄黑条纹报错 |
约束的类型
BoxConstraints(
minWidth: 0, maxWidth: 300,
minHeight: 0, maxHeight: double.infinity, // 无限高
)常见的三种形态:
| 名称 | 含义 | 谁会给 |
|---|---|---|
| tight(紧约束) | min == max,尺寸被完全定死 | Container(width: 100) |
| loose(松约束) | min = 0,子可以在 0~max 之间选 | Center、Padding |
| unbounded(无限) | max = double.infinity | Row/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 给无限高加约束:
return Column(
children: [
header, // 固定高度
Expanded(child: list), // 占满剩余空间(约束了 ListView 的高度)
],
);这里的 Expanded 就是告诉 ListView:「你的高度 = Column 剩下的一切」,避免 unbounded height 报错。
四、常用布局 Widget
4.1 Row / Column(≈ display: flex)
| CSS flex | Flutter |
|---|---|
flex-direction: row | Row |
flex-direction: column | Column |
justify-content | mainAxisAlignment |
align-items | crossAxisAlignment |
flex: 1 | Expanded |
gap | spacing 参数(Flutter 3.27+)或手动 SizedBox |
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, // 主轴(水平)分布
crossAxisAlignment: CrossAxisAlignment.center, // 交叉轴(垂直)对齐
children: [
Text('左'),
Text('右'),
],
)主轴与交叉轴:
Row的主轴是水平方向(children 横着排)Column的主轴是垂直方向(children 竖着排)
4.2 Expanded / Flexible(≈ flex: 1)
Row(
children: [
const Text('标签:'),
Expanded( // 占满剩余宽度
child: Text(longText, overflow: TextOverflow.ellipsis),
),
],
)| Widget | 行为 |
|---|---|
Expanded | 强制填满剩余空间(fit: FlexFit.tight) |
Flexible | 可以小于剩余空间,按内容来(fit: FlexFit.loose) |
前端类比:Expanded ≈ flex: 1,Flexible ≈ flex: 0 1 auto。
⚠️ Expanded 必须是 Row/Column/Flex 的直系子节点,中间隔一层 Padding 就报错(见「常见报错」)。
4.3 Stack + Positioned(≈ position: absolute)
Stack(
children: [
Image.network(url), // 底层
const Positioned( // 定位层
top: 8,
right: 8,
child: Badge('新'),
),
],
)alignment 控制未定位子节点的对齐(默认 topLeft,与 CSS 不同)。
4.4 ListView(≈ overflow: scroll)
// 少量固定子项
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:
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 height | ListView 在 Column 内 | 父给无限高,子想无限延展 |
An InputDecorator…cannot have an unbounded width | TextField 在 Row 内 | 文本框想按无限宽决定自己宽 |
RenderFlex overflowed by X pixels | Row/Column 子节点超出父约束 | 子请求尺寸大于父分配(黄黑条纹) |
Incorrect use of ParentData widget | Expanded 不在 Row/Column 直系 | ParentDataWidget 找不到合适祖先 |
RenderBox was not laid out | 上述错误的级联副作用 | 忽略,往上找主错误 |
⚠️ 排查第一原则
看到 RenderBox was not laid out 不要直接修它。它是上游约束失败的级联结果。
从堆栈栈顶往栈底扫,找第一条含以下关键词的错误:
unboundedoverflowedcannot have anIncorrect use of ParentData
修复套路
① unbounded height:ListView 放进 Column
// ❌
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:内容撑爆
// ❌ 长文本撑爆 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 层级错了
// ❌ 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。
正确做法:
- 查 TDesign 组件的 API 参数(
maxWidth/width等) - 业务侧通过参数传约束,而不是外层包装
- 需要自定义封装时,走
he_components封装流程
项目特有:const 与 token 不兼容
const SizedBox(height: AppTokens.spacingMd) // ❌ 编译失败
SizedBox(height: AppTokens.spacingMd) // ✅AppTokens.* 是 getter(内部走 ScreenUtil 缩放),不是编译期常量。
六、BuildContext 与 of(context)
是什么
BuildContext 是当前 Widget 在 Element 树中的位置句柄。所有 Xxx.of(context) 都是从这个位置向上查找最近的祖先。
前端类比
最接近的是 React Context:
const theme = useContext(ThemeContext); // 向上找最近的 Providerfinal tokens = AppTokens.of(context); // 向上找最近的 AppTokens常见的 of(context)
| 写法 | 拿到什么 | 定义位置 |
|---|---|---|
Theme.of(context) | 当前 ThemeData | Flutter 内置 |
MediaQuery.of(context) | 屏幕尺寸、padding、亮度 | Flutter 内置 |
AppTokens.of(context) | 本项目设计 token | lib/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:
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);
}所以这两种等价,推荐后者(更短):
final tokens = AppTokens.of(context);
final tokens = context.appTokens;⚠️ 陷阱:跨 BuildContext 失效
of(context) 从调用处的 context 向上找。如果 context 位置不对,会找不到或找到错的。
典型报错场景:在 Scaffold 的同级 build 里调用 Scaffold.of(context)——找不到,因为 Scaffold 是当前 Widget 的子,不是祖先。
解法:用 Builder 造一个子 context,或用 ScaffoldMessenger。
七、文本与图片的本项目规范
文本:禁止字面量
// ❌ 禁止:硬编码文案,无法国际化
Text('没有更多了')
// ✅ 正确
Text(l10n.t('bTerminal.nomore'))图片:禁止字面量路径
// ❌ 禁止
Image.asset('assets/images/auth/logo.png')
// ✅ 正确:flutter_gen 生成的强类型引用
Assets.images.auth.logo.image()详见 11-组件与主题。
图标:用 AppIcon
// ❌ 不推荐:直接用 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:布局约束感知
// 目标:理解「约束向下」
// 1. 写一个 Column,里面放两个 Container(一个高 100,一个高 100)
// 2. 再包一层 SizedBox(height: 150),观察是否报错、报什么错
// 3. 给第二个 Container 包上 Expanded,再观察练习 2.2:复现并修复三种报错
依次写出下面三种报错,然后修好:
- unbounded height:
Column里放ListView - overflowed:
Row里放一段超长文本 - ParentData:
Expanded被Padding包住
每复现一次,读一遍报错信息,确认自己能认出主错误(而不是被 RenderBox was not laid out 带偏)。
练习 2.3:照抄一个真实列表项
照 lib/ui/core/widgets/paged_list_body.dart:190-199 的 pagedNoMoreTail,写一个「设备卡片」:
- 左侧图标
- 中间:设备名 + 状态标签
- 右侧:箭头
要求:用 Row + Expanded,长设备名截断。
九、自检清单
- [ ] 能说出三棵树各自的作用,以及为什么需要三棵
- [ ] 知道
ConsumerWidget和StatelessWidget的选型依据 - [ ] 能背出布局铁律:约束向下、尺寸向上、父定位置
- [ ] 看到
unbounded height知道是ListView在Column里,用Expanded修 - [ ] 看到
RenderBox was not laid out知道要往上找主错误 - [ ] 知道
Expanded必须是Row/Column直系子节点 - [ ] 知道
AppTokens不能放进const - [ ] 能用
Row+Expanded写出一个左中右布局
下一步
03~06 是项目里少见但必须懂的通用知识。如果你急着上手写页面, 也可以先跳到
07-项目架构总览,回头再补。