09 · 数据层链路
目标:读完能独立接入一个新接口,并理解错误是怎么从后端一路传到 toast 的。
开篇:前端概念对照
| 前端概念 | 本项目对应 |
|---|---|
| Axios 实例 | ApiClient(Dio 单例) |
| Axios 拦截器 | authInterceptor / errorInterceptor / loggingInterceptor |
src/api/xxx.js | lib/data/services/api/<module>/<module>_api_service.dart |
| 请求/响应 TS interface | @freezed DTO(lib/data/services/api/models/) |
src/services/xxx.js(业务封装) | lib/data/repositories/<module>/ |
try/catch 或 Either | Result<T> + 模式匹配 |
AbortController | CancelToken |
| Mock.js / msw | xxx_repository_mock.dart + useMockApiProvider |
一、分层回顾与铁律
ViewModel → Repository → ApiService → Dio 拦截器链 → 后端
↑ ↑ ↑
Result<T> 数据源抽象 Retrofit 契约两条铁律
① 禁止跨层
// ❌ 禁止:ViewModel 直接调 ApiService
ref.read(plantApiServiceProvider).getPlantPage(...);
// ❌ 禁止:ViewModel 里 new Dio
final dio = Dio();
// ✅ 正确:ViewModel 只认 Repository
ref.read(plantRepositoryProvider).getPlantPage(...);② 调用方只看到 Result<T>
ViewModel 永远看不到 ApiResponse、DioException。这些都被 Repository 的 guard 函数消化掉了。
二、Result<T>:错误包装的核心
定义
lib/domain/models/result.dart 全文:
@freezed
sealed class Result<T> with _$Result<T> {
const factory Result.ok(T value) = Ok<T>;
const factory Result.error(Exception error) = ErrorResult<T>;
}两个分支:Ok(value) 和 ErrorResult(error)。
前端类比:函数式编程的 Either<Error, T>。
为什么用 sealed
sealed 让编译器知道所有可能的子类型,所以 switch 可以不写 default——如果以后加了第三个分支,所有漏改的地方编译报错。
消费方式:模式匹配
final result = await repository.fetch();
return switch (result) {
Ok(value: final data) => processData(data),
ErrorResult(error: final e) => throw e,
};⚠️ 本项目禁止 result.isSuccess / result.isError——这两个方法不存在(这是 sealed class 不是普通类)。
为什么 ErrorResult 分支要 throw e
throw 之后,Riverpod 的 AsyncValue.guard 会捕获它,变成 AsyncValue.error 态——UI 自动显示错误页。这是故意的设计:把「错误」交给框架统一处理。
三、guard 函数:错误收敛的收口
lib/data/services/api/api_response_helpers.dart 提供了五个 guard 函数。所有 Remote Repository 都要用它们。
选型表
| 接口形态 | 用哪个 | data 为 null 时 |
|---|---|---|
单对象 ApiResponse<T> | guardApiObject<T> | 抛 ApiBusinessException |
列表 ApiResponse<List<T>> | guardApiList<T> | 返回空 [] |
| 动作型(不关心 data) | guardApiVoid<T> | 不关心 |
| 动态 Map(如字典) | guardApiObjectMap | 非 Map 时返回 error |
| 字符串(如返回 message) | 手写(见下) |
guardApiObject 的实现
lib/data/services/api/api_response_helpers.dart:36-50:
Future<Result<T>> guardApiObject<T extends Object>(
Future<ApiResponse<T>> Function() fetch,
) async {
try {
final raw = await fetch();
return Result.ok(_unwrap(raw));
} on DioException catch (e) {
if (e.type == DioExceptionType.cancel) {
return Result.error(CancellationException(e.message ?? 'cancelled'));
}
return Result.error(e);
} catch (e) {
return Result.error(e is Exception ? e : Exception(e.toString()));
}
}三层捕获:
_unwrap抛的业务异常(success != true或 data 为 null)DioException—— 且取消被单独识别成CancellationException- 其他所有异常
⭐ 关键设计:取消不是错误
if (e.type == DioExceptionType.cancel) {
return Result.error(CancellationException(e.message ?? 'cancelled'));
}为什么要单独识别:用户主动取消(退出页面、改搜索词)不应该弹错误 toast。
CancellationException 会被上层静默吞掉。这是本项目的一个重要约定。
_unwrap 的业务校验
api_response_helpers.dart:15-29:
T _unwrap<T extends Object>(ApiResponse<T> raw) {
if (!raw.success) {
throw ApiBusinessException(raw.message ?? '请求失败', code: raw.code.toString());
}
if (raw.data == null) {
throw ApiBusinessException('响应 data 字段为空', code: raw.code.toString());
}
return raw.data!;
}异常类型
| 异常 | 来源 | 含义 |
|---|---|---|
ApiBusinessException | api_response_helpers.dart:145 | 业务错误(后端返回 success: false) |
DioException | Dio | 网络错误、超时、状态码错误 |
CancellationException | lib/data/services/api/cancellation_exception.dart | 主动取消,不算错误 |
class ApiBusinessException implements Exception {
final String message;
final String? code;
final String? traceId;
ApiBusinessException(this.message, {this.code, this.traceId});
}不用 guard 的例外情况
有些接口需要保留后端的 message 或特殊 code,此时手写:
lib/data/repositories/device/device_repository_remote.dart:50-67:
try {
final raw = await _apiService.deleteDevices(body, cancelToken: cancelToken);
if (!raw.success) {
return Result.error(ApiBusinessException(
raw.message ?? '删除失败',
code: raw.code.toString(),
));
}
return Result.ok(raw.message ?? '');
} on DioException catch (e) {
if (e.type == DioExceptionType.cancel) {
return Result.error(CancellationException(e.message ?? 'cancelled'));
}
return Result.error(e);
} catch (e) {
return Result.error(e is Exception ? e : Exception(e.toString()));
}注意:即便手写,也必须保留 cancel 识别这一段。
另一个例子 —— lib/data/repositories/device/self_check_repository_remote.dart:11-13 的注释:
「不走
guardApiObject:自检接口code=10001是业务语义(设备离线),需保留传给 ViewModel 判定」
判断标准:默认用 guard;只有当「后端的错误码本身有业务含义」时才手写。
四、Repository 三件套
文件组织
lib/data/repositories/<module>/
├── <module>_repository.dart 抽象接口
├── <module>_repository_remote.dart 远程实现
└── <module>_repository_mock.dart Mock 实现以 device 模块为例,该目录下有 4 组三件套(device / data_detail / inverter_detail / self_check)共 13 个文件。
① 抽象接口
lib/data/repositories/device/device_repository.dart:15-59:
abstract class DeviceRepository {
Future<Result<DevicePageDTO<DeviceItemDTO>>> getDevicePage(
DevicePageRequestDTO body, {
CancelToken? cancelToken,
});
Future<List<int>> queryInverterTypes({CancelToken? cancelToken});
Future<Result<String>> deleteDevices(
DeviceDeleteRequestDTO body, {
CancelToken? cancelToken,
});
Future<Result<void>> changeDeviceName(
String? deviceSn, String? deviceName, {CancelToken? cancelToken});
}约定:
- 返回
Result<T>(少数不需要错误处理的除外,如queryInverterTypes返回Future<List<int>>) - 每个方法都带
CancelToken? cancelToken命名参数
② Remote 实现
lib/data/repositories/device/device_repository_remote.dart:18-43:
class DeviceRepositoryRemote implements DeviceRepository {
final DeviceApiService _apiService;
DeviceRepositoryRemote({required DeviceApiService apiService})
: _apiService = apiService;
@override
Future<Result<DevicePageDTO<DeviceItemDTO>>> getDevicePage(
DevicePageRequestDTO body, {
CancelToken? cancelToken,
}) =>
guardApiObject<DevicePageDTO<DeviceItemDTO>>(
() => _apiService.getDevicePage(body, cancelToken: cancelToken),
);
@override
Future<List<int>> queryInverterTypes({CancelToken? cancelToken}) async {
final result = await guardApiList<DeviceTypeDTO>(
() => _apiService.queryByType(cancelToken: cancelToken),
);
return switch (result) {
Ok(value: final list) => list.map((e) => e.typeCode).whereType<int>().toList(),
ErrorResult() => defaultInverterTypes, // 失败时用兜底常量
};
}
}注意 queryInverterTypes:失败时返回兜底常量 defaultInverterTypes(定义在 device_repository.dart:64-75),不向上抛错误——因为逆变器类型不是核心数据,失败了用默认值即可。
这是一个很好的设计示范:不是所有失败都要打断用户。
③ Mock 实现
class DeviceRepositoryMock implements DeviceRepository {
@override
Future<Result<DevicePageDTO<DeviceItemDTO>>> getDevicePage(
DevicePageRequestDTO body, {
CancelToken? cancelToken,
}) async {
// 读取 assets/mock_data/*.json
...
}
}Mock 数据放在 assets/mock_data/ 下(如 设备列表_post_meta_device_app_getDevicePage.json),命名规则是「中文名_方法_路径」。
④ Provider 注册(DI 切换)
@riverpod
DeviceApiService deviceApiService(Ref ref) {
final dio = ref.watch(apiClientProvider).dio;
return DeviceApiService(dio);
}
@riverpod
DeviceRepository deviceRepository(Ref ref) {
if (ref.watch(useMockApiProvider)) return DeviceRepositoryMock();
return DeviceRepositoryRemote(apiService: ref.watch(deviceApiServiceProvider));
}切换开关是 useMockApiProvider(lib/config/providers/settings_providers.dart:19),可在 devtools 面板一键切换。
⚠️ Release 模式强制 false(源码注释:Release 模式强制 false(且不可切换))。
五、Retrofit ApiService
定义
@RestApi()
abstract class DeviceApiService {
factory DeviceApiService(Dio dio, {String? baseUrl}) = _DeviceApiService;
@POST(ApiPaths.getDevicePage)
Future<ApiResponse<DevicePageDTO<DeviceItemDTO>>> getDevicePage(
@Body() DevicePageRequestDTO body, {
@CancelRequest() CancelToken? cancelToken,
});
@GET(ApiPaths.queryByType)
Future<ApiResponse<List<DeviceTypeDTO>>> queryByType({
@CancelRequest() CancelToken? cancelToken,
});
}
part 'device_api_service.g.dart';强制约定
| 约定 | 说明 |
|---|---|
@CancelRequest() CancelToken? cancelToken | 必须有,且注解来自 package:retrofit/dio.dart |
返回 Future<ApiResponse<T>> | 统一包装 |
动作型用 ApiResponse<dynamic> | 禁止 ApiResponse<void> / ApiResponse<Object?> |
路径走 ApiPaths 常量 | 禁止硬编码 URL |
末尾加 part 'xxx.g.dart'; |
⚠️ 高频坑:@CancelRequest() 不是 @CancelToken()
@CancelRequest() CancelToken? cancelToken // ✅
@CancelToken() CancelToken? cancelToken // ❌ 不存在这个注解
CancelToken? cancelToken // ❌ 会被忽略,取消失效注解必须带括号,且 import 来自 package:retrofit/dio.dart。
验证方法:跑完 build_runner 后看 .g.dart 里是否有 cancelToken: cancelToken。
⚠️ ApiResponse<T> 的 T 必须有 fromJson
ApiResponse 用了 genericArgumentFactories: true,所以 T 必须是:
- ✅ freezed DTO(有
fromJson) - ❌
Object、void - ⚠️
dynamic/Map<String, dynamic>—— 只用于动作型接口,且要用guardApiVoid/guardApiObjectMap
注解速查
| 注解 | 用途 |
|---|---|
@GET(path) / @POST(path) / @PUT / @DELETE | HTTP 方法 |
@Body() | 请求体 |
@Query('name') | URL 查询参数 |
@Path('id') | 路径参数 |
@Header('Xxx') | 请求头 |
@Field / @FormUrlEncoded() | 表单 |
@Part() / @MultiPart() | 文件上传 |
@CancelRequest() | 取消令牌(必须有) |
六、Dio 拦截器链
配置位置
lib/config/providers/network_providers.dart:135-152:
@Riverpod(keepAlive: true)
ApiClient apiClient(Ref ref) {
final url = ref.watch(baseUrlProvider);
final interceptor = ref.watch(authInterceptorProvider);
...
return ApiClient(authInterceptor: interceptor, ...);
}顺序
请求:Auth → RUM(release) → Error → Logging → 服务器
响应:Logging → Error → RUM → Auth → Repository(响应顺序是请求顺序的逆序——Dio 拦截器是栈式结构)
各拦截器职责
| 拦截器 | 职责 |
|---|---|
AuthInterceptor | 注入 token;401 时刷新 token 并重放请求 |
| RUM | 阿里云性能监控(仅 release) |
ErrorInterceptor | 错误归一化处理 |
LoggingInterceptor | 请求/响应日志 |
401 自动刷新机制
AuthInterceptor 检测到 401 时:
- 调 refresh 接口拿新 token
- 用原 Dio 实例重放原请求
- 成功则返回重放结果
⚠️ 一次「401」实际是 2 次后端调用。
⚠️ 登录接口不要走 AuthInterceptor——登录本身就是在拿 token,没有 401 的概念。
环境配置
lib/data/services/api/environment.dart 定义环境(test / prod 等)。
运行时环境由 bootstrapOverrides 解析(lib/config/dependencies.dart:47-49):
final appEnv = forcedEnvironment ?? await EnvironmentConfig.getEnvironment();
RuntimeEnvironment.current = appEnv;优先级:入口点强制 > SharedPreferences > dart-define > 默认。
七、错误如何传到 UI
完整链路
后端返回 success: false
↓
_unwrap 抛 ApiBusinessException
↓
guardApiObject 捕获 → Result.error(exception)
↓
ViewModel: ErrorResult(error: final e) => throw e
↓
AsyncValue.guard 捕获 → AsyncValue.error
↓
LoadingStateHandler 显示错误页
↓
ref.listen 检测到首次进入错误态 → HeLoading.showError(msg)错误消息翻译
resolveApiErrorMessage(lib/ui/core/utils/api_error_message.dart)把异常翻译成用户可读的文案。
⚠️ 它会跳过 CancellationException —— 主动取消不弹 toast。
UI 层标准写法
ref.listen<AsyncValue<MyData>>(myViewModelProvider, (previous, next) {
if (next.hasError && !next.isLoading &&
(previous == null || previous.isLoading || !previous.hasError)) {
final msg = resolveApiErrorMessage(next.error, l10n);
if (msg != null) HeLoading.showError(msg);
}
});PagedListBody 把它收口成 setupErrorListener(lib/ui/core/widgets/paged_list_body.dart:59-63)。
八、接入新接口:8 步检查清单
按顺序做,每步打勾:
- [ ] 1.
ApiPaths加了路径常量(禁止硬编码 URL) - [ ] 2. DTO 有
@freezed+fromJson(放lib/data/services/api/models/<module>/) - [ ] 3. ApiService 方法加了
@CancelRequest() CancelToken? cancelToken - [ ] 4. build_runner 跑了
fvm dart run build_runner build --delete-conflicting-outputs - [ ] 5. Repository 三件套 齐全(接口 + Remote + Mock)
- [ ] 6. Remote 用
guardApiObject/List/Void包裹(除特殊路径) - [ ] 7. Provider 在
lib/config/providers/注册,走useMockApiProvider切换 - [ ] 8. ViewModel 用
switch模式匹配Result,ErrorResult分支throw e
ViewModel 模板:
@riverpod
class XxxViewModel extends _$XxxViewModel {
@override
Future<XxxData> build() async {
final cancelToken = CancelToken();
ref.onDispose(cancelToken.cancel);
final result = await ref.read(xxxRepositoryProvider).fetch(
cancelToken: cancelToken,
);
return switch (result) {
Ok(value: final data) => XxxData.from(data),
ErrorResult(error: final e) => throw e,
};
}
Future<void> refresh() async => ref.invalidateSelf();
}⚠️ 禁止旧模板(Notifier<AsyncState<T>> + Future.microtask(loadData))。
九、常见坑速查
| 坑 | 现象 | 解法 |
|---|---|---|
@CancelToken() | 注解不存在,编译错 | 用 @CancelRequest()(带括号) |
ApiResponse<void> | 生成无效 Object.fromJson | 用 ApiResponse<dynamic> + guardApiVoid |
T 无 fromJson | 反序列化失败 | T 必须是 freezed DTO |
| 忘了跑 build_runner | 新方法找不到 | 跑 build_runner build |
| 取消被当成错误 | 退出页面弹错误 toast | guard 已处理;手写时要保留 cancel 分支 |
| 改了 DTO 字段 | Mock 构造报错 | 同步更新 Mock 里的构造调用 |
| 401 循环 | 反复刷新失败 | 检查登录接口是否误走 AuthInterceptor |
十、自检清单
- [ ] 能说出 ViewModel → Repository → ApiService → 拦截器的完整链路
- [ ] 知道
Result<T>只有Ok/ErrorResult两个分支,用switch匹配 - [ ] 会根据接口形态选
guardApiObject/guardApiList/guardApiVoid - [ ] 知道
CancellationException不是错误、不弹 toast - [ ] 知道 ApiService 必须用
@CancelRequest() - [ ] 知道 Mock 切换走
useMockApiProvider,release 强制 false - [ ] 能独立完成接入新接口的 8 步