Chopper实战:用注解驱动重构Flutter网络层
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}) FutureResponseUser getUser(Path(id) int id); Post(path: /login) FutureResponseLoginResult login(Body() MapString, dynamic body); Get(path: /all) FutureResponseListUser getUsers(); static UserService create() _$UserService(); }这个抽象类的写法有几个关键细节ChopperApi(baseUrl: /users)声明了这组接口的公共路径前缀。注意它和ChopperClient的baseUrl是拼接关系实际的请求URL是https://api.example.com/users/{id}。Get(path: /{id})中的花括号是一个路径参数占位符通过Path(id)绑定方法参数。这个语法和Retrofit几乎一模一样。方法返回类型是FutureResponseT这里的泛型T是希望Converter最终解析出来的目标对象。抽象类里还要定义一个静态工厂方法static UserService create() _$UserService();其中_$UserService是build_runner根据这个抽象类生成的实现类。你没看错类名是私有的带一个下划线前缀生成部分的文件通常是user_service.gchp.dart。生成代码的大致形态如下理解它有助于排查问题class _$UserService extends UserService { _$UserService(ChopperClient client) : super(client); override FutureResponseUser getUser(int id) { final Uri url Uri.parse(${client.baseUrl}/users/${id}); final Request request Request(GET, url, client.baseUrl); return client.sendUser(request); } }你会发现生成代码其实非常直白就是把注解翻译成URL拼接和请求对象构建。所以当你的请求路径不对时先看生成的.gchp.dart文件基本一眼就能定位是注解写得不对还是方法签名写错了。2.3 Converter请求前和响应后的“翻译官”Converter是Chopper里最有特色的设计。它有两个方法需要实现class MyConverter extends ChopperConverter { override FutureRequest convertRequest(Request request) async { // 可以在这里统一处理请求body比如把对象序列化成JSON字符串 return request; } override FutureResponse convertResponse(Response response) async { // 可以在这里读取response.body执行JSON解码、类型转换 return response; } }默认的JsonConverter做了一件很基础但有用的事情把Map类型的body转成JSON字符串并设置Content-Type: application/json同时在响应端把JSON字符串decode成Map或List。但默认Converter不会帮你把Map转成具体的Model对象也就是说如果你声明返回FutureResponseUser默认情况下得到的只是一个包含Map数据的Response泛型User类型并不会自动被填充。想要获得类型安全的Model对象有两种常见的做法在调用侧手动转换response.body拿到的Map自己再调User.fromJson。自定义Converter在convertResponse里判断泛型类型调用对应模型的fromJson完成转换。第一种做法简单粗暴但每个调用点都要写一遍我不推荐。第二种把转换逻辑放在了统一的位置虽然第一次写的时候稍微麻烦一点但对项目长期维护来说非常值。后面我会放一段完整的自定义Converter实现。2.4 拦截器是横切逻辑的入口Chopper的拦截器接口定义如下class MyInterceptor implements ChopperInterceptor { override FutureRequest onRequest(Request request) async { // 请求发出前添加header、签名、公共参数 return request; } override FutureResponse onResponse(Response response) async { // 响应返回正常时可以统一记录日志、处理通用逻辑 return response; } override FutureResponse 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 ApiResultT { final int code; final String message; final T data; ApiResult({ required this.code, required this.message, required this.data, }); factory ApiResult.fromJson(MapString, 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(MapString, dynamic json) _$UserFromJson(json); }接下来定义Service。这里先按最基本的方式写把data字段解析的事情留给后续的Converter处理ChopperApi(baseUrl: /users) abstract class UserService extends ChopperService { Get(path: /{id}) FutureResponseUser getUser(Path(id) int id); Get(path: /all) FutureResponseListUser getUsers(); static UserService create() _$UserService(); }注意这里返回类型声明成FutureResponseUser意味着Converter最终的转换目标是User对象。如果直接使用默认JsonConverter这个泛型不会生效拿到的是Map。我们第三步就来解决它。3.3 自定义Converter处理统一包装和类型转换自定义Converter的核心逻辑是从响应body里取出data字段判断目标类型然后调用对应的fromJson完成转换。class ApiResultConverter extends ChopperConverter { override FutureRequest 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 FutureResponse 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 MapString, 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的ResponseT泛型类型在运行时能够通过response.type或者我们定义额外的类型参数来获取。我在项目中常用的方案是定义一个带泛型参数的类型令牌class TypeTokenT { final Type type T; }然后在Service方法里额外传递类型信息这会让接口签名变得有点丑。另一个更常见且更优雅的方案是直接在调用侧用一个装饰器方法来显式指定转换后的类型。我在实际项目中最终采用的是这样一个方式Service的返回类型统一写成FutureResponsedynamic然后在调用方通过封装好的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 FutureResponseUser getUser(int id) { final Uri url Uri.parse(${client.baseUrl}/users/${id}); final Request request Request(GET, url, client.baseUrl); return client.sendUser(request); } override FutureResponseListUser getUsers() { final Uri url Uri.parse(${client.baseUrl}/users/all); final Request request Request(GET, url, client.baseUrl); return client.sendListUser(request); } }最后在应用的入口或者网络层初始化模块中组装ChopperClientfinal chopper ChopperClient( baseUrl: https://api.example.com, services: [UserService.create()], converter: ApiResultConverter(), interceptors: [ HttpLoggingInterceptor(), AuthInterceptor(), ], );这样第一个请求就可以通过chopper.getServiceUserService().getUser(1)发出去了。4. 统一响应结构的适配我在项目里最终采用的方案4.1 用两层Service隔离业务方法与网络转换在把模拟项目X的几十个接口全部迁到Chopper之后我越来越发现一个现实问题如果一个Service里既有返回User的方法又有返回ListUser的方法还有返回MapString, dynamic的方法Converter内部要做大量类型判断代码很快变成一坨if-else。于是我把方案调整为两层网络层Service统一返回FutureResponsedynamic只负责声明HTTP路径、参数、请求方式。业务层Repository/API类包装网络层Service的调用显式指定目标Model类型的转换逻辑。举例来说网络层的接口声明变成ChopperApi(baseUrl: /users) abstract class UserService extends ChopperService { Get(path: /{id}) FutureResponsedynamic getUser(Path(id) int id); Get(path: /all) FutureResponsedynamic getUsers(); static UserService create() _$UserService(); }然后自定义Converter只做一件事剥离统一包装返回data部分的原始JSON数据不执行Model转换。这样Converter逻辑非常简单稳定class ApiResultConverter extends ChopperConverter { override FutureRequest 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 FutureResponse convertResponse(Response response) async { final body response.body; if (body is! String) { return response; } final MapString, dynamic json jsonDecode(body) as MapString, 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; FutureUser getUser(int id) async { final service _chopperClient.getServiceUserService(); final response await service.getUser(id); // Converter已经剥离包装response.body里就是data字段对应的Map return User.fromJson(response.body as MapString, dynamic); } FutureListUser getUsers() async { final service _chopperClient.getServiceUserService(); final response await service.getUsers(); final list response.body as Listdynamic; return list .map((item) User.fromJson(item as MapString, dynamic)) .toList(); } }你可能觉得这样不如“全自动转换”那么酷但我在维护了四十多个接口之后非常确定这个设计是更稳的。原因有三点Dart的泛型运行时能力有限强行做全自动转换会在Converter里堆满类型判断维护成本反而更高。把转换放在Repository层类型转换逻辑和业务语义放在一起代码可读性和可调试性都好很多。出现转换异常时报错信息明确指向具体的Repository方法排查速度比在Converter里加一长串判断快得多。4.2 错误状态码和业务异常的映射在模拟项目X里我们约定业务上code ! 0就视为失败失败信息放在message字段。所以我在Converter里抛出一个自定义的BusinessException。调用方只需要关心这个异常不需要在页面层强依赖网络库的类型。如果你需要区分401未登录、403无权限、500服务器错误等不同情况可以在BusinessException里保存code和httpStatus然后由全局错误处理模块决定跳转登录页还是弹提示。我项目的做法是提供了一个handleApiError函数所有业务层方法统一调用避免在每个Repository里写try-catchFutureT safeApiCallT(FutureT 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 FutureRequest 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类型的锁变量避免并发请求时重复刷新TokenFutureString?? _refreshTokenFuture; override FutureResponse 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 泛型返回ListT时的Converter坑如果你用的Converter是全自动类型转换方案并且Service方法声明了FutureResponseListUser那么Converter里判断泛型时会发现ListT的类型来自于response.type这个属性。在Chopper的实现中client.sendListUser(request)会把ListUser作为泛型参数传入但运行时Dart并不知道List里的元素类型它只能拿到这个泛型参数的字符串表示。我一开始试图直接在Converter里通过反射构造ListUser折腾了好一阵子后放弃了。最终方案就是我前面提到的两层架构网络层方法统一返回dynamic业务层显式转换。这个方案虽然牺牲了一点框架的“智能感”但在类型安全、可维护性和可读性之间取得了最好的平衡。如果你一定要追求全自动一个折中方案是为返回list的场景单独定义Model比如UserListResponse让它内部持有ListUser用json_serializable生成fromJsonConverter只根据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文件就能知道系统调了哪些后端接口每个接口传什么参数返回什么结构。这种“代码即文档”的效果在维护期带来的收益会随着接口数量增加越发明显。如果你也在为网络层越来越臃肿发愁值得花一天时间把这条路走一遍。