关于 flutter-architecture
flutter-architecture 是一项指导 Flutter 应用采用适度、按功能组织的 MVVM 架构的技能,以 Riverpod 3.x 管理状态和依赖注入。它面向 Flutter 开发者,强调清晰的层级边界,并避免无实际需要的抽象或拆包。
主要功能
- 01按功能组织代码,再划分层级
- 02使用 Riverpod 3.x 管理状态和依赖注入
- 03定义 UI、数据及按需添加的领域层
- 04规定仓库写入方式和数据流向
- 05在提交 PR 前检查架构
适用场景
- 01判断新增 Flutter 代码应放置的位置
- 02为功能模块安排视图和 ViewModel
- 03决定是否增加接口或领域层
- 04在提交 PR 前审查应用架构
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill flutter-architecture安装后在 Claude Code 中输入/flutter-architecture确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/composition_root.dartfeature_slice.dartreferences/module-and-layers.mdright-sizing.mdstate-di-riverpod.mdscripts/check_architecture.sh详细介绍
Flutter 架构
将每个 Flutter 应用构建为分层、以功能为先、规模与应用相匹配的 MVVM 系统。你必须始终能在一秒内回答“这段代码应该放在哪里?”。本架构基于 Flutter 官方的 应用架构指南,并使用 Riverpod 3.x 作为状态管理和依赖注入的统一机制。
两种失误会摧毁架构:结构不足(小组件直接访问数据库)和结构过度(用一个 *UseCase 包装一次仓库调用,或为已经能在测试中运行的代码添加接口)。本技能旨在避免这两种情况。从简单开始;只有在某一层承担了明确职责时,才添加这一层。
根据当前任务阅读相应参考文档:
references/module-and-layers.md— 分层模型、从文件夹到软件包的连续范围、DAG、barrel 文件、功能文件夹结构,以及“何时使用多软件包(workspace)”说明。references/right-sizing.md— 拒绝过度设计的对照表、“只对无法测试的部分抽象”,以及架构如何随应用规模扩展。references/state-di-riverpod.md— Provider 图、keepAlive 与 autoDispose、占位符覆盖组合根、可在 isolate 中安全使用的工厂,以及ProviderContainer测试。
创建 PR 前运行 scripts/check_architecture.sh。
不可妥协的规则
- 一秒内回答“这应该放在哪里?”。 按功能优先、分层其次分组。不要设置一个庞大的、覆盖整个应用的
screens/、models/或widgets/桶——它们会把一个功能拆散到整个目录树中,让每次编辑都不得不查找名称。 - 至少分为两层:UI 和 Data。只有当逻辑跨越多个仓库时(投影、聚合、多步骤工作流),才需要领域层/用例层。一个只转发单次仓库调用的
*UseCase只是改了个名字,并不构成边界——Flutter 自身的指导将领域层列为有条件需要,并指出大多数应用都不需要它。 - 只抽象无法在测试中运行的部分——明确说明抽象让什么变得可测试。 在
flutter test中无法执行的平台通道、网络客户端或插件,才值得配备接口和 fake。已经能在无头环境中运行的代码(内存数据库、纯计算器、基于它的仓库)应保持具体。“这样更干净”不是充分理由;如果你说不清它提供了什么接缝,就不要添加它。 - 数据单向流动;绝不跳过或反转任何一跳。 数据向下流动:
Service → Repository → ViewModel → View。事件向上流动:View → ViewModel → Repository。组件只调用其 ViewModel;ViewModel 只调用仓库。禁止双向绑定,组件不得直接访问数据源。 - View 应保持简单。 View 只负责布局、根据状态执行
if/switch、动画和导航——除此之外什么也不做。不包含业务逻辑、格式化/数字运算、try/catch或数据访问。如果组件在计算或获取数据,就说明它位于错误的层。 - 每个功能对应一个 ViewModel,且状态不可变。 ViewModel 持有私有可变状态,并公开意图方法(
load()、add(...));每次状态转换都要赋予一个新的不可变值,并具备值相等性。不要公开 setter,也不要让 UI 原地修改可变字段。 - 仓库既是唯一真实数据源,也是唯一写入路径。 每次变更都必须调用一个具名仓库方法,该方法先持久化,再发布(如果存储支持,则使用单个事务)。绝不可先发布再持久化——两者之间若发生崩溃,就会把从未保存的状态记入账面。
- 派生计算,不要存储。 计数、总计、连续天数、筛选列表都应从真实数据源计算得出(使用流/派生 provider),绝不要额外存储一个可能与实际情况不同步的计数器。
- 在边界处映射;边缘层不要接触领域类型。 仓库将存储行/JSON DTO 映射为不可变领域值对象。生成的 Drift 行或
Map绝不能传到 ViewModel 或组件;单位运算和区域设置格式化也绝不能在组件中进行。 - 通过 providers 注入;仅在确有价值时依赖抽象。 不要使用全局变量、单例,组件也不得通过服务定位器获取对象;
DateTime.now()只能通过注入的Clock使用。组合根只负责装配一次对象图。 - 功能以文件夹组织;只有当编译隔离边界不可或缺时,基础层才拆成包。 默认使用单一包。一个功能绝不导入另一个功能——通过基础层共享,或按路由ID导航。依赖图必须是严格的仅向下 DAG。
- 边界处的错误应是带类型的值。 仓库/用例应返回封闭的
Result/Failure,而不是跨层抛出异常;UI 应穷尽处理所有情况。参见error-handling-typed-results。
分层结构(单个包,默认方案)
project-structure-and-packages 负责物理目录结构;这里展示的是它定义的按功能划分的布局,
用于说明各层的语义。功能以文件夹的形式放在 lib/features/ 下;功能的视图和
对应的一个 ViewModel 位于 presentation/ 中。
lib/
main.dart # thin entrypoint → bootstrap()
bootstrap.dart # composition root: build infra, override placeholder providers, runApp
app.dart # MaterialApp.router, theme, top-level ProviderScope children
core/ # PURE foundation, NO Flutter: value objects, Result/Failure, the Clock seam,
# extensions, pure calculators (the sanctioned foundation, not a junk-drawer)
data/ # shared data layer: Drift db, DAOs, repositories, row→model mappers
services/ # injectable side-effect ports + live impls (services/native/ = MethodChannel)
routing/ # the single go_router config, typed routes, guards
theme/ l10n/ # ThemeExtension token sets; generated AppLocalizations + ARB
features/
tasks/
presentation/
task_list_screen.dart # View: dumb ConsumerWidget
task_list_notifier.dart # ViewModel: StreamNotifier over immutable state
widgets/ # widget CLASSES used only by this feature
application/ # optional: use-cases, ONLY when logic spans repositories
domain/ # optional: feature-local models
# data/ usually ABSENT — features read shared repos from lib/data/
经验法则:core/ 不依赖更上层的任何内容,并且不使用 BuildContext;data/ 不导入
Flutter UI;一个功能可以依赖 core、data 和 services,但绝不依赖同级功能。只有当逻辑跨越多个仓库时,
才添加功能本地的 domain/ + application/(用例)文件夹。
功能切片(Riverpod 优先的“做法”)
简单的 View 读取一个 ViewModel;ViewModel 持有私有状态,并公开意图方法。
// features/tasks/presentation/task_list_notifier.dart — the ViewModel.
// build() returns the live repository stream; a mutation commits through the single write
// path and the watched stream RE-EMITS. No manual `state = ...` republish.
final class TaskListNotifier extends StreamNotifier<List<Task>> {
@override
Stream<List<Task>> build() {
ref.onDispose(() {/* controllers/subscriptions released here */});
return ref.watch(taskRepositoryProvider).watchTasks(); // track every emission
}
Future<void> add(String title) async { // intent method — no setter
final id = ref.read(idGeneratorProvider)(); // injected id source — never a UI UniqueKey
await ref.read(taskRepositoryProvider).add(Task.create(id: id, title: title));
// No republish: the committed write makes watchTasks() re-emit and build()'s stream updates state.
}
}
final taskListNotifierProvider =
StreamNotifierProvider<TaskListNotifier, List<Task>>(TaskListNotifier.new);
其背后的仓库是唯一可信数据源,也是唯一写入路径——通过 watchTasks()
流,以及一个先持久化、再由流重新发出数据的 add() 实现。只有当真实实现无法在测试中运行时(规则 3),
才为它定义 abstract interface;使用内存数据库时,具体类就足够了。
// features/tasks/task_list_screen.dart — the View is dumb: read one provider, render.
final class TaskListScreen extends ConsumerWidget {
const TaskListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(taskListNotifierProvider);
return tasks.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(onRetry: () => ref.invalidate(taskListNotifierProvider)),
data: (list) => ListView(
children: [for (final t in list) TaskTile(task: t)], // widget CLASS, not a _buildTile()
),
);
}
}
Provider 就是依赖注入。通过 ref.watch 装配依赖,使用 family 为每个实体的状态设定键,
并用 autoDispose 管理每个屏幕的控制器。完整的 API 使用规范(watch/read/listen 的区分、.select、闭包过期风险),
请参阅 state-management-riverpod。
组合根(对象图只在此处编写一次)
异步构建的基础设施(数据库、目录、密钥存储)通过 bootstrap.dart 中的**占位
Provider(在被覆盖前会抛出异常)**注入——测试也使用同一个接缝。
// data/providers.dart — placeholder: throws until bootstrap() overrides it (and every test overrides it).
final appDatabaseProvider =
Provider<AppDatabase>((ref) => throw UnimplementedError('override in bootstrap()'));
// bootstrap.dart — the ONE place async infra is built and the graph is composed.
Future<void> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await openAppDatabase(); // plain top-level factory, no BuildContext
runApp(ProviderScope(
overrides: [appDatabaseProvider.overrideWithValue(db)],
child: const App(),
));
}
让基础设施(数据库、仓库、时钟)保持 keepAlive;切勿对可能在操作中途被销毁的单例使用
autoDispose。关于 main() 的执行顺序和错误处理器,请参阅 app-startup-and-bootstrap。
合理控制规模:文件夹与包,以及需要抽象的内容
根据应用规模调整架构。按顺序考虑以下三个问题:
- 需要新建文件夹还是新建文件? 几乎总是在现有功能/层中添加文件即可。
- 需要 domain/use-case 层吗? 仅当逻辑跨越多个仓库时才需要。仅涉及一个仓库的 CRUD 不需要。
- 需要接口吗? 仅当真实实现无法在
flutter test中运行,或确实需要切换开发/生产实现时才需要。命名要体现它所支持的假实现。 - 需要拆分成独立的包吗? 仅当编译期边界不可或缺——偏离它会导致正确性错误(规范单位/货币计算、整个数据层、调度器)时才需要。请参阅
references/right-sizing.md中拒绝过度设计的表格。
何时采用多包(工作区)。 大型应用(多个团队、需要通过黄金测试的纯 Dart 核心、可复用的 设计系统)可以将基础模块拆分为 Dart pub 工作区下的包。此时: 每个成员都设置
resolution: workspace,并共用根目录下的pubspec.lock;每个包仅公开一个 导出文件(core.dart),封装私有的src/——绝不导入其他包的src/;纯 Dart 的core包不声明flutter依赖,这样意外导入就会成为编译错误;依赖有向无环图保持 无环(core→ 无内部依赖;所有内容 →core;应用外壳 → 所有内容)。这是大型应用 可以逐步采用的方案,绝非小型应用的要求——默认方案仍是使用单个包和文件夹。
反模式
- 用一个
*UseCase/领域层包装一次仓库调用。 纯属形式;只是给仓库方法改了个名。只有当逻辑跨越多个仓库时才添加这一层。 abstract interface class的唯一内容是单词 "Repository",而代码可以在测试中运行。 这道隔离层毫无益处;基于 Map 的伪实现甚至可能接受真实架构拒绝的行。应针对真实内存存储测试具体类。- 在 View 中处理业务逻辑、格式化或数字/单位运算。 将其移到 ViewModel 或
core。 - 在
build()或initState()中调用http/sqflite/SharedPreferences/插件。 数据访问应放在仓库之后,并通过 provider 调用。 - 先发布再持久化/提交返回前乐观写入。 期间发生崩溃,会把从未保存的状态计入。先持久化,再由流重新发布。
- 存储可推导数据的计数器(在各行旁存一个
count列)。它会逐渐偏离。应从唯一事实来源推导。 - ViewModel 为了使用
BuildContext/组件而导入package:flutter/material.dart。 ViewModel 应尽量少依赖 UI 框架;把BuildContext传入数据层更糟。 - 除 Riverpod 外,再用
get_it/injectable/package:provider作为第二个 DI 容器,或从组件访问全局单例/可变的static存储。 Provider 是唯一的 DI 机制。 - 跨功能导入导致循环依赖。通过基础层共享,或使用路由 ID 导航。
- 为了“整洁”把小应用拆分成多个包。 这会让 pubspec 和代码生成数量倍增,却无法提供任何编译时保证。只有在需要一道关键隔离边界时,才将其提升为包。
完成定义
- 新代码位于正确的
features/<x>/或core/data目录下;按功能优先分组。 - View 是纯展示型的
ConsumerWidget——不访问数据、不处理业务逻辑、不做格式化,也不使用try/catch。 - ViewModel 是
Notifier/AsyncNotifier/StreamNotifier,提供不可变状态和意图方法;没有公共 setter,在 Riverpod 路径中不调用notifyListeners()。 - 每次变更都通过一个仓库方法进行,该方法先持久化再发布;被监听的流会重新发出(无需手动重新发布);派生读取使用流/派生 provider,而非存储计数器。
- 仓库将存储行/DTO 映射为不可变值对象;Drift/DTO/
Map类型不会传递到 ViewModel 或组件。 - 仅在真实实现无法用于测试(或需要真实的开发/生产环境切换)时才创建接口;每个接口都应有一个具名伪实现。
- 依赖通过组合根中的 provider 注入;不新增全局变量/单例;除非使用注入的时钟,否则不调用
DateTime.now()。 - 仅当逻辑跨越多个仓库时才添加领域/用例层;除非编译隔离边界至关重要,否则应用保持为单个包。
-
scripts/check_architecture.sh通过;dart analyze无问题。
相关技能
- 关于 Notifier/AsyncNotifier API、watch/read/listen 的区分、
family+autoDispose以及过期闭包隐患,请参阅state-management-riverpod。 - 关于如何构建纯 View(规则 5)的深入说明——优先使用 const widget 类而非
_buildX()辅助方法、保持build()简洁,以及 widget 在此分层有向无环图中的位置,请参阅widget-composition。 - 关于分步骤创建功能文件夹并注册类型化 go_router 的工作流,请参阅
scaffold-feature-module。 - 关于作为审计产物的 pubspec、barrel 文件以及单向分层机制,请参阅
project-structure-and-packages。 - 关于 main() 的执行顺序、全局错误处理器以及根节点的 DI 覆盖,请参阅
app-startup-and-bootstrap。 - 关于跨边界返回的封闭
Result/Failure主干,请参阅error-handling-typed-results。 - 关于封闭类型、穷尽式 switch 和不可变值类型,请参阅
dart3-idioms-and-coding-standards。 - 关于可通过 grep 识别的
Screen/Notifier/Repository/Service/Failure角色后缀,请参阅naming-conventions。 - 关于 DAO/repository 分界以及每次变更对应一个事务的持久化方式,请参阅
persistence-drift。
参考资料
- Flutter — 应用架构指南
- Flutter — 架构建议
- Flutter — UI 层案例研究(纯 View 可以包含哪些内容)
- Flutter — 离线优先支持(本地存储为权威数据源)
- Riverpod 3 文档
- Dart — Pub 工作区(monorepo 支持)
Provider / ChangeNotifier 附录
当代码库使用 package:provider + ChangeNotifier,而非 Riverpod 时,同一套规则也适用于 Flutter 官方指南所述的技术栈。规则 1–9、11、12 保持不变。只有状态管理和 DI 的「实现方式」不同:
- ViewModel =
ChangeNotifier。 它仍然持有私有可变字段并暴露意图方法;状态确实发生变化后调用一次notifyListeners()——绝不在build()中调用,也绝不在紧密循环中调用。通过 getter 暴露状态,并返回不可修改的视图(List.unmodifiable)。不可变性仍适用于 ViewModel 持有的领域模型(copyWith、值相等性)。 - DI = 通过在根节点附近接入
provider实现构造函数注入。 应用根节点的MultiProvider创建服务和 repository;屏幕的ChangeNotifierProvider通过context.read<XRepository>()创建 ViewModel。依赖抽象 repository 类型,以便测试时注入 fake。 - 读取与监听: 使用
context.watch<T>()/Consumer在变更时触发重建;在回调中使用context.read<T>()。绝不要将BuildContext传入数据层以读取 provider——应传递数据。 - 以下禁令依然有效:widget 不得访问
get_it/服务定位器,不得使用全局可变状态,View 中不得访问数据。此技能中的其他所有内容(分层、单一写入路径、派生而非存储、在边界处映射、只抽象无法测试的部分、文件夹与 package 的取舍)均完全相同。