Retrofit 的自定义 Converter,写一个没那么难
Retrofit 的自定义 Converter,写一个没那么难
Retrofit 的 Converter 机制被很多人视为黑盒。官方文档里轻描淡写一句 "addConverterFactory",Stack Overflow 上搜到的答案大多是 "用 GsonConverterFactory 就行了"。但当你真的遇到一个非标准 API——比如返回外层包着固定格式、data 字段内容却随接口变化的响应,或者需要把 Protobuf 的变种格式塞进 HTTP body——你会发现现成的 Converter 要么过度封装,要么完全不够用。
我去年维护一个内部 SDK 时被迫手写了一个 Converter,过程中踩了几个 Retrofit 源码里埋好的坑。这篇文章记录那次实现,目标是把 Converter 的注册机制、调用时机、以及和 OkHttp 的交互边界讲清楚。
从一个真实接口说起
我们有个老后端系统,所有接口返回统一格式:
{
"code": 0,
"message": "ok",
"data": { ... }
}但 data 的类型每个接口都不同。更麻烦的是,部分接口在 code != 0 时,data 可能直接是 null,也可能是一个错误详情的 JSON 对象,甚至偶尔返回字符串 "null"(带引号,后端某版本 Tomcat 的锅)。
GsonConverterFactory 在这种场景下很别扭。你可以用 Response<T> 包装,但每个接口的 Call 类型都要写成 Call<BaseResponse<<ActualData>>,然后自己拆包。RxJava 的 Adapter 能稍微简化,但 BaseResponse 的泛型嵌套在 Kotlin 里写起来很丑,而且错误分支的处理散落在各个 Service 方法里。
我的目标是一个 Converter 能自动完成:HTTP 200 时把 data 提出来反序列化,同时统一处理 code != 0 的业务错误,甚至把那个字符串 "null" 也兼容掉。最终 Service 接口应该直接写 Call<<ActualData>。
Converter.Factory 的注册顺序陷阱
Retrofit 2.9.0(我们当时锁定的版本)的 Builder 允许注册多个 Converter.Factory。很多人没意识到顺序直接影响行为:
Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(MyWrapperConverter.create())
.addConverterFactory(GsonConverterFactory.create())
.build();Retrofit 内部用一个 List 保存 factories,遍历时按添加顺序匹配。MyWrapperConverter 的 responseBodyConverter 方法会在每次请求反序列化时被调用,如果它返回 null,Retrofit 会继续尝试下一个 factory。
这个设计意味着你的自定义 Converter 必须明确声明"我能处理这个类型",否则就 pass 给下一个。但判断依据只有 Type 和 Annotation[]——你没有 Response 的实例,不知道 HTTP status code,也读不到 body 内容。
这是第一个坑:Converter 的匹配时机在 IO 之前,你只能基于声明类型做决策,不能基于实际响应内容。
所以我的 MyWrapperConverter 不能无脑拦截所有 ResponseBody 到任意类型的转换——那样会吃掉 GsonConverterFactory 的工作。我需要一种标记机制,让 Service 接口显式声明"这个响应需要拆包"。
用自定义注解标记目标类型
解决方案是加一个运行时注解 @WrappedResponse:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WrappedResponse {
}Service 接口这样写:
public interface UserService {
@GET("user/profile")
@WrappedResponse
Call<UserProfile> getProfile();
}Converter 的 responseBodyConverter 方法里检查注解数组:
@Override
public Converter<ResponseBody, ?> responseBodyConverter(
Type type, Annotation[] annotations, Retrofit retrofit) {
boolean isWrapped = false;
for (Annotation a : annotations) {
if (a instanceof WrappedResponse) {
isWrapped = true;
break;
}
}
if (!isWrapped) {
return null; // 让 GsonConverterFactory 处理
}
// 创建真正的 Converter,内部委托给 Gson
return new WrappedResponseBodyConverter<>(type, gson);
}这里有个细节:Retrofit 的 retrofit.nextResponseBodyConverter(this, type, annotations) 可以拿到下一个匹配的 Converter,避免直接依赖 GsonConverterFactory 的实例。这是 Retrofit 2.5.0 引入的 API,之前版本需要手动持有 Gson 实例。
实现 WrappedResponseBodyConverter
核心逻辑在 convert 方法里。我需要先读 ResponseBody 的字符串,解析外层 JSON,提取 data 字段,再用 Gson 反序列化到目标类型。
final class WrappedResponseBodyConverter<T> implements Converter<ResponseBody, T> {
private final Type type;
private final Gson gson;
WrappedResponseBodyConverter(Type type, Gson gson) {
this.type = type;
this.gson = gson;
}
@Override
public T convert(ResponseBody value) throws IOException {
String bodyString = value.string();
JsonObject jsonObject;
try {
jsonObject = JsonParser.parseString(bodyString).getAsJsonObject();
} catch (IllegalStateException e) {
// 处理那个字符串 "null" 的 case
if ("null".equals(bodyString.trim())) {
return null;
}
throw new IOException("Malformed JSON wrapper: " + bodyString, e);
}
int code = jsonObject.get("code").getAsInt();
String message = jsonObject.get("message").getAsString();
if (code != 0) {
throw new BusinessException(code, message);
}
JsonElement dataElement = jsonObject.get("data");
if (dataElement == null return null;
}
return gson.fromJson(dataElement, type);
}
}value.string() 这里有个 OkHttp 的坑:ResponseBody 的字符串只能读一次。如果你先读一遍做外层解析,发现 code 不对想抛异常,或者需要把原始 body 传给下一个 Converter——做不到,流已经关了。Retrofit 的设计是 Converter 消费掉 ResponseBody,上层不再复用。
这意味着你的 Converter 必须一次性完成所有判断和转换,没有回头路。我在测试时曾尝试先 peek 一部分内容判断格式,但 ResponseBody 的 source() 虽然支持 peek(),实际用起来和 Content-Length 的交互很微妙,某些 chunked 编码的响应会出问题。最终放弃,直接全读进内存。
对于我们的接口规模(body 通常几十 KB),这可以接受。但如果你要处理大文件下载,这种 Converter 完全不适用——它会把整个文件读进 String。Retrofit 的 Converter 机制本来就不是为流式处理设计的,大文件应该用 @Streaming 加 ResponseBody 直接返回,绕过 Converter。
BusinessException 的传递路径
convert 方法抛出的异常会沿着 Retrofit 的调用链向上传播。对于同步 execute(),直接抛到调用者。对于异步 enqueue(),异常会进入 Callback.onFailure()。
但我们希望业务错误(code != 0)走 onResponse() 还是 onFailure()?这是个设计决策。我倾向于让业务错误进 onFailure(),因为调用方写 onResponse() 时通常假设已经拿到合法数据。不过 Retrofit 的 Callback 接口里,onResponse() 的参数是 Response<T>,包含 HTTP 信息;onFailure() 只有 Throwable。
我的折中方案是:Converter 抛出 BusinessException extends IOException,这样 onFailure(Throwable t) 里用 instanceof 判断。选 IOException 是因为 Retrofit 的 CallAdapter 和 RxJava 集成时,网络层异常统一是 IOException 子类,这样 RxJava 的 onError 也能收到。
但这里有个版本相关的坑:Retrofit 2.6.0 之前,Kotlin Coroutine 的 suspend 函数如果 Converter 抛异常,异常类型会被包装。2.6.0 修复了这个问题,让异常直接抛出。我们项目当时混用 Java 和 Kotlin,这个差异导致同样的 BusinessException,Java 的 enqueue() 能正确捕获,Kotlin 的 suspend 函数却收到 HttpException 包装。升级到 2.9.0 后统一。
请求体的 Converter:对称实现
响应体的 Converter 搞定了,但我们的接口还有 POST 请求需要把参数包进同样的 wrapper 格式。Retrofit 的 Converter 是对称的:有 responseBodyConverter 就有 requestBodyConverter。
@Override
public Converter<?, RequestBody> requestBodyConverter(
Type type, Annotation[] parameterAnnotations,
Annotation[] methodAnnotations, Retrofit retrofit) {
boolean isWrapped = false;
for (Annotation a : methodAnnotations) {
if (a instanceof WrappedResponse) {
isWrapped = true;
break;
}
}
if (!isWrapped) {
return null;
}
return new WrappedRequestBodyConverter<>(gson);
}实现上把目标对象包进 wrapper:
final class WrappedRequestBodyConverter<T> implements Converter<T, RequestBody> {
private final Gson gson;
private static final MediaType MEDIA_TYPE = MediaType.get("application/json; charset=UTF-8");
@Override
public RequestBody convert(T value) throws IOException {
JsonObject wrapper = new JsonObject();
wrapper.addProperty("timestamp", System.currentTimeMillis());
wrapper.add("data", gson.toJsonTree(value));
String json = gson.toJson(wrapper);
return RequestBody.create(MEDIA_TYPE, json);
}
}注意 RequestBody.create(MediaType, String) 在 OkHttp 3.14 之后标记为 deprecated,推荐用 RequestBody.create(String, MediaType) 参数顺序反转的版本。Retrofit 2.9.0 依赖的 OkHttp 是 3.14.9,两种都能用,但新项目应该跟 OkHttp 的演进方向。
和 CallAdapter 的交叉地带
Converter 只负责 body 的序列化/反序列化,但调用方最终感知的是 Call<T> 或 Observable<T> 这类类型。如果 Service 方法返回 Call<BaseResponse<T>>,Retrofit 的默认行为是找 T 的 Converter,然后由内置的 DefaultCallAdapterFactory 包成 Call。
我们的 @WrappedResponse 标记在方法上,Converter 能读到。但如果项目里同时用了 RxJava 的 RxJava3CallAdapterFactory,它会在 Converter 之前介入,把 Call 转成 Observable。这时异常路径是否还正确?
我实际测试发现:RxJava 的 onError 能收到 BusinessException,但 onNext 永远不会收到 null(因为 data 为 null 时 Converter 返回 null,而 RxJava 的 Single 不允许 null,会抛 NullPointerException)。这迫使我把 Service 接口里可能返回 null data 的接口从 Single<T> 改成 Maybe<T>。
这个改动不是 Converter 能控制的,是 CallAdapter 的语义决定的。Retrofit 的架构里,CallAdapter 和 Converter 是两层,但异常流和数据流会交叉。设计 Converter 时必须考虑上层 Adapter 的约束。
泛型擦除的边界 case
Java 泛型擦除导致 Type 参数在运行时不带泛型信息。比如 Call<List<User>> 的 Converter 收到 type 是 List,不知道元素是 User。Gson 通过 TypeToken 绕过这个限制,但我们的 Wrapper 需要二次解析 data 字段,必须正确传递原始 Type。
Retrofit 的 Utils.getParameterUpperBound(0, (ParameterizedType) type) 可以提取泛型参数。但前提是 type 确实是 ParameterizedType。如果 Service 方法返回 Call<T> 而 T 是泛型参数(比如基类定义的 abstract Call<T> load()),运行时的 type 可能是 TypeVariable,这时连 Gson 也束手无策。
我们的项目里遇到过一个 case:基类封装分页加载,子类指定具体数据类型。Retrofit 传给 Converter 的 type 是 T extends DataItem,Gson 的 fromJson 会把它当成 DataItem 反序列化,子类字段丢失。最终解决方案是子类在注解里显式传 Class:
@WrappedResponse(dataClass = User.class)
Call<List<User>> loadUsers();注解定义改成:
@interface WrappedResponse {
Class<?> dataClass() default Void.class;
}Converter 里优先用 dataClass 覆盖 type。这是泛型擦除的妥协,没有更干净的方案。
测试时的 MockWebServer 配合
写单元测试时,MockWebServer 很有用,但要注意它和 Converter 的交互。MockWebServer 的 enqueue(MockResponse) 设置 body 时,如果 Content-Type 没设,默认是 text/plain; charset=utf-8。而我们的 Converter 里 value.string() 不检查 Content-Type,Gson 解析 JSON 也不依赖它,所以测试能通过。
但生产环境有个接口返回 application/octet-stream(后端配置错误),Converter 照样读字符串解析,居然也过了。这提醒我们:Converter 作为 Retrofit 和 OkHttp 的边界,应该对 Content-Type 做校验,或者至少暴露给上层决策。
我在最终版本里加了可选的严格模式:
if (strictMode && !"application/json".equals(value.contentType().type() + "/" + value.contentType().subtype())) {
throw new IOException("Unexpected Content-Type: " + value.contentType());
}默认关闭,避免打破现有接口。Retrofit 的 ResponseBody.contentType() 返回 MediaType,可能为 null(HTTP 204 No Content 或某些畸形响应),这里要判空。
性能:没想象中重要,但有个细节
很多人担心自定义 Converter 的性能。我测过:同样 1000 次请求,GsonConverterFactory 直接解析 vs 我们的 WrapperConverter 先解析外层再委托 Gson,平均耗时差异在 5% 以内,主要开销在字符串读取和 JSON 解析本身,多一层 JsonObject 遍历 negligible。
真正影响性能的是 value.string() 造成的字符串复制。OkHttp 的 ResponseBody 底层是 BufferedSource,string() 方法先全读进 Buffer,再按 charset 解码。如果响应很大,这确实浪费内存。但对于我们的场景(<< 100KB), profiling 显示 GC 压力无显著差异。
一个意外发现:Gson 的 JsonParser.parseString 在 2.8.6 之前对输入字符串有长度限制相关的 bug(某些极端长字符串会 StackOverflow),但我们没触发。升级到 Gson 2.10.1 后更稳妥。
与 Retrofit 3.0 的迁移风险
写这篇文章时 Retrofit 3.0.0 还在 alpha(3.0.0-alpha01,2024 年 10 月发布)。主要变化是 Kotlin 优先,内部用 Kotlin 重写,但 Java API 保持兼容。Converter 的 Factory 接口没变,所以我们的实现理论上直接可用。
但有个潜在风险:3.0 引入了 suspend 函数的原生支持重构,异常处理路径可能调整。BusinessException extends IOException 的策略是否还成立,需要等正式版验证。目前 alpha 的 release note 没提 Converter 层改动。
另一个观察:Retrofit 3.0 的 artifact group id 从 com.squareup.retrofit2 变成 com.squareup.retrofit3,包名也换。这意味着两个版本可以共存,但迁移时 Converter 的 import 要全部更新。如果公司内部多个 SDK 依赖不同版本的 Retrofit,这种 breaking change 会很痛苦。
源码里的一处设计选择
最后提一个 Retrofit 源码里的细节。Converter.Factory 是抽象类而非接口,这是 Java 8 之前的遗产——Retrofit 2.0 发布于 2016 年,目标兼容 Java 7。抽象类的好处是可以在不破坏二进制兼容的情况下加新方法(Java 8 的 default 方法在接口上也能做到,但 Android 的 desugaring 当时还不成熟)。
responseBodyConverter 和 requestBodyConverter 的参数设计反映了 Retrofit 的架构假设:响应体转换只需要知道目标类型和方法注解,请求体转换还需要参数注解(因为 @Body 注解的参数可能带 @Wrapped 之类的标记)。stringConverter 方法的存在是为了处理 @Field、@Query 等字符串编码场景,和 body 转换无关。
理解这些参数的设计意图,能帮你判断某个需求该在 Converter 里做还是该在 Interceptor 里做。比如需要在请求头里加签名,那是 OkHttp Interceptor 的领域,Retrofit Converter 接触不到 Request 的 headers。反过来,如果需要在序列化前对对象做校验(比如检查必填字段),Interceptor 做不到,因为那时对象还没转成字节流。
一个没采用的替代方案
期间我考虑过不用 Converter,改用 OkHttp 的 Interceptor 做包装/拆包。Interceptor 能读到原始 Response,修改后再传给 Retrofit。这样 Service 接口可以写 Call<BaseResponse<T>>,Interceptor 把 body 替换为只含 data 的 Response,让 GsonConverterFactory 正常解析。
这个方案的优点是 Service 接口类型诚实(返回 BaseResponse),缺点也明显:Interceptor 里需要知道目标类型才能正确序列化 data,但 Interceptor 的 Chain 接口不携带泛型信息。你只能再用反射或注解传递类型,复杂度不比 Converter 方案低,而且破坏了 OkHttp 层的中立性(Interceptor 应该对业务类型无知)。
最终放弃。Retrofit 的 Converter 机制虽然限制多,但类型信息完整,是正确的设计边界。
代码组织的建议
自定义 Converter 如果散落在业务模块里,很快会变成技术债。我们的实践是单独一个 retrofit-converters 模块,内部按格式细分:
retrofit-converters/
wrapper/
WrappedResponse.java
WrappedResponseConverterFactory.java
WrappedResponseBodyConverter.java
WrappedRequestBodyConverter.java
protobuf/
...Retrofit 官方也是这个思路(converter-gson、converter-moshi 等独立 artifact)。好处是版本管理和测试隔离,业务模块只依赖需要的 Converter,不会被迫引入 Gson 或 Protobuf 的传递依赖。
模块内的 ConverterFactory 用静态工厂方法 create() 暴露,内部控制 Gson 实例的创建(比如设置 lenient 模式、自定义 TypeAdapter)。不要暴露构造器让调用者传 Gson,那样配置分散,容易出兼容问题。
最后的踩坑:ProGuard/R8 规则
上线前 R8 压缩后, @WrappedResponse 注解在运行时读不到,因为 RetentionPolicy.RUNTIME 的注解默认会被保留,但注解所在的包如果被 shrink,可能出问题。需要在 proguard-rules 里显式保留:
-keep @interface com.example.retrofit.converter.wrapper.WrappedResponse
-keepclassmembers class * {
@com.example.retrofit.converter.wrapper.WrappedResponse <methods>;
}第二行确保被注解的方法不被 obfuscate,否则 Retrofit 读方法注解时类名方法名变了,注解匹配失败。这个坑在 debug 构建测不出来,因为 R8 的 full mode 只在 release 启用。我们是在 Firebase Crashlytics 上报了 NullPointerException(Converter 返回 null,GsonConverterFactory 也没匹配上,最终 body 没 Converter 处理)才发现。
Retrofit 本身的 Platform 类会在启动时检查方法注解,但那是针对 Retrofit 内置注解(@GET、@POST 等),自定义注解的保留需要自己处理。这是 Android 特有的坑,纯 Java 后端项目通常不碰 R8。