235 lines
12 KiB
Markdown
235 lines
12 KiB
Markdown
# 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/
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 各层职责
|
|||
|
|
|
|||
|
|
- **presentation**:widgets、Riverpod `Notifier`/`Provider`。只处理 UI 状态和用户交互,不直接调用 `data` 层的具体实现类,通过依赖注入拿到抽象类型。
|
|||
|
|
- **domain**(可选):`entity` 定义业务模型,`repository` 接口声明数据契约,`use case` 封装跨 repository 协调或多步骤业务规则。
|
|||
|
|
- **data**:`repository` 接口的具体实现,内部再拆 `remote_datasource`(走 `core_network`)和 `local_datasource`(走 `core_storage`)。
|
|||
|
|
|
|||
|
|
## 何时可以跳过 domain 层
|
|||
|
|
|
|||
|
|
判断标准:
|
|||
|
|
|
|||
|
|
- **可以跳过**:功能是简单 CRUD、没有跨 repository 协调、没有多步骤业务规则——`presentation` 直接依赖 `data` 层定义的 repository 接口即可,`repository` 接口挪到 `data` 层里声明。
|
|||
|
|
- **必须要有**:涉及多步骤业务规则(如支付的多步校验)、需要协调多个 repository、包含状态机或需要独立于 UI 单元测试的核心业务逻辑——`repository` 接口放在 `domain`,`data` 层依赖 `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](https://pub.dev/packages/json_serializable) 生成 `fromJson`/`toJson`,不手写;**不引入 freezed**。
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
dependencies:
|
|||
|
|
json_annotation: ^4.9.0
|
|||
|
|
|
|||
|
|
dev_dependencies:
|
|||
|
|
json_serializable: ^6.9.0
|
|||
|
|
build_runner: ^2.15.2
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- **为什么不上 freezed**:freezed 主要提供不可变类、`copyWith`、联合类型(sealed class)。Dart 3 已经原生支持 `sealed class`/`final class` 和模式匹配,联合类型这块的收益大幅缩水;而 `copyWith` 的收益不足以抵消"再加一个 codegen 目标 + 生成文件体积翻倍 + 编译变慢"的成本。项目里已经有 `riverpod_generator`、`drift_dev`、`json_serializable`、`pigeon` 四个 codegen 目标,能不加就不加(同 [09-testing.md](./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](../../conti-backend/docs/06-api-design.md))。**解包统一发生在 `core_network` 的拦截器里,不在各 feature 的 repository 里重复写**:
|
|||
|
|
|
|||
|
|
- `code == 0` → 把 `data` 取出来交给 repository,repository 的 `fromJson` 只需要认识 `data` 的结构,完全不用感知外层包装。
|
|||
|
|
- `code != 0` → 直接抛 `BusinessException(code, message, traceId)`。
|
|||
|
|
- `traceId` 无论成功失败都记录进日志。
|
|||
|
|
|
|||
|
|
完整契约见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。这条规则的意义是:以后如果后端调整了包装格式,只有 `core_network` 一个地方要改。
|
|||
|
|
|
|||
|
|
## 分页的统一约定
|
|||
|
|
|
|||
|
|
PRD §21.1 要求列表页支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 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](./01-project-structure.md))。
|
|||
|
|
|
|||
|
|
### 要解决的问题
|
|||
|
|
|
|||
|
|
如果 UI 代码里直接写网络请求、直接 new 一个 `Dio` 实例、直接操作数据库——短期能跑,但会导致两个问题:
|
|||
|
|
|
|||
|
|
1. **没法单独测试业务逻辑**:想验证"支付金额校验规则对不对",得连 widget 一起跑测试,跑得慢还容易因为 UI 变了导致业务逻辑测试跟着挂。
|
|||
|
|
2. **换底层实现要动 UI 代码**:比如把网络库从 `dio` 换掉,或者把本地存储从 `shared_preferences` 换成 `Drift`,如果 UI 直接依赖具体实现类,改动会散落得到处都是。
|
|||
|
|
|
|||
|
|
**分层的本质**:把"业务规则"和"业务规则的具体实现方式(用什么网络库、存什么数据库)"分开,中间用抽象接口隔开。这就是 [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 这套思想的核心,我们只取最简化的三层版本,不套用它完整的同心圆规则。
|
|||
|
|
|
|||
|
|
### 依赖方向是关键
|
|||
|
|
|
|||
|
|
三层最重要的不是"分了几层",而是**依赖只能单向流动**:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
presentation ──依赖──> domain ──定义接口,不依赖任何人
|
|||
|
|
▲
|
|||
|
|
│ 实现接口(依赖倒置)
|
|||
|
|
data
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`domain` 不 import `data`,也不 import `presentation`——它甚至不知道 `data` 层是用 `dio` 还是别的什么网络库实现的,只定义"我需要一个能拿到 `PaymentOrder` 的东西"(接口),至于这个东西具体怎么实现,由 `data` 层负责,这就是[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)。好处是:`domain` 层的业务规则可以完全脱离网络、脱离 UI 单独写单元测试。
|
|||
|
|
|
|||
|
|
### 示例一:有 domain 层(`feature_payment`,支付确认——多步骤业务规则)
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 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)
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 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 模板代码。
|
|||
|
|
|
|||
|
|
## 参考链接
|
|||
|
|
|
|||
|
|
- [Flutter 官方状态管理文档](https://docs.flutter.dev/data-and-backend/state-mgmt)
|
|||
|
|
- [The Clean Architecture(Uncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
|||
|
|
- [依赖倒置原则(Dependency Inversion Principle)](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
|
|||
|
|
- [json_serializable | Dart package](https://pub.dev/packages/json_serializable)
|
|||
|
|
- [Dart 3 sealed class 与模式匹配](https://dart.dev/language/patterns)
|