概述
最近想要写一个类似于 Vue 或 React 那样的,但是在 JVM 上运行的 MVVN 框架,即 Contexts Reactions。我的想法大概是这样的:
// 通过 MutableReactive<T> 构造一个响应式的,可以监听变化的值。
var debug by reactive(false)
@View
fun ReactionManager.displayDebugInfo() {
println("Current debug value: $debug")
}
因为我们在 displayDebugInfo
函数内使用了 debug
这个响应式值,所以框架会自动把这个视图(即带 @View
注解的函数)和 debug
绑定在一起。当 debug
的值在视图之外的地方修改,框架会自动检测哪些视图函数需要重新执行,并销毁它们此前产生的资源,随后重新执行它们并记录新的依赖关系。
对于简单的情况,例如响应式值是一些最终类,这种变化的监听容易实现。因为我们只需要简单地比较新旧值是否相同(Any#equals
)即可。然而,很多时候我们的响应式值是复杂的结构体,这带来了很多挑战。这篇文章便对这些坑做简要记录。
成果
一会儿 debug 完了再放。
挑战
判断是否需要重构状态
Contexts 的上下文有未知的副作用,例如创建或销毁 Bukkit 任务,所以我们不能轻易地重构整个视图,也不能实现类似于虚拟 DOM 比对和剪枝之类的优化(Contexts 里也没有类似虚拟 DOM 的概念)。我们只能尽可能缩小重建状态的范围。
于是,这个问题就变成了如何知道我是否需要调用一个视图函数(即那些带有 @View
的函数)。我们认为视图函数是幂等的,这意味着对于它依赖的那些响应式值,如果这些值所表现出来的行为没有发生变化,即不需要重新调用。
这些值所表现出来的行为是什么呢?不难想到,其实就是函数调用的结果。具体地,当视图函数只是读取某个值(后面简写为“Reactive 读”)时,我们缓存它第一次读取到的结果;如果还调用了视图对象的函数,我们记录每一次调用的参数和结果。当我们需要检测是否需要调用这个视图函数时,只需要把它调用过的那些函数再调用一遍看看结果是否变化即可。
不妨看看下面的例子:
interface Game {
var name: String
}
interface Configuration {
var debug: Boolean
var games: MutableMap<String, Game>
}
此时如果我们想显示一个游戏的名字,我们可能会编写下面的代码:
var configuration by createLazyInitMutableReactive<Configuration>()
@View
fun Context.displayGameName() {
val game = configuration.games["id"]
if (game != null) {
println("Game name: ${game.name}")
} else {
println("Game not found!")
}
}
假设存在键为 "id"
的游戏,则此次调用时,视图函数和 configuration.games["id"].name
的结果绑定。当我们检测是否需要重构 displayGameName
时,我们只需要再调用一遍这个表达式,看看它的结果是否变化即可。
一般情况下,我们确实无法知道一个函数调用了哪些方法。但是借助动态字节码生成技术,我们可以在读取 configuration
时生成一个 Configuration
类的代理,调用这个代理类型的任何方法都会产生记录,同时:
- 如果这个方法的编译时返回类型是一个可被代理的类,我们返回结果值的代理,以便检测是否调用了结果的函数。例如,
configuration.games["id"]
返回的是Game
的代理,这样我们就能知道后续还调用了name
。 - 否则,我们只能缓存返回的结果,而不能进一步对后续依赖了
Game
的哪些属性做分析。一旦两次获取到的Game
不同(用Any#equals
检测),我们就认为需要刷新。
这确实是有缺陷的,因为有开发者预期之外的函数调用。然而这一般没有问题,因为应该没人在 @View
里写逻辑。如果只是读取属性的话,多读几次没事的。
检测值变化
检测值变化难道也是挑战吗?确实是的。如果视图之外的函数调用了 configuration.games["id"] = newGame
,尽管 configuration
的值确实发生了变化,但因为这种变化不是通过 configuration = newConfiguration
这样直接调用代理的 setValue
实现的,导致我们无法监听到。
所以,我们不应只是在 MutableReactive.setValue
时检测刷新,而应该在响应对象的任何方法调用之后检测是否需要刷新。这听起来让人害怕,但确实如此,否则我们便不能检测 configuration.games.clear()
之类的动作。
容易想到我们仍然通过代理实现这一操作,这里会出现新的坑。
生成用于记录函数调用的代理
此处遇到的主要困难是类型擦除。我们知道,Map<K, V>
这种类型(Type)只是编译时存在的,运行时会被擦除为 Map
这一类(Class)。它的函数也是类似的,例如我们刚才使用到的 Map.get(k: Key): V
,在编译后会变成类似这样的代码:
final Game game = (Game)configuration.getGames().get("id");
Java 编译器帮我们插入了类型转换,运行时反射获取 Map.get
的返回类型时,获取到的是泛型参数 V
的最小上界,即 Any?
。所以我们会生成一个代理 Any
内函数的对象,然后 JVM 拼尽全力无法转换,最终 ClassCastException
。
除非我们使用 Any
来接这个获取出来的值(因为 toString()
也被代理了,所以看起来右侧的 first
的提示是一个 Game
,然而它并没有实现这个接口):
但这会让我们倒退回 Java 8 之前没有泛型的时代。没泛型几十年了,是时候了,不能再等了(x
读者可能疑惑为什么我们不生成一个实际类型的代理(比如此处可能是 GameImpl
),这是因为 Kotlin 默认一般类型都是最终类,除非在定义类型前加上 open
,这导致我们无法生成代理。这是一个好设计,JVM 运行时对最终类的调用有优化(@404E)。
实际上我们需要推导出这里应该返回 Game?
而不是类型擦除后的 Any?
。JVM 并不会完全擦除泛型,通过反射我们可以获悉此处返回的是 V
,然后我们查看 Map
的泛型参数定义便能知道它对应第二个泛型实参,随后从 Configuration
的 games
属性类型 Map<String, Game>
内就能解析出实际类型了。
为了实现这个目标,我写了一个 Kotlin 类型解析器。它可以实现类型参数解析和计算,支持从外部类中提取。这是几个例子:
- 泛型实参提取:给定
String
,求它实现的Comparable<in T>
里的T
的实际类型(即String
)。 - 泛型实参推导:给定下图红框里的类型
Foo3<Float>.Foo3.Foo3<Double, String>
,求最内侧foo
函数的参数t
的类型(即黄框的部分,是Map<Map<String, List<Map<Double, String>>>, String>
)。 - 类型赋值计算(类似于
Class#isAssignableFrom
,支持协变逆变)、空安全支持等。
真是非常地好啊(赞赏)。
有了这一工具,我们只需要推导函数编译时的返回类型,然后生成这个类型的代理即可。下图是我们成功推导出返回 Game
类型的截图。
确定函数调用的结果是否变化
这难道也有坑吗?确实,这是因为如果函数返回的是同一个可变对象,我们很难知道它的内部状态是否变化。看起来我们可以用 oldResult == newResult
检查,但是因为 Any#equals
实现要求 this == this
,所以它永远返回 true
。
这种现象还很常见,例如上面的 getGames()
返回的就是同一个 MutableMap
。也许我们可以尝试每次使用 clone()
,并对不支持深拷贝的对象抛出异常,但我们很快就会意识到 Boolean
之类的基本数据类型没有实现 Clonable
,难道我们的框架连基本数据类型都不能支持吗?
实际上,在不能复制对象时,我们可以缓存两次结果的 hashCode()
。如果前后两次结果的哈希值不同,那对象内部状态肯定发生了变化——这很符合 Any#equals
的规约。情况看起来有所缓解,但哈希值不同并不能说明对象内部没有变化,该怎么办呢?
其实没什么好办法,只能把这个锅甩给开发者。如果一个对象既不能复制,哈希值又没变化,那我们只能认为它没变。
Kotlin 带来的问题
可变容器支持
尽管这看起来似乎并不困难,但是只要看到下图便会意识到它的困难:MutableMap<K, V>
和 Map<K, V>
的类型对应的 classifier
都指向 Map
,而 Map::class.memberFunctions
里没有 MutableMap
的函数。
这是因为在编译后,实际上没有 MutableMap
这一类:
所以在解析 MutableMap
类型时,我们没有解析 MutableMap
比 Map
多出来的那部分函数,自然也没有推导对应的返回类型。遇到这种情况我们只能运行时用 Kotlin TypeResolver 进行一次推导。
这就是 Kotlin Mutable 带给我的自卑。
Kotlin 语言带来的问题
今天注意到如果代理 CharSequence
,在尝试分析 CharSequence.get(index: Int): Char
时,调用 method.kotlinFunction
会抛出 KotlinReflectionInternalError
:
kotlin.reflect.jvm.internal.KotlinReflectionInternalError: Could not compute caller for function: public abstract operator fun get(index: kotlin.Int): kotlin.Char defined in kotlin.CharSequence[DeserializedSimpleFunctionDescriptor@3c5dbdf8] (member = null)
at kotlin.reflect.jvm.internal.KFunctionImpl$caller$2.invoke(KFunctionImpl.kt:88)
at kotlin.reflect.jvm.internal.KFunctionImpl$caller$2.invoke(KFunctionImpl.kt:61)
at kotlin.reflect.jvm.internal.ReflectProperties$LazyVal.invoke(ReflectProperties.java:63)
at kotlin.reflect.jvm.internal.ReflectProperties$Val.getValue(ReflectProperties.java:32)
at kotlin.reflect.jvm.internal.KFunctionImpl.getCaller(KFunctionImpl.kt:61)
at kotlin.reflect.jvm.ReflectJvmMapping.getJavaMethod(ReflectJvmMapping.kt:63)
at kotlin.reflect.jvm.ReflectJvmMapping.getKotlinFunction(ReflectJvmMapping.kt:136)
看起来像某种 bug。其中的 KotlinReflectionInternalError
还是外部无法访问的,只能写类似这样的丑陋代码:
private val Method.rawFunctionOrNull: KFunction<*>?
get() = try { kotlinFunction } catch (e: Error) {
checkKotlinReflectionInternalError(e)
null
}
private fun checkKotlinReflectionInternalError(e: Throwable) {
if (e::class.qualifiedName != "kotlin.reflect.jvm.internal.KotlinReflectionInternalError") {
throw e
}
}
后记
这里记录了几个我觉得比较有代表性的坑,实际上的坑远比这个多。例如,解析 String
时,它实现了 Comparable<String>
,这会导致递归解析至爆栈。所以只能先使用解析到一半的 ResolvableType<String>
等等。