mcpField
S
scaffold-feature-module

scaffold-feature-module

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

关于 scaffold-feature-module

这项技能指导在 Flutter 应用中按统一结构创建功能模块,采用 Riverpod、仓储层和类型化路由。它明确界面、ViewModel、共享数据与其他功能之间的边界,并提供脚手架和验证脚本。

主要功能

  1. 01标准功能文件夹结构
  2. 02Riverpod Provider 与 Notifier ViewModel
  3. 03通过仓储层读写数据
  4. 04注册类型化 go_router 路由
  5. 05脚手架生成与验证脚本

适用场景

  1. 01按统一模块结构添加页面
  2. 02将功能状态连接到仓储数据流
  3. 03提交前检查功能边界

安装

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

$npx skills add https://github.com/zakariaf/flutter-skills --skill scaffold-feature-module

安装后在 Claude Code 中输入/scaffold-feature-module确认已加载。

包含的文件

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

examples/
tasks_notifier.dart
tasks_screen.dart
references/
add-persisted-record.md
anatomy-and-wiring.md
routing-and-l10n.md
scripts/
scaffold_feature.sh
verify_feature.sh

详细介绍

搭建功能模块

始终以相同方式搭建一个功能:创建 lib/features/<feature>/ 文件夹,其中 presentation/ 存放一个无逻辑的 View、与其一一对应的 Notifier/AsyncNotifier ViewModel、叶子节点 widgets/ 和作用域限定的 providers(功能本地模型可选地存放在 domain/ 中)。功能是文件夹,不是 package。它读取 lib/data/ 中的共享 repositories 和 lib/core/ 中的共享基础设施,将所有写入 都交由它们处理,并且只依赖更底层的模块——绝不横向依赖另一个功能。 (单 package 目录树由 project-structure-and-packages 管理。)

与具体实现无关的核心原则(适用于任何状态库):View 不包含业务逻辑;每个功能由一个 ViewModel 负责私有可变状态,并通过意图方法暴露操作,这些方法会赋予状态一个新的不可变值;读取由 repository 推导而来(唯一事实来源),写入通过一个 repository 方法完成(唯一写入路径,先持久化再发布);功能依赖 抽象,并通过路由 ID 导航。下面优先介绍 Riverpod 的实现方式,使用 Notifier、作用域限定的 StreamProviders 和作为 DI 的 providers。

根据当前任务阅读参考资料:

  • references/anatomy-and-wiring.md — 固定的文件夹结构、Notifier + 作用域限定的 stream、watch/read/listen、family+autoDispose 与 keepAlive、唯一写入路径,以及 ViewModel 测试。
  • references/routing-and-l10n.md — 类型安全的 go_router 注册(使用路径参数,不使用 state.extra)以及 ARB 对照流程。
  • references/add-persisted-record.md — 功能读取全新记录时可选的 model → table → DAO → repository 链;具体做法遵循 persistence-drift。

运行 scripts/scaffold_feature.sh <feature> 生成骨架,然后在提交 PR 前运行 scripts/verify_feature.sh。

不可妥协的规则

  1. 功能是一个文件夹,绝不是新建的 package。 使用 lib/features/<feature>/,命名采用 lower_snake_case,并以屏幕命名(tasks、task_detail、settings、onboarding)。新建文件夹是常规扩展;新建 package 则是有意为之的边界决策(参见 project-structure-and-packages)。不要臆造编号文件夹或固定 package 数量的约定。应用 package 不使用 lib/src/——那是多 package 项目的约定(参见 project-structure-and-packages)。
  2. 结构固定,每个文件仅包含一个主要公开类型。 在 lib/features/<feature>/presentation/ 下:<feature>_screen.dart(无逻辑的 View)、<feature>_notifier.dart(一一对应的 Notifier/AsyncNotifier ViewModel——根据 naming-conventions,文件名 = 其主要声明的名称)、widgets/(叶子视图),以及 <feature>_providers.dart(仅限此功能范围内的 providers,绝不放全局 providers)。功能本地模型可放在可选的 domain/<feature>.dart 中。结构清晰可预测 = 评审者一秒就能找到任何部分。
  3. View 不含逻辑;ViewModel 与 View 一一对应。 View 只读取一个控制器并渲染——仅包含显示/隐藏用的 if、布局、动画和导航。不得调用 repository,不得包含 try/catch 业务逻辑,也不得在 build() 中进行单位换算或格式化。逻辑放在 ViewModel 中。
  4. 功能绝不导入其他功能。 通过基础层(core/data)共享,或通过路由 ID 导航。依赖只能向下——lint/grep 检查会阻止跨功能导入。横向耦合会让两个功能变成一个无法拆分的死结。
  5. 仅通过 repository providers 读取存储;将 .watch() 包装在有作用域的 StreamProvider 中。 不得从 widget 或 ViewModel 直接访问 Drift、安全存储或平台通道。按键(所有者/父级 ID、时间窗口)限定流的作用域,确保一次写入不会导致整个应用重新发出数据。
  6. 在 build 中使用 ref.watch,在回调中使用 ref.read,用 ref.listen 处理一次性副作用(SnackBar、导航)。在回调中 watch 会重新订阅;在 build 中 read 会错过重建。(完整划分参见 state-management-riverpod。)
  7. 按键区分的状态使用 family + autoDispose;单例使用 keepAlive。 任何限定到特定实体/会话的 provider,都必须以稳定且可相等比较的值作为 family 键,并使用 autoDispose,使其随屏幕销毁。绝不使用无键的“当前对象” provider;绝不对应用作用域的 db/service 单例使用 autoDispose。
  8. 每次变更都通过一个 repository 方法完成——单一写入路径,先持久化再发布。 ViewModel 中的命令调用一个具名 repository 方法,该方法先提交,然后流重新发出数据,UI 随之重建。不得乐观地重新发布数据,不得使用 setState,不得手动触碰缓存,也不得“稍后保存”。
  9. 派生计算,不要存储。 计数、总额、筛选列表、连续记录都从事实来源计算(使用派生/流 provider),绝不存储第二份会失去同步的计数器。
  10. Repository 将行映射为值对象;ViewModel 传递值对象。 Drift 行、companion 或 Map 绝不能传到 ViewModel 或 widget。测量量应使用值对象(参见 value-objects-money-and-units)——金额绝不使用裸 double。
  11. 错误是以 AsyncValue 呈现的类型化值。 Repository 返回封闭的 Result/Failure;ViewModel 将其映射为 AsyncData/AsyncError;View 使用 switch 穷尽处理,并在展示层边界本地化 Failure——绝不显示原始异常字符串。(参见 error-handling-typed-results。)
  12. 在唯一的 go_router 中注册带类型的路由;身份信息放在路径参数中。 添加 TypedGoRoute/GoRouteData;将可用于深层链接的身份信息放在路径参数(:taskId)中,绝不使用 state.extra(冷启动/OS 恢复时为空)。全屏添加/编辑流程设置 parentNavigatorKey: rootNavigatorKey。路由器本身——shell/branch 结构、redirect/auth 守卫、深层链接、转场——由 navigation-and-routing 负责;此功能只需向其中注册自己的路由。
  13. 从 View 或路由器 redirect 导航——绝不从 ViewModel 导航。 控制器发布状态;View 或 redirect 决定显示什么页面。在控制器中 push 路由会让逻辑与导航耦合,并破坏无头测试。
  14. 注入“当前时间”;绝不在功能中调用 DateTime.now()。 ViewModel 读取 Clock provider,这样整个流程都可以通过固定的假时钟进行断言。(参见 async-safety。)
  15. 只使用方向性几何属性和本地化字符串。 使用 EdgeInsetsDirectional/AlignmentDirectional——绝不使用 .left/.right、Alignment.centerLeft 或 TextAlign.left。所有面向用户的字符串都必须来自 gen-l10n,并确保所有语言环境的 ARB 文件中的键和占位符一致。(参见 i18n-rtl-l10n。)
  16. 通过伪造 repository 测试 ViewModel,而不是伪造 Notifier。 使用 ProviderContainer.test() + overrideWith 注入伪造的 repository,并使用 clockProvider.overrideWithValue(Clock.fixed(t));不进行 widget pump;仅验证依赖连接和 UI 状态映射。(参见 testing-strategy。)

标准 Notifier + 作用域流

默认使用手写 provider(state-management-riverpod 负责规范;@riverpod 代码生成是可选方案,而非默认方案)。将作用域流和命令 Notifier 放在 <feature>_providers.dart / <feature>_notifier.dart 中。完整文件: examples/tasks_notifier.dart。

// features/tasks/presentation/tasks_providers.dart — reactive backbone, scoped so one write is not app-wide.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app/core/result.dart';          // Result, Ok, Err, Failure (shared foundation, lib/core/)
import 'package:app/data/task_repository.dart';  // taskRepositoryProvider (keepAlive; lives in lib/data/)
import '../domain/task.dart';                     // feature-local immutable model (value objects, not rows)

/// Wrap the repository's scoped `.watch()` in a family StreamProvider, keyed by projectId.
/// `.autoDispose` frees the subscription when the screen unmounts. The repository maps rows ->
/// domain models at the boundary; this never sees a Drift row.
final taskListProvider =
    StreamProvider.autoDispose.family<List<Task>, String>((ref, projectId) =>
        ref.watch(taskRepositoryProvider).watchTasks(projectId));
// features/tasks/presentation/tasks_notifier.dart — the 1:1 ViewModel: command state + writes.
final tasksNotifierProvider =
    AsyncNotifierProvider.autoDispose<TasksNotifier, void>(TasksNotifier.new);

class TasksNotifier extends AsyncNotifier<void> {
  @override
  FutureOr<void> build() {} // no synchronous heavy work here

  /// A command the View binds. The write goes through ONE repository method (single write path);
  /// the typed Result is mapped to AsyncValue for the View to switch on exhaustively.
  Future<void> add(Task draft) async {
    state = const AsyncLoading();
    final Result<void, Failure> result =
        await ref.read(taskRepositoryProvider).addTask(draft);
    state = switch (result) {
      Ok() => const AsyncData(null),
      Err(:final failure) => AsyncError(failure, StackTrace.current),
    };
    // No manual republish: the committed write makes the Drift stream re-emit and the list rebuilds.
  }
}

简单的 View

View 监听作用域流,在 .when 中渲染,并在回调中调用 ref.read(...notifier)。它使用 ref.listen 监听控制器,以处理一次性副作用。完整文件:examples/tasks_screen.dart。

class TasksScreen extends ConsumerWidget {
  const TasksScreen({required this.projectId, super.key});
  final String projectId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final l10n = AppLocalizations.of(context);

    ref.listen(tasksNotifierProvider, (prev, next) {             // one-shot effects only
      if (next case AsyncError(:final error)) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(localizeFailure(l10n, error))), // typed Failure -> localized text
        );
      }
    });

    final async = ref.watch(taskListProvider(projectId));        // watch in build
    return Scaffold(
      appBar: AppBar(title: Text(l10n.tasksTitle)),
      body: async.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => FailureView(message: localizeFailure(l10n, e)),
        data: (tasks) => tasks.isEmpty
            ? EmptyState(title: l10n.tasksEmptyTitle)
            : ListView.builder(
                padding: const EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8),
                itemCount: tasks.length,
                itemBuilder: (context, i) => TaskTile(
                  task: tasks[i],
                  onTap: () => TaskDetailRoute(               // navigate by ID, from the View
                    projectId: projectId,
                    taskId: tasks[i].id,
                  ).go(context),
                ),
              ),
      ),
    );
  }
}

生成器无法完成的两个手动步骤

  1. 在唯一的 go_router 中注册路由(TypedGoRoute、路径参数标识, 全屏流程使用 parentNavigatorKey: rootNavigatorKey),然后使用 build_runner 重新生成类型化路由。参见 references/routing-and-l10n.md。
  2. 先将字符串添加到模板 ARB,再以相同的占位符名称和 ICU 结构同步到每个语言环境的 ARB。运行一致性检查,然后运行代码生成,最后进行分析。

可选:该功能需要读取全新的持久化记录

如果屏幕需要读取尚不存在的记录,请先添加整条链路,再接入该功能: 不可变值类型(纯粹,不导入存储)→ Drift 表(包含架构不变量)→ 将行映射为值类型的 DAO → 开启一个事务的 repository 方法。然后,该功能照常读取其作用域 .watch() 流。完整流程见 persistence-drift 和 run-migration;简要说明见 references/add-persisted-record.md。

反模式

  • 只需要一个屏幕,却新建一个 package。 屏幕是一个文件夹;package 则是边界决策。
  • 在同一个文件中放置两个主要类型;在 Riverpod 路径中将 ViewModel 命名为 <feature>_view_model.dart / *Controller;或者在约定使用 _screen.dart 时将 View 命名为 <feature>_view.dart。 每个文件只放一个主要公开类型,文件名与其声明一致(naming-conventions);Riverpod ViewModel 应为 <feature>_notifier.dart → <Feature>Notifier。
  • 在 build() 中调用 repository / engine,或编写 try/catch 业务逻辑。 View 读取一个控制器并进行渲染;逻辑放在 ViewModel 中。
  • 从 widget 或 ViewModel 访问 Drift / secure-storage / platform channel。 读取操作应通过 repository provider;行结构是数据层的内部细节。
  • 提交前乐观地重新发布、使用 setState、手动触发缓存更新,或延迟“稍后保存”。 每次写入都应调用一个 repository 方法来持久化,然后由流重新发出数据。
  • 将存储的计数器当作事实依据。 读取时应从事实来源推导计数、总数和连续记录。
  • 使用未带键的“当前实体” provider,或对应用级单例使用 autoDispose。 按键区分的状态使用 family + autoDispose;单例使用 keepAlive。
  • 在功能中使用 DateTime.now()。 应读取注入的 Clock provider,以便断言流程。
  • 使用 state.extra 存储可通过深层链接访问的标识,或使用 Navigator.push 推入主要流程。 使用路径参数 + 类型化 GoRoute;由 View / 重定向逻辑决定导航,而非控制器。
  • 导入其他功能的文件夹。 依赖只能向下,不能横向。
  • 硬编码左右方向的几何参数或内联 UI 字面量。 使用 EdgeInsetsDirectional + 来自 gen-l10n 的字符串。
  • 测试时 mock Notifier,而不是伪造 repository。 伪造边界;测试接线方式。

完成标准

  • 功能位于 lib/features/<feature>/ 文件夹中,并在 presentation/ 下遵循固定结构:<feature>_screen.dart(纯展示 View)、<feature>_notifier.dart(一对一对应的 Notifier/AsyncNotifier)、widgets/、作用域限定的 <feature>_providers.dart。
  • View 只读取一个控制器并负责渲染;build() 中不调用仓库,也不进行业务 try/catch;.when 会显示平和的错误提示。
  • 依赖只能向下指向;不得导入其他功能的文件夹;功能中不得导入 Drift/DAO/安全存储/平台通道。
  • 读取操作使用基于仓库 .watch() 的作用域 StreamProvider;按键读取的 Provider 以稳定值作为 family 键并设置 autoDispose;单例设置 keepAlive。
  • 每次变更都通过一个具名仓库方法(单一写入路径)处理,并在重新发布之前完成提交;派生状态在读取时计算,不存储。
  • ViewModel 传递值对象;Drift 行/companion/Map 不得传入 widget;金额不得使用裸 double。
  • 错误使用封闭式 Result/Failure,并映射为 AsyncValue;View 穷尽处理各分支,并在边界处本地化 Failure。
  • “现在”取自注入的 Clock;功能中不得调用 DateTime.now()。
  • 在唯一的路由器中注册类型化 GoRoute;身份信息放在路径参数中;全屏流程设置 parentNavigatorKey;不得从 ViewModel 导航。
  • 字符串来自 gen-l10n,并遵循先写模板再保持一致的原则;几何布局仅使用 Directional。
  • 使用 ProviderContainer.test()、伪仓库和固定的 Clock 测试 ViewModel(使用 clockProvider.overrideWithValue(Clock.fixed(t)),而非模拟 Notifier)。
  • dart format 和 dart analyze --fatal-infos 均无问题;scripts/verify_feature.sh 通过。

相关技能

  • flutter-architecture — 本脚手架遵循的分层模型和仅向下的 DAG。
  • state-management-riverpod — Notifier/AsyncNotifier 规范,以及 watch/read/listen、family+autoDispose、优先手动创建 Provider 的原则。
  • navigation-and-routing — 管理唯一的 go_router,本功能会在其中注册类型化路由。
  • forms-and-input — 本功能启动的添加/编辑流程所遵循的 Form/验证/焦点规范。
  • widget-composition — 纯展示 View、小型 const widget 类和结构化布局。
  • error-handling-typed-results — 通过 AsyncValue 显示的封闭式 Result/Failure 主干。
  • persistence-drift 和 run-migration — 新记录背后的仓库/DAO/表/迁移流程。
  • i18n-rtl-l10n — ARB 一致性与 Directional 几何布局约定。
  • value-objects-money-and-units — ViewModel 传递的规范值对象。
  • testing-strategy — 通过伪造仓库(和 clockProvider)对 ViewModel 进行无界面测试。
  • project-structure-and-packages — 管理单包目录结构;当某项基础功能确实值得独立成包,而非仅作为文件夹时适用。

参考资料

Provider / ChangeNotifier 附录

使用官方 Flutter 指南中的技术栈(package:provider + ChangeNotifier)?规则完全相同;只有接线方式不同:

  • ViewModel 是一个 ChangeNotifier;意图方法会修改私有字段,并在末尾调用 notifyListeners()(每次调用仍只产生相当于一个不可变状态快照的变化)。
  • 在功能路由处通过 ChangeNotifierProvider 提供它;View 在 build 中通过 context.watch<TasksViewModel>() 读取,在回调中通过 context.read<TasksViewModel>() 读取 — 与 Riverpod 一样,区分在 build 中使用 watch、在回调中使用 read。
  • 响应式读取:公开仓库的 Stream,并使用 StreamProvider(provider 包的 StreamProvider 或 StreamBuilder)处理流 — 不要在 ViewModel 中缓存可变副本; 在每次发出数据时进行派生。
  • 每个屏幕的作用域取代 family+autoDispose:在屏幕处创建 ChangeNotifierProvider,使其随路由一同释放;将应用级服务保留在根 Provider 中。
  • 在 ChangeNotifier.dispose() 中释放控制器和订阅。

其余内容 — 简单的 View、单一写入路径、类型化的 Result/Failure、路径参数路由、 注入时钟、ARB 一致性、Directional 几何、在测试中伪造仓库 — 均保持不变。

评论

支持 Markdown