1. 网络层到底痛在哪:从手写Dio请求到注解驱动的一次重构
1.1 一个团队项目里的历史包袱
几个月前我接手了一个Flutter业务项目,功能本身不算复杂,但网络层写得很让人头大。打开Service文件,几百行代码里塞满了各种Dio请求方法,每个方法里都有一段类似的样板逻辑:拼baseUrl、拼路径、塞header、手动把JSON decode成Map、再手写fromJson拆字段。每个接口要处理的状态码、错误提示、Token刷新的逻辑散落在各个页面里,经常是同一个错误处理代码复制粘贴了七八遍。后来接口数量从十几个涨到四十多个,这套写法的维护成本就开始失控了:改一个公共header要全局搜索替换,新增接口时想找一个可以参考的完整范例都费劲,代码评审的时候大家也主要在review重复的模板部分,真正的业务逻辑反而被淹没了。
那时候我就在想,能不能像Android端写Retrofit接口那样,用一个注解加一个方法签名就把请求声明清楚,剩下的URL拼接、参数序列化、响应解析全部交给框架去处理。在Flutter生态里,Chopper就是干这件事的。
1.2 为什么选Chopper而不是继续用Dio裸写
先说明一点,Dio本身是一个非常优秀的网络库,我没有任何否定它的意思。但在接口数量多、团队协作规模大的场景下,裸写Dio会有三个实际痛点:
第一,接口定义和调用方式不够直观。Dio把请求路径、参数、headers全部当作运行时参数传给一个通用方法,调用方看到dio.get('/users', queryParameters: {...})时,得自己去查接口文档才能确认参数名和返回结构。而Chopper通过注解把接口定义集中在一个抽象类里,每个方法就是一条接口,方法名、路径、参数类型、返回类型都在签名上写死了,阅读代码的过程就是阅读接口文档。
第二,响应转换逻辑分散。Dio拿到的是原始Response,要自己处理JSON解码、类型转换、统一包装结构拆解。Chopper提供了Converter概念,可以在请求发送前统一处理参数序列化,在响应返回后统一解析数据、剥离包装层、执行类型转换。数据转换逻辑被收敛到一处,而不是散落在各个页面。
第三,拦截器链路的可组合性。Chopper的拦截器模型和Dio的InterceptorsWrapper不太一样,它提供了基于ChopperInterceptor的抽象,支持在请求前、请求后、出错时分别介入,而且多个拦截器可以按顺序组成管道。这个设计我觉得比Dio的单一回调列表更清晰一些,尤其适合统一处理日志、鉴权、错误映射这类横切逻辑。
当然,选择Chopper也意味着引入代码生成机制。第一次配置chopper_generator加上build_runner的时候,会有一种“这不就是Java的APT那一套吗”的熟悉感。确实,它和Retrofit、Dagger的注解处理器思路是同一脉的。所以如果你之前有过Android或Java注解处理器的经验,上手成本会非常低;即使没有,跟着流程跑一遍生成代码,也能很快理解它的工作模式。
2. Chopper的核心概念拆解:Client、Service、Converter和拦截器各自管什么
2.1 ChopperClient是总装车间
在Chopper里,ChopperClient是所有请求的入口和装配中心。你可以把它理解成整个网络请求管道的“总装车间”:它知道baseUrl是什么、默认header有哪些、请求超时时间是多少、要不要启用日志拦截器、用哪个Converter来做数据转换。
实际创建时大概是这样的:
final chopperClient = ChopperClient( baseUrl: 'https://api.example.com', services: [UserService.create()], converter: const JsonConverter(), interceptors: [ HttpLoggingInterceptor(), AuthInterceptor(), ], );这里有两个容易误解的点我说明一下。
第一个是services。它不是把网络库当成全局单例挂在ChopperClient上,而是把一组或者多组接口服务注册进来。每个Service其实就是一组接口的集合,比如UserService管用户相关接口,OrderService管订单相关接口。ChopperClient会把这些Service的方法绑定到具体的HTTP请求上,它是通过动态代理加代码生成的方式把注解翻译成实际的Dio调用。
第二个是converter。Chopper的Converter不像Dio那样主要靠Transformer来处理响应数据流。Chopper的Converter分成convertRequest和convertResponse两个方向,convertRequest负责把body参数序列化成可以发送的数据,convertResponse负责把返回数据反序列化成目标类型。稍后我会单独讲这一块的适配经验。
2.2 Service是接口声明的模板
Service在Chopper中是一个抽象类,继承自ChopperService。你在里面写的方法就是实际的网络接口。每个方法用注解标记HTTP动词和路径,方法参数通过@Path()、@Query()、@Body()这些注解绑定到URL、查询参数或请求体上。
看一个典型的用户模块示例:
@ChopperApi(baseUrl: '/users') abstract class UserService extends ChopperService { @Get(path: '/{id}') Future<Response<User>> getUser(@Path('id') int id); @Post(path: '/login') Future<Response<LoginResult>> login(@Body() Map<String, dynamic> body); @Get(path: '/all') Future<Response<List<User>>> getUsers(); static UserService create() => _$UserService(); }这个抽象类的写法有几个关键细节:
@ChopperApi(baseUrl: '/users')声明了这组接口的公共路径前缀。注意,它和ChopperClient的baseUrl是拼接关系,实际的请求URL是https://api.example.com/users/{id}。@Get(path: '/{id}')中的花括号是一个路径参数占位符,通过@Path('id')绑定方法参数。这个语法和Retrofit几乎一模一样。- 方法返回类型是
Future<Response<T>>,这里的泛型T是希望Converter最终解析出来的目标对象。 - 抽象类里还要定义一个静态工厂方法
static UserService create() => _$UserService();,其中_$UserService是build_runner根据这个抽象类生成的实现类。你没看错,类名是私有的,带一个下划线前缀,生成部分的文件通常是user_service.gchp.dart。
生成代码的大致形态如下,理解它有助于排查问题:
class _$UserService extends UserService { _$UserService(ChopperClient client) : super(client); @override Future<Response<User>> getUser(int id) { final Uri url = Uri.parse('${client.baseUrl}/users/${id}'); final Request request = Request('GET', url, client.baseUrl); return client.send<User>(request); } }你会发现生成代码其实非常直白,就是把注解翻译成URL拼接和请求对象构建。所以当你的请求路径不对时,先看生成的.gchp.dart文件,基本一眼就能定位是注解写得不对还是方法签名写错了。
2.3 Converter:请求前和响应后的“翻译官”
Converter是Chopper里最有特色的设计。它有两个方法需要实现:
class MyConverter extends ChopperConverter { @override Future<Request> convertRequest(Request request) async { // 可以在这里统一处理请求body,比如把对象序列化成JSON字符串 return request; } @override Future<Response> convertResponse(Response response) async { // 可以在这里读取response.body,执行JSON解码、类型转换 return response; } }默认的JsonConverter做了一件很基础但有用的事情:把Map类型的body转成JSON字符串并设置Content-Type: application/json,同时在响应端把JSON字符串decode成Map或List。但默认Converter不会帮你把Map转成具体的Model对象,也就是说,如果你声明返回Future<Response<User>>,默认情况下得到的只是一个包含Map数据的Response,泛型User类型并不会自动被填充。
想要获得类型安全的Model对象,有两种常见的做法:
- 在调用侧手动转换:
response.body拿到的Map,自己再调User.fromJson。 - 自定义Converter:在
convertResponse里判断泛型类型,调用对应模型的fromJson完成转换。
第一种做法简单粗暴,但每个调用点都要写一遍,我不推荐。第二种把转换逻辑放在了统一的位置,虽然第一次写的时候稍微麻烦一点,但对项目长期维护来说非常值。后面我会放一段完整的自定义Converter实现。
2.4 拦截器是横切逻辑的入口
Chopper的拦截器接口定义如下:
class MyInterceptor implements ChopperInterceptor { @override Future<Request> onRequest(Request request) async { // 请求发出前:添加header、签名、公共参数 return request; } @override Future<Response> onResponse(Response response) async { // 响应返回正常时:可以统一记录日志、处理通用逻辑 return response; } @override Future<Response> onError(Response response) async { // 响应返回错误时:统一映射错误信息、处理401等特殊状态 return response; } }这三个方法的执行顺序是:onRequest按拦截器数组顺序依次执行,然后把请求发给服务器;收到响应后,如果状态码正常,执行onResponse;如果状态码异常被判断为错误,执行onError。注意,onError并不是网络异常时的回调,而是HTTP响应状态码不属于2xx时的处理入口。真正的网络异常(比如断网、超时)会通过Future抛出异常到调用方。
我把拦截器理解成一个洋葱模型,请求从最外层拦截器进入,逐层向内直到发出去;响应从最内层逐层向外返回。所以多个拦截器之间的顺序是有意义,比如日志拦截器通常放在最外层,这样能记录到最完整的请求和响应信息。
3. 从pubspec到第一个请求:完整落地步骤
3.1 先看依赖清单
我用一个名叫“模拟项目X”的Flutter工程来演示实际落地过程。第一步是往pubspec.yaml里加依赖:
dependencies: flutter: sdk: flutter chopper: ^7.0.0 json_annotation: ^4.8.0 dev_dependencies: build_runner: ^2.4.0 chopper_generator: ^7.0.0 json_serializable: ^6.7.0这里补充一个容易忽略的点:json_serializable和json_annotation是为了配合Model类生成fromJson/toJson用的,并不是Chopper的必需依赖。但实际项目中几乎总会配上,因为你要解析JSON到Model,所以这两个依赖基本是跑不掉的。版本号是个关键,chopper和chopper_generator的版本必须保持一致的大版本,否则生成代码时会出现API不匹配的问题。我之前就遇到过chopper升到7.x而generator还停留在6.x的情况,结果生成的代码引用了不存在的API,编译直接报错。
然后执行flutter pub get,确保依赖拉取成功。
3.2 写一个UserService并适配统一响应结构
接着定义我们的接口服务。为了让例子贴近真实项目,我模拟一个标准的后台响应结构:{ "code": 0, "message": "success", "data": ... },其中code = 0表示成功,message是提示信息,data是业务数据。
先定义对应的响应包装类:
@JsonSerializable() class ApiResult<T> { final int code; final String message; final T data; ApiResult({ required this.code, required this.message, required this.data, }); factory ApiResult.fromJson(Map<String, dynamic> json) => _$ApiResultFromJson(json); }然后定义User模型:
@JsonSerializable() class User { final int id; final String name; final String email; User({ required this.id, required this.name, required this.email, }); factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json); }接下来定义Service。这里先按最基本的方式写,把data字段解析的事情留给后续的Converter处理:
@ChopperApi(baseUrl: '/users') abstract class UserService extends ChopperService { @Get(path: '/{id}') Future<Response<User>> getUser(@Path('id') int id); @Get(path: '/all') Future<Response<List<User>>> getUsers(); static UserService create() => _$UserService(); }注意这里返回类型声明成Future<Response<User>>,意味着Converter最终的转换目标是User对象。如果直接使用默认JsonConverter,这个泛型不会生效,拿到的是Map。我们第三步就来解决它。
3.3 自定义Converter处理统一包装和类型转换
自定义Converter的核心逻辑是:从响应body里取出data字段,判断目标类型,然后调用对应的fromJson完成转换。
class ApiResultConverter extends ChopperConverter { @override Future<Request> convertRequest(Request request) async { final body = request.body; if (body != null && body is! String) { // 把Map或Model对象序列化成JSON字符串 request = request.copyWith( body: jsonEncode(body), headers: { ...request.headers, HttpHeaders.contentTypeHeader: 'application/json', }, ); } return request; } @override Future<Response> convertResponse(Response response) async { if (response.body is! String) { return response; } final jsonString = response.body as String; if (jsonString.isEmpty) { return response; } final jsonMap = jsonDecode(jsonString) as Map<String, dynamic>; final code = jsonMap['code'] as int? ?? -1; if (code != 0) { throw BusinessException( code: code, message: jsonMap['message'] as String? ?? '未知错误', ); } final data = jsonMap['data']; final genericType = _getResponseGenericType(response); final convertedBody = _convertData(data, genericType); return response.copyWith(body: convertedBody); } Type? _getResponseGenericType(Response response) { // 通过反射方式获取方法的泛型类型 // ... } }不过这里有一个现实问题:Dart不支持像Java那样简单的TypeReference泛型获取,所以我们需要一种额外的机制。Chopper的Response<T>泛型类型在运行时能够通过response.type或者我们定义额外的类型参数来获取。我在项目中常用的方案是定义一个带泛型参数的类型令牌:
class TypeToken<T> { final Type type = T; }然后在Service方法里额外传递类型信息,这会让接口签名变得有点丑。另一个更常见且更优雅的方案,是直接在调用侧用一个装饰器方法来显式指定转换后的类型。我在实际项目中最终采用的是这样一个方式:Service的返回类型统一写成Future<Response<dynamic>>,然后在调用方通过封装好的ApiClient方法指定泛型,由内部的Converter做转换。这种方案稍显绕,但是避免了Dart泛型运行时开销和反射限制。
如果你不想在泛型转换上折腾,还有一个务实的选择:在Converter里不自动转Model,而是只剥离包装、返回data对应的Map/List,然后在页面里用User.fromJson(response.body)手动转。牺牲一点自动化,换来清晰度。对于小项目,这完全够用。
3.4 跑build_runner生成代码并完成装配
依赖装好、Service定义好之后,运行命令生成代码:
flutter pub run build_runner build --delete-conflicting-outputs注意我特意加了--delete-conflicting-outputs,这个参数会删除旧的生成文件,避免因为上一次生成结果残留导致冲突。在多人协作时,如果代码生成文件被误提交到git,经常会出现“我这边跑出来和仓库里的不一样”的冲突,这个参数能省掉很多麻烦。
命令跑完后,会在Service同目录下生成user_service.gchp.dart和user.g.dart。打开user_service.gchp.dart,你会看到类似这样的内容:
abstract class _$UserService extends UserService { _$UserService(ChopperClient client) : super(client); @override Future<Response<User>> getUser(int id) { final Uri url = Uri.parse('${client.baseUrl}/users/${id}'); final Request request = Request('GET', url, client.baseUrl); return client.send<User>(request); } @override Future<Response<List<User>>> getUsers() { final Uri url = Uri.parse('${client.baseUrl}/users/all'); final Request request = Request('GET', url, client.baseUrl); return client.send<List<User>>(request); } }最后在应用的入口或者网络层初始化模块中,组装ChopperClient:
final chopper = ChopperClient( baseUrl: 'https://api.example.com', services: [UserService.create()], converter: ApiResultConverter(), interceptors: [ HttpLoggingInterceptor(), AuthInterceptor(), ], );这样,第一个请求就可以通过chopper.getService<UserService>().getUser(1)发出去了。
4. 统一响应结构的适配:我在项目里最终采用的方案
4.1 用两层Service隔离业务方法与网络转换
在把模拟项目X的几十个接口全部迁到Chopper之后,我越来越发现一个现实问题:如果一个Service里既有返回User的方法,又有返回List<User>的方法,还有返回Map<String, dynamic>的方法,Converter内部要做大量类型判断,代码很快变成一坨if-else。于是我把方案调整为两层:
- 网络层Service:统一返回
Future<Response<dynamic>>,只负责声明HTTP路径、参数、请求方式。 - 业务层Repository/API类:包装网络层Service的调用,显式指定目标Model类型的转换逻辑。
举例来说,网络层的接口声明变成:
@ChopperApi(baseUrl: '/users') abstract class UserService extends ChopperService { @Get(path: '/{id}') Future<Response<dynamic>> getUser(@Path('id') int id); @Get(path: '/all') Future<Response<dynamic>> getUsers(); static UserService create() => _$UserService(); }然后自定义Converter只做一件事:剥离统一包装,返回data部分的原始JSON数据,不执行Model转换。这样Converter逻辑非常简单稳定:
class ApiResultConverter extends ChopperConverter { @override Future<Request> convertRequest(Request request) async { final body = request.body; if (body != null && body is! String) { request = request.copyWith( body: jsonEncode(body), headers: { ...request.headers, HttpHeaders.contentTypeHeader: 'application/json', }, ); } return request; } @override Future<Response> convertResponse(Response response) async { final body = response.body; if (body is! String) { return response; } final Map<String, dynamic> json = jsonDecode(body) as Map<String, dynamic>; final code = json['code'] as int? ?? -1; if (code != 0) { throw BusinessException( code: code, message: json['message'] as String? ?? '请求失败', ); } return response.copyWith(body: json['data']); } }业务层的调用看起来是这样:
class UserRepository { UserRepository(this._chopperClient); final ChopperClient _chopperClient; Future<User> getUser(int id) async { final service = _chopperClient.getService<UserService>(); final response = await service.getUser(id); // Converter已经剥离包装,response.body里就是data字段对应的Map return User.fromJson(response.body as Map<String, dynamic>); } Future<List<User>> getUsers() async { final service = _chopperClient.getService<UserService>(); final response = await service.getUsers(); final list = response.body as List<dynamic>; return list .map((item) => User.fromJson(item as Map<String, dynamic>)) .toList(); } }你可能觉得这样不如“全自动转换”那么酷,但我在维护了四十多个接口之后,非常确定这个设计是更稳的。原因有三点:
- Dart的泛型运行时能力有限,强行做全自动转换会在Converter里堆满类型判断,维护成本反而更高。
- 把转换放在Repository层,类型转换逻辑和业务语义放在一起,代码可读性和可调试性都好很多。
- 出现转换异常时,报错信息明确指向具体的Repository方法,排查速度比在Converter里加一长串判断快得多。
4.2 错误状态码和业务异常的映射
在模拟项目X里,我们约定业务上code != 0就视为失败,失败信息放在message字段。所以我在Converter里抛出一个自定义的BusinessException。调用方只需要关心这个异常,不需要在页面层强依赖网络库的类型。
如果你需要区分401未登录、403无权限、500服务器错误等不同情况,可以在BusinessException里保存code和httpStatus,然后由全局错误处理模块决定跳转登录页还是弹提示。我项目的做法是提供了一个handleApiError函数,所有业务层方法统一调用,避免在每个Repository里写try-catch:
Future<T> safeApiCall<T>(Future<T> Function() apiCall) async { try { return await apiCall(); } on BusinessException catch (e) { // 记录日志,并给UI层抛出统一的错误类型 throw e; } on TimeoutException { throw const NetworkException('网络超时,请稍后重试'); } on SocketException { throw const NetworkException('网络连接失败,请检查网络'); } }5. 拦截器的实用场景:日志、鉴权与请求重放
5.1 用日志拦截器看清每一次请求
Chopper提供了HttpLoggingInterceptor,它能输出HTTP方法和URL、请求头、请求体、响应状态码和响应体。这在开发阶段特别有用。但我不建议把它直接用在生产环境,因为响应体里可能包含敏感字段,比如手机号、Token、地址之类的。我的做法是用kReleaseMode判断:
ChopperClient( interceptors: [ if (!kReleaseMode) HttpLoggingInterceptor(), ], )这段代码写在入口模块里,需要导入package:flutter/foundation.dart。
如果你觉得内置的日志格式不够清楚,也可以实现自己的LoggerInterceptor,在onRequest和onResponse里组合print或统一走日志系统。我习惯把请求耗时也记录下来,在onRequest时记录DateTime.now(),在onResponse时计算差值,这样能直观看到每个接口的耗时情况。
5.2 Token刷新与请求重放的实现思路
这是拦截器最有价值的场景之一。当Token过期时,服务器返回401,我们需要自动刷新Token并用新的Token重放原来的请求。
在Chopper里,我实现了一个AuthInterceptor,核心逻辑有三个阶段。
第一阶段:在onRequest中读取当前Token,添加到Authorization头。
@override Future<Request> onRequest(Request request) async { final token = await _tokenStorage.getToken(); if (token != null) { return request.copyWith( headers: { ...request.headers, HttpHeaders.authorizationHeader: 'Bearer $token', }, ); } return request; }第二阶段:在onError中,只对401状态码做处理。使用一个Future类型的锁变量,避免并发请求时重复刷新Token:
Future<String?>? _refreshTokenFuture; @override Future<Response> onError(Response response) async { if (response.statusCode == 401) { _refreshTokenFuture ??= _refreshToken(); try { final newToken = await _refreshTokenFuture; _refreshTokenFuture = null; if (newToken != null) { // 用新Token重放原请求 return await _retryRequest(response); } } catch (_) { _refreshTokenFuture = null; rethrow; } } return response; }第三阶段:用新Token构造一个新的Request重新发送。这里有个细节:response对象本身只包含响应数据,我们从逻辑上关联到原始请求,可能需要在onRequest阶段就把原始Client的引用保存下来,或在请求对象上附加自定义字段。我在项目中采用的方式是,在拦截器内部保存ChopperClient的引用,在重放时通过client.send重新发起。
重放请求时需要注意,如果原始请求方法不是GET,要谨慎重放,防止重复提交。实际项目里,我们通常只对GET请求自动重放,写操作请求会直接抛出一个“登录状态已过期,请重新登录”的业务异常,让用户手动重试。
6. 踩坑记录:代码生成、类型转换、混合调用的经验
6.1 永远不要手改生成文件
先说一个最基础的坑:.gchp.dart和.g.dart这类文件是自动生成的,每次跑build_runner都会被覆盖。如果你在里面手改了逻辑,下次生成时改动会丢失,这是必然的。真正麻烦的是另一种情况:你改了Service的注解,但没重新跑生成命令,运行的时候用的还是旧代码,导致接口路径对不上。所以每次修改Service定义后,务必重新执行:
flutter pub run build_runner build --delete-conflicting-outputs我习惯把这个命令写进项目的README里,并加一条提醒:生成的.gchp.dart文件不要提交到版本控制,或者至少约定好每次由统一方式重新生成,否则很容易出现合并冲突。
6.2 泛型返回List<T>时的Converter坑
如果你用的Converter是全自动类型转换方案,并且Service方法声明了Future<Response<List<User>>>,那么Converter里判断泛型时会发现List<T>的类型来自于response.type这个属性。在Chopper的实现中,client.send<List<User>>(request)会把List<User>作为泛型参数传入,但运行时Dart并不知道List里的元素类型,它只能拿到这个泛型参数的字符串表示。
我一开始试图直接在Converter里通过反射构造List<User>,折腾了好一阵子后放弃了。最终方案就是我前面提到的两层架构:网络层方法统一返回dynamic,业务层显式转换。这个方案虽然牺牲了一点框架的“智能感”,但在类型安全、可维护性和可读性之间取得了最好的平衡。
如果你一定要追求全自动,一个折中方案是:为返回list的场景单独定义Model,比如UserListResponse,让它内部持有List<User>,用json_serializable生成fromJson,Converter只根据response.type判断到UserListResponse这个具体类。这样能绕开泛型擦除问题,代价是你需要为每个列表接口都定义一个包装类,有点繁琐。
6.3 和Dio混合使用时的注意事项
Chopper底层虽然是基于Dio实现的,但你在一个项目里同时用Dio和Chopper时,会面临一个现实的隔离问题:Dio实例的拦截器和Chopper的拦截器是两套体系。我在模拟项目X的迁移过程中,老代码还在用Dio发的请求,新代码慢慢切到Chopper,结果发现Dio拦截器里的鉴权逻辑和Chopper拦截器里的鉴权逻辑并行存在,一旦Token刷新的状态没有同步,两边会遇到互相踩踏的情况。
我的建议是:迁移期间,以Chopper为主通道,老代码的Dio请求不要自行处理Token,统一把Dio实例的拦截器关掉或最小化,让所有鉴权逻辑只发生在Chopper拦截器链路里。等接口全部迁完,再彻底移除Dio的直接使用。
另外,Chopper的ChopperClient内部会创建自己的Dio实例,如果你需要自定义Dio的HttpClientAdapter(比如调试代理、自签名证书等),可以通过ChopperClient构造参数传入底层的Dio配置。需要注意的是不同大版本Chopper暴露Dio配置的方式不太一样,升级时记得看一下变更日志。
6.4 下载和上传大文件的取舍
最后聊一个方案边界的问题。Chopper的注解式接口设计在普通JSON请求上很好用,但遇到大文件上传或下载时,Stream式的Progress回调体验不如直接用Dio方便。Dio本身有onSendProgress和onReceiveProgress参数,Chopper在这方面的抽象会绕一些。
我在实际项目中,文件上传下载的接口仍然直接用Dio去发,普通业务接口用Chopper,两者共存。这样各用各的强项,不会被一个框架束缚住手脚。
7. 迁移上线的最后几步:测试、代码评审与监控
当Service迁移得差不多,实际联调也跑通了,上线前我还做了三件事,顺便分享给你。
第一,把所有Service的调用方都改成通过Repository统一访问,禁止页面直接拿ChopperClient.getService去发请求。这样后续如果要换网络层,只需要改业务层方法,不用动UI代码。
第二,补充基础单测。对于Converter,我写了几个简单的用例来验证不同的body情况和code分支,不追求覆盖率,但至少保证包装剥离和异常抛出的行为是稳定的。对于拦截器,重点验证Token刷新时的并发场景,模拟多个请求同时返回401,确认只刷新了一次Token。
第三,在线上日志里增加了一个可观测性埋点,记录每个请求的URL、状态码、耗时,以及是否走到了Token重放逻辑。这样如果发布后有接口异常,能第一时间通过日志定位是服务端问题还是客户端拦截器逻辑问题。Chopper自身的拦截器好比一个天然的切面点,在这个位置挂埋点非常顺滑。
从我个人的体会来讲,Chopper最值得推荐的地方并不是它帮你省了几行样板代码,而是它把网络接口从“散落在页面里的业务代码”变成了“集中声明的接口文档”。团队里新来的同学,只看Service文件就能知道系统调了哪些后端接口,每个接口传什么参数,返回什么结构。这种“代码即文档”的效果,在维护期带来的收益会随着接口数量增加越发明显。如果你也在为网络层越来越臃肿发愁,值得花一天时间把这条路走一遍。