Files
2026-08-17 15:29:55 +08:00

235 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 取出来交给 repositoryrepository 的 `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 ArchitectureUncle 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)