关于 widget-composition
Widget Composition 是面向 Flutter 开发的技能,指导使用职责单一的小型 Widget 类构建界面。它涵盖组合方式、重建行为、状态边界、布局、键和无障碍,并提供参考文档与 PR 前检查脚本。
主要功能
- 01用职责明确的 Widget 类组合界面
- 02保持 build 方法简洁浅层
- 03分离视图、状态与领域逻辑
- 04使用延迟列表和稳定键
- 05采用方向性布局并提供手势替代操作
- 06运行 Widget 组合检查脚本
适用场景
- 01设计 Flutter 界面或共享组件
- 02检查 Widget 重建与状态边界
- 03实现列表、网格或适配布局
- 04提交 PR 前检查 Widget 组合
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill widget-composition安装后在 Claude Code 中输入/widget-composition确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/feature_view.dartstructural_grid.dartreferences/rebuild-mechanics.mdstructural-layout.mdwidget-and-data-selection.mdscripts/check-widget-composition.sh详细介绍
Widget 组合
使用许多小巧、用途单一的 const Widget 类来构建每个屏幕。只做一件事的 Widget 重建开销低、易于阅读、可单独测试,也可以提取到共享组件库中。View 是哑视图:只监听一个 ViewModel 并进行渲染;逻辑放在 Notifier 中,状态放在不可变值对象中,布局则使用具有方向性的结构基元。此模式适用于任何屏幕、列表、卡片或共享组件。
阅读与当前任务相关的参考资料:
references/rebuild-mechanics.md— 为什么类优于方法(Element.updateChild)、const规范化、完整的 key 策略,以及用于缩小重建范围的.select。references/structural-layout.md— 全屏背景与SafeArea内容、计算单元格尺寸、GridView的交叉轴/主轴陷阱、具有方向性的内边距,以及 IME/resizeToAvoidBottomInset。references/widget-and-data-selection.md— 最低开销 Widget 表格、数据类决策表(drift 行 vs record vs 手写 vs freezed),以及手势→回退规则。
提交 PR 前运行 scripts/check-widget-composition.sh。
不容妥协的规则
- 提取
Widget类,绝不提取返回Widget的方法。Widget _buildHeader()不是一个 widget——它的子树没有自己的Element,因此Element.updateChild无法比较新旧对象并跳过更新;它会随父级一起重建,不能声明为const,不能设置Key,测试中也无法通过find.byType找到它。 - 在所有允许的地方使用
const,每个字段都声明为final。const构造函数会将相同的 widget 规范化为同一个对象,从而让 Flutter 跳过子树重建。启用prefer_const_constructors和prefer_const_constructors_in_immutables。不要花时间手动调整const——让dart fix --apply自动处理;绝不要为了让某些内容能声明为const而重构树结构。 - 让
build()保持简短、浅层。 超出dart3-idioms-and-coding-standards规定的复杂度限制时(build()≤ 80 行;widget build 嵌套层级 ≤ 5——表格对 widget 树比逻辑多允许的深层嵌套例外为 ≤ 3),提取子 widget 类。需要滚动才能读完的build()就太长了。 - 每个 widget 只负责一件事。 一个组件只渲染一项内容;它不会同时获取数据、格式化值、读取时钟、持有应用状态或安排副作用。
build()中不得执行 I/O、格式化或领域计算。build()每秒可能运行 60 次——不要调用 repository/DAO,不要使用DateTime.now()、NumberFormat,也不要排序。Notifier 会预先计算好可直接渲染的 UI 状态值;widget 只负责绘制。- View 应保持简单,并且只监听一个 ViewModel。
ConsumerWidget调用ref.watch(oneNotifierProvider)并进行渲染;用户操作通过ref.read(...notifier)传回。它绝不会直接访问 repository 或 DAO,也不会修改持久化状态。 - 默认使用
StatelessWidget/ConsumerWidget。 只有在确实需要局部、短暂的 UI 状态时,才使用StatefulWidget/ConsumerStatefulWidget(AnimationController、TextEditingController、ScrollController、FocusNode)。应用、领域和会话状态应放在 Notifier 中。 - 始终对在
State中创建的控制器调用dispose()。 泄漏的AnimationController/TextEditingController/ScrollController/FocusNode/subscriptions 会占用内存,并在销毁后触发回调。cancel_subscriptions/close_sinks会产生错误。 - 列表应采用惰性构建。 对可变长度或较长的列表使用
ListView.builder/SliverList。对于无界数据,绝不要使用ListView(children: items.map(...).toList())——它会一次性构建每一行。 - 在回调中解析标识,而不是内容。 点击闭包应捕获稳定的 ID(
onTap: () => onSelect(item.id)),而不是捕获内容值——编辑后再次点击时,不应使用过时的捕获值。 - 每种手势都需要醒目且可聚焦的备用方式。 任何可通过触摸触发的行为,也必须能通过带标签的可聚焦控件触发。仅能通过拖动或长按触发的操作,无法由开关辅助操作和屏幕阅读器用户使用。
- 使用主题/令牌和逻辑方向设置样式。 颜色/文本样式应来自
Theme.of(context)或ThemeExtension,绝不要使用字面量十六进制值。使用EdgeInsetsDirectional/AlignmentDirectional(start/end),绝不要硬编码左/右方向,以便 RTL 布局自然镜像。
提取 widget 类还是方法
// WRONG — helper method: rebuilds with parent, no const, no key, no isolation, untestable
Widget _buildProductCard(Product product) {
return Card(child: Text(product.name));
}
// RIGHT — widget class: const-constructible, keyable, independently rebuildable, testable
class ProductCard extends StatelessWidget {
const ProductCard({required this.name, required this.price, this.onTap, super.key});
final String name; // already-localized; the feature mapped Product -> name
final String price; // already-formatted; no currency math here
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context); // read theme in the leaf that uses it
return Card(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsetsDirectional.all(12), // logical, RTL-safe
child: Row(
children: [
Expanded(child: Text(name, style: theme.textTheme.titleMedium)),
Text(price, style: theme.textTheme.bodyMedium),
],
),
),
),
);
}
}
精简 build()——由 Notifier 预先计算
// WRONG — domain math + formatting inside build()
@override
Widget build(BuildContext context, WidgetRef ref) {
final order = ref.read(orderRepositoryProvider).find(id); // repo call in build!
final remaining = order.total - order.paid; // domain math in UI
final label = NumberFormat.currency().format(remaining); // formatting in UI
return Text(remaining <= 0 ? 'Done' : label);
}
// RIGHT — the Notifier exposes ready-to-render UI state; select narrows the rebuild
@override
Widget build(BuildContext context, WidgetRef ref) {
final status = ref.watch(orderNotifierProvider(id).select((s) => s.balanceStatus));
return Text(status.label, style: status.isSettled ? _settledStyle : _dueStyle);
}
ref.watch(p.select((s) => s.field)) 只会在该字段变化时触发重建——这是 Riverpod 中与叶节点 const 等效的写法。关于 watch/read/listen 的划分,请参阅 state-management-riverpod;关于 .select 和 const 子树作用域,请参阅 flutter-performance。
组合指导
- 将屏幕拆分为多个区块(页眉、主体、页脚),分别使用独立的 widget 类;当行中的某个部分会被复用或结构复杂时,将该行拆分为多个部分。
- 数据向下传递,回调向上冒泡。 组件接收其要显示的内容,并公开
onX回调;它不会访问父组件或自己不拥有的 provider。 - 在需要读取
MediaQuery/Theme的叶子节点中读取,不要在大型build()的顶部读取——在顶部读取会导致整个子树在每次指标变化时重建。 - 优先选择开销最低的 widget: 用
SizedBox代替Container实现间距;单一效果优先使用ColoredBox/DecoratedBox,而不是Container;用Align/Center代替Row+Spacer。参见references/widget-and-data-selection.md。 - 将不会变化的子树提取为
const局部变量,使其在重建时得以保留。 - 共享组件不感知领域,也不依赖 provider。 它通过构造函数接收基本类型、本地化标签、不可变视图数据和回调——绝不接收领域实体、仓库或
ref。依赖方向是单向的:功能模块导入共享库,反之则不行。
键
只有在一种情况下键才重要:框架必须判断,子列表中同一位置的新 Widget 是否可以复用原有的 Element(及其 State)。典型问题是在列表重新排序后,过时的 State 泄漏到复用的位置。
| 情况 | 键 |
|---|---|
| 可重新排序、可筛选或长度可变的列表中的一行 | ValueKey(item.id) — 稳定标识可在移动后保留 |
| 顺序和长度固定的列表;叶子无状态 widget | 无 — 键不会改变任何内容 |
| 交换两个同类型 widget 并需要保留状态 | 用于区分它们的 ValueKey |
某个 widget 突然“需要”GlobalKey | 停下来 — 这是设计异味,而非修复方法 |
不要为了标识或查找添加 GlobalKey——它会强制进行全局注册表查找,并启用跨树重新挂接;大多数应用都不需要这套机制。完整机制说明见 references/rebuild-mechanics.md。
视图数据的数据类
如果生成器已经能生成数据类,就不要手动编写。判断规则如下:
| 形状 | 用法 |
|---|---|
| 持久化行 | 直接使用 drift 生成的行类——它已经提供 ==、hashCode、copyWith |
| 单层内的临时多值返回 | 使用 record — (int row, int col);绝不跨越层边界 |
| 手写的领域值 / UI 状态 | 简单值使用 @immutable+const+final;非简单值类型、联合类型或频繁使用 copyWith 的场景使用 freezed — 阈值由 dart3-idioms-and-coding-standards 规定 |
| 生成器无法生成的组合形状 | 手写 @immutable 类 |
不要为了一个 == 添加 equatable;Object.hash(...) 只需五行代码。完整表格和理由见 references/widget-and-data-selection.md。值类型和 record 规则参见 dart3-idioms-and-coding-standards。
结构布局
窗口采用全边到边布局;交互目标则需要内缩——这是两个不同的问题。将背景绘制为延伸至系统栏后方的全出血布局,然后用 SafeArea 包裹可交互内容,并在其内部添加边距。
Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, // full-bleed, behind bars
resizeToAvoidBottomInset: false, // a fixed grid must not reflow under the IME
body: SafeArea(
child: Padding(
padding: const EdgeInsetsDirectional.all(16), // logical inset, RTL-safe
child: child,
),
),
)
根据视口计算单元格尺寸;绝不硬编码单元格尺寸,也绝不设置最小尺寸断言(尺寸下限恰好会在大字号最需要空间时触发——真正的限制是标签能够容纳得下)。GridView 的轴向陷阱不易察觉:在垂直网格中,crossAxisSpacing 是列间距,mainAxisSpacing 是行间距。
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 12, // COLUMN gap in a vertical grid
mainAxisSpacing: 16, // ROW gap
),
itemCount: items.length,
itemBuilder: (context, i) => ProductCard(name: items[i].name, price: items[i].price),
);
完整说明——包括计算尺寸的数学方法、IME 行为、MediaQuery.viewInsets,以及何时应使用 Column+网格而非跨列单元格——见 references/structural-layout.md。有关通过 MediaQuery 读取无障碍状态且绝不限制 textScaler,请参见 accessibility-as-code;有关令牌和主题结构,请参见 design-system-structure。
反模式
- 对于任何非简单 UI,避免使用
Widget _buildXxx()辅助方法 ——没有Element边界、无法使用 const、没有 key,也无法测试。应创建一个类。 - 巨型 Widget:在一个 300 行的
build()中内联整个屏幕。 - 在
build()或initState()中调用 Repository/DAO/SharedPreferences——应通过 Notifier 和单一写入路径处理。 - 在 Widget 中调用
DateTime.now()——Widget 绝不读取系统时钟;Notifier 应通过clockProvider注入的Clock预先计算依赖时间的值(参见state-management-riverpod)。 - 在
build()中创建控制器(每帧都会重新创建),而不是在State中创建并调用dispose()。 - 使用
setState()刷新属于 Notifier 的应用/会话数据 ——setState仅用于本地临时 UI 状态。 ListView(children: hugeList.map(...).toList())——会一次性构建所有行。请使用.builder。- 所有内容都用
Container包裹(过度包裹)——请使用更专用、开销更低的 Widget。 - 用
GlobalKey/ObjectKey标识身份 ——删除;列表会重新排序时,使用ValueKey(id)。 - 硬编码十六进制颜色 / 左右内边距 / 用户可见字符串字面量 ——请使用主题令牌、
EdgeInsetsDirectional和 ARB 本地化。 - 手势没有可见的备用操作,或通过限制
textScaler强行让布局适配 ——应修复布局,而不是缩放比例。 - 共享库中的 Widget 导入领域类型或读取 provider ——这样就无法再独立推理或测试该 Widget。
完成标准
- 每个可复用或非简单的子树都是一个
Widget类(而不是_buildX()方法);在允许的情况下使用const;所有字段均为final。 -
build()不超过约 80 行;嵌套层次浅;复杂部分已提取。 -
build()中没有 I/O、Repository 调用、DateTime.now()、格式化或领域数学运算 ——Notifier 应预先计算 UI 状态。 - View 是监听一个 Notifier 的
ConsumerWidget;意图通过ref.read(...notifier)路由;Widget 中不会修改持久化状态。 -
StatefulWidget仅用于本地临时状态;每个创建的控制器都调用dispose()。 - 长列表或长度可变的列表使用
.builder;可重新排序的列表带有ValueKey(id);不使用GlobalKey标识身份。 - 回调解析稳定 ID,而不是捕获内容值。
- 任何共享 Widget 仅接收基本类型/回调,不导入领域类型或 provider。
- 背景铺满全屏,内容位于
SafeArea内;使用方向性内边距;GridView间距轴设置正确;不硬编码或设置单元格尺寸下限。 - 样式来自主题/令牌;每个手势都有可见且可聚焦的备用操作;绝不限制
textScaler。 -
dart format检查通过;dart analyze --fatal-infos检查通过;scripts/check-widget-composition.sh执行成功。
相关技能
flutter-architecture— 本组件所处的分层 DAG 和哑视图契约;widget-composition 深入讲解了如何构建这些视图。flutter-performance— const 子树、.select重建范围控制、RepaintBoundary、dispose 规范以及在 profile 模式下进行测量。state-management-riverpod— 本视图监听的每个功能对应一个 Notifier 的 ViewModel,以及 watch/read/listen 的区分。design-system-structure— 令牌、主题和组件的结构与使用方式(本技能读取的样式来源)。accessibility-as-code— 从MediaQuery读取无障碍状态、Semantics角色/标签、排序键,以及绝不限制textScaler。i18n-rtl-l10n— 负责方向性几何(EdgeInsetsDirectional/AlignmentDirectional)和 ARB 本地化规则;这些组件渲染的字符串和内边距必须遵循这些规则。dart3-idioms-and-coding-standards— 本规则引用的复杂度限制表;各数据类应采用的构造;记录仅限层内使用;不可变值类型。adaptive-layout— 响应式断点、平板/可折叠设备上的主从布局,以及根据宽度选择 NavigationRail 还是 BottomNav(围绕这些结构原语的响应式布局层)。forms-and-input— 本屏幕组合使用的输入字段所需的Form/TextFormField验证、焦点遍历和键盘操作配置。widget-golden-and-a11y-testing— 如何对提取出的组件类执行 pump,并针对不同设备和缩放比例断言布局/溢出/黄金测试结果。custom-canvas-and-gestures— 唯一的例外:像素由CustomPainter绘制,而不是由组件树呈现。
参考资料
- Flutter — 性能最佳实践(const 构造函数、尽量减少重建、惰性构建器)
- Flutter — UI 层案例研究 / 哑视图
- Flutter API —
StatelessWidget·ListView.builder·EdgeInsetsDirectional·SafeArea - Riverpod — 读取 provider /
ConsumerWidget、select - Dart — Effective Dart:风格 · Linter 规则