Moshi处理Null值并应用默认值的实用解决方案
时间:2026-08-21 | 作者:深海捕梦者 | 阅读:0先看问题
对于Json中的Null值,常规处理方式是给对象加一个号。
{
"str": null,
"i":"5"
}
data class Test(
val str: String = "abc", // 必须加问号,否则会崩溃
val i: Int = 1,
)
这样会有一个问题:str最终会被赋值为null。
但我们真正期望的是:当值为null时,使用默认值abc。
这一点做不到。除非后台不返回str这个字段,才会走默认值。
激烈讨论过程
对于null问题的理解,在Moshi Github上有非常激烈的讨论。
总结一下,官方观点是:后台返回 null 其实是业务的一种表现形式,代表该业务不存在。
所以在团队协作中,应当约定一个规范的 Json 格式行为,而不是在所有情况下都兜底 null 值。
官方最终的解释:
It has one answer in Moshi and this is Moshi's opinion :).
答案只有一个,那就是 Moshi 的看法:)。
好好好,Moshi团队你有你的看法。
但是理想很美好,现实不允许。先不说后台大哥好不好说话,就说历史API接口,没人敢动,也没人想动。
解决方案
在Github的讨论中,有各种各样的解决方案。比如JakeWharton大神给出的自定义注解解决方案。
但这个方案用起来很难。我不可能每个字段都去加。
在最后的回复里,我看到了一个很开脑洞的方案,确实巧妙。
核心思路:自行定义JsonReader,在读取到 null 字段时,直接将其忽略,给上层造成该字段不存在的假象。
这样一来,上层就会调用默认数值构造函数。
这一招的好处是:无需添加任何自定义注解,就能融入现有项目。
首先自定义 DefaultIfNullFactory
它用于向moshi注册解析器,属于常规步骤,这里不过多展开。
class DefaultIfNullFactory : JsonAdapter.Factory {
override fun create(
type: Type,
annotations: MutableSet<out Annotation>,
moshi: Moshi
): JsonAdapter<*> {
if (annotations.isNotEmpty()) return null
// 基础数据类型忽略,moshi自己处理,就是直接是 基础数据类型的json:例如“1”,“true”
if (type === String::class.ja va) return null
if (type === Boolean::class.ja vaPrimitiveType) return null
if (type === Byte::class.ja vaPrimitiveType) return null
if (type === Char::class.ja vaPrimitiveType) return null
if (type === Double::class.ja vaPrimitiveType) return null
if (type === Float::class.ja vaPrimitiveType) return null
if (type === Int::class.ja vaPrimitiveType) return null
if (type === Long::class.ja vaPrimitiveType) return null
if (type === Short::class.ja vaPrimitiveType) return null
if (type === Boolean::class.ja va) return null
if (type === Byte::class.ja va) return null
if (type === Char::class.ja va) return null
if (type === Double::class.ja va) return null
if (type === Float::class.ja va) return null
if (type === Int::class.ja va) return null
if (type === Long::class.ja va) return null
if (type === Short::class.ja va) return null
val delegate = moshi.nextAdapter(this, type, annotations)
// 对象类型的走我们自己的,例如打括号包起来的:"{}"
return SkipNullsAdapter(delegate)
}
}
再看 SkipNullsAdapter
SkipNullsAdapter 本身没有太多复杂逻辑。
它的作用是:如果当前读取的是对象结构,就用我们自己的 JsonReader 包一层。
private class SkipNullsAdapter(
private val delegate: JsonAdapter
) : JsonAdapter() {
override fun fromJson(reader: JsonReader): Any {
// 如果是对象结构,才包装 reader 跳过 null
return if (reader.peek() == JsonReader.Token.BEGIN_OBJECT) {
// 使用自己的 reader
delegate.fromJson(JsonReaderSkipNullValuesWrapper(reader))
} else {
delegate.fromJson(reader)
}
}
override fun toJson(writer: JsonWriter, value: Any) {
delegate.toJson(writer, value)
}
}
欺骗的艺术来了
这里是整个方案最关键的地方。
需要自己新建一个这个com.squareup.moshi包名(路径名)下的类。因为JsonReader 不允许外部继承。
所以我们得假装自己是它包里的类。
然后自定义一个JsonReaderSkipNullValuesWrapper。
class JsonReaderSkipNullValuesWrapper(
private val wrapped: JsonReader
) : JsonReader() {
private var ignoreSkipName = false
private var ignoreSkipValue = false
override fun close() {
wrapped.close()
}
override fun beginArray() {
wrapped.beginArray()
}
override fun endArray() {
wrapped.endArray()
}
override fun beginObject() {
wrapped.beginObject()
}
override fun endObject() {
wrapped.endObject()
ignoreSkipName = false
ignoreSkipValue = false
}
override fun hasNext(): Boolean {
return wrapped.hasNext()
}
override fun peek(): Token {
return wrapped.peek()
}
override fun nextName(): String {
return wrapped.nextName()
}
override fun selectName(options: Options): Int {
val index = wrapped.selectName(options)
// 这里进行欺骗,返回-1表示没有这个字段
return if (index >= 0 && wrapped.peek() == Token.NULL) {
wrapped.skipValue()
ignoreSkipName = true
ignoreSkipValue = true
-1
} else {
index
}
}
override fun skipName() {
if (ignoreSkipName) {
ignoreSkipName = false
return
}
wrapped.skipName()
}
override fun nextString(): String {
return wrapped.nextString()
}
override fun selectString(options: Options): Int {
return wrapped.selectString(options)
}
override fun nextBoolean(): Boolean {
return wrapped.nextBoolean()
}
override fun nextNull(): T {
return wrapped.nextNull()
}
override fun nextDouble(): Double {
return wrapped.nextDouble()
}
override fun nextLong(): Long {
return wrapped.nextLong()
}
override fun nextInt(): Int {
return wrapped.nextInt()
}
override fun nextSource(): BufferedSource {
return wrapped.nextSource()
}
override fun skipValue() {
if (ignoreSkipValue) {
ignoreSkipValue = false
return
}
wrapped.skipValue()
}
override fun peekJson(): JsonReader {
return wrapped.peekJson()
}
override fun promoteNameToValue() {
wrapped.promoteNameToValue()
}
}
使用
val moshi = Moshi.Builder()
.add(DefaultIfNullFactory()) // 注册进来
.build()
结果验证
Json
{
"list": null,
"str": null,
"i":"5"
}
data class
data class Test(
val list: List<Int> = null, // 如果想保持为null,就加上 问号
val str: String = "abc", // 不想要 null,就不要加问号
val i: Int = 1,
)
输出
Test(list=null, str=abc, i=5)
解释下(AI生成的,我改了改。但是我觉得解释的足够清楚了)
工作原理
这个类最核心的魔法发生在 selectName() 方法中。
- 第一步:调用被包装的
JsonReader的selectName()方法,查找下一个字段。 - 第二步:如果找到了匹配字段(
index >= 0),立刻用peek()查看该字段的值。 - 第三步:如果值是
Token.NULL,就执行下面这几个动作:- 调用
skipValue(),先把这个null消耗掉。 - 把
ignoreSkipName和ignoreSkipValue标志位设为true。 - 向调用者(KSP Adapter)谎称 没有这个字段,返回
-1。
- 调用
- 第四步:KSP生成的Adapter收到
-1后,会认为这是未知字段,并进入case -1分支。 - 这个分支会尝试调用
skipName()和skipValue(),同时使用属性默认值。 - 第五步:我们重写的
skipName()和skipValue()会检查对应的ignore...标志位。 - 如果标志位为
true,它们就直接返回,从而屏蔽KSP Adapter多余的跳过操作,避免在错误的流位置上操作导致异常。
结果
通过这套流程,值为null的字段会被巧妙地从解析流中移除。
这样,Moshi后续处理时就会认为该字段在JSON中是“缺失”的。
Kotlin 的 data class 中对应的非空属性 必须 要提供默认值,例如 val name: String = ""。
这样Moshi才会使用这个默认值。否则会导致异常。
状态管理
ignoreSkipName和ignoreSkipValue是处理这种“欺骗”行为所必需的状态。- 它们的作用是:在
selectName手动跳过一个键值对后,正确拦截并忽略Adapter后续多余的skip调用。 - 在
endObject()方法中,这些状态会被强制重置。 - 这是一个防御性措施。它能确保一个对象解析作用域结束时,所有临时状态都被清理干净,避免状态泄露并污染外层对象解析。
Bingo
巧妙的思路。
免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。
相关文章
更多-
- Java 泛型不变性引发的类型转换问题及解决方案
- 时间:2026-08-24
-
- 每日互动AITA智能营销全新升级打造实用级AI营销解决方案
- 时间:2026-08-21
-
- ComfyUI影视级写实AI短片制作教程:分镜渲染与时序稳定解决方案
- 时间:2026-08-21
-
- Java List接口add方法用法及常见插入异常解决方案
- 时间:2026-08-20
-
- 得一微电子AI-MemoryX存力解决方案亮相FMS 2026大会
- 时间:2026-08-20
-
- Git stash pop冲突后安全回退并切回原分支方法
- 时间:2026-08-20
-
- 知达AI全学段教育大模型:18万用户验证的教学智能化方案
- 时间:2026-08-18
-
- C#中ASCII码转换方法与常见问题解决方案
- 时间:2026-08-18
精选合集
更多大家都在玩
大家都在看
更多-
- 糖尿病完全不能吃糖吗
- 时间:2026-09-15
-
- 蚂蚁庄园小课堂2026年9月16日最新题目答案
- 时间:2026-09-15
-
- 小鸡答题今天的答案是什么2026年9月16日
- 时间:2026-09-15
-
- 蚂蚁庄园每日答题答案2026年9月16日
- 时间:2026-09-15
-
- 以下哪种粮食是酿造绍兴黄酒的主要原料 蚂蚁庄园今日答案9月16日
- 时间:2026-09-15
-
- 劝学名句“及时当勉励,岁月不待人”出自哪位诗人 蚂蚁庄园今日答案9.16
- 时间:2026-09-15
-
- 蚂蚁庄园今天答题答案2026年9月16日
- 时间:2026-09-15
-
- 蚂蚁庄园答题今日答案2026年9月16日
- 时间:2026-09-15
