Files
conti-docs/flutter-app/02-layering.md
T

12 KiB
Raw Blame History

02. 分层架构规范

决策

每个 feature_* 包内部采用简化版分层,domain可选,不强制每个 feature 都有:

feature_xxx/
  lib/
    feature_xxx.dart      # 对外唯一导出文件
    src/
      presentation/        # widgets + Riverpod provider/notifier
      domain/               # 可选:entity + repository 接口 + use case
      data/                 # repository 实现 + remote/local datasource
  test/

各层职责

  • presentationwidgets、Riverpod Notifier/Provider。只处理 UI 状态和用户交互,不直接调用 data 层的具体实现类,通过依赖注入拿到抽象类型。
  • domain(可选):entity 定义业务模型,repository 接口声明数据契约,use case 封装跨 repository 协调或多步骤业务规则。
  • datarepository 接口的具体实现,内部再拆 remote_datasource(走 core_network)和 local_datasource(走 core_storage)。

何时可以跳过 domain 层

判断标准:

  • 可以跳过:功能是简单 CRUD、没有跨 repository 协调、没有多步骤业务规则——presentation 直接依赖 data 层定义的 repository 接口即可,repository 接口挪到 data 层里声明。
  • 必须要有:涉及多步骤业务规则(如支付的多步校验)、需要协调多个 repository、包含状态机或需要独立于 UI 单元测试的核心业务逻辑——repository 接口放在 domaindata 层依赖 domain 反向实现接口。

Repository 接口的位置规则

  • domain 层:接口定义在 domain/repository/data/repository_impl/ 实现它,presentation 只依赖 domain 里的抽象类型。
  • domain 层:接口直接定义在 data/repository/,同文件或同目录下给出实现类,presentation 依赖这个接口类型。

两种情况下,presentation 都不允许直接依赖 data 层的具体实现类(如 XxxRepositoryImpl),只依赖接口——这条不因为是否跳过 domain 层而改变。

跨层依赖规则

presentation → domain(或直接 → data 的接口,若跳过 domain
domain → 不依赖 presentation / data
data → 依赖 domain 的接口(若有),依赖 core_network / core_storage

domain 层禁止 import 的东西,不只是 Flutter SDK

  • package:flutter/...UI 框架)
  • package:dio/...(网络库)
  • package:drift/...(数据库)
  • 任何做 IO 的第三方库

domain 只允许 dart:core/dart:async 这类纯语言能力和项目内的纯 Dart 类型。这条如果松了,"domain 可以脱离 UI 和网络单独跑 unit test"就名存实亡——只要 import 了 dio,测试就得处理它的初始化和平台依赖。

数据模型与 JSON 序列化

决策DTO 用 json_serializable 生成 fromJson/toJson,不手写;不引入 freezed

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  json_serializable: ^6.9.0
  build_runner: ^2.15.2
  • 为什么不上 freezedfreezed 主要提供不可变类、copyWith、联合类型(sealed class)。Dart 3 已经原生支持 sealed class/final class 和模式匹配,联合类型这块的收益大幅缩水;而 copyWith 的收益不足以抵消"再加一个 codegen 目标 + 生成文件体积翻倍 + 编译变慢"的成本。项目里已经有 riverpod_generatordrift_devjson_serializablepigeon 四个 codegen 目标,能不加就不加(同 09-testing.md 里不选 mockito 的理由)。

  • DTO 与 entity 是否分两套类型:默认不分data 层的 DTO 直接当 domain 的 entity 用,只在下面两种情况才拆两套并写转换函数:

    1. 后端字段结构明显不适合业务使用(比如时间戳是字符串、状态是魔法数字、嵌套层级很深)。
    2. 同一个业务概念由多个接口拼出来(比如首页 tile 聚合了多个 Mini 域的返回)。

    拆两套要付出双份类型 + 一份转换代码的成本,多数简单 CRUD 场景不值得。

  • domain 层的 feature 如果拆了两套类型,转换函数放在 data 层(domain 不能知道 JSON 长什么样)。

后端统一响应包装在哪一层解开

后端所有接口返回 ApiResult<T> { code, message, data, traceId }(见 backend/06-api-design.md)。解包统一发生在 core_network 的拦截器里,不在各 feature 的 repository 里重复写

  • code == 0 → 把 data 取出来交给 repositoryrepository 的 fromJson 只需要认识 data 的结构,完全不用感知外层包装。
  • code != 0 → 直接抛 BusinessException(code, message, traceId)
  • traceId 无论成功失败都记录进日志。

完整契约见 12-error-and-api-contract.md。这条规则的意义是:以后如果后端调整了包装格式,只有 core_network 一个地方要改。

分页的统一约定

列表页要支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名:

// core_network 里定义的通用分页类型
class PageQuery {
  const PageQuery({required this.page, this.size = 20});
  final int page;   // 从 1 开始
  final int size;
}

class PageResult<T> {
  const PageResult({required this.items, required this.total, required this.page});
  final List<T> items;
  final int total;
  final int page;
  bool get hasMore => items.length + (page - 1) * items.length < total;
}

// feature 侧
abstract class PurchaseOrderRepository {
  Future<PageResult<PurchaseOrder>> fetchOrders(PageQuery query);
}

具体字段名以后端最终约定为准(backend 06 的「待补充」里也挂着分页约定这一项),联调前需要跟后端对齐一次。

附录:分层架构是什么,为什么要分层

给还没接触过这套分层习惯的同学看的入门说明。

下面示例里的 feature_payment / feature_store 是为了讲清分层概念用的简化例子,不是最终包清单(实际包清单见 01-project-structure.md)。

要解决的问题

如果 UI 代码里直接写网络请求、直接 new 一个 Dio 实例、直接操作数据库——短期能跑,但会导致两个问题:

  1. 没法单独测试业务逻辑:想验证"支付金额校验规则对不对",得连 widget 一起跑测试,跑得慢还容易因为 UI 变了导致业务逻辑测试跟着挂。
  2. 换底层实现要动 UI 代码:比如把网络库从 dio 换掉,或者把本地存储从 shared_preferences 换成 Drift,如果 UI 直接依赖具体实现类,改动会散落得到处都是。

分层的本质:把"业务规则"和"业务规则的具体实现方式(用什么网络库、存什么数据库)"分开,中间用抽象接口隔开。这就是 Clean Architecture 这套思想的核心,我们只取最简化的三层版本,不套用它完整的同心圆规则。

依赖方向是关键

三层最重要的不是"分了几层",而是依赖只能单向流动

presentation ──依赖──> domain ──定义接口,不依赖任何人
                          ▲
                          │ 实现接口(依赖倒置)
                        data

domain 不 import data,也不 import presentation——它甚至不知道 data 层是用 dio 还是别的什么网络库实现的,只定义"我需要一个能拿到 PaymentOrder 的东西"(接口),至于这个东西具体怎么实现,由 data 层负责,这就是依赖倒置原则。好处是:domain 层的业务规则可以完全脱离网络、脱离 UI 单独写单元测试。

示例一:有 domain 层(feature_payment,支付确认——多步骤业务规则)

// domain/entity/payment_order.dart
class PaymentOrder {
  final String orderId;
  final int amountCents;
  final PaymentStatus status;
  const PaymentOrder({required this.orderId, required this.amountCents, required this.status});
}

// domain/repository/payment_repository.dart
abstract class PaymentRepository {
  Future<PaymentOrder> fetchOrder(String orderId);
  Future<void> confirmPayment(String orderId, String pinToken);
}

// domain/use_case/confirm_payment_use_case.dart
class ConfirmPaymentUseCase {
  final PaymentRepository _repository;
  ConfirmPaymentUseCase(this._repository);

  Future<void> call(String orderId, String pinToken) async {
    final order = await _repository.fetchOrder(orderId);
    if (order.status != PaymentStatus.pending) {
      throw StateError('订单状态不允许支付: ${order.status}');
    }
    if (order.amountCents <= 0) {
      throw ArgumentError('订单金额非法');
    }
    await _repository.confirmPayment(orderId, pinToken);
  }
}

// data/repository/payment_repository_impl.dart
class PaymentRepositoryImpl implements PaymentRepository {
  final ApiClient _api; // 来自 core_network,不是裸 Dio,见 05-networking.md
  PaymentRepositoryImpl(this._api);

  @override
  Future<PaymentOrder> fetchOrder(String orderId) async {
    // 注意:返回的已经是 ApiResult 里的 data 部分——
    // { code, message, data, traceId } 这层包装由 core_network 的拦截器统一解开,
    // repository 不感知它的存在(见上文「后端统一响应包装在哪一层解开」)
    final json = await _api.get<Map<String, dynamic>>('/api/v1/orders/$orderId');
    return PaymentOrder(
      orderId: json['orderId'] as String,
      amountCents: json['amountCents'] as int,
      status: PaymentStatus.values.byName(json['status'] as String),
    );
  }

  @override
  Future<void> confirmPayment(String orderId, String pinToken) =>
      _api.post('/api/v1/orders/$orderId/confirm', data: {'pinToken': pinToken});
}

ConfirmPaymentUseCase 的多步校验规则可以直接用假的 PaymentRepository 实现来做单元测试,完全不需要启动 Flutter engine 或起一个 mock server。

示例二:跳过 domain 层(feature_store,门店列表——简单 CRUD

// data/repository/store_repository.dart
abstract class StoreRepository {
  Future<List<Store>> fetchNearbyStores(double lat, double lng);
}

class StoreRepositoryImpl implements StoreRepository {
  final ApiClient _api;
  StoreRepositoryImpl(this._api);

  @override
  Future<List<Store>> fetchNearbyStores(double lat, double lng) async {
    // 同上:拿到的是解开 ApiResult 包装之后的 data
    final list = await _api.get<List<dynamic>>(
      '/api/v1/stores',
      query: {'lat': lat, 'lng': lng},
    );
    return list.map((e) => Store.fromJson(e as Map<String, dynamic>)).toList();
  }
}

没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 data 层,presentation 直接依赖 StoreRepository 这个接口,省掉一层 domain 目录和 use case 模板代码。

参考链接