widget-composition

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Widget Composition

Widget 组合

Build every screen from many small,
const
, single-purpose widget classes. A widget that does one thing rebuilds cheaply, reads clearly, tests in isolation, and can be lifted into a shared component library. The View is dumb: it watches one ViewModel and renders; logic lives in the Notifier, state in immutable value objects, layout in directional structural primitives. Applies to any screen, list, card, or shared component.
Read the reference for the task at hand:
  • references/rebuild-mechanics.md
    — why a class beats a method (
    Element.updateChild
    ),
    const
    canonicalization, the full key policy, and
    .select
    for narrowed rebuilds.
  • references/structural-layout.md
    — edge-to-edge background vs
    SafeArea
    content, computed cell sizing, the
    GridView
    cross/main-axis trap, directional insets, and IME/
    resizeToAvoidBottomInset
    .
  • references/widget-and-data-selection.md
    — cheapest-widget table, data-class decision table (drift row vs record vs hand-written vs freezed), and gesture→fallback rules.
Run
scripts/check-widget-composition.sh
before a PR.
每个 screen 都应由许多小型、
const
、单一职责的 Widget 类组成。只做一件事的 widget rebuild 成本低、读起来清晰、可独立测试,并且可以提取到共享组件库中。View 应当保持 dumb:只 watch 一个 ViewModel 并负责渲染;逻辑放在 Notifier 中,状态放在不可变的 value object 中,布局则使用支持方向的结构化 primitive。适用于任何 screen、列表、卡片或共享组件。
根据当前任务阅读对应的参考资料:
  • references/rebuild-mechanics.md
    ——为什么 class 优于 method(
    Element.updateChild
    )、
    const
    规范化、完整的 key 策略,以及通过
    .select
    缩小 rebuild 范围。
  • references/structural-layout.md
    ——edge-to-edge 背景与
    SafeArea
    内容、动态计算的 cell 尺寸、
    GridView
    交叉轴/主轴陷阱、方向性 inset,以及 IME/
    resizeToAvoidBottomInset
    。
  • references/widget-and-data-selection.md
    ——开销最低的 widget 对照表、data class 决策表(drift row、record、手写类或 freezed),以及手势到回退操作的规则。
提交 PR 前运行
scripts/check-widget-composition.sh
。

Non-negotiable rules

不可妥协的规则

  1. Extract a
    Widget
    class, never a
    Widget
    -returning method.
    Widget _buildHeader()
    is not a widget — its subtree has no
    Element
    of its own, so
    Element.updateChild
    never gets to compare old vs. new and short-circuit; it rebuilds with the parent, cannot be
    const
    , cannot take a
    Key
    , and
    find.byType
    cannot reach it in a test.
  2. const
    everywhere it is legal, every field
    final
    .
    const
    constructors canonicalize identical widgets to the same object, letting Flutter prune the subtree rebuild. Turn on
    prefer_const_constructors
    +
    prefer_const_constructors_in_immutables
    . Budget zero minutes hand-tuning const — let
    dart fix --apply
    write it; never restructure a tree to make something const-able.
  3. Keep
    build()
    small and shallow.
    Past the complexity limits owned by
    dart3-idioms-and-coding-standards
    (
    build()
    ≤ 80 lines; widget build nesting ≤ 5 — the deeper-nesting exception that table states for widget trees vs. logic ≤ 3), extract child widget classes. A
    build()
    you must scroll to read is too big.
  4. One responsibility per widget. A component renders one thing; it does not also fetch data, format values, read a clock, hold app state, or schedule side effects.
  5. No I/O, formatting, or domain math in
    build()
    .
    build()
    can run 60×/second — no repository/DAO call, no
    DateTime.now()
    , no
    NumberFormat
    , no sorting. The Notifier precomputes a ready-to-render UI-state value; the widget only paints it.
  6. The View is dumb and watches exactly one ViewModel. A
    ConsumerWidget
    does
    ref.watch(oneNotifierProvider)
    and renders; intents go back via
    ref.read(...notifier)
    . It never reaches a repository or DAO directly and never mutates persisted state.
  7. StatelessWidget
    /
    ConsumerWidget
    by default.
    Use
    StatefulWidget
    /
    ConsumerStatefulWidget
    only for genuinely local, ephemeral UI state (
    AnimationController
    ,
    TextEditingController
    ,
    ScrollController
    ,
    FocusNode
    ). App/domain/session state lives in the Notifier.
  8. Always
    dispose()
    controllers created in
    State
    .
    Leaked
    AnimationController
    /
    TextEditingController
    /
    ScrollController
    /
    FocusNode
    /subscriptions retain memory and fire after teardown.
    cancel_subscriptions
    /
    close_sinks
    are errors.
  9. Lists are lazy. Use
    ListView.builder
    /
    SliverList
    for variable/long lists. Never
    ListView(children: items.map(...).toList())
    for unbounded data — it builds every row eagerly.
  10. Resolve identity, not content, in callbacks. A tap closure captures the stable id (
    onTap: () => onSelect(item.id)
    ), never a captured content value — a re-tap after an edit must not act on a stale capture.
  11. Every gesture needs a visible, focusable fallback. Any behaviour reachable by touch must also be reachable by a labelled focusable control. A drag-only or long-press-only action is unreachable to switch-access and screen-reader users.
  12. Style from theme/tokens and logical directions. Colors/text styles come from
    Theme.of(context)
    or a
    ThemeExtension
    , never literal hex. Use
    EdgeInsetsDirectional
    /
    AlignmentDirectional
    (
    start
    /
    end
    ), never hardcoded left/right, so RTL mirrors by construction.
  1. 提取
    Widget
    类,绝不要使用返回
    Widget
    的方法。
    Widget _buildHeader()
    并不是一个独立 widget——它的子树没有自己的
    Element
    ,因此
    Element.updateChild
    无法比较新旧实例并提前短路;它会随父级一起 rebuild,无法使用
    const
    ,不能接收
    Key
    ,测试中也无法通过
    find.byType
    找到它。
  2. 在合法处一律使用
    const
    ,每个字段都设为
    final
    。
    const
    constructor 会将相同的 widget 规范化为同一对象,让 Flutter 可以跳过子树重建。启用
    prefer_const_constructors
    和
    prefer_const_constructors_in_immutables
    。不要浪费时间手工调优 const——让
    dart fix --apply
    来写;也不要为了使某段内容可声明为 const 而重构 widget 树。
  3. 保持
    build()
    小而浅。
    在
    dart3-idioms-and-coding-standards
    所定义的复杂度限制内(
    build()
    ≤ 80 行;widget build 嵌套 ≤ 5——相对于逻辑代码 ≤ 3,该表为 widget 树规定了更深嵌套的例外),应提取子 widget 类。需要滚动才能读完的
    build()
    一定太大。
  4. 每个 widget 只承担一项职责。 组件只负责渲染一件事;它不应同时获取数据、格式化数值、读取时钟、持有应用状态或调度副作用。
  5. 禁止在
    build()
    中执行 I/O、格式化或领域计算。
    build()
    每秒最多可能执行 60 次——不得调用 repository/DAO、调用
    DateTime.now()
    、使用
    NumberFormat
    或执行排序。Notifier 应预先计算出可直接渲染的 UI-state 值;widget 只负责绘制它。
  6. View 保持 dumb,并且只 watch 恰好一个 ViewModel。
    ConsumerWidget
    执行
    ref.watch(oneNotifierProvider)
    并负责渲染;intent 通过
    ref.read(...notifier)
    回传。它绝不直接访问 repository 或 DAO,也绝不修改持久化状态。
  7. 默认使用
    StatelessWidget
    /
    ConsumerWidget
    。
    只有真正属于本地的临时 UI 状态才使用
    StatefulWidget
    /
    ConsumerStatefulWidget
    (如
    AnimationController
    、
    TextEditingController
    、
    ScrollController
    、
    FocusNode
    )。应用状态、领域状态和会话状态都放在 Notifier 中。
  8. 始终
    dispose()
    在
    State
    中创建的 controller。
    泄漏的
    AnimationController
    /
    TextEditingController
    /
    ScrollController
    /
    FocusNode
    /subscription 会保留内存,并在 teardown 后继续触发。遗漏
    cancel_subscriptions
    /
    close_sinks
    属于错误。
  9. 列表必须惰性构建。 对可变或长列表使用
    ListView.builder
    /
    SliverList
    。对于无界数据,绝不要使用
    ListView(children: items.map(...).toList())
    ,因为它会立即构建所有 row。
  10. 回调中解析身份标识,而不是内容。 点击闭包应捕获稳定的 id(
    onTap: () => onSelect(item.id)
    ),绝不捕获内容值——编辑后再次点击时,不得对过期捕获执行操作。
  11. 每个手势都需要可见且可聚焦的回退操作。 任何可通过触摸触达的行为,都必须能够通过带标签且可聚焦的控件触达。仅支持拖拽或长按的操作,对 switch-access 用户和屏幕阅读器用户不可用。
  12. 使用 theme/token 和逻辑方向设置样式。 颜色和文本样式来自
    Theme.of(context)
    或
    ThemeExtension
    ,绝不使用字面量 hex。使用
    EdgeInsetsDirectional
    /
    AlignmentDirectional
    (
    start
    /
    end
    ),绝不硬编码 left/right,从而确保 RTL 自动镜像。

Extract a widget vs. a method

提取 widget 还是使用 method

dart
// 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;

  
  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),
            ],
          ),
        ),
      ),
    );
  }
}
dart
// 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;

  
  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),
            ],
          ),
        ),
      ),
    );
  }
}

Keep build() lean — the Notifier precomputes

保持 build() 精简——由 Notifier 预计算

dart
// WRONG — domain math + formatting inside build()

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

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))
rebuilds only when that field changes — the Riverpod equivalent of a leaf
const
. See
state-management-riverpod
for the watch/read/listen split and
flutter-performance
for
.select
and const-subtree scoping.
dart
// WRONG — domain math + formatting inside build()

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

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))
仅在该字段发生变化时 rebuild——这是 Riverpod 中与叶子
const
等效的机制。
state-management-riverpod
介绍了 watch/read/listen 的职责划分,
flutter-performance
则介绍了
.select
和 const 子树的作用范围。

Composition guidance

组合指南

  • Split a screen into sections (header, body, footer) as separate widget classes; split a row into its parts when a part is reused or complex.
  • Pass data down, callbacks up. A component receives what it shows and exposes
    onX
    callbacks; it never reaches into a parent or a provider it does not own.
  • Read
    MediaQuery
    /
    Theme
    in the leaf that needs it
    , not at the top of a big
    build()
    — reading at the top rebuilds the whole subtree on every metrics change.
  • Prefer the cheapest widget:
    SizedBox
    over
    Container
    for spacing;
    ColoredBox
    /
    DecoratedBox
    over
    Container
    for a single effect;
    Align
    /
    Center
    over
    Row
    +
    Spacer
    . See
    references/widget-and-data-selection.md
    .
  • Hoist unchanging subtrees into
    const
    locals
    so they survive rebuilds.
  • A shared component is domain-blind and provider-free. It takes primitives, localized labels, immutable view-data, and callbacks through its constructor — never a domain entity, a repository, or a
    ref
    . The dependency arrow is one-way: features import the shared library, never the reverse.
  • 将 screen 拆分为多个 section(header、body、footer),每个 section 使用独立的 widget class;如果 row 中的某一部分会被复用或逻辑复杂,也应继续拆分。
  • 数据向下传递,回调向上传递。 组件只接收展示所需的数据,并通过
    onX
    回调对外暴露;绝不访问父级,也不读取自己不拥有的 provider。
  • 只在需要它的叶子节点中读取
    MediaQuery
    /
    Theme
    ,不要在大型
    build()
    顶部读取——在顶部读取会导致每次尺寸变化都重建整个子树。
  • 优先使用开销最低的 widget: 间距使用
    SizedBox
    而非
    Container
    ;单一视觉效果使用
    ColoredBox
    /
    DecoratedBox
    而非
    Container
    ;居中或对齐使用
    Align
    /
    Center
    ,而不是
    Row
    +
    Spacer
    。参见
    references/widget-and-data-selection.md
    。
  • 将不会变化的子树提升为
    const
    局部变量
    ,使其能够在 rebuild 后继续复用。
  • 共享组件应与领域无关且不依赖 provider。 它只通过 constructor 接收基础类型、已本地化标签、不可变 view-data 和回调——绝不接收领域实体、repository 或
    ref
    。依赖方向是单向的:feature 导入 shared library,反向依赖绝不允许。

Keys

Key

Keys matter in exactly one situation: the framework must decide whether an
Element
(and its
State
) can be reused for a new
Widget
at the same position in a child list. The classic bug is stale
State
bleeding into a reused slot after a list reorders.
SituationKey
A row in a reorderable / filterable / variable list
ValueKey(item.id)
— stable identity survives the move
A fixed-order, fixed-length list; leaf stateless widgetsnone — a key changes nothing
Swapping two widgets of the same type and needing to preserve statea
ValueKey
distinguishing them
A widget that suddenly "wants" a
GlobalKey
stop — that is a design smell, not a fix
Never add
GlobalKey
for identity or lookups — it forces a global registry lookup and enables cross-tree reparenting, machinery most apps do not need. Full mechanism in
references/rebuild-mechanics.md
.
Key 仅在一种情况下很重要:框架需要判断同一子列表位置上的新 Widget 是否可以复用旧 Widget 的
Element
(及其
State
)。典型 bug 是列表重排后,过期的
State
渗入被复用的 slot。
场景Key
可重排、可过滤或长度可变列表中的 row
ValueKey(item.id)
——稳定身份在移动后依然有效
顺序固定、长度固定的列表;叶子 stateless widget无——key 不会带来任何变化
交换两个同类型 widget,且需要保留其状态使用能够区分二者的
ValueKey
突然“想要”使用
GlobalKey
的 widget
停止——这是设计异味,而不是修复方案
绝不要为了身份标识或查找而添加
GlobalKey
——它会强制执行全局 registry 查找,并支持跨树 reparenting,大多数应用都不需要这些机制。完整机制见
references/rebuild-mechanics.md
。

Data classes for view-data

用于 view-data 的 data class

Do not hand-roll a data class where a generator already emits one. The decision rule:
ShapeUse
Persisted rowthe drift-generated row class directly — it has
==
,
hashCode
,
copyWith
already
Ephemeral multi-return inside one layera record —
(int row, int col)
; never crosses a layer boundary
Hand-authored domain value / UI-state
@immutable
+
const
+
final
for a trivial value;
freezed
for non-trivial value types, unions, or
copyWith
churn —
dart3-idioms-and-coding-standards
owns the threshold
A joined shape no generator emitshand-written
@immutable
class
Do not add
equatable
for one
==
;
Object.hash(...)
is five lines. Full table and rationale in
references/widget-and-data-selection.md
. See
dart3-idioms-and-coding-standards
for value-type and record rules.
如果已有 generator 能生成 data class,就不要手写。决策规则如下:
结构使用方式
持久化 row直接使用由 drift 生成的 row class——它已经具备
==
、
hashCode
和
copyWith
某一层内部的临时多返回值使用 record——
(int row, int col)
;绝不跨层边界
手写的领域值或 UI-state简单值使用
@immutable
+
const
+
final
;复杂的 value type、union 或频繁变化的
copyWith
使用
freezed
——具体阈值由
dart3-idioms-and-coding-standards
决定
没有 generator 可生成的组合结构手写
@immutable
class
不要仅为实现一个
==
就添加
equatable
;
Object.hash(...)
只需五行。完整对照表和设计理由见
references/widget-and-data-selection.md
。value type 和 record 规则参见
dart3-idioms-and-coding-standards
。

Structural layout

结构化布局

The window is edge-to-edge; the targets are inset — never the same question. Paint the background full-bleed behind the system bars, then wrap the interactive content in
SafeArea
and add margins inside it.
dart
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,
    ),
  ),
)
Compute cell size from the viewport; never hardcode a tile dimension and never assert a minimum one (a size floor fires exactly where large text needs the room most — the real constraint is that the label fits). The
GridView
axis trap is silent: in a vertical grid,
crossAxisSpacing
is the column gap and
mainAxisSpacing
is the row gap.
dart
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),
);
Full treatment — computed sizing math, IME behaviour,
MediaQuery.viewInsets
, and when to use a
Column
+grid instead of a spanning cell — in
references/structural-layout.md
. See
accessibility-as-code
for reading a11y state from
MediaQuery
and never clamping
textScaler
, and
design-system-structure
for token/theme structure.
窗口采用 edge-to-edge;交互目标则需要 inset——不要把二者当成同一个问题。背景应在 system bar 后方满幅绘制,而交互内容应包裹在
SafeArea
中,并在其内部添加 margin。
dart
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,
    ),
  ),
)
根据 viewport 计算 cell 尺寸;绝不硬编码 tile 尺寸,也绝不设置尺寸下限——尺寸下限恰好会在大文本最需要空间时触发,真正的约束是 label 必须能够容纳。
GridView
的轴向陷阱悄无声息:在纵向 grid 中,
crossAxisSpacing
是列间距,
mainAxisSpacing
是行间距。
dart
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
+ grid,而不是让某个单元格横跨多列——见
references/structural-layout.md
。从
MediaQuery
读取 a11y 状态以及绝不限制
textScaler
,参见
accessibility-as-code
;token/theme 结构参见
design-system-structure
。

Anti-patterns

反模式

  • Widget _buildXxx()
    helper methods
    for any non-trivial UI — no
    Element
    boundary, no const, no key, untestable. Make a class.
  • God-widget: a 300-line
    build()
    with the whole screen inline.
  • Repository/DAO/
    SharedPreferences
    call in
    build()
    or
    initState()
    — route through the Notifier and a single write path.
  • DateTime.now()
    in a widget
    — a widget never reads a wall clock; the Notifier precomputes time-derived values from
    Clock
    injected via
    clockProvider
    (see
    state-management-riverpod
    ).
  • Creating controllers in
    build()
    (recreated every frame) instead of in
    State
    +
    dispose()
    .
  • setState()
    to refresh app/session data
    that belongs in a Notifier —
    setState
    is only for local ephemeral UI.
  • ListView(children: hugeList.map(...).toList())
    — builds every row eagerly. Use
    .builder
    .
  • Container
    for everything
    (over-wrapping) — use the specific, cheaper widget.
  • GlobalKey
    /
    ObjectKey
    for identity
    — delete; use
    ValueKey(id)
    where a list reorders.
  • Hardcoded hex / left-right padding / literal user strings — use theme tokens,
    EdgeInsetsDirectional
    , and ARB localization.
  • A gesture with no visible fallback, or clamping
    textScaler
    to force a layout to fit — fix the layout, not the scale.
  • A shared-library widget importing a domain type or reading a provider — it can no longer be reasoned about or tested in isolation.
  • 对任何非简单 UI 使用
    Widget _buildXxx()
    辅助方法
    ——它没有
    Element
    边界、没有 const、没有 key,也无法独立测试。应当创建 class。
  • God-widget:将整个 screen 内联到 300 行的
    build()
    中。
  • 在
    build()
    或
    initState()
    中调用 Repository/DAO/
    SharedPreferences
    ——应通过 Notifier 和单一写入路径处理。
  • 在 widget 中调用
    DateTime.now()
    ——widget 绝不读取墙上时钟;Notifier 应从通过
    clockProvider
    注入的
    Clock
    预计算时间派生值(参见
    state-management-riverpod
    )。
  • 在
    build()
    中创建 controller(每帧都会重新创建),而不是在
    State
    中创建并调用
    dispose()
    。
  • 使用
    setState()
    刷新属于 Notifier 的应用或会话数据
    ——
    setState
    仅用于本地临时状态。
  • 使用
    ListView(children: hugeList.map(...).toList())
    ——会立即构建所有 row。改用
    .builder
    。
  • 所有地方都使用
    Container
    ,造成过度包装——应使用更具体、开销更低的 widget。
  • 使用
    GlobalKey
    /
    ObjectKey
    作为身份标识
    ——应删除;可重排列表使用
    ValueKey(id)
    。
  • 硬编码 hex、left-right padding 或直接写入用户文案——应使用 theme token、
    EdgeInsetsDirectional
    和 ARB localization。
  • 手势没有可见回退操作,或为了迫使布局适配而限制
    textScaler
    ——应修复布局,而不是缩放比例。
  • 共享库 widget 导入领域类型或读取 provider——这样将无法再对其进行独立推理和测试。

Definition of done

完成标准

  • Every reusable/non-trivial subtree is a
    Widget
    class (not a
    _buildX()
    method);
    const
    where legal; all fields
    final
    .
  • build()
    ≤ ~80 lines; nesting shallow; complex parts extracted.
  • No I/O, repository call,
    DateTime.now()
    , formatting, or domain math in
    build()
    — the Notifier precomputes UI state.
  • The View is a
    ConsumerWidget
    watching one Notifier; intents routed via
    ref.read(...notifier)
    ; nothing mutates persisted state in the widget.
  • StatefulWidget
    only for local ephemeral state; every created controller
    dispose()
    d.
  • Long/variable lists use
    .builder
    ; reorderable lists carry
    ValueKey(id)
    ; no
    GlobalKey
    for identity.
  • Callbacks resolve a stable id, not a captured content value.
  • Any shared widget takes primitives/callbacks only, imports no domain type and no provider.
  • Background full-bleed, content in
    SafeArea
    ; directional insets;
    GridView
    spacing axes correct; no hardcoded/floored cell size.
  • Styling from theme/tokens; every gesture has a visible focusable fallback;
    textScaler
    never clamped.
  • dart format
    clean;
    dart analyze --fatal-infos
    clean;
    scripts/check-widget-composition.sh
    passes.
  • 每个可复用或非简单子树都是
    Widget
    class,而不是
    _buildX()
    方法;合法处使用
    const
    ;所有字段均为
    final
    。
  • build()
    ≤ 约 80 行;嵌套浅显;复杂部分已提取。
  • build()
    中没有 I/O、repository 调用、
    DateTime.now()
    、格式化或领域计算——由 Notifier 预计算 UI state。
  • View 是 watch 一个 Notifier 的
    ConsumerWidget
    ;intent 通过
    ref.read(...notifier)
    路由;widget 不修改任何持久化状态。
  • StatefulWidget
    仅用于本地临时状态;所有创建的 controller 均已
    dispose()
    。
  • 长列表或可变列表使用
    .builder
    ;可重排列表携带
    ValueKey(id)
    ;不以
    GlobalKey
    作为身份标识。
  • 回调解析稳定的 id,而不是捕获内容值。
  • 所有共享 widget 只接收基础类型和回调,不导入领域类型,也不读取 provider。
  • 背景满幅铺开,内容位于
    SafeArea
    中;使用方向性 inset;
    GridView
    间距轴向正确;cell 尺寸既不硬编码也不设置下限。
  • 样式来自 theme/token;每个手势都有可见且可聚焦的回退操作;绝不限制
    textScaler
    。
  • dart format
    无差异;
    dart analyze --fatal-infos
    无问题;
    scripts/check-widget-composition.sh
    检查通过。

Related skills

相关 skill

  • flutter-architecture
    — the layer DAG and the dumb-View contract this widget sits inside; widget-composition is the deep dive on building those Views.
  • flutter-performance
    — const subtrees,
    .select
    rebuild scoping,
    RepaintBoundary
    , dispose discipline, measuring in profile mode.
  • state-management-riverpod
    — the one-Notifier-per-feature ViewModel this View watches, and the watch/read/listen split.
  • design-system-structure
    — how tokens/theme/components are structured and consumed (the source of the styles this skill reads).
  • accessibility-as-code
    — reading a11y state from
    MediaQuery
    ,
    Semantics
    roles/labels, sort keys, never clamping
    textScaler
    .
  • i18n-rtl-l10n
    — owns the directional-geometry (
    EdgeInsetsDirectional
    /
    AlignmentDirectional
    ) and ARB-localization rules the strings and insets these widgets render must follow.
  • dart3-idioms-and-coding-standards
    — the complexity-limit table these rules cite; which construct each data class earns; records intra-layer only; immutable value types.
  • adaptive-layout
    — responsive breakpoints, tablet/foldable master-detail, and NavigationRail-vs-BottomNav by width (the responsiveness layer around these structural primitives).
  • forms-and-input
    —
    Form
    /
    TextFormField
    validation, focus traversal, and keyboard-action wiring for the input fields these screens compose.
  • widget-golden-and-a11y-testing
    — pumping an extracted widget class and asserting layout/overflow/golden across device and scale.
  • custom-canvas-and-gestures
    — the one exception where pixels live on a
    CustomPainter
    instead of a widget tree.
  • flutter-architecture
    ——widget 所处的层级 DAG 和 dumb-View 契约;widget-composition 则是深入构建这些 View 的专题说明。
  • flutter-performance
    ——const 子树、
    .select
    rebuild 范围控制、
    RepaintBoundary
    、dispose 纪律,以及在 profile mode 中进行测量。
  • state-management-riverpod
    ——每个 feature 使用一个 Notifier 的 ViewModel,以及 watch/read/listen 的职责划分。
  • design-system-structure
    ——token/theme/component 的组织与使用方式,也是此 skill 所读取样式的来源。
  • accessibility-as-code
    ——从
    MediaQuery
    读取 a11y 状态、
    Semantics
    role/label、sort key,以及绝不限制
    textScaler
    。
  • i18n-rtl-l10n
    ——负责方向几何(
    EdgeInsetsDirectional
    /
    AlignmentDirectional
    ),以及 widget 所渲染字符串和 inset 必须遵循的 ARB-localization 规则。
  • dart3-idioms-and-coding-standards
    ——这些规则引用的复杂度限制表;每种数据结构应采用的构造;record 仅限层内使用;不可变 value type。
  • adaptive-layout
    ——响应式断点、tablet/foldable master-detail,以及根据宽度在 NavigationRail 和 BottomNav 之间选择。
  • forms-and-input
    ——
    Form
    /
    TextFormField
    校验、focus traversal,以及输入字段的 keyboard-action 连接。
  • widget-golden-and-a11y-testing
    ——泵入提取后的 widget class,并跨设备和缩放比例断言布局、overflow 与 golden。
  • custom-canvas-and-gestures
    ——唯一允许像素绘制在
    CustomPainter
    中,而非 widget 树中的例外。

References

参考资料