Retrofit第四章-支持kotlin空安全之自定义Converter

4,926 阅读5分钟

点击这里查看第一章

点击这里查看第二章

点击这里查看第三章

在第三章的最后,我们介绍了自定义Converter,是根据GsonConverterFactory改的,但其实Gson并不支持kotlin空安全特性,可以概括为:即使在kotlin class中定义非空字段,Gson解析null字段时会绕过空安全检查,最终将null值赋值给非空字段。

示例:

data class User(
    val name: String,
    val avatar: String,
    val age: Int
)

定义如下Json
{
"name": "张三",
"age":0
}

示例中,Json并不包含avatar字段,但最终结果是正常解析,不会报错。但是由于没有avatar字段,所以avatar为null。这就导致我们只能在调用avatar时必须判断空值情况,即使kotlin提示你此字段不可空。

原因是什么?

data class中的字段没有默认值,那么就不会生成空参构造。在Gson内部解析的时候没有调用空参构造(因为没有生成)。涉及到Gson内部解析细节,简单说明就是Gson解析会有如下优先级 1.newDefaultConstructor

2.newDefaultImplementationConstructor(这个方法里面都是一些集合类相关对象的逻辑,直接跳过。)

3.newUnsafeAllocator

由于没有空参构造,所以1不会走,2原因如上,所以会走3。是个不安全的操作,并不建议,具体上网查询答案。

处理方案?

从业务逻辑出发

既然avatar可空,那我就设置为可空。

data class User(
    val name: String,
    val avatar: String?,
    val age: Int
)

这种写法当然可以,但有缺陷。

如果我有这样的需求呢:当Json中不包含avatar,那么Gson解析结果中avatar将采用默认值。

data class User(
    val name: String,
    val avatar: String? = "http:.....",
    val age: Int
)

这个需求就满足不了,原因之前讲过,没有空参构造,执行不安全的初始化对象逻辑,最终Json中缺省的avatar字段依旧是null。

从源码分析出发

既然没有空参构造会导致Gson内部执行不安全的初始化对象操作,那我们给出空参构造方法不就行了。

空参构造方法有很多种,这里我只介绍一种

给所有字段添加默认值

data class User(
    val name: String = "",
    val avatar: String = "",
    val age: Int = 11
)

这样就会生成空参构造,同时满足默认值的要求,就是写起来麻烦,不过可以借助插件实现。

但如果我们解析如下数据时

{
"name": "张三",
"age":0,
"avatar":null
}

最终还是会将null赋值给avatar字段,即使将avatar设置为可空,那么之前提到的Json字段缺省即采用默认值的需求还是无法解决。

那么,该怎么办呢?

不怎么办,要么忍受,要么就用其他解析库吧。比如moshi和官方出品的kotlinx.serialization。

moshi

我们先来看看如何使用,以及对kotlin的支持如何

具体的可以查看官方文档: github.com/square/mosh…

moshi有两种解析方式,一个依靠反射,一个不依靠反射,我们采用的是不依靠反射的方式。

val moshi: Moshi = Moshi.Builder().build()
val jsonAdapter: JsonAdapter<User> = moshi.adapter(User::class.java)
val user = jsonAdapter.fromJson(json)
println(user)

写法相比与Gson要麻烦一点,而且解析的结果user也是可空类型,如果我们要想使用这个user就必须判空。

不过我们先来看一下对kotlin空安全的支持如何,比如我们定义如下的json

{
  "name": "haha",
  "age": 0,
  "avatar":null
}

User定义如下

data class User(
    val name: String,
    val avatar: String,
    val age: Int
)

此时会发生什么呢?会直接抛出JsonDataException: Non-null value 'avatar' was null

如果我将avatar设置一个默认值

data class User(
    val name: String,
    val avatar: String = "https...",
    val age: Int
)

json数据中不给出avatar字段时会发生什么呢?

{
  "name": "haha",
  "age": 0
}

答案是保留了默认值,这一点也与Gson不一样,可见在支持kotlin非空字段上moshi做得比Gson要好一点。

不过当我再去搜索一些文章的时候发现一些对moshi的吐槽。

  1. 无法支持kotlin中泛型场景的空安全

    测试一下

    data class User<T>(
        val age: Int = 0, // 0
        val avatar: String = "https...",
        val name: String, // 张三
        val data:T
    )
    

    json如下

    {
      "name": "张三",
      "age": 0,
      "data": null
    }
    

    有泛型的情况下,需要这样写

    val moshi: Moshi = Moshi.Builder().build()
    val listOfCardsType: Type = Types.newParameterizedType(
        User::class.java,
        UserData::class.java
    )
    val jsonAdapter: JsonAdapter<User<UserData>> = moshi.adapter(listOfCardsType)
    

    结果是抛出了异常JsonDataException: Non-null value 'data_' (JSON name 'data') was null,符合预期

    如果Json是这样的呢?没有data字段

    {
      "name": "张三",
      "age": 0
    }
    

    会抛出JsonDataException: Required value 'data_' (JSON name 'data') missing

    如果是List呢?

    json如下

    [
      null,
      null,
      null
    ]
    

    代码如下

    val moshi: Moshi = Moshi.Builder().build()
    val listOfCardsType: Type = Types.newParameterizedType(
        List::class.java,
        UserData::class.java
    )
    val jsonAdapter: JsonAdapter<List<UserData>> = moshi.adapter(listOfCardsType)
    val result = jsonAdapter.fromJson(json)
    
    Log.i(TAG, "result: " + result?.get(0))
    

    结果是正常解析,并且log打印出result: null,还是将null赋值给了非null的UserData,这是不符合预期的,应该抛出异常才对。

  2. 对ArrayList和List的支持

    定义类

    data class User(
        val age: Int = 0, // 0
        val data: List<Data> = listOf(),
        val name: String = "" // 张三
    ) {
        data class Data(
            val key: Int = 0 // 1
        )
    }
    

    代码

    val jsonAdapter: JsonAdapter<User> = moshi.adapter(User::class.java)
    

    结果是正常解析,但如果将data的类型改为ArrayList,就会报错

    No JsonAdapter for java.util.ArrayList<User$Data>, you should probably use List instead of ArrayList (Moshi only supports the collection interfaces by default) or else register a custom JsonAdapter
    

    要么使用List或者MutableList,要么自定义一个JsonAdapter解析ArrayList。

小结:

moshi使用稍微麻烦一点,对ArrayList不支持,对List中T的空值不支持。除此之外,对kotlin的空安全支持还是比Gson好的。

kotlinx.serialization

地址:github.com/Kotlin/kotl…

配置请看官方....

val user = Json.decodeFromString<User>(json)

代码比较简单,就一行代码,而且返回的User也是非空类型,这一点比moshi要好一点。

关于kotlinx.serialization的测试,就不详细演示了。moshi支持的kotlinx.serialization都支持,moshi不支持的,kotlinx.serialization也支持,比如:

定义如下json和User

{
  "name": "张三",
  "age": 0,
  "data": [
    null,
    null
  ]
}

data class User(
    val age: Int = 0, // 0
    val data: List<Data> = listOf(),
    val name: String = "" // 张三
) {
    data class Data(
        val key: Int = 0 // 1
    )
}

结果符合预期,解析报错

kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 45: Expected start of the object '{', but had ' ' instead

也支持ArrayList。

小结:

kotlinx.serialization使用起来比moshi方便很多,moshi不支持的解析kotlinx.serialization都支持,对kotlin空安全支持是目前最好的。


说了这么多,其实还没有写到我想说的部分,这篇主要是来展示一下Retrofit中自定义Converter,用moshi或者kotlinx.serialization来替代GsonConverterFactory的。


Moshi自定义解析

先来看看如何在Retrofit中使用moshi。

首先是造好的轮子:com.squareup.retrofit2:converter-moshi。只需要依赖最新版本即可。

private val moshi = Moshi.Builder()
    .build()

Retrofit.Builder().addConverterFactory(MoshiConverterFactory.create(moshi))

MoshiConverterFactory.create需要传入一个moshi对象,这里直接初始化即可,这就是最基本的使用。

不过,还记得我们第三章业务需求吗?这里我再贴一下

请求结果是

{
  "data": [...],
  "errorCode": 0,
  "errorMsg": ""
}

我们要达到的效果

lifecycleScope.launch {
    val result = service.banner()
    //这个BizSuccess就包含了body.errorCode == 0
    if (result is ApiResult.BizSuccess) {
        tvResult.setText(result.data.toString())
    }
}

我们需要做的是拿到请求结果后,对Json进行手动解析,如果满足业务成功条件(errorCode=0)就返回ApiResult.BizSuccess,否则就是ApiResult.BizError等,现在我们来看下moshi的自定义解析。

private val moshi = Moshi.Builder()
    //增加自定义解析即可
    .add(MoshiApiResultConverterFactory())
    .build()

Retrofit.Builder().addConverterFactory(MoshiConverterFactory.create(moshi))

我们增加了一个MoshiApiResultConverterFactory用于处理ApiResult的自定义解析,直接贴代码了

class MoshiApiResultConverterFactory : JsonAdapter.Factory {
    override fun create(
        type: Type,
        annotations: MutableSet<out Annotation>,
        moshi: Moshi
    ): JsonAdapter<*>? {
        //判断是否为ApiResult<T>
        val rawType = type.rawType
        if (rawType != ApiResult::class.java) return null
        //获取 ApiResult 的泛型参数,比如 Banner
        val dataType: Type = (type as? ParameterizedType)
            ?.actualTypeArguments?.firstOrNull()
            ?: return null
        //代码1 : 获取 Banner 的 JsonAdapter
        val dataTypeAdapter = moshi.nextAdapter<Any>(
            this, dataType, annotations
        )
        return ApiResultTypeAdapter(rawType, dataTypeAdapter)
    }

    class ApiResultTypeAdapter<T>(
        private val outerType: Type,
        private val dataTypeAdapter: JsonAdapter<T>
    ) : JsonAdapter<T>() {
        override fun fromJson(reader: JsonReader): T? {
            //开始读取数据
            reader.beginObject()
            var code: Int? = null
            var msg: String? = null
            var data: Any? = null
            //可空的stringAdapter
            val nullableStringAdapter: JsonAdapter<String?> = Moshi.Builder().build().adapter(String::class.java,
                emptySet(), "message")
                
            //同样是分段读取
            while (reader.hasNext()) {
                when (reader.nextName()) {
                    "code" -> code = reader.nextString().toIntOrNull()
                    "message" -> msg = nullableStringAdapter.fromJson(reader)
                    "data" -> data = dataTypeAdapter.fromJson(reader)
                    else -> reader.skipValue()
                }
            }
            reader.endObject()

            return if (code != 0)
                ApiResult.BizError(
                    code ?: -1,
                    msg ?: "N/A"
                ) as T
            else ApiResult.BizSuccess(
                code,
                msg ?: "N/A",
                data
            ) as T?
        }

        // 不需要序列化的逻辑
        override fun toJson(writer: JsonWriter, value: T?): Unit = TODO("Not yet implemented")
    }
}

代码也很好理解,直接看注释即可。其中代码1

val dataTypeAdapter = moshi.nextAdapter<Any>(
            this, dataType, annotations
        )

因为每一个解析类,比如我们这里是Banner都需要添加一个@JsonClass(generateAdapter = true)注解用来生成对应的adapter解析json。这里的nextAdapter()方法的设计倒是很像Retrofit。总之,我们拿到了ApiResult中的T对应的adapter,在接下来解析数据的时候,用获取到的adapter解析data。

注意的是在第三章中写好的ApiResultCallAdapter中的responseType()传什么。

应该是ApiResult,而不是T。否则无法匹配到我们自定义的MoshiApiResultConverterFactory

kotlinx.serialization自定义解析

先说结论吧,kotlinx.serialization是支持自定义解析的,但是由于当前最新版本kotlinx-serialization-json:1.3.1对带有泛型类的自定义解析存在问题,所以我们自定义解析ApiResult编译不通过。

相关问题链接如下

这是我遇到的问题:github.com/Kotlin/kotl…

这个指出会不会是kotlin-kapt的问题:github.com/Kotlin/kotl…

另外对于sealed修饰的ApiResult在自定义解析时也会报错。

抛开这些待解决的问题,回到业务来,其实也有供我们使用的kotlinx-serialization-converter,来自于jakewharton巨佬,地址:github.com/JakeWharton…

普通使用很简单,如下所示:

val contentType = "application/json".toMediaType()

Retrofit.Builder().addConverterFactory(Json.asConverterFactory(contentType))

那如何进行自定义解析ApiResult呢,因为代码并没有跑起来,无法验证,而且我用的是Banner< T >来代替的ApiResult< T >,所以我只能说不带泛型的Banner的自定义解析是成功的,但是带有泛型的Banner< T >和ApiResult< T >只能看伪代码。

给出如下Json,这也是wanAndroid的Banner接口结果

{
  "data": [
    {
      "desc": "一起来做个App吧",
      "id": 10,
      "imagePath": "https://www.wanandroid.com/blogimgs/50c115c2-cf6c-4802-aa7b-a4334de444cd.png",
      "isVisible": 1,
      "order": 1,
      "title": "一起来做个App吧",
      "type": 0,
      "url": "https://www.wanandroid.com/blog/show/2"
    }
  ],
  "errorCode": 0,
  "errorMsg": ""
}

定义如下Bean

//CustomBannerSerializer即指定了自定义解析的类
@Serializable(with = CustomBannerSerializer::class)
data class Banner<T>(
    //这里我定义一个List<T>泛型,用来演示,虽然我们知道这个T就是指的Banner.Data
    val data: List<T>? = listOf(),
    val errorCode: Int = 0, // 0
    val errorMsg: String = ""
) {
    data class Data(
        val desc: String = "", // 一起来做个App吧
        val id: Int = 0, // 10
        val imagePath: String = "", // https://www.wanandroid.com/blogimgs/50c115c2-cf6c-4802-aa7b-a4334de444cd.png
        val isVisible: Int = 0, // 1
        val order: Int = 0, // 1
        val title: String = "", // 一起来做个App吧
        val type: Int = 0, // 0
        val url: String = "" // https://www.wanandroid.com/blog/show/2
    )
}

自定义的CustomBannerSerializer,用于解析Banner

class CustomBannerSerializer<T>(private val dataSerializer: KSerializer<T>) : KSerializer<Banner<T>> {

    override fun serialize(encoder: Encoder, value: Banner<T>) {}

    override fun deserialize(decoder: Decoder): Banner<T> {
        return decoder.decodeStructure(descriptor) {
            var data:List<T>? = null
            var errorCode: Int = 0
            var errorMsg: String = ""
            while (true) {
                when (val index = decodeElementIndex(descriptor)) {
                    //因为在Banner中索引为0的是data
                    0 -> data = decoder.decodeSerializableValue(ListSerializer<T>(dataSerializer))
                    1 -> errorCode = decodeIntElement(descriptor, 1)
                    2 -> errorMsg = decodeStringElement(descriptor, 2)
                    CompositeDecoder.DECODE_DONE -> break
                    else -> error("Unexpected index: $index")
                }
            }
            Banner(data,errorCode,errorMsg).also {
                println(it)
            }
        }
    }
    //有待确认
    override val descriptor: SerialDescriptor = dataSerializer.descriptor
}

我们将原来的ApiResult变成Banner,但是逻辑是一样的(分段解析),只不过ApiResult是sealed的(sealed修饰的类自定义解析时需要注意,kotlinx.serialization不支持)。

至此,kotlinx.serialization的自定义解析也分析完成了,有兴趣的同学可以自己试试或者kotlinx-serialization新版本更新后说不定就编译通过了。

代码已上传到Github:github.com/lt19931203/…

参考链接:

1.blog.yujinyan.me/posts/kotli…

2.blog.csdn.net/taotao11012…