Android 优雅地处理后台返回的骚数据

20,471 阅读13分钟

前言

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 {
    @JvmStatic
    fun create(): MyGsonConverterFactory {
      return create(Gson())
    }

    @JvmStatic
    fun create(gson: Gson?): MyGsonConverterFactory {
      if (gson == null) throw NullPointerException("gson == null")
      return MyGsonConverterFactory(gson)
    }
  }
}
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<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 的代码,因为是在这个类解析数据。可以在上面有注释的地方加入自己的处理。到底加什么代码,看完后面的内容就知道了。

虽然能达到我们想要得效果,但是也有点弊端:

  • 由于 GsonResponseBodyConverter 有 final 修饰不能被继承修改,被迫拷贝出 3 个类来修改其中一个类的代码,那另外两个类有点冗余。
  • 这是针对 Retrofit 进行处理的,如果公司用的是自己封装的 OkHttp 请求工具,就没法用这个方案了。

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

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

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

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

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

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

为什么会这样子?有兴趣的可以看下这篇文章《为何 response.body().string() 只能调用一次?》。简单总结一下就是考虑到应用重复读取数据的可能性很小,所以将其设计为一次性流,读取后即关闭并释放资源。我们在拦截器里用通常的 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_8
      if (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
  @Override
  public 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) throws IOException;
}

主要是拿到 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? = null
    try {
      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? = null
    try {
      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 封装的框架的话,通过本文章的解决问题思路应该也能寻找到相应的解决方案。