mcpField
F
flutter-architecture

flutter-architecture

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

关于 flutter-architecture

flutter-architecture 是一项指导 Flutter 应用采用适度、按功能组织的 MVVM 架构的技能,以 Riverpod 3.x 管理状态和依赖注入。它面向 Flutter 开发者,强调清晰的层级边界,并避免无实际需要的抽象或拆包。

主要功能

  1. 01按功能组织代码,再划分层级
  2. 02使用 Riverpod 3.x 管理状态和依赖注入
  3. 03定义 UI、数据及按需添加的领域层
  4. 04规定仓库写入方式和数据流向
  5. 05在提交 PR 前检查架构

适用场景

  1. 01判断新增 Flutter 代码应放置的位置
  2. 02为功能模块安排视图和 ViewModel
  3. 03决定是否增加接口或领域层
  4. 04在提交 PR 前审查应用架构

安装

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

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

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

包含的文件

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

examples/
composition_root.dart
feature_slice.dart
references/
module-and-layers.md
right-sizing.md
state-di-riverpod.md
scripts/
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。

不可妥协的规则

  1. 一秒内回答“这应该放在哪里?”。 按功能优先、分层其次分组。不要设置一个庞大的、覆盖整个应用的 screens/、models/ 或 widgets/ 桶——它们会把一个功能拆散到整个目录树中,让每次编辑都不得不查找名称。
  2. 至少分为两层:UI 和 Data。只有当逻辑跨越多个仓库时(投影、聚合、多步骤工作流),才需要领域层/用例层。一个只转发单次仓库调用的 *UseCase 只是改了个名字,并不构成边界——Flutter 自身的指导将领域层列为有条件需要,并指出大多数应用都不需要它。
  3. 只抽象无法在测试中运行的部分——明确说明抽象让什么变得可测试。 在 flutter test 中无法执行的平台通道、网络客户端或插件,才值得配备接口和 fake。已经能在无头环境中运行的代码(内存数据库、纯计算器、基于它的仓库)应保持具体。“这样更干净”不是充分理由;如果你说不清它提供了什么接缝,就不要添加它。
  4. 数据单向流动;绝不跳过或反转任何一跳。 数据向下流动:Service → Repository → ViewModel → View。事件向上流动:View → ViewModel → Repository。组件只调用其 ViewModel;ViewModel 只调用仓库。禁止双向绑定,组件不得直接访问数据源。
  5. View 应保持简单。 View 只负责布局、根据状态执行 if/switch、动画和导航——除此之外什么也不做。不包含业务逻辑、格式化/数字运算、try/catch 或数据访问。如果组件在计算或获取数据,就说明它位于错误的层。
  6. 每个功能对应一个 ViewModel,且状态不可变。 ViewModel 持有私有可变状态,并公开意图方法(load()、add(...));每次状态转换都要赋予一个新的不可变值,并具备值相等性。不要公开 setter,也不要让 UI 原地修改可变字段。
  7. 仓库既是唯一真实数据源,也是唯一写入路径。 每次变更都必须调用一个具名仓库方法,该方法先持久化,再发布(如果存储支持,则使用单个事务)。绝不可先发布再持久化——两者之间若发生崩溃,就会把从未保存的状态记入账面。
  8. 派生计算,不要存储。 计数、总计、连续天数、筛选列表都应从真实数据源计算得出(使用流/派生 provider),绝不要额外存储一个可能与实际情况不同步的计数器。
  9. 在边界处映射;边缘层不要接触领域类型。 仓库将存储行/JSON DTO 映射为不可变领域值对象。生成的 Drift 行或 Map 绝不能传到 ViewModel 或组件;单位运算和区域设置格式化也绝不能在组件中进行。
  10. 通过 providers 注入;仅在确有价值时依赖抽象。 不要使用全局变量、单例,组件也不得通过服务定位器获取对象;DateTime.now() 只能通过注入的 Clock 使用。组合根只负责装配一次对象图。
  11. 功能以文件夹组织;只有当编译隔离边界不可或缺时,基础层才拆成包。 默认使用单一包。一个功能绝不导入另一个功能——通过基础层共享,或按路由ID导航。依赖图必须是严格的仅向下 DAG。
  12. 边界处的错误应是带类型的值。 仓库/用例应返回封闭的 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。

参考资料

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 的取舍)均完全相同。

评论

支持 Markdown