网易首页 > 网易号 > 正文 申请入驻

哥纵横Android多年,竟然翻车在Json上

0
分享至

码个蛋(codeegg)第 791 次推文

作者:DylanCai

博客:https://juejin.im/post/5dadac2ae51d4524c3745219

码妞看世界

有谁认识它嘛?

前言

Retrofit 是目前主流的网络请求框架,不少用过的小伙伴会遇到这样的问题,绝大部分接口测试都正常,就个别接口尤其是返回失败信息时报了个奇怪的错误信息,而看了自己的代码逻辑也没什么问题。别的接口都是一样的写,却没出现这样的情况,可是后台人员看了也说不关他们的事。刚遇到时会比较懵,有些人不知道什么原因也就无从下手。

问题原因

排查问题也很简单,把信息百度一下,会发现是解析异常。那就先看下后台返回了什么,用 PostMan 请求一下查看返回结果,发现是类似下面这样的:

{"code": 500,"msg": "登录失败","data": ""}

也可能是这样的:

{"code": 500,"msg": "登录失败","data": 0}

或者是这样的:

{"code": 500,"msg": "登录失败","data": []}

仔细观察后突然恍然大悟,这不是坑爹吗?后台这样返回解析肯定有问题呀,我要将 data 解析成一个对象,而后台返回的是一个空字符串、整形或空数组,肯定解析报错。

嗯,这就是后台的问题,是后台写得不“规范”,所以就跑过去和后台理论让他们改。如果后台是比较好说话,肯配合改还好说。但有些可能是比较“倔强”的性格,可能会说,“这很简单呀,知道是失败状态不解析 data 不就好了?,或者说,“为什么 iOS 可以,你这边却不行?你们 Android 有问题就不能自己处理掉吗?”。如果遇到这样的同事就会比较尴尬。

其实就算后台能根据我们要求改,但也不是长远之计。后台人员变动或自己换个环境可能还是会遇到同样的情况,每次都和后台沟通配合改也麻烦,而且没准就刚好遇到“倔强”不肯改的。

是后台人员写得不规范吗?我个人认为并不是,因为并没有约定俗成的规范要这么写,其实只是后台人员不知道这么返回数据会对 Retrofit 的解析有影响,不知道这么写对 Android 不太友好。后台人员也没有错,我们所觉得的“规范”没人告诉过他呀。可以通过沟通解决问题,不过也建议自己把问题处理了,一劳永逸。

解决方案

既然是解析报错了,那么在 Gson 解析成对象之前,先验证状态码,判断是错误的情况就抛出异常,这样就不进行后续的 Gson 解析操作去解析 data,也就没问题了。

最先想到的当然是从解析的地方入手,而 Retrofit 能进行 Gson 解析是配置了一个 Gson 转换器。

retrofit = Retrofit.Builder()// 其它配置.addConverterFactory(GsonConverterFactory.create()).build()

所以我们修改 GsonConverterFactory 不就好了。

自定义 GsonConverterFactory 处理返回结果

试一下会发现并不能直接继承 GsonConverterFactory 重载修改相关方法,因为该类用了 final 修饰。所以只好把 GsonConverterFactory 源码复制出来改,其中关联的两个类 GsonRequestBodyConverter 和 GsonResponseBodyConverter 也要复制修改。下面给出的是 Kotlin 版本的示例。

class MyGsonConverterFactory private constructor(private val gson: Gson) : Converter.Factory() {
override fun responseBodyConverter(type: Type, annotations: Array<Annotation>,retrofit: Retrofit): Converter<ResponseBody, *> {val adapter = gson.getAdapter(TypeToken.get(type))return MyGsonResponseBodyConverter(gson, adapter)}
override fun requestBodyConverter(type: Type,parameterAnnotations: Array<Annotation>,methodAnnotations: Array<Annotation>,retrofit: Retrofit): Converter<*, RequestBody> {val adapter = gson.getAdapter(TypeToken.get(type))return MyGsonRequestBodyConverter(gson, adapter)}
companion object {@JvmStaticfun create(): MyGsonConverterFactory {return create(Gson())}
@JvmStaticfun create(gson: Gson?): MyGsonConverterFactory {if (gson == null) throw NullPointerException("gson == null")return MyGsonConverterFactory(gson)}}}


MyGsonRequestBodyConverter

class MyGsonRequestBodyConverter<T>(private val gson: Gson,private val adapter: TypeAdapter<T>) :Converter<T, RequestBody> {
@Throws(IOException::class)override fun convert(value: T): RequestBody {val buffer = Buffer()val writer = OutputStreamWriter(buffer.outputStream(), UTF_8)val jsonWriter = gson.newJsonWriter(writer)adapter.write(jsonWriter, value)jsonWriter.close()return buffer.readByteString().toRequestBody(MEDIA_TYPE)}
companion object {private val MEDIA_TYPE = "application/json; charset=UTF-8".toMediaType()private val UTF_8 = Charset.forName("UTF-8")}}

class MyGsonResponseBodyConverter

class MyGsonResponseBodyConverter<T>(private val gson: Gson,private val adapter: TypeAdapter<T>) : Converter<ResponseBody, T> {
@Throws(IOException::class)override fun convert(value: ResponseBody): T {// 在这里通过 value 拿到 json 字符串进行解析// 判断状态码是失败的情况,就抛出异常val jsonReader = gson.newJsonReader(value.charStream())value.use {val result = adapter.read(jsonReader)if (jsonReader.peek() != JsonToken.END_DOCUMENT) {throw JsonIOException("JSON document was not fully consumed.")}return result}}}

上面三个类中只需要修改 GsonResponseBodyConverter 的代码,因为是在这个类解析数据。可以在上面有注释的地方加入自己的处理。到底加什么代码,看完后面的内容就知道了。

虽然得到了我们想要的效果,但总感觉并不是很优雅,因为这只是在 gson 解析之前增加一些判断,而为此多写了很多和源码重复的代码。还有这是针对 Retrofit 进行处理的,如果公司用的是自己封装的 OkHttp 请求工具,就没法用这个方案了。

观察一下发现其实只是对一个 ResponseBody 对象进行解析判断状态码,就是说只需要得到个 ResponseBody 对象而已。那么还有什么办法能在 gson 解析之前拿到 ResponseBody 呢?

自定义拦截器处理返回结果

很容易会想到用拦截器,按道理来说是应该是可行的,通过拦截器处理也不局限于使用 Retrofit,用 OkHttp 的也能处理。

想法很美好,但是实际操作起来并没有想象中的简单。刚开始可能会想到用response.body().string()读出 json 字符串。

public abstract class ResponseBodyInterceptor implements Interceptor {@NotNull@Overridepublic Response intercept(@NotNull Chain chain) throws IOException {Response response = chain.proceed(chain.request());String json = response.body().string();// 对 json 进行解析判断状态码是失败的情况就抛出异常return response;}}

看着好像没问题,但是尝试后发现,状态码是失败的情况确实没毛病,然而状态码是正确的情况却有问题了。

为什么会这样子?有兴趣的可以看下这篇文章《为何 response.body().string() 只能调用一次?》(https://juejin.im/post/5a524eef518825732c536025)。简单总结一下就是考虑到应用重复读取数据的可能性很小,所以将其设计为一次性流,读取后即关闭并释放资源。我们在拦截器里用通常的 Response 使用方法会把资源释放了,后续解析没有资源了就会有问题。

那该怎么办呢?自己对 Response 的使用又不熟悉,怎么知道该怎么读数据不影响后续的操作。可以参考源码呀,OkHttp 也是用了一些拦截器处理响应数据,它却没有释放掉资源。

这里就不用大家去看源码研究怎么写的了,我直接封装好一个工具类提供大家使用,已经把响应数据的字符串得到了,大家可以直接编写自己的业务代码,拷贝下面的类使用即可。

abstract class ResponseBodyInterceptor : Interceptor {override fun intercept(chain: Interceptor.Chain): Response {val request = chain.request()val url = request.url.toString()val response = chain.proceed(request)response.body?.let { responseBody ->val contentLength = responseBody.contentLength()val source = responseBody.source()source.request(Long.MAX_VALUE)var buffer = source.buffer
if ("gzip".equals(response.headers["Content-Encoding"], ignoreCase = true)) {GzipSource(buffer.clone()).use { gzippedResponseBody ->buffer = Buffer()buffer.writeAll(gzippedResponseBody)}}val contentType = responseBody.contentType()val charset: Charset =contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8if (contentLength != 0L) {return intercept(response,url, buffer.clone().readString(charset))}}return response}
abstract fun intercept(response: Response, url: String, body: String): Response}

由于 OkHttp 源码已经用 Kotlin 语言重写了,所以只有个 Kotlin 版本的。但是可能还有很多人还没有用 Kotlin 写项目,所以个人又手动翻译了一个 Java 版本的,方便大家使用,同样拷贝使用即可。

public abstract class ResponseBodyInterceptor implements Interceptor {@NotNull@Overridepublic Response intercept(@NotNull Chain chain) throws IOException {Request request = chain.request();String url = request.url().toString();Response response = chain.proceed(request);ResponseBody responseBody = response.body();if (responseBody != null) {long contentLength = responseBody.contentLength();BufferedSource source = responseBody.source();source.request(Long.MAX_VALUE);Buffer buffer = source.getBuffer();
if ("gzip".equals(response.headers().get("Content-Encoding"))) {GzipSource gzippedResponseBody = new GzipSource(buffer.clone());buffer = new Buffer();buffer.writeAll(gzippedResponseBody);}
MediaType contentType = responseBody.contentType();Charset charset;if (contentType == null || contentType.charset(StandardCharsets.UTF_8) == null) {charset = StandardCharsets.UTF_8;} else {charset = contentType.charset(StandardCharsets.UTF_8);}if (charset != null && contentLength != 0L) {return intercept(response,url, buffer.clone().readString(charset));}}return response;}
abstract Response intercept(@NotNull Response response,String url, String body);}

主要是拿到 source 再获得 buffer,然后通过 buffer 去读出字符串。说下其中的一段gzip相关的代码,为什么需要有这段代码的处理,自己看源码的话可能会漏掉。这是因为 OkHttp 请求时会添加支持gzip压缩的预处理,所以如果响应的数据是gzip编码的,需要对gzip压缩数据解包再去读数据。

好了废话不多说,到底这个工具类怎么用,其实和拦截器一样使用,继承我封装好的ResponseBodyInterceptor类,在重写方法里加上自己需要的业务处理代码,body 参数就是我们想要的 json 字符串数据,可以进行解析判断状态码是失败情况并抛出异常。下面给一个简单的解析例子参考,json 结构是文章开头给出的例子,这里假设状态码不是 200 都抛出一个自定义异常。

class HandleErrorInterceptor : ResponseBodyInterceptor() {override fun intercept(response: Response, body: String): Response {var jsonObject: JSONObject? = nulltry {jsonObject = JSONObject(body)} catch (e: Exception) {e.printStackTrace()}if (jsonObject != null) {if (jsonObject.optInt("code", -1) != 200 && jsonObject.has("msg")) {throw ApiException(jsonObject.getString("msg"))}}return response}}

然后在 OkHttpClient 中添加该拦截器就可以了。

val okHttpClient = OkHttpClient.Builder()// 其它配置.addInterceptor(HandleErrorInterceptor()).build()

万一后台返回的是更骚的数据呢?

本人目前只遇到过失败时 data 类型不一致的情况,下面是一些小伙伴反馈的,如果大家有遇到类似或更骚的,都建议和后台沟通改成返回方便自己写业务逻辑代码的数据。实在沟通无果,再参考下面的案例看下是否有帮助。

后面所给出的参考方案都是缓兵之计,不能根治问题。想彻底地解决只能和后台人员沟通一套合适的规范。

数据需要去 msg 里取

有位小伙伴提到的:骚的时候数据还会去 msg 取。(大家都经历过了什么...)还是强调一下建议让后台改,实在没办法必须要这么做的话,再往下看。

假设返回的数据是下面这样的:

{"code": 200,"msg": {"userId": 123456,"userName": "admin"}}

通常 msg 返回的是个字符串,但这次居然是个对象,而且是我们需要得到的数据。我们解析的实体类已经定义了 msg 是字符串,当然不可能因为一个接口把 msg 改成泛型,所以我们需要偷偷地把数据改成我们想要得到的形式。

{"code": 200,"msg": "登录成功""data": {"userId": 123456,"userName": "张三"}}

那么该怎么操作呢?代码比较简单,就不啰嗦了,记得要把该拦截器配置了。

class HandleLoginInterceptor: ResponseBodyInterceptor() {
override fun intercept(response: Response, url: String, body: String): Response {var jsonObject: JSONObject? = nulltry {jsonObject = JSONObject(body)if (url.contains("/login")) { // 当请求的是登录接口才处理if (jsonObject.getJSONObject("msg") != null) {jsonObject.put("data", jsonObject.getJSONObject("msg"))jsonObject.put("msg", "登录成功")}}} catch (e: Exception) {e.printStackTrace()}
val contentType = response.body?.contentType()val responseBody = jsonObject.toString().toResponseBody(contentType)return response.newBuilder().body(responseBody).build() // 重新生成响应对象}}

如果用 Java 的话,是这样来重新生成响应对象。

MediaType contentType = response.body().contentType();ResponseBody responseBody = ResponseBody.create(jsonObject.toString(), contentType);return response.newBuilder().body(responseBody).build();

数据多和数据少返回的类型不一样

又有位小伙伴说道:数据少给你返回 JSONObject,数据多给你返回 JSONArray,数据没有给你返回 “null”,null,“”。(这真的不会被打吗...)

再强调一次,建议让后台改。如果硬要这么做,再参考下面思路。

小伙伴没给具体的例子,这里我自己假设数据的几种情况。

{"code": 200,"msg": "","data": "null"}


{"code": 200,"msg": "","data": {"key1": "value1","key2": "value2"}}


{"code": 200,"msg": "","data": [{"key1": "value1","key2": "value2"},{"key1": "value3","key2": "value4"}]}

data 的类型会有多种,我们直接请求的话,应该只能将 data 定义成 String,然后解析判断到底是哪种情况,再写逻辑代码,这样处理起来麻烦很多。个人建议用拦截器手动将 data 统一转成 JSONArray 的形式,这样 data 类型只有一种,处理起来更加方便,代码逻辑也更清晰。

{"code": 200,"msg": "","data": []}{"code": 200,"msg": "","data": [{"key1": "value1","key2": "value2"}]}{"code": 200,"msg": "","data": [{"key1": "value1","key2": "value2"},{"key1": "value3","key2": "value4"}]}

具体的代码就不给出了,实现是类似上一个例子,主要是提供思路给大家参考。

直接返回 http 状态码,响应报文可能没有或者不是 json

这是有两位小伙伴说的情况:后台直接返回 http 状态码,响应报文为空、null、"null"、""、[] 等这些数据。

还是那句话,建议让后台改。如果不肯改,其实这个处理起来也还好。

大概了解下后台返回的 http 状态码是一个 600 以上的数字,一个状态码对应着一个没有返回数据的操作。响应报文可能没有,可能不是 json。

看起来像是不同类型的响应报文,比数据类型不同更难处理。其实这比之前两个例子简单很多,因为不用考虑读数据。具体处理是判断一下状态码是多少,然后抛出对应的自定义异常,请求时对该的异常进行处理。响应报文都是些“空代表”处理起来好像挺麻烦,但我们没必要去管,抛了异常就不会进行解析。

class HandleHttpCodeInterceptor : ResponseBodyInterceptor() {override fun intercept(response: Response, url: String, body: String): Response {when (response.code) {600,601,602 -> {throw ApiException(response.code, "msg")}else -> {}}return response}}

在 header 里取 data 数据

居然还有这种骚操作,涨见识了...

建议先让后台改。后台不改自己再手动把 header 里的数据提取出来,转成自己想要的 json 数据。

class ConvertDataInterceptor : ResponseBodyInterceptor() {override fun intercept(response: Response, url: String, body: String): Response {val json = "{\"code\": 200}" // 创建自己需要的数据结构val jsonObject = JSONObject(json)jsonObject.put("data", response.headers["Data"]) // 将 header 里的数据设置到 json 里val contentType = response.body?.contentType()val responseBody = jsonObject.toString().toResponseBody(contentType)return response.newBuilder().body(responseBody).build() // 重新生成响应对象}}

总结

大家遇到这些情况建议先与后台人员沟通。刚开始说的失败时 data 类型不一致的情况有不少人遇到过,有需要的可以提前处理预防一下。至于那些更骚的操作最好还是和后台沟通一个合适的规范,实在沟通无果再参考文中部分案例的处理思路。

自定义 GsonConverter 与源码有不少冗余代码,并不推荐。而且如果想对某个接口的结果进行处理,不好拿到该地址。拦截器的方式难点主要是该怎么写,所以封装好了工具类供大家使用。

文中提到了用拦截器将数据转换成方便我们编写逻辑的结构,并不是鼓励大家帮后台擦屁股。这种用法或许对某些复杂的接口来说会有奇效。

刚开始只是打算分享自己封装好的类,说一下怎么使用来解决问题。不过后来还是花了很多篇幅详细描述了我解决问题的整个心路历程,主要是见过太多人求助这类问题,所以就写详细一点,后续如果还有人问就直接发文章过去,应该能有效解决他的疑惑。另外如果公司用的请求框架即不是 Retrofit 也不是基于 OkHttp 封装的框架的话,通过本文章的解决问题思路应该也能寻找到相应的解决方案。

  • 我打赌你一定没搞明白的Activity启动模式

  • 码仔:学了就忘怎么办?

  • 热更新再牛,也少不了Android 增量更新

今日问题:

你遇到过什么奇葩数据?

专属升级社区:《这件事情,我终于想明白了》

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
高考倒计时 4 天,湖北 53 万大军过独木桥,公办本科入场券有多难拿

高考倒计时 4 天,湖北 53 万大军过独木桥,公办本科入场券有多难拿

辉哥说动漫
2026-06-02 22:15:39
安徽省省长深入煤矿井下910多米检查

安徽省省长深入煤矿井下910多米检查

新京报
2026-06-02 22:04:02
蜂蜜被点名!医生提醒:糖尿病患者常喝蜂蜜水很快迎来4个改变!

蜂蜜被点名!医生提醒:糖尿病患者常喝蜂蜜水很快迎来4个改变!

芹姐说生活
2026-05-20 23:42:03
下一轮暴雨,时间定了!

下一轮暴雨,时间定了!

FM96.2广州新闻电台
2026-06-02 14:19:38
网友们这几天都在吃著名毛巾集团洁丽雅的瓜,讽刺其家族“丑闻”

网友们这几天都在吃著名毛巾集团洁丽雅的瓜,讽刺其家族“丑闻”

网络易不易
2026-05-17 12:29:12
华为9 款新机官宣:6月1日,全新首发!

华为9 款新机官宣:6月1日,全新首发!

科技堡垒
2026-05-31 09:37:47
美股QDII额度告急,怎么抢?攻略来了

美股QDII额度告急,怎么抢?攻略来了

21世纪经济报道
2026-06-02 20:28:55
异性对接吻一定要慎重,一旦“接吻”了,关系就会发生重大变化!

异性对接吻一定要慎重,一旦“接吻”了,关系就会发生重大变化!

皓皓情感说
2026-05-15 12:29:38
6死7伤!乡政府大楼被炸震惊中央,四川凉山州6.26特大爆炸案始末

6死7伤!乡政府大楼被炸震惊中央,四川凉山州6.26特大爆炸案始末

易玄
2024-09-11 10:52:41
赵海燕儿子婚礼,闫学晶缺席,儿媳体态遭嘲讽引风波

赵海燕儿子婚礼,闫学晶缺席,儿媳体态遭嘲讽引风波

丁鸊惊悚影视解说
2026-06-02 12:17:55
宇树科技回应与英伟达合作机器人:新产品“H2 Plus”下半年上市

宇树科技回应与英伟达合作机器人:新产品“H2 Plus”下半年上市

IT之家
2026-06-02 23:00:15
1-3!球迷怒斥广厦赢球靠裁判!大V们却盛赞4人,将矛头对准孙总

1-3!球迷怒斥广厦赢球靠裁判!大V们却盛赞4人,将矛头对准孙总

后仰大风车
2026-06-02 23:00:32
再这样错下去,中产的崩溃是早晚的事!

再这样错下去,中产的崩溃是早晚的事!

光远看经济
2026-06-02 19:41:45
李斌谈特斯拉 FSD 进入中国:算力优势非常大,对整个智驾发展是正面的事情!

李斌谈特斯拉 FSD 进入中国:算力优势非常大,对整个智驾发展是正面的事情!

新浪财经
2026-06-01 00:29:09
葡萄牙队世界杯号码公布:C罗身披7号领衔,B费8号、B席10号

葡萄牙队世界杯号码公布:C罗身披7号领衔,B费8号、B席10号

懂球帝
2026-06-02 18:00:13
白人女性与黑人女性的体味差异,网友真实分享引发热议

白人女性与黑人女性的体味差异,网友真实分享引发热议

特约前排观众
2025-12-22 00:20:06
重磅!山西煤老板出手!13亿民资砸向太原超级地标

重磅!山西煤老板出手!13亿民资砸向太原超级地标

靓仔情感
2026-06-02 18:48:59
2026年山西省退休养老金调整将至,养老金低的人能多涨一些吗?

2026年山西省退休养老金调整将至,养老金低的人能多涨一些吗?

暖心人社
2026-06-02 16:46:47
成龙、李连杰、甄子丹、吴京!《1941》立项,港片真正的王要来了

成龙、李连杰、甄子丹、吴京!《1941》立项,港片真正的王要来了

得得电影
2026-05-31 13:49:16
法国拦截了,俄方怒批:近乎国际强盗行径

法国拦截了,俄方怒批:近乎国际强盗行径

观察者网
2026-06-01 19:29:28
2026-06-02 23:40:49
码个蛋
码个蛋
码个蛋
808文章数 298关注度
往期回顾 全部

科技要闻

烧掉千亿后,美团、阿里、京东谁先止血?

头条要闻

演员魏宗万去世 曾在94版《三国演义》中饰演"司马懿"

头条要闻

演员魏宗万去世 曾在94版《三国演义》中饰演"司马懿"

体育要闻

1米74的业余联赛替补,在英超踢中卫

娱乐要闻

奚梦瑶何猷君补办婚礼超幸福

财经要闻

智元和宇树的“暗战”愈演愈烈

汽车要闻

星途神秘新车轮廓曝光 又一款性能SUV要来了?

态度原创

亲子
时尚
教育
艺术
军事航空

亲子要闻

进口针一支1.7万,年生长11厘米,国人怒了

蓝色系下装看着清爽不闷,裤子、裙子都凉快,随便穿都不出错

教育要闻

“你知道几号高考吗?”18岁纹身女孩的生日美照,诠释了物以类聚

艺术要闻

周杰伦花 1.36 亿拍下这幅画

军事要闻

伊朗媒体新发布最高领袖照片

无障碍浏览 进入关怀版