Skip to content

09 · 数据层链路

目标:读完能独立接入一个新接口,并理解错误是怎么从后端一路传到 toast 的。


开篇:前端概念对照

前端概念本项目对应
Axios 实例ApiClient(Dio 单例)
Axios 拦截器authInterceptor / errorInterceptor / loggingInterceptor
src/api/xxx.jslib/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/catchEitherResult<T> + 模式匹配
AbortControllerCancelToken
Mock.js / mswxxx_repository_mock.dart + useMockApiProvider

一、分层回顾与铁律

ViewModel  →  Repository  →  ApiService  →  Dio 拦截器链  →  后端
   ↑              ↑              ↑
Result<T>    数据源抽象    Retrofit 契约

两条铁律

① 禁止跨层

dart
// ❌ 禁止:ViewModel 直接调 ApiService
ref.read(plantApiServiceProvider).getPlantPage(...);

// ❌ 禁止:ViewModel 里 new Dio
final dio = Dio();

// ✅ 正确:ViewModel 只认 Repository
ref.read(plantRepositoryProvider).getPlantPage(...);

② 调用方只看到 Result<T>

ViewModel 永远看不到 ApiResponseDioException。这些都被 Repository 的 guard 函数消化掉了。


二、Result<T>:错误包装的核心

定义

lib/domain/models/result.dart 全文:

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——如果以后加了第三个分支,所有漏改的地方编译报错

消费方式:模式匹配

dart
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

dart
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()));
  }
}

三层捕获

  1. _unwrap 抛的业务异常(success != true 或 data 为 null)
  2. DioException —— 且取消被单独识别CancellationException
  3. 其他所有异常

⭐ 关键设计:取消不是错误

dart
if (e.type == DioExceptionType.cancel) {
  return Result.error(CancellationException(e.message ?? 'cancelled'));
}

为什么要单独识别:用户主动取消(退出页面、改搜索词)不应该弹错误 toast

CancellationException 会被上层静默吞掉。这是本项目的一个重要约定。

_unwrap 的业务校验

api_response_helpers.dart:15-29

dart
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!;
}

异常类型

异常来源含义
ApiBusinessExceptionapi_response_helpers.dart:145业务错误(后端返回 success: false
DioExceptionDio网络错误、超时、状态码错误
CancellationExceptionlib/data/services/api/cancellation_exception.dart主动取消,不算错误
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

dart
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

dart
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

dart
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 实现

dart
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 切换)

dart
@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));
}

切换开关useMockApiProviderlib/config/providers/settings_providers.dart:19),可在 devtools 面板一键切换。

⚠️ Release 模式强制 false(源码注释:Release 模式强制 false(且不可切换))。


五、Retrofit ApiService

定义

dart
@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()

dart
@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
  • Objectvoid
  • ⚠️ dynamic / Map<String, dynamic> —— 只用于动作型接口,且要用 guardApiVoid / guardApiObjectMap

注解速查

注解用途
@GET(path) / @POST(path) / @PUT / @DELETEHTTP 方法
@Body()请求体
@Query('name')URL 查询参数
@Path('id')路径参数
@Header('Xxx')请求头
@Field / @FormUrlEncoded()表单
@Part() / @MultiPart()文件上传
@CancelRequest()取消令牌(必须有)

六、Dio 拦截器链

配置位置

lib/config/providers/network_providers.dart:135-152

dart
@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 时:

  1. 调 refresh 接口拿新 token
  2. 原 Dio 实例重放原请求
  3. 成功则返回重放结果

⚠️ 一次「401」实际是 2 次后端调用

⚠️ 登录接口不要走 AuthInterceptor——登录本身就是在拿 token,没有 401 的概念。

环境配置

lib/data/services/api/environment.dart 定义环境(test / prod 等)。

运行时环境由 bootstrapOverrides 解析(lib/config/dependencies.dart:47-49):

dart
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)

错误消息翻译

resolveApiErrorMessagelib/ui/core/utils/api_error_message.dart)把异常翻译成用户可读的文案。

⚠️ 它会跳过 CancellationException —— 主动取消不弹 toast。

UI 层标准写法

dart
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 把它收口成 setupErrorListenerlib/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. RemoteguardApiObject/List/Void 包裹(除特殊路径)
  • [ ] 7. Providerlib/config/providers/ 注册,走 useMockApiProvider 切换
  • [ ] 8. ViewModelswitch 模式匹配 ResultErrorResult 分支 throw e

ViewModel 模板

dart
@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.fromJsonApiResponse<dynamic> + guardApiVoid
T 无 fromJson反序列化失败T 必须是 freezed DTO
忘了跑 build_runner新方法找不到build_runner build
取消被当成错误退出页面弹错误 toastguard 已处理;手写时要保留 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 步

下一步

10 · 页面开发实战