☰
ECC Dart/Flutter 架构模式实战指南:Repository 到 GoRouter 的分层落地规范
2026/9/30 22:10:23 网站建设 项目流程

ECC Dart/Flutter 架构模式实战指南:Repository 到 GoRouter 的分层落地规范

【免费下载链接】ECCThe agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.项目地址: https://gitcode.com/GitHub_Trending/ev/ECC

<output_article>

ECC Dart/Flutter 架构模式实战指南:Repository 到 GoRouter 的分层落地规范

本文基于 ECC 仓库中 Dart/Flutter Patterns 规则文档(及其日文译本 docs/ja-JP/rules/dart/patterns.md)展开,系统梳理 Dart/Flutter 项目在 ECC 规则体系下的推荐架构模式:数据层 Repository 抽象、BLoC/Cubit 与 Riverpod 双轨状态管理、依赖注入、UseCase 封装、freezed 不可变状态、清洁架构分层边界以及 GoRouter 声明式导航。读完本文,你将掌握一套可直接复制到pubspec.yaml项目中的分层代码骨架,并能结合 ECC 的代码评审技能(skills/flutter-dart-code-review/SKILL.md)对既有代码进行架构级检查。

规则文档的定位:它是如何进入你的项目的

在 ECC 的规则体系中,patterns.md属于"语言特定规则"层。根据 rules/README.md 的说明,规则被组织为common(通用层)+ 语言特定目录两层结构:

  • rules/common/patterns.md提供与语言无关的通用模式原则(如 Repository 模式、API 响应封装格式);
  • rules/dart/patterns.md则在通用原则之上,补充 Dart、Flutter 及其生态特有的实现细节与代码示例。

该文档头部通过 YAML frontmatter 声明其适用文件范围:

paths: - "**/*.dart" - "**/pubspec.yaml"

这意味着任何.dart源文件或pubspec.yaml配置都在此规则的约束范围内。安装规则时,需要把rules/common与rules/dart整体复制(不可用/*拍平,否则语言特定文件会覆盖通用文件,并破坏文档内../common/相对引用);当语言特定规则与通用规则冲突时,语言特定规则优先(specific overrides general),这与 CSS 优先级或.gitignore覆盖规则类似。

需要强调的是,本文讨论的每一条模式,都同时被 skills/flutter-dart-code-review/SKILL.md 的评审清单覆盖,形成"规则定标准、技能教实操"的闭环。

Repository 模式:把数据访问封装在统一接口之后

规则文档给出的核心思路与 rules/common/patterns.md 一脉相承:业务逻辑依赖抽象接口而非存储机制,从而支持数据源自由切换与 Mock 测试。Dart 侧的落地方案使用abstract interface class定义仓库契约:

abstract interface class UserRepository { Future<User?> getById(String id); Future<List<User>> getAll(); Stream<List<User>> watchAll(); Future<void> save(User user); Future<void> delete(String id); } class UserRepositoryImpl implements UserRepository { const UserRepositoryImpl(this._remote, this._local); final UserRemoteDataSource _remote; final UserLocalDataSource _local; @override Future<User?> getById(String id) async { final local = await _local.getById(id); if (local != null) return local; final remote = await _remote.getById(id); if (remote != null) await _local.save(remote); return remote; } @override Future<List<User>> getAll() async { final remote = await _remote.getAll(); for (final user in remote) { await _local.save(user); } return remote; } @override Stream<List<User>> watchAll() => _local.watchAll(); @override Future<void> save(User user) => _local.save(user); @override Future<void> delete(String id) async { await _remote.delete(id); await _local.delete(id); } }

这份示例透露出几个值得细读的设计信号:

  • 接口同时暴露 Future 与 Stream:getById/getAll是一次性读取,watchAll则面向本地数据源的持续监听(如数据库变更通知),支撑 UI 层的响应式更新;
  • 读写分离 + 缓存回填:读取走"本地优先、远程兜底"策略(cache-aside),远程命中后回写本地;写入则双写(先远端后本地),保证两端一致;
  • const构造器:UserRepositoryImpl的构造器声明为const,与 rules/dart/coding-style.md 中"所有字段均为final时使用const构造器"的约定一致,也便于在依赖图中以常量形式注册。

从测试角度看,这个接口形态与 rules/dart/testing.md 中手写FakeUserRepository的方案天然契合——fake 只需要实现这 5 个方法即可,无需依赖任何网络栈或数据库。

状态管理之一:BLoC/Cubit(事件驱动与简单状态迁移)

规则文档给出了两种粒度的 BLoC 系写法:

Cubit——适合简单状态迁移,直接以方法调用触发emit:

class CounterCubit extends Cubit<int> { CounterCubit() : super(0); void increment() => emit(state + 1); void decrement() => emit(state - 1); }

BLoC——适合事件驱动的复杂流程,通过sealed class建模事件全集,再用copyWith维持不可变状态:

@immutable sealed class CartEvent {} class CartItemAdded extends CartEvent { CartItemAdded(this.item); final Item item; } class CartItemRemoved extends CartEvent { CartItemRemoved(this.id); final String id; } class CartCleared extends CartEvent {} @immutable class CartState { const CartState({this.items = const []}); final List<Item> items; CartState copyWith({List<Item>? items}) => CartState(items: items ?? this.items); } class CartBloc extends Bloc<CartEvent, CartState> { CartBloc() : super(const CartState()) { on<CartItemAdded>((event, emit) => emit(state.copyWith(items: [...state.items, event.item]))); on<CartItemRemoved>((event, emit) => emit(state.copyWith(items: state.items.where((i) => i.id != event.id).toList()))); on<CartCleared>((_, emit) => emit(const CartState())); } }

这段代码与 rules/dart/coding-style.md 的多条规范互相印证:

  • Dart 3sealed类:CartEvent被声明为sealed,意味着编译器可以穷尽检查所有事件分支,杜绝"未知事件"的遗漏处理;
  • 不可变性:状态类字段final,集合变更一律通过copyWith产生新实例,绝不原地修改items;
  • const集合字面量:this.items = const []作为默认值。

配套的单元测试方案在 rules/dart/testing.md 中有完整示例:使用bloc_test的blocTest<CartBloc, CartState>分别验证CartItemAdded后发出的状态序列与CartCleared的种子状态重置,expect中逐项断言emit序列。评审层面,flutter-dart-code-review 技能 还提醒:BLoC 之间不应直接相互依赖,跨组件通信应共享 Repository 或在展示层协调;所有状态迁移(loading → success、loading → error、retry)都必须有对应测试。

状态管理之二:Riverpod(Provider 图与 Notifier)

规则文档展示了 Riverpod 三种核心构件:

异步 Provider——用ref.watch表达 provider 间依赖:

@riverpod Future<List<User>> users(Ref ref) async { final repo = ref.watch(userRepositoryProvider); return repo.getAll(); }

可变状态的 Notifier——build()返回初始值,通过state =赋值触发重建:

@riverpod class CartNotifier extends _$CartNotifier { @override List<Item> build() => []; void add(Item item) => state = [...state, item]; void remove(String id) => state = state.where((i) => i.id != id).toList(); void clear() => state = []; }

ConsumerWidget——UI 消费端只做声明式读取,不持有任何业务逻辑:

class CartPage extends ConsumerWidget { const CartPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final items = ref.watch(cartNotifierProvider); return ListView( children: items.map((item) => CartItemTile(item: item)).toList(), ); } }

在测试侧,rules/dart/testing.md 建议用ProviderContainer+overrides注入 fake:

final container = ProviderContainer( overrides: [userRepositoryProvider.overrideWithValue(FakeUserRepository())], ); addTearDown(container.dispose); final result = await container.read(usersProvider.future); expect(result, isNotEmpty);

Widget 测试则通过ProviderScope(overrides: [...])覆盖 provider,再以tester.pumpWidget验证 UI 行为。评审技能还给出了一个值得记住的原则对照表:Riverpod 中 provider 依赖 provider 是预期行为,但要警惕循环依赖或过度缠绕的链条;选择器(ref.watch(p.select(...)))应尽量窄化重建范围。

依赖注入:构造器注入优先,组合根统一注册

规则文档明确偏好构造器注入,并在组合根(composition root)使用get_it或 Riverpod provider 完成装配:

void setupDependencies() { final di = GetIt.instance; di.registerSingleton<ApiClient>(ApiClient(baseUrl: Env.apiUrl)); di.registerSingleton<UserRepository>( UserRepositoryImpl(di<ApiClient>(), di<LocalDatabase>()), ); di.registerFactory(() => UserListViewModel(di<UserRepository>())); }

注意这里区分了两种生命周期:

  • registerSingleton:全局唯一实例,适合无状态的ApiClient和贯穿全 App 的UserRepository;
  • registerFactory:每次解析都新建,适合承载页面级状态的ViewModel。

flutter-dart-code-review 技能 对 DI 的评审要点包括:分层边界处类应依赖抽象接口而非具体实现;依赖必须由外部注入(构造器、DI 框架或 provider 图),禁止在类内部自行创建;注册要区分 singleton/factory/lazy singleton;避免 DI 图循环依赖;服务定位器调用不应散落在业务逻辑各处。与之呼应的是,本仓库的代码生成规则(rules/dart/coding-style.md)要求.g.dart、.freezed.dart等生成文件要么提交、要么统一 gitignore,且绝不手工编辑。

ViewModel 模式:不依赖 BLoC/Riverpod 的轻量方案

规则文档提供了一种仅基于 Flutter 内置ChangeNotifier的轻量状态容器,适合不想引入状态管理框架的场景。其核心是用密封状态类表达"加载中 / 成功 / 失败"三种互斥状态:

class UserListViewModel extends ChangeNotifier { UserListViewModel(this._repository); final UserRepository _repository; AsyncState<List<User>> _state = const Loading(); AsyncState<List<User>> get state => _state; Future<void> load() async { _state = const Loading(); notifyListeners(); try { final users = await _repository.getAll(); _state = Success(users); } on Exception catch (e) { _state = Failure(e); } notifyListeners(); } }

这里的AsyncState正是 rules/dart/coding-style.md 中定义的密封类层次(Loading/Success/Failure)。coding-style 文档特别强调:UI 层必须对密封类型做穷尽式switch,禁止用if (state is Loading)的零散判断,也不允许出现默认分支——这样"不可能的状态"(如同时 isLoading 且 hasError)在编译期就无法表达。on Exception catch (e)的写法也与错误处理规范一致:永远指定异常类型,绝不写裸catch (e),也绝不捕获Error子类型(它们表示编程缺陷)。

UseCase 模式:把业务规则搬出 UI 与状态管理器

规则文档给出的 UseCase 是"单方法类",通过实现call让实例可被直接调用:

class GetUserUseCase { const GetUserUseCase(this._repository); final UserRepository _repository; Future<User?> call(String id) => _repository.getById(id); } class CreateUserUseCase { const CreateUserUseCase(this._repository, this._idGenerator); final UserRepository _repository; final IdGenerator _idGenerator; // 注入 —— 领域层不应直接依赖 uuid 包 Future<void> call(CreateUserInput input) async { // 校验、应用业务规则,然后持久化 final user = User(id: _idGenerator.generate(), name: input.name, email: input.email); await _repository.save(user); } }

两个细节值得注意:

  • 依赖注入贯穿用例层:CreateUserUseCase的 ID 生成器由外部注入,领域层因此不直接依赖uuid包,保持纯 Dart 可测试性;
  • 展示层只调用用例、不直连仓库:这是清洁架构分层纪律的体现(见下文),也是评审技能中"业务逻辑必须位于 widget 层之外"的具体落实。

freezed 不可变状态:代码生成替代手写样板

规则文档推荐用freezed生成不可变状态类,省去手写==、hashCode、copyWith的样板代码:

@freezed class UserState with _$UserState { const factory UserState({ @Default([]) List<User> users, @Default(false) bool isLoading, String? errorMessage, }) = _UserState; }

@Default([])与@Default(false)提供了字段默认值,生成的类自带值语义(value equality)与copyWith。评审技能在"不可变性与值相等"一节提醒:无论用Equatable、freezed、Dart records 还是手写==/hashCode,机制必须在项目内保持一致;状态对象内部集合不得以裸可变List/Map暴露。生成文件(.freezed.dart)应遵循 coding-style 的代码生成策略统一处理,且@freezed注解只保留在规范源文件上。

清洁架构分层边界:domain / data / presentation 三层的纪律

规则文档给出了推荐的目录骨架:

lib/ ├── domain/ # 纯 Dart —— 不依赖 Flutter,不依赖外部包 │ ├── entities/ │ ├── repositories/ # 抽象接口 │ └── usecases/ ├── data/ # 实现领域接口 │ ├── datasources/ │ ├── models/ # 带 fromJson/toJson 的 DTO │ └── repositories/ └── presentation/ # Flutter 组件 + 状态管理 ├── pages/ ├── widgets/ └── providers/ (or blocs/ or viewmodels/)

伴随三条硬性纪律:

  • 领域层不得import 'package:flutter'或任何数据层包——保持纯 Dart,保证可单测、可移植;
  • 数据层在仓库边界处把 DTO 映射为领域实体——DTO 的fromJson/toJson只存在于data/models,不向上泄漏;
  • 展示层调用用例(UseCase),不直接使用仓库——UI 只关心"做什么",不关心"怎么做"。

这套结构与 rules/dart/testing.md 的测试目录组织一一对应(test/unit/domain/use_cases/、test/unit/data/repositories/、test/widget/presentation/pages/),并且与评审技能中"特性优先还是分层优先的目录结构必须保持一致"的检查项呼应。import 规范上,rules/dart/coding-style.md 要求跨特性、跨层一律使用package:导入,禁用../相对导入,顺序为dart:→ 外部package:→ 内部package:。

导航:GoRouter 声明式路由与认证重定向

规则文档推荐用GoRouter做声明式导航,并给出了带认证守卫的完整配置:

final router = GoRouter( routes: [ GoRoute( path: '/', builder: (context, state) => const HomePage(), ), GoRoute( path: '/users/:id', builder: (context, state) { final id = state.pathParameters['id']!; return UserDetailPage(userId: id); }, ), ], // refreshListenable 会在认证状态变化时重新评估 redirect refreshListenable: GoRouterRefreshStream(authCubit.stream), redirect: (context, state) { final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated; if (!isLoggedIn && !state.matchedLocation.startsWith('/login')) { return '/login'; } return null; }, );

三个关键点:

  • 路径参数:/users/:id通过state.pathParameters['id']!取参——这里使用!是因为路由匹配已保证参数存在,属于"空值即编程错误"的合理场景(coding-style 允许在确属编程错误时使用 bang operator);
  • refreshListenable+redirect组合:把认证状态流(authCubit.stream)接入GoRouterRefreshStream,登录状态一变即重新执行 redirect,实现集中式路由守卫,避免在各页面重复判断;
  • state.matchedLocation白名单:对/login自身放行,防止重定向死循环。

评审技能对导航的补充要求:全项目只使用一种路由方案(禁止Navigator.push与声明式路由混用);路由参数必须是强类型而非Map<String, dynamic>;路径定义成常量或枚举,避免魔法字符串;深链 URL 在导航前必须校验与清洗。

相关资源:从模式到落地的一站式入口

规则文档末尾(rules/dart/patterns.md#L258-L261)指向了两项配套技能:

  • skills/flutter-dart-code-review/SKILL.md:库无关的 Flutter/Dart 代码评审清单,覆盖组件最佳实践、状态管理(BLoC、Riverpod、Provider、GetX、MobX、Signals)、性能、无障碍、安全与清洁架构,并附有一张"通用原则 × 各状态管理方案"的对照速查表;
  • skills/compose-multiplatform-patterns:Kotlin Multiplatform 与 Flutter 的互操作模式参考。

若要把本文的模式投入日常开发,还可配套查阅同目录下的姊妹规则:rules/dart/coding-style.md(格式化、命名、空安全、Dart 3 模式匹配)、rules/dart/testing.md(flutter_test/bloc_test/fake_async/golden 测试与覆盖率门槛)、rules/dart/hooks.md(dart format/dart analyze/flutter test的自动化钩子配置)。整套规则遵循"语言特定优先于通用"的覆盖原则,并可通过./install.sh dart安装到项目(详见 rules/README.md)。本文所有代码均可在 Dart/Flutter 项目中直接落地:先按清洁架构建目录,再以 Repository + UseCase 搭数据与业务层,按团队偏好选 BLoC/Riverpod/ChangeNotifier 之一管理状态,最后用 GoRouter 统一导航——这正是 ECC 规则体系为 Dart/Flutter 团队预设的可执行架构基线。 </output_article>

【免费下载链接】ECCThe agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.项目地址: https://gitcode.com/GitHub_Trending/ev/ECC

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询