文章

Moshi with Kotlin Json 库—现代化的最佳损友

Moshi 与 Kotlin

Android(Java) 平台已经有许多 Json 库了,包括 Google 推荐的 Gson,广受欢迎的 Jackson,阿里的 FastJson 等,但今天要说的是大名鼎鼎 Square 公司的 Moshi. (什么?没听说过 Square?OkHttp, Retrofit 可都是他的著作)

轮子已经有很多了,不过自从 Google 将 Kotlin 定为 Android 亲儿子开发语言后,竟然包括 Gson 在内的几乎所有 Json 库均不支持,这当然可以理解,因为他们大多数采用了 Java 反射机制,注定难以适配 Kotlin 独有的特性。因此今天要介绍 Moshi —— 一个与 Kotlin 兼容极好的现代化解析库。

传统 Java Json 库用于 Kotlin 主要产生两个问题:

  1. 不支持空安全。即使 Kotlin 变量定义为非空,若 Json 为空则解析出 null 而不会抛出异常,直到数据使用时才抛出诡异的 NPE. 为啥说诡异?因为定义非空的变量默认不需要进行空判断,但实际上他是空的,有点挂羊头买狗肉的意思。
  2. 不支持默认参数。Kotlin 的 data class 极大地方便了开发,默认参数的语法更是直呼太爽。可惜这种现代化写法或遇到两个问题:①默认参数无效。②解析失败因为没有无参构造函数。而解决办法更是令人崩溃:①不要使用默认参数。②给所有形参全部加上默认参数。

因此,如果你已经使用 Kotlin 作为主要语言,Moshi 将会是绝佳的选择。(KT 自带的解析库也可以考虑)

另外 Moshi 的贡献者也是 Gson 的主要贡献者,因为 Gson 的先天不足且他已经离开了 Google,故开发了 Moshi,具体可以参考他在 Reddit 的回答:Why use Moshi over Gson?

基础使用

在 Kotlin 中使用 Moshi 有两个方案:①使用 Kotlin 反射。②使用注解生成。因为 Kotlin 反射库高达 2MB,一般我们采用第二个方案,而且理论上它也比反射效率更高一些,因为大多数工作在编译器就做完了。

Moshi 使用一个叫 Adapter 的东西负责序列化与反序列化,每个 Kotlin 类都对应一个 Adapter,命名规则为 数据类名+JsonAdapter,借助于内置的基本数据类型 Adapter(Int, String 等) 从而实现任意类的解析。显然,我们需要在 Adapter 内部列出每个字段并定义解析方法,这是模板化的重复性工作,可以利用注解帮助实现。

上面一段看不懂也没关系,只需要记得在需要序列化(反序列化)的类上加上 @JsonClass(generateAdapter = true) 注解就行了,例如:

@JsonClass(generateAdapter = true)
data class Person(
        val name: String,
        val age: Int,
        val sex: Boolean
)

补充一下 Adapter 以便理解

传统的 Json 库中,每当读取到一个 Json 属性可以利用反射找到 class 中对应的字段(变量)进行赋值。现在我们抛弃了反射,那么就需要一个手段找到这个变量,解决方案非常粗暴,那就是在编译时就把已知变量全部列出来,没有列出来的就忽略。负责执行这个工作的就是 Adapter.

为什么不用反射?

①若使用 Java 反射那么无法支持空安全等 Kotlin 特性。②若使用 Kotlin 反射则需要引入一个 2MB 大小的 jar 文件。

之后的使用类似 Gson 非常简单,相比 FastJson 麻烦一丢丢,毕竟要先创建实例。

val json = "..."

val moshi: Moshi = Moshi.Builder().build()
val jsonAdapter: JsonAdapter<Person> = moshi.adapter(Person::class.java)

person: Person = jsonAdapter.fromJson(json)
System.out.println(person)

实际使用中可以将 Moshi 作为单例。

Moshi 异常处理也非常规范,它一共只会抛出两种异常:

  1. IOException:读取 Json 过程中出现 IO 异常,或者 Json 格式错误。
  2. JsonDataException :数据类型不匹配。

如果要解析成集合,需要先用 Types.newParameterizedType() 包装一下:

val personArrayJson: String = "..."
val type: Type = Types.newParameterizedType(List::class.java, Person::class.java)
val adapter: JsonAdapter<List<Person>> = moshi.adapter(type)
val persons: List<Person> = adapter.fromJson(personArrayJson)

流式手动解析

基础解析

Adapter 底层其实使用了 JsonReaderJsonWriter 进行(反)序列化,这两个类几乎是从 Gson 抄过来的,API 非常类似,官方原话:Moshi uses the same streaming and binding mechanisms as Gson. If you’re a Gson user you’ll find Moshi works similarly.

对于动态(脏)的 Json 数据,我们难以预先得知其包含的字段,此时不得不使用流式解析。Moshi 使用 Okio 作为底层,我们需要通过 Okio 创建数据源来创建 JsonReader,下面是一个解析 Person 数组的例子

val reader = JsonReader.of(Okio.buffer(Okio.source(jsonFile)))

fun readPersonArray(reader: JsonReader): List<Person> {
    val list = mutableListOf<Person>()
    reader.beginArray()
    while (reader.hasNext()) {
        var name = ""
        var age = 0
        var sex = true

        reader.beginObject()
        while (reader.hasNext()) {
            when (reader.nextName()) {
                "name" -> name = reader.nextString()
                "age" -> age = reader.nextInt()
                "sex" -> sex = reader.nextBoolean()
                else -> reader.skipValue()
            }
        }
        reader.endObject()
        val person = Person(name, age, sex)
        list.add(person)
    }
    reader.endArray()
    return list
}

selectName() 优化

我们注意到一些字段名会重复出现(尤其是解析数组时),每当此时 Moshi 不得不进行 UTF8 解码并分配内存。相比之下我们可以事先准备好有可能出现的字段名,然后直接进行二进制比对,并返回在字段名序列中的下标。

举个栗子🌰:批改作业时每次都把每道题算一遍再比对答案是很低效的,因为我们已经知道了有哪些题目,不妨先算出正确答案然后直接对比即可。

首先使用 JsonReader.Options.of() 创建字段名称数组,然后使用 reader.selectName() 读取并匹配字段名,优化后代码如下:

val names = JsonReader.Options.of("name", "age", "sex")

fun readPersonArray(reader: JsonReader): List<Person> {
    val list = mutableListOf<Person>()
    reader.beginArray()
    while (reader.hasNext()) {
        var name = ""
        var age = 0
        var sex = true

        reader.beginObject()
        while (reader.hasNext()) {
            when (reader.selectName(names)) {
                0 -> name = reader.nextString()
                1 -> age = reader.nextInt()
                2 -> sex = reader.nextBoolean()
                else -> {
                    reader.skipName()
                    reader.skipValue()
                }
            }
        }
        reader.endObject()
        val person = Person(name, age, sex)
        list.add(person)
    }
    reader.endArray()
    return list
}

自定义 Adapter

更多时候,Json 数据格式是已知的,但其值的格式与 Kotlin Class 定义不同,此时若完全使用流式 API 解析就太麻烦了,自定义 Adapter 应运而生,通过 Adapter 我们可以控制 Json 与 class 如何转换。

有趣的是,Adapter 就是一个普通的类,习惯上我们给类名加上 Adapter 后缀以示区分,但实际上它并不继承自任何父类,也无需实现任何接口。只需要定义两个函数分别用于 Json→Class 与 Class→Json 的转换,并分别加上 @FromJson@ToJson 注解就行了。

Demo

为了演示首先改变一下 Person 的定义:

data class Person(
        val name: String,
        val age: Int,
        val sex: Sex // 将性别换为枚举
)

enum class Sex {
    MALE, FEMALE
}

现在 Sex 不再是基础数据类型 Moshi 无法识别,我们来创建一个 SexAdapter 帮助 Moshi 在 SexBoolean 之间转换:

class SexAdapter {
    @FromJson
    fun fromJson(value: Boolean): Sex {
        return if (value) Sex.MALE else Sex.FEMALE
    }

    @ToJson
    fun toJson(sex: Sex): Boolean {
        return sex == Sex.MALE
    }
}

最后记得注册一下,然后就能成功解析了:

val json = "..."
val moshi = Moshi.Builder().add(SexAdapter()).build()
val person: Person = moshi.adapter(Person::class.java).fromJson(json)

令人发指的灵活👍

肯定有小伙伴好奇,为什么不直接继承某个类或实现某个接口,偏偏用注解的方式定义函数呢?

这一设计一开始确实令人困扰,包括这些函数参数应该填什么、返回值应该是是什么都没有准确的文档。我第一次运行的感觉就是“这居然跑的通?”“它为什么不崩溃呢?”🤣 而这些看似繁杂的设计正是 Moshi Adapter 的灵活性精髓所在。

事实上,@FromJson@ToJson 所注释的函数的参数类型或返回值类型是任意的,只要它能被 Moshi 识别即可! 换句话说你可以把 Adapter 当成一个中间步骤,许多 Adapter 组成处理链将数据一步步转化成所需的类型。以上面的 Demo 为例🌰,我们接受一个 Boolean 类型的参数并返回 Sex(也就是最终所需的类型),那么整个反序列化过程其实有两个 Adapter 依次参与,分别是 JsonAdapter<Boolean>SexAdapter,前者是 Moshi 内置的,后者是我们自定义并添加到 Moshi 实例的。

假如 Json 源使用 Int 表示 Boolean,那么我们可以再写一个 BooleanAdapter,接受 Int 类型参数返回 Boolean,然后再通过 SexAdapter 最终得到 Sex.

当然,你也可以直接在 SexAdapter 中接受 Int 参数并返回 Sex. 但是多一个 Adapter 的优点是若还有其他场合需要用到 Boolean,就可以复用这段逻辑了。

What's more! 这还仅仅是 Adapter 的第一类用法,下面还有更丧心病狂的函数签名供食用😝。

自定义 Adapter 中的委托🤝

假设我们有这样一个 Json 定义:

[
	{
		"type": "person",
		"data": {
			"name": "Bob",
			"age": 23,
			"sex": true
		}
	},
	{
		"type": "job",
		"data": {
			"name": "developer",
			"salary": 20000
		}
	}
]

这是一个数组,每一项都有 typedata 两个字段,讨厌的是根据 type 的不同,data 的类型也不同。对于这种“脏”数据我们可以考虑使用流式 API 手动解析,但若 data 类型很复杂或很多怎么办?

我们想,能不能写一个 Adapter 先判断 type 的值,根据值的不同再选用不同的 Adapter 进一步解析,而这些 Adapter 就可以根据 data class 通过 @JsonClass(generateAdapter = true) 注解自动生成了。

这个需求通过已有方式很难解决,它的核心是 “目标类型是半已知的”

  • 已知:直接使用 Adapter 处理。
  • 未知:使用流式 API 手动解析。
  • 半已知:使用流式 API 解析一部分,然后委托给其他 Adapter 处理。

先来看一下 Adapter 中函数的具体签名要求:

@FromJson:

<any access modifier> R fromJson(T value) throws <any>;
<any access modifier> R fromJson(JsonReader jsonReader) throws <any>;
<any access modifier> R fromJson(JsonReader jsonReader, JsonAdapter<any> delegate, <any more delegates>) throws <any>;

@ToJson:

<any access modifier> R toJson(T value) throws <any>;\n");
<any access modifier> void toJson(JsonWriter writer, T value) throws <any>;
<any access modifier> void toJson(JsonWriter writer, T value, JsonAdapter<any> delegate, <any more delegates>) throws <any>;

上面我们一直使用的是第一个函数签名,即:接受一个任意类型的(已经支持转换的)参数并返回一个任意类型。现在我们要使用第三类,即:接受一个 JsonReader(用于流式解析)以及一系列可能用到的 Adapter(用于委托),然后返回一个任意类型。

首先定义一下数据类:

sealed class Item {
    abstract val name: String
}

@JsonClass(generateAdapter = true)
data class Person(
        override val name: String,
        val age: Int,
		val sex: Boolean
) : Item()

@JsonClass(generateAdapter = true)
data class Job(
        override val name: String,
        val salary: Int
) : Item()

然后就可以写出 Adapter:

class ItemAdapter {

    private val names = JsonReader.Options.of("type", "data")

    @FromJson
    fun fromJson(
            reader: JsonReader,
            person: JsonAdapter<Person>,
            job: JsonAdapter<Job>
    ): Item? {
        reader.beginObject()
        // 解析 type 字段
        // peek() 用于获得一个新的 JsonReader 以便重复解析。
        // 因为我们无法确定 type 和 data 哪一个会先读取到,因此需要利用 peek 先单独取出 type,
        // 然后再开始正式读取。
        var type: String? = null
        val peek = reader.peekJson()
        loop@ while (peek.hasNext()) {
            when (peek.selectName(names)) {
                0 -> {
                    type = peek.nextString()
                    break@loop // 只要找到 type 就行了
                }
                1 -> peek.skipValue()
                else -> {
                    peek.skipName()
                    peek.skipValue()
                }
            }
        }

        // 真正开始解析数据
        var item: Item? = null
        while (reader.hasNext()) {
            when (reader.selectName(names)) {
                0 -> reader.skipValue()
                1 -> when (type) {
                    "person" -> item = person.fromJson(reader)
                    "job" -> item = job.fromJson(reader)
                    else -> reader.skipValue() // 未知 type,跳过
                }
                else -> {
                    reader.skipName()
                    reader.skipValue()
                }
            }
        }
        reader.endObject()
        return item
    }

    @ToJson
    fun toJson(
            writer: JsonWriter,
            value: Item,
            person: JsonAdapter<Person>,
            job: JsonAdapter<Job>
    ) {
        // begin(end)Object 不可以放在 when 外面,否则若遇到不支持的类型会解析出空对象“{}”
        when (value) {
            is Person -> writer.writeItem("person", person, value)
            is Job -> writer.writeItem("job", job, value)
        }
    }

    private fun <T> JsonWriter.writeItem(type: String, adapter: JsonAdapter<T>, data: T) {
        beginObject()
        name("type")
        value(type)
        name("data")
        adapter.toJson(this, data)
        endObject()
    }
}

在这个例子中我们首先通过 JsonReader 手动解析出了 type 字段,然后根据具体的值将后续解析委托给了自动生成的 Adapter. 需要注意的一点是在流式 API 中字段解析的顺序依赖于 Json 字符串,因此有可能先解析出 data,在这种情况下由于缺少 type 我们是无法处理这个数据的(不知道委托给谁)。因此我们要先取出 type 并忽略其他一切字段,但是 Reader 的读取是单向的,如果忽略了先解析出的 data 后续就没法再次取得。于是使用 reader.peekJson() 方法取得一个临时 Reader,它的读取不会影响原先 Reader.

序列化同理,判断具体的数据类型后委托给对应的 Adapter 就行了。

在反序列化时,也许有同学想先临时保存一下 data 的原始数据,在得到 type 后再进行解析,从而避免使用 peekJson()。问题在于 Moshi 是没有中间层的(例如 Gson 中的 JsonElement),要么解析成一个具体的数据类型,要么忽略,所以这个想法不可行。

参考