news 2026/6/21 16:23:24

Freezed + json_serializable:DTO / Domain 分层(完整工程版:Dio 错误治理 + Failure + 分页范式)(可复制) 下篇

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Freezed + json_serializable:DTO / Domain 分层(完整工程版:Dio 错误治理 + Failure + 分页范式)(可复制) 下篇

目标:让 API 变化、字段脏数据、分页、错误处理都收敛在 data 层;UI 与状态管理只依赖稳定的 Domain 模型。

0. 一句话架构:脏世界进 DTO,干净世界用 Domain

  • DTO(data/dto):适配接口、容错、兼容字段演进、可空字段很正常

  • Domain(domain/entity):业务语义、不可变、尽量非空、稳定供 UI/UseCase 使用

  • Mapper(data/mappers):清洗、转换、兜底(工程最关键

  • Repository(domain/repo + data/repo_impl):对上只返回 Domain/Failure

1. 工程目录(Feature 组件化落地)

lib/ core/ network/ dio_client.dart interceptors.dart api_exception.dart errors/ failure.dart error_mapper.dart pagination/ page.dart utils/ json.dart features/ user/ data/ dto/ user_dto.dart page_dto.dart mappers/ user_mapper.dart page_mapper.dart datasource/ user_remote_ds.dart repo_impl/ user_repository_impl.dart domain/ entity/ user.dart repo/ user_repository.dart usecase/ get_me.dart list_users.dart presentation/ state/ user_notifier.dart

硬规则:presentation 只能 import domain;data 永远不被 UI 直接依赖。

2. 依赖与 build_runner

pubspec.yaml(核心)

dependencies: freezed_annotation: ^2.4.1 json_annotation: ^4.9.0 dio: ^5.4.0 dev_dependencies: build_runner: ^2.4.9 freezed: ^2.5.7 json_serializable: ^6.8.0

生成命令:

dart run build_runner build --delete-conflicting-outputs

3. Core:统一网络层 + 统一错误模型

3.1 Failure:Domain 层唯一错误类型(给 UI 用)

core/errors/failure.dart

import 'package:freezed_annotation/freezed_annotation.dart'; part 'failure.freezed.dart'; @freezed sealed class Failure with _$Failure { const factory Failure.network({String? message}) = NetworkFailure; const factory Failure.timeout({String? message}) = TimeoutFailure; const factory Failure.unauthorized({String? message}) = UnauthorizedFailure; const factory Failure.forbidden({String? message}) = ForbiddenFailure; const factory Failure.notFound({String? message}) = NotFoundFailure; const factory Failure.validation({String? message, Map<String, dynamic>? fields}) = ValidationFailure; const factory Failure.server({String? message, int? code}) = ServerFailure; const factory Failure.unknown({String? message}) = UnknownFailure; }

UI/State 层只需要认识Failure,别让 DioException、HTTP code 满天飞。

3.2 ApiException:data 层把错误收敛为一种异常(内部用)

core/network/api_exception.dart

class ApiException implements Exception { final int? statusCode; final String? message; final Map<String, dynamic>? data; ApiException({this.statusCode, this.message, this.data}); @override String toString() => 'ApiException($statusCode, $message)'; }

3.3 DioClient:统一配置 + 统一抛 ApiException

core/network/dio_client.dart

import 'package:dio/dio.dart'; import 'api_exception.dart'; class DioClient { final Dio _dio; DioClient(String baseUrl, {List<Interceptor>? interceptors}) : _dio = Dio(BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), sendTimeout: const Duration(seconds: 10), headers: {'Content-Type': 'application/json'}, )) { if (interceptors != null) _dio.interceptors.addAll(interceptors); } Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query}) async { try { return await _dio.get<T>(path, queryParameters: query); } on DioException catch (e) { throw _toApiException(e); } } Future<Response<T>> post<T>(String path, {Object? body, Map<String, dynamic>? query}) async { try { return await _dio.post<T>(path, data: body, queryParameters: query); } on DioException catch (e) { throw _toApiException(e); } } ApiException _toApiException(DioException e) { final res = e.response; if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.sendTimeout || e.type == DioExceptionType.receiveTimeout) { return ApiException(statusCode: res?.statusCode, message: 'Timeout', data: _safeMap(res?.data)); } if (e.type == DioExceptionType.connectionError) { return ApiException(statusCode: res?.statusCode, message: 'Network error', data: _safeMap(res?.data)); } return ApiException( statusCode: res?.statusCode, message: _extractMessage(res?.data) ?? e.message, data: _safeMap(res?.data), ); } Map<String, dynamic>? _safeMap(dynamic data) => data is Map<String, dynamic> ? data : null; String? _extractMessage(dynamic data) { if (data is Map<String, dynamic>) { final msg = data['message'] ?? data['msg'] ?? data['error']; if (msg is String) return msg; } return null; } }

3.4 ErrorMapper:ApiException → Failure(唯一映射点)

core/errors/error_mapper.dart

import '../network/api_exception.dart'; import 'failure.dart'; class ErrorMapper { static Failure map(Object e) { if (e is ApiException) { final code = e.statusCode; final msg = e.message; if (code == 401) return Failure.unauthorized(message: msg); if (code == 403) return Failure.forbidden(message: msg); if (code == 404) return Failure.notFound(message: msg); if (code == 422) return Failure.validation(message: msg, fields: e.data?['fields'] as Map<String, dynamic>?); if (code != null && code >= 500) return Failure.server(message: msg, code: code); // 没状态码 / 非预期 return Failure.unknown(message: msg); } return Failure.unknown(message: e.toString()); } }

4. Result/Either:Repository 返回“成功或失败”

不用引第三方库也行,自己写一个轻量Result<T>

core/utils/result.dart

import '../errors/failure.dart'; sealed class Result<T> { const Result(); R fold<R>(R Function(Failure f) onFail, R Function(T v) onOk); } class Ok<T> extends Result<T> { final T value; const Ok(this.value); @override R fold<R>(R Function(Failure f) onFail, R Function(T v) onOk) => onOk(value); } class Err<T> extends Result<T> { final Failure failure; const Err(this.failure); @override R fold<R>(R Function(Failure f) onFail, R Function(T v) onOk) => onFail(failure); }

5. DTO:接口模型(含字段演进兼容)

5.1 UserDto

features/user/data/dto/user_dto.dart

import 'package:freezed_annotation/freezed_annotation.dart'; part 'user_dto.freezed.dart'; part 'user_dto.g.dart'; @freezed class UserDto with _$UserDto { const factory UserDto({ required String id, // 演进:旧字段 user_name,新字段 name @JsonKey(name: 'user_name') String? userName, @JsonKey(name: 'name') String? name, // 演进:avatar_url -> avatar @JsonKey(name: 'avatar_url') String? avatarUrl, @JsonKey(name: 'avatar') String? avatar, // 0/1 @JsonKey(name: 'vip') @Default(0) int vipFlag, // created_at 可能为空/乱格式 @JsonKey(name: 'created_at') String? createdAt, }) = _UserDto; factory UserDto.fromJson(Map<String, dynamic> json) => _$UserDtoFromJson(json); }

字段演进:DTO 双字段并存,由 Mapper 决定优先级。

6. Domain:业务实体(不可变、语义稳定)

features/user/domain/entity/user.dart

import 'package:freezed_annotation/freezed_annotation.dart'; part 'user.freezed.dart'; @freezed class User with _$User { const factory User({ required String id, required String name, required String avatarUrl, required bool isVip, required DateTime createdAt, }) = _User; }

7. Mapper:DTO → Domain(清洗 + 转换 + 兜底)

features/user/data/mappers/user_mapper.dart

import '../../domain/entity/user.dart'; import '../dto/user_dto.dart'; extension UserDtoMapper on UserDto { User toDomain() { final resolvedName = _pickFirstNonEmpty([name, userName]) ?? 'Unknown'; final resolvedAvatar = _pickFirstNonEmpty([avatar, avatarUrl]) ?? ''; final resolvedCreatedAt = DateTime.tryParse(createdAt ?? '') ?? DateTime.fromMillisecondsSinceEpoch(0); return User( id: id, name: resolvedName, avatarUrl: resolvedAvatar, isVip: vipFlag == 1, createdAt: resolvedCreatedAt, ); } String? _pickFirstNonEmpty(List<String?> xs) { for (final x in xs) { final v = x?.trim(); if (v != null && v.isNotEmpty) return v; } return null; } }

8. 分页:DTO + Domain 的统一范式(工程高频点)

8.1 Domain Page

core/pagination/page.dart

import 'package:freezed_annotation/freezed_annotation.dart'; part 'page.freezed.dart'; @freezed class Page<T> with _$Page<T> { const factory Page({ required List<T> items, required int page, required int pageSize, required int total, required bool hasMore, }) = _Page<T>; }

8.2 PageDto(接口分页结构你按真实接口改)

features/user/data/dto/page_dto.dart

import 'package:freezed_annotation/freezed_annotation.dart'; part 'page_dto.freezed.dart'; part 'page_dto.g.dart'; @freezed class PageDto<T> with _$PageDto<T> { const factory PageDto({ @Default([]) List<T> items, @JsonKey(name: 'page') @Default(1) int page, @JsonKey(name: 'page_size') @Default(20) int pageSize, @JsonKey(name: 'total') @Default(0) int total, }) = _PageDto<T>; factory PageDto.fromJson( Map<String, dynamic> json, T Function(Object?) fromJsonT, ) => _$PageDtoFromJson(json, fromJsonT); }

Freezed + json_serializable 的泛型 fromJson 写法就是这种。

8.3 PageMapper

features/user/data/mappers/page_mapper.dart

import '../../../../core/pagination/page.dart'; import '../dto/page_dto.dart'; extension PageDtoMapper<T> on PageDto<T> { Page<R> mapItems<R>(R Function(T) mapper) { final mapped = items.map(mapper).toList(); final hasMore = page * pageSize < total; return Page<R>( items: mapped, page: page, pageSize: pageSize, total: total, hasMore: hasMore, ); } }

9. DataSource:只返回 DTO(别在这里做业务清洗)

features/user/data/datasource/user_remote_ds.dart

import '../../../../core/network/dio_client.dart'; import '../dto/user_dto.dart'; import '../dto/page_dto.dart'; class UserRemoteDataSource { final DioClient client; UserRemoteDataSource(this.client); Future<UserDto> getMe() async { final res = await client.get<Map<String, dynamic>>('/me'); return UserDto.fromJson(res.data!); } Future<PageDto<UserDto>> listUsers({required int page, int pageSize = 20}) async { final res = await client.get<Map<String, dynamic>>( '/users', query: {'page': page, 'page_size': pageSize}, ); return PageDto<UserDto>.fromJson( res.data!, (obj) => UserDto.fromJson(obj as Map<String, dynamic>), ); } }

10. Repository:唯一对外返回 Domain / Failure 的地方

10.1 Domain Repo

features/user/domain/repo/user_repository.dart

import '../../../../core/pagination/page.dart'; import '../../../../core/utils/result.dart'; import '../entity/user.dart'; abstract class UserRepository { Future<Result<User>> getMe(); Future<Result<Page<User>>> listUsers({required int page, int pageSize}); }

10.2 RepoImpl:try/catch + ErrorMapper + Mapper

features/user/data/repo_impl/user_repository_impl.dart

import '../../../../core/errors/error_mapper.dart'; import '../../../../core/pagination/page.dart'; import '../../../../core/utils/result.dart'; import '../../domain/entity/user.dart'; import '../../domain/repo/user_repository.dart'; import '../datasource/user_remote_ds.dart'; import '../mappers/page_mapper.dart'; import '../mappers/user_mapper.dart'; class UserRepositoryImpl implements UserRepository { final UserRemoteDataSource remote; UserRepositoryImpl(this.remote); @override Future<Result<User>> getMe() async { try { final dto = await remote.getMe(); return Ok(dto.toDomain()); } catch (e) { return Err(ErrorMapper.map(e)); } } @override Future<Result<Page<User>>> listUsers({required int page, int pageSize = 20}) async { try { final dtoPage = await remote.listUsers(page: page, pageSize: pageSize); final domainPage = dtoPage.mapItems((dto) => (dto as dynamic).toDomain()); return Ok(domainPage); } catch (e) { return Err(ErrorMapper.map(e)); } } }

这里为了示例简化了泛型 cast,实际写法更稳:把PageDto<UserDto>明确类型,然后dtoPage.mapItems((x) => x.toDomain())就行。

11. UseCase:把业务动作封装成可测试的单元

features/user/domain/usecase/get_me.dart

import '../../../../core/utils/result.dart'; import '../entity/user.dart'; import '../repo/user_repository.dart'; class GetMe { final UserRepository repo; GetMe(this.repo); Future<Result<User>> call() => repo.getMe(); }

分页 usecase:

features/user/domain/usecase/list_users.dart

import '../../../../core/pagination/page.dart'; import '../../../../core/utils/result.dart'; import '../entity/user.dart'; import '../repo/user_repository.dart'; class ListUsers { final UserRepository repo; ListUsers(this.repo); Future<Result<Page<User>>> call({required int page, int pageSize = 20}) => repo.listUsers(page: page, pageSize: pageSize); }

12. Presentation:状态只依赖 Domain(分页范式)

以 Riverpod/StateNotifier 的思路写一个通用分页 state:

import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/pagination/page.dart'; import '../../domain/entity/user.dart'; part 'user_state.freezed.dart'; @freezed class UserListState with _$UserListState { const factory UserListState({ @Default([]) List<User> items, @Default(1) int page, @Default(false) bool loading, Failure? failure, @Default(true) bool hasMore, }) = _UserListState; }

加载下一页(伪代码):

Future<void> loadNext() async { if (state.loading || !state.hasMore) return; state = state.copyWith(loading: true, failure: null); final res = await listUsers(page: state.page); res.fold( (f) => state = state.copyWith(loading: false, failure: f), (page) => state = state.copyWith( loading: false, items: [...state.items, ...page.items], page: state.page + 1, hasMore: page.hasMore, ), ); }

13. 字段演进实战:接口改名/拆分/合并怎么做?

场景 A:user_name改为name

  • DTO:两个字段都保留(userName+name

  • Mapper:优先name,fallbackuserName

  • Domain:永远只叫name

无需改 UI/业务逻辑

场景 B:vip从 int 变成 bool

  • DTO:先宽松接收(可以临时dynamic vip或加一个新字段)

  • Mapper:兼容两种格式

  • Domain:只输出isVip: bool

场景 C:时间字段格式从2026-01-04变成时间戳

  • DTO:先按String?/dynamic?

  • Mapper:统一解析(tryParse / int parse)

14. 最终“工程铁律”总结(你团队照着执行就稳)

  1. UI 只能依赖 Domain,不准 import DTO

  2. DTO 允许可空、允许脏字段、允许演进兼容

  3. 清洗/转换/兜底只写在 Mapper

  4. Repository 对外只返回 Domain + Failure(Result/Either)

  5. 错误映射集中在 ErrorMapper,一处改全局生效

  6. 分页 Page<T> 统一抽象,所有列表都复用同一套 state 模板

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/18 7:35:11

地理定位优化服务的技术现状与行业分析

在当下数字化转型如浪潮般涌来的情形里&#xff0c;一种叫做GEO也就是地理定位优化的服务&#xff0c;已然变成了企业在网络上精准获取客户、提高本地化营销效率的关键技术方面的支撑。这项服务借助对搜索引擎、地图应用以及各类本地生活平台的内容开展有针对性的优化&#xff…

作者头像 李华
网站建设 2026/6/13 3:28:41

GLM-TTS批量处理功能详解:自动化生成千条音频的正确姿势

GLM-TTS批量处理功能详解&#xff1a;自动化生成千条音频的正确姿势 在有声书平台需要为上百位作者生成专属朗读音频&#xff0c;或教育科技公司要为数千课程片段配音时&#xff0c;传统的逐条语音合成方式早已不堪重负。手动上传、等待生成、下载保存——这一流程哪怕只重复十…

作者头像 李华
网站建设 2026/6/12 0:11:29

心脏手术指南:如何安全地为运行中的系统更换“数据库引擎”?

本文是「架构师的技术基石」系列的第5-1篇。查看系列完整路线图与所有文章目录&#xff1a;【重磅系列】架构师技术基石全景图&#xff1a;以「增长中台」贯穿16讲硬核实战 摘要&#xff1a;将一个核心生产数据库从单机MySQL迁移到分布式NewSQL&#xff0c;其风险与复杂性不亚于…

作者头像 李华
网站建设 2026/6/15 13:29:15

语音合成任务自动化:Python脚本驱动GLM-TTS批量生成

语音合成任务自动化&#xff1a;Python脚本驱动GLM-TTS批量生成 在内容创作日益依赖AI的今天&#xff0c;为成百上千条文本配上风格统一、音色一致的语音&#xff0c;早已不再是人工逐条点击就能胜任的任务。无论是制作多语言课件、打造AI主播语料库&#xff0c;还是为游戏角色…

作者头像 李华
网站建设 2026/6/20 20:43:57

无需编程,用Coze和NoCode打造你的AI产品帝国

文章介绍两款免费AI应用开发工具Coze和NoCode&#xff0c;展示如何无需编程知识就能开发AI助手、小游戏、市场调研报告等产品。强调AI工具普及使非技术人员也能进入产品开发领域&#xff0c;轻资产创业成为趋势&#xff0c;抓住用户需求和产品创新是提升竞争力的关键&#xff0…

作者头像 李华