mcpField
W
widget-composition

widget-composition

github.com/zakariaf开发63176 次安装5 周前更新
代码仓库

关于 widget-composition

Widget Composition 是面向 Flutter 开发的技能,指导使用职责单一的小型 Widget 类构建界面。它涵盖组合方式、重建行为、状态边界、布局、键和无障碍,并提供参考文档与 PR 前检查脚本。

主要功能

  1. 01用职责明确的 Widget 类组合界面
  2. 02保持 build 方法简洁浅层
  3. 03分离视图、状态与领域逻辑
  4. 04使用延迟列表和稳定键
  5. 05采用方向性布局并提供手势替代操作
  6. 06运行 Widget 组合检查脚本

适用场景

  1. 01设计 Flutter 界面或共享组件
  2. 02检查 Widget 重建与状态边界
  3. 03实现列表、网格或适配布局
  4. 04提交 PR 前检查 Widget 组合

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zakariaf/flutter-skills --skill widget-composition

安装后在 Claude Code 中输入/widget-composition确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
feature_view.dart
structural_grid.dart
references/
rebuild-mechanics.md
structural-layout.md
widget-and-data-selection.md
scripts/
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。

不容妥协的规则

  1. 提取 Widget 类,绝不提取返回 Widget 的方法。 Widget _buildHeader() 不是一个 widget——它的子树没有自己的 Element,因此 Element.updateChild 无法比较新旧对象并跳过更新;它会随父级一起重建,不能声明为 const,不能设置 Key,测试中也无法通过 find.byType 找到它。
  2. 在所有允许的地方使用 const,每个字段都声明为 final。 const 构造函数会将相同的 widget 规范化为同一个对象,从而让 Flutter 跳过子树重建。启用 prefer_const_constructors 和 prefer_const_constructors_in_immutables。不要花时间手动调整 const——让 dart fix --apply 自动处理;绝不要为了让某些内容能声明为 const 而重构树结构。
  3. 让 build() 保持简短、浅层。 超出 dart3-idioms-and-coding-standards 规定的复杂度限制时(build() ≤ 80 行;widget build 嵌套层级 ≤ 5——表格对 widget 树比逻辑多允许的深层嵌套例外为 ≤ 3),提取子 widget 类。需要滚动才能读完的 build() 就太长了。
  4. 每个 widget 只负责一件事。 一个组件只渲染一项内容;它不会同时获取数据、格式化值、读取时钟、持有应用状态或安排副作用。
  5. build() 中不得执行 I/O、格式化或领域计算。 build() 每秒可能运行 60 次——不要调用 repository/DAO,不要使用 DateTime.now()、NumberFormat,也不要排序。Notifier 会预先计算好可直接渲染的 UI 状态值;widget 只负责绘制。
  6. View 应保持简单,并且只监听一个 ViewModel。 ConsumerWidget 调用 ref.watch(oneNotifierProvider) 并进行渲染;用户操作通过 ref.read(...notifier) 传回。它绝不会直接访问 repository 或 DAO,也不会修改持久化状态。
  7. 默认使用 StatelessWidget/ConsumerWidget。 只有在确实需要局部、短暂的 UI 状态时,才使用 StatefulWidget/ConsumerStatefulWidget(AnimationController、TextEditingController、ScrollController、FocusNode)。应用、领域和会话状态应放在 Notifier 中。
  8. 始终对在 State 中创建的控制器调用 dispose()。 泄漏的 AnimationController/TextEditingController/ScrollController/FocusNode/subscriptions 会占用内存,并在销毁后触发回调。cancel_subscriptions/close_sinks 会产生错误。
  9. 列表应采用惰性构建。 对可变长度或较长的列表使用 ListView.builder/SliverList。对于无界数据,绝不要使用 ListView(children: items.map(...).toList())——它会一次性构建每一行。
  10. 在回调中解析标识,而不是内容。 点击闭包应捕获稳定的 ID(onTap: () => onSelect(item.id)),而不是捕获内容值——编辑后再次点击时,不应使用过时的捕获值。
  11. 每种手势都需要醒目且可聚焦的备用方式。 任何可通过触摸触发的行为,也必须能通过带标签的可聚焦控件触发。仅能通过拖动或长按触发的操作,无法由开关辅助操作和屏幕阅读器用户使用。
  12. 使用主题/令牌和逻辑方向设置样式。 颜色/文本样式应来自 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 绘制,而不是由组件树呈现。

参考资料

评论

支持 Markdown