关于 scaffold-feature-module
这项技能指导在 Flutter 应用中按统一结构创建功能模块,采用 Riverpod、仓储层和类型化路由。它明确界面、ViewModel、共享数据与其他功能之间的边界,并提供脚手架和验证脚本。
主要功能
- 01标准功能文件夹结构
- 02Riverpod Provider 与 Notifier ViewModel
- 03通过仓储层读写数据
- 04注册类型化 go_router 路由
- 05脚手架生成与验证脚本
适用场景
- 01按统一模块结构添加页面
- 02将功能状态连接到仓储数据流
- 03提交前检查功能边界
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill scaffold-feature-module安装后在 Claude Code 中输入/scaffold-feature-module确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/tasks_notifier.darttasks_screen.dartreferences/add-persisted-record.mdanatomy-and-wiring.mdrouting-and-l10n.mdscripts/scaffold_feature.shverify_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。
不可妥协的规则
- 功能是一个文件夹,绝不是新建的 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)。 - 结构固定,每个文件仅包含一个主要公开类型。 在
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中。结构清晰可预测 = 评审者一秒就能找到任何部分。 - View 不含逻辑;ViewModel 与 View 一一对应。 View 只读取一个控制器并渲染——仅包含显示/隐藏用的
if、布局、动画和导航。不得调用 repository,不得包含try/catch业务逻辑,也不得在build()中进行单位换算或格式化。逻辑放在 ViewModel 中。 - 功能绝不导入其他功能。 通过基础层(core/data)共享,或通过路由 ID 导航。依赖只能向下——lint/grep 检查会阻止跨功能导入。横向耦合会让两个功能变成一个无法拆分的死结。
- 仅通过 repository providers 读取存储;将
.watch()包装在有作用域的 StreamProvider 中。 不得从 widget 或 ViewModel 直接访问 Drift、安全存储或平台通道。按键(所有者/父级 ID、时间窗口)限定流的作用域,确保一次写入不会导致整个应用重新发出数据。 - 在
build中使用ref.watch,在回调中使用ref.read,用ref.listen处理一次性副作用(SnackBar、导航)。在回调中 watch 会重新订阅;在build中 read 会错过重建。(完整划分参见state-management-riverpod。) - 按键区分的状态使用
family+autoDispose;单例使用keepAlive。 任何限定到特定实体/会话的 provider,都必须以稳定且可相等比较的值作为family键,并使用autoDispose,使其随屏幕销毁。绝不使用无键的“当前对象” provider;绝不对应用作用域的 db/service 单例使用autoDispose。 - 每次变更都通过一个 repository 方法完成——单一写入路径,先持久化再发布。 ViewModel 中的命令调用一个具名 repository 方法,该方法先提交,然后流重新发出数据,UI 随之重建。不得乐观地重新发布数据,不得使用
setState,不得手动触碰缓存,也不得“稍后保存”。 - 派生计算,不要存储。 计数、总额、筛选列表、连续记录都从事实来源计算(使用派生/流 provider),绝不存储第二份会失去同步的计数器。
- Repository 将行映射为值对象;ViewModel 传递值对象。 Drift 行、companion 或
Map绝不能传到 ViewModel 或 widget。测量量应使用值对象(参见value-objects-money-and-units)——金额绝不使用裸double。 - 错误是以
AsyncValue呈现的类型化值。 Repository 返回封闭的Result/Failure;ViewModel 将其映射为AsyncData/AsyncError;View 使用switch穷尽处理,并在展示层边界本地化 Failure——绝不显示原始异常字符串。(参见error-handling-typed-results。) - 在唯一的 go_router 中注册带类型的路由;身份信息放在路径参数中。 添加
TypedGoRoute/GoRouteData;将可用于深层链接的身份信息放在路径参数(:taskId)中,绝不使用state.extra(冷启动/OS 恢复时为空)。全屏添加/编辑流程设置parentNavigatorKey: rootNavigatorKey。路由器本身——shell/branch 结构、redirect/auth 守卫、深层链接、转场——由navigation-and-routing负责;此功能只需向其中注册自己的路由。 - 从 View 或路由器 redirect 导航——绝不从 ViewModel 导航。 控制器发布状态;View 或
redirect决定显示什么页面。在控制器中 push 路由会让逻辑与导航耦合,并破坏无头测试。 - 注入“当前时间”;绝不在功能中调用
DateTime.now()。 ViewModel 读取Clockprovider,这样整个流程都可以通过固定的假时钟进行断言。(参见async-safety。) - 只使用方向性几何属性和本地化字符串。 使用
EdgeInsetsDirectional/AlignmentDirectional——绝不使用.left/.right、Alignment.centerLeft或TextAlign.left。所有面向用户的字符串都必须来自 gen-l10n,并确保所有语言环境的 ARB 文件中的键和占位符一致。(参见i18n-rtl-l10n。) - 通过伪造 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),
),
),
),
);
}
}
生成器无法完成的两个手动步骤
- 在唯一的 go_router 中注册路由(
TypedGoRoute、路径参数标识, 全屏流程使用parentNavigatorKey: rootNavigatorKey),然后使用 build_runner 重新生成类型化路由。参见references/routing-and-l10n.md。 - 先将字符串添加到模板 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()。 应读取注入的Clockprovider,以便断言流程。 - 使用
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— 管理单包目录结构;当某项基础功能确实值得独立成包,而非仅作为文件夹时适用。
参考资料
- Flutter — 应用架构指南:https://docs.flutter.dev/app-architecture
- Riverpod 3.x:https://riverpod.dev
- go_router — 类型安全路由:https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html
- Flutter — 国际化(gen-l10n):https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization
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 几何、在测试中伪造仓库 — 均保持不变。