Flutter Clean 架构中的 Riverpod 在哪里

813 阅读11分钟

本篇文章与你深入探讨在 Flutter 中如何运用 Clean Architecture,并结合 Riverpod、Go_Router 和 Retrofit 这套现代化工具栈,打造一个高内聚、低耦合、可扩展、易维护的顶级应用架构。

我们将从“道”的层面(高屋建瓴的架构哲学)入手,再深入到“术”的层面(具体分层、组件和痛点解决方案),并最终通过一个实例来将理论落地。


第一部分:高屋建瓴的架构统领 (The "Why" and "How" at 30,000 Feet)

在引入任何具体技术之前,我们必须先确立架构的核心指导思想。对于 Clean Architecture + Riverpod + Go_Router + Retrofit 这套组合拳,其核心思想是:

“通过依赖倒置(Dependency Inversion),实现业务逻辑与外部世界的彻底解耦,并利用现代化的状态管理和路由框架,优雅地将它们粘合起来。”

这个思想可以拆解为三个关键原则:

  1. 同心圆法则 (The Dependency Rule): 这是 Clean Architecture 的灵魂。内层(Domain)绝对不能依赖外层(Infrastructure, Presentation)。所有依赖关系都指向内部。这意味着,你的核心业务逻辑(比如商品折扣如何计算)不应该知道它是被一个 Flutter App 使用,数据是来自一个 REST API,还是存储在 Hive 数据库里。

  2. 职责分离原则 (Separation of Concerns):

    • Clean Architecture 负责 “结构” 的分离:它定义了代码应该放在哪里(Domain, Application, Presentation, Infrastructure)。
    • Riverpod 负责 “状态与依赖” 的分离:它作为依赖注入(DI)容器和服务定位器(SL),将各层实现“注入”到需要它们的地方;同时,它作为状态管理(SM)框架,将 UI 状态与 UI 渲染分离。
    • Go_Router 负责 “UI 与导航” 的分离:它将页面导航逻辑从 UI 组件的调用中抽离出来,变为中心化的、基于路由地址的声明式导航,极大降低了页面间的耦合。
    • Retrofit/Dio 负责 “业务与数据获取” 的分离:它将 HTTP 请求的构造和解析细节封装起来,让我们的数据层实现更专注于“获取什么数据”,而不是“如何获取”。
  3. 面向接口编程 (Programming to an Interface): 这是实现依赖倒置的具体手段。应用层(Application)和基础设施层(Infrastructure)都依赖于领域层(Domain)中定义的抽象接口(Repository Interfaces),而不是具体的实现。这使得我们可以轻松地替换数据来源(比如从网络 API切换到 Mock 数据或本地数据库)而无需修改任何业务逻辑或 UI 代码。

一言以蔽之:我们的目标是构建一个稳定的“业务核心”(Domain + Application),外部的变化(UI框架升级、数据库更换、API变更)不会轻易撼动这个核心。


第二部分:深入阐述各关键点与痛点分析

现在,我们深入到具体的“术”。

2.1 Clean 架构分层详解

一个典型的 Flutter Clean Architecture 项目结构如下:

  • Domain Layer (领域层):

    • 职责: 包含最核心、最纯粹的业务逻辑和业务实体,完全独立于任何框架。
    • 包含内容:
      • Entities (实体): 代表业务对象的类,如 User, Product, Order。它们可以包含只依赖自身属性的业务逻辑(例如,一个 Order 实体可以有一个 isCompleted() 的 getter 方法)。
      • Repositories (仓库接口): 抽象接口,定义了获取和操作业务实体的数据契约。例如,abstract class ProductRepository { Future<Product> getProductById(String id); }。它只定义“做什么”,不定义“怎么做”。
      • Value Objects (值对象):Email, Password 等,用于封装验证逻辑,保证数据的有效性。
    • 特点: 无任何 Flutter/Dart 外部库依赖(除了可能的 equatable 等辅助库)。这是最稳定、最可移植的一层。
  • Application Layer (应用层 / Use Cases):

    • 职责: 编排和调度 Domain 层和 Infrastructure 层,执行具体的应用功能。它代表了“用户想要做什么”。
    • 包含内容:
      • Use Cases (或称 Interactors): 每个 Use Case 代表一个单一的应用功能点。例如 GetProductDetailsUseCase, AddToCartUseCase。它们会调用一个或多个 Repository 接口来完成任务。
    • 特点: 依赖于 Domain 层。它不知道 UI,也不知道数据来自网络还是本地。它只负责协调。例如,AddToCartUseCaseexecute 方法可能会先调用 ProductRepository 获取商品信息,再调用 UserRepository 检查用户资格,最后调用 CartRepository 将商品加入购物车。
  • Presentation Layer (表现层):

    • 职责: 显示 UI,响应用户交互,并将用户行为传递给 Application 层。
    • 包含内容:
      • Widgets/Pages (UI): Flutter 的 StatelessWidgetConsumerWidget。它们应该尽可能“笨”,只负责根据状态渲染 UI 和转发用户事件。
      • State Management (Providers/ViewModels): Riverpod Providers 就在这里扮演核心角色。通常我们会为每个页面或复杂组件创建一个 StateNotifierProviderAsyncNotifierProvider,它扮演着 ViewModel/Controller 的角色。这个 Provider 会调用相应的 Use Case,处理返回结果(成功、失败、加载中),并管理 UI 所需的状态。
      • Navigation (Go_Router): 路由配置和导航逻辑。
  • Infrastructure Layer (基础设施层):

    • 职责: 实现 Domain 层定义的接口,处理所有与外部世界的交互。
    • 包含内容:
      • Repository Implementations: 对 Domain 层 Repository 接口的具体实现。例如,ProductRepositoryImpl 会实现 ProductRepository 接口,其内部会调用一个或多个数据源。
      • Data Sources:
        • Remote Data Source: 使用 RetrofitDio 来访问网络 API。
        • Local Data Source: 使用 Hive, Isar, shared_preferences 等进行本地数据持久化。
      • Other Services: 封装其他平台相关的功能,如设备信息、权限管理、推送通知、分析服务等。

2.2 Riverpod 的角色与层次归属

这是一个常见困惑点。Riverpod 不属于任何单一层,而是贯穿各层的“粘合剂”和“电力系统”

  • 在 Presentation 层:

    • UI 组件 (ConsumerWidget) 通过 ref.watch 来订阅 Provider 暴露的 UI 状态,实现响应式刷新。
    • 用户事件(如按钮点击)通过 ref.readref.notifier 来调用 Provider 中的方法,从而触发业务逻辑。
  • 作为依赖注入 (DI) 容器:

    • 核心作用:main.dart 或应用启动时,我们通过 ProviderScopeoverride 来“组装”我们的应用。
    • 示例:
      // Domain Layer (product_repository.dart)
      abstract class ProductRepository { ... }
      
      // Infrastructure Layer (product_repository_impl.dart)
      class ProductRepositoryImpl implements ProductRepository { ... }
      
      // Application Layer (get_product_usecase.dart)
      class GetProductDetailsUseCase {
        final ProductRepository _repo;
        GetProductDetailsUseCase(this._repo);
        ...
      }
      
      // Riverpod Providers (providers.dart)
      // 1. 提供基础设施层的具体实现
      final productRepositoryProvider = Provider<ProductRepository>((ref) {
        // 在这里可以根据环境返回 Mock 或真实实现
        return ProductRepositoryImpl(ref.watch(dioProvider));
      });
      
      // 2. 提供应用层的 UseCase,并自动注入依赖
      final getProductDetailsUseCaseProvider = Provider<GetProductDetailsUseCase>((ref) {
        // Riverpod 自动处理依赖关系!
        final repository = ref.watch(productRepositoryProvider);
        return GetProductDetailsUseCase(repository);
      });
      
      // 3. 提供表现层的 StateNotifier/ViewModel
      final productDetailsViewModelProvider = StateNotifierProvider.family<...>((ref, productId) {
        // ViewModel 可以访问 UseCase
        final useCase = ref.watch(getProductDetailsUseCaseProvider);
        return ProductDetailsViewModel(useCase, productId);
      });
      
    • 通过这种方式,ProductDetailsViewModel 依赖 GetProductDetailsUseCase,后者又依赖 ProductRepository 接口。但在运行时,Riverpod 悄悄地将 ProductRepositoryImpl 这个具体实现注入了进来,完美实现了依赖倒置。

2.3 项目组件划分:以“四级存储”为例

在实际项目中,一个功能(比如获取用户信息)可能涉及多级数据源。我们可以设计一个经典的四级存储策略:

  1. 一级缓存 (Memory Cache):

    • 实现: Riverpod Provider 本身。使用 AsyncNotifierProvider.autoDispose 或普通的 AsyncNotifierProvider 配合 keepAlive 就可以实现。数据存在内存中,速度最快,但随 Provider 销毁而失效。
    • 场景: 页面内或短时间内的重复数据请求。
  2. 二级缓存 (Local Persistence Cache):

    • 实现: 在 Repository 实现中引入本地数据源(如 Hive/Isar)。
    • 场景: 跨应用会话的数据缓存,如用户配置、不常变动的列表数据。启动应用时可以先从这里加载,给用户即时反馈。
  3. 三级存储 (Network):

    • 实现: 远程数据源,通过 Retrofit 调用 API。
    • 场景: 数据的最终来源(Source of Truth)。
  4. 四级存储 (Bundled/Pre-populated Data):

    • 实现: 打包在 App assets 里的 JSON 或数据库文件。
    • 场景: 初始配置、地区列表、默认数据等。

RepositoryImpl 中的逻辑流可能是这样的:

Future<User> getUser(String userId) async {
  // 1. 尝试从内存缓存获取 (由 Riverpod 的 Provider 缓存策略管理)
  // Riverpod 自身就处理了这部分,如果 provider 还在,就不会重新执行 fetch 逻辑

  // 2. 尝试从本地数据库获取
  final localUser = await _localDataSource.getUser(userId);
  if (localUser != null && !isStale(localUser.timestamp)) {
    return localUser;
  }

  // 3. 从网络获取
  try {
    final remoteUser = await _remoteDataSource.fetchUser(userId);
    // 成功后,更新本地数据库
    await _localDataSource.saveUser(remoteUser);
    return remoteUser;
  } catch (e) {
    // 网络失败,如果本地有旧数据,也可以考虑返回
    if (localUser != null) return localUser;
    // 实在没有,就抛出异常
    throw e;
  }
}

第三部分:痛点识别与解决方案

这是架构实践中最具挑战性的部分。

痛点1:如何区分业务逻辑?(Domain Logic vs. Application Logic)

  • 困惑: 一段逻辑,是应该放在 Entity 里,还是放在 Use Case 里?
  • 解决方案与心法:
    • 问自己一个问题:“这个逻辑是否具有普适性,并且只依赖于实体自身的数据?”

      • 是 -> 放入 Domain Entity。 例如:Order 实体有一个 totalPrice 属性,一个 calculateTotalPrice() 方法根据其 lineItems 列表计算总价。这个计算逻辑是 Order 固有的,不依赖外部服务。
      • 否 -> 放入 Application Use Case。 例如:ApplyCouponToOrderUseCase。这个逻辑需要:1. 获取 Order;2. 获取 Coupon 信息(可能要调 CouponRepository);3. 验证优惠券是否适用于该订单和用户(可能要调 UserRepository);4. 最后计算出新的价格。这个过程是在协调多个实体和仓库,它是一个应用级别的操作,因此属于 Use Case。
    • 简单法则:

      • Domain Logic: 规则和计算。
      • Application Logic: 流程和编排。

痛点2:如何划分模块?(Monolith vs. Feature-based Modules)

  • 困惑: 项目变大后,lib 文件夹变得臃肿不堪。所有功能的代码混在一起,commonshared 文件夹成为“垃圾场”。
  • 解决方案:功能驱动的垂直切分 (Vertical Slicing by Feature)
    • 放弃按层级划分顶级目录 (data, domain, presentation)。 这种方式在小项目里还行,大项目里会导致你为了修改一个功能,不得不在三个相距甚远的文件夹里跳来跳去。
    • 拥抱按功能划分模块。 这是现代大型应用架构的趋势。

第四部分:以一个“可扩展的电商App”为例进行实战演练

项目背景: 一个电商 App,初期只有商品浏览、购物车、用户中心。未来需要快速迭代,加入直播带货、社区分享等新功能。

目标: 设计一个能够支撑这种演进的架构。

项目结构 (采用功能驱动模块化):

flutter_ecommerce_app/
├── lib/
│   ├── **features/**                   # 核心:按功能划分的模块
│   │   ├── **auth/**                   # 认证模块
│   │   │   ├── presentation/
│   │   │   │   ├── screens/login_screen.dart
│   │   │   │   └── providers/auth_providers.dart
│   │   │   ├── application/
│   │   │   │   └── usecases/login_usecase.dart
│   │   │   ├── domain/
│   │   │   │   ├── entities/auth_token.dart
│   │   │   │   └── repositories/auth_repository.dart (interface)
│   │   │   └── infrastructure/
│   │   │       ├── datasources/auth_remote_datasource.dart
│   │   │       └── repositories/auth_repository_impl.dart
│   │   ├── **products/**               # 商品模块 (列表、详情)
│   │   │   └── ... (同样遵循 presentation/app/domain/infra 结构)
│   │   ├── **cart/**                   # 购物车模块
│   │   │   └── ...
│   │   └── **profile/**                # 用户中心模块
│   │       └── ...
│   │
│   ├── **core/**                     # 跨功能共享的核心代码
│   │   ├── **domain/**                 # 共享的 Domain (e.g., User, AppError)
│   │   │   └── entities/user_entity.dart
│   │   ├── **ui/**                     # 共享的 UI 组件 (e.g., PrimaryButton, LoadingIndicator)
│   │   ├── **network/**                # 共享的网络配置 (Dio instance, interceptors)
│   │   │   └── dio_client.dart
│   │   ├── **navigation/**             # 共享的导航配置
│   │   │   └── app_router.dart (Go_Router 配置)
│   │   ├── **storage/**                # 共享的存储封装 (e.g., Hive helper)
│   │   └── **utils/**                  # 共享的工具类 (e.g., formatters, validators)
│   │
│   ├── **main.dart**                   # 应用入口,组装 ProviderScope 和 GoRouter
│   └── **injection_container.dart**    # (可选) 集中管理所有顶层 Provider 的声明
│
└── pubspec.yaml

这个结构的优势:

  1. 高内聚: auth 相关的所有代码都在 features/auth 目录下,从 UI 到数据源一目了然。
  2. 低耦合:
    • products 模块不直接依赖 cart 模块。如果需要交互(比如“添加到购物车”),products 模块的 AddToCartUseCase 会调用 CartRepository 接口。这个接口的实现在 cart 模块中,但依赖关系是 products -> cart/domain,而不是 products -> cart/infrastructure,耦合度很低。
    • 可移除性: 如果老板说“我们不要购物车功能了”,理论上你可以直接删除 features/cart 文件夹,修复一下路由和调用点,应用主体依然能运行。
  3. 可扩展性:
    • 当需要加入“直播带货”功能时,只需新建一个 features/live_streaming 模块,按照同样的结构进行开发。
    • 新模块可以复用 core 里的所有组件,如 core/network 的 Dio 实例,core/ui 的按钮等。
  4. 团队协作: 不同的团队可以并行开发不同的 feature 模块,冲突仅限于 corepubspec.yaml,大大提高了开发效率。

Go_Router 在此结构中的作用:core/navigation/app_router.dart 中,你会定义所有路由:

final GoRouter router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => ProductsListScreen(), // from products feature
    ),
    GoRoute(
      path: '/product/:id',
      builder: (context, state) => ProductDetailsScreen(id: state.pathParameters['id']!), // from products feature
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => LoginScreen(), // from auth feature
    ),
    GoRoute(
      path: '/cart',
      builder: (context, state) => CartScreen(), // from cart feature
    ),
  ],
);

这样,ProductsListScreen 只需调用 context.go('/product/123'),而无需知道 ProductDetailsScreen 的存在,实现了导航解耦。

总结

作为架构师,我们的工作不是选择最“时髦”的技术,而是构建一个能够应对未来不确定性的、有弹性的系统。

  • Clean Architecture 提供了抵御变化的“防火墙”。
  • 功能模块化 提供了应对业务增长的“扩展坞”。
  • Riverpod 提供了连接一切的、灵活高效的“能源和通信网络”。
  • Go_RouterRetrofit 则是这个网络中负责特定任务(导航、网络)的、可替换的“标准化插件”。

下一篇, 将在此基础上, 讲解如何开发 User 模块.