thumbnail
Contexts MVVM 实现中的挑战
由 ChatGPT 生成的文章摘要
这篇文章介绍了在JVM上实现MVVM框架——Contexts Reactions的过程及其挑战。作者希望通过使用响应式编程模型来管理视图和数据之间的绑定,类似于Vue或React。文章首先展示了如何通过`MutableReactive`创建可监听的响应式值,并且讲解了如何将这些值与视图函数绑定,当响应式值发生变化时,框架会自动更新视图。 接着,文章讨论了如何应对复杂的响应式值(如嵌套对象)所带来的挑战,尤其是如何判断是否需要重新渲染视图。为了实现这一点,框架需要记录视图函数依赖的值的行为,并在值变化时重新执行相关视图函数。具体来说,当视图函数只读取某个值时,框架会缓存第一次读取的结果,如果函数执行时有变化,则重新执行该视图函数。 文章还深入探讨了如何通过动态字节码生成技术代理对象,以便记录函数调用和依赖的变化,确保只有在必要时才重构视图状态。这种方法虽然有效,但也存在一些限制,尤其是在处理复杂的对象依赖时可能无法完美覆盖所有情况。

概述

最近想要写一个类似于 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 类的代理,调用这个代理类型的任何方法都会产生记录,同时:

  1. 如果这个方法的编译时返回类型是一个可被代理的类,我们返回结果值的代理,以便检测是否调用了结果的函数。例如,configuration.games["id"] 返回的是 Game 的代理,这样我们就能知道后续还调用了 name
  2. 否则,我们只能缓存返回的结果,而不能进一步对后续依赖了 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

file

除非我们使用 Any 来接这个获取出来的值(因为 toString() 也被代理了,所以看起来右侧的 first 的提示是一个 Game,然而它并没有实现这个接口):

file

但这会让我们倒退回 Java 8 之前没有泛型的时代。没泛型几十年了,是时候了,不能再等了(x

读者可能疑惑为什么我们不生成一个实际类型的代理(比如此处可能是 GameImpl),这是因为 Kotlin 默认一般类型都是最终类,除非在定义类型前加上 open,这导致我们无法生成代理。这是一个好设计,JVM 运行时对最终类的调用有优化(@404E)。

实际上我们需要推导出这里应该返回 Game? 而不是类型擦除后的 Any?。JVM 并不会完全擦除泛型,通过反射我们可以获悉此处返回的是 V,然后我们查看 Map 的泛型参数定义便能知道它对应第二个泛型实参,随后从 Configurationgames 属性类型 Map<String, Game> 内就能解析出实际类型了。

为了实现这个目标,我写了一个 Kotlin 类型解析器。它可以实现类型参数解析和计算,支持从外部类中提取。这是几个例子:

  1. 泛型实参提取:给定 String,求它实现的 Comparable<in T> 里的 T 的实际类型(即 String)。
  2. 泛型实参推导:给定下图红框里的类型 Foo3<Float>.Foo3.Foo3<Double, String>,求最内侧 foo 函数的参数 t 的类型(即黄框的部分,是 Map<Map<String, List<Map<Double, String>>>, String>)。
  3. 类型赋值计算(类似于 Class#isAssignableFrom,支持协变逆变)、空安全支持等。

file

真是非常地好啊(赞赏)。

有了这一工具,我们只需要推导函数编译时的返回类型,然后生成这个类型的代理即可。下图是我们成功推导出返回 Game 类型的截图。

file

确定函数调用的结果是否变化

这难道也有坑吗?确实,这是因为如果函数返回的是同一个可变对象,我们很难知道它的内部状态是否变化。看起来我们可以用 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 这一类:

file

所以在解析 MutableMap 类型时,我们没有解析 MutableMapMap 多出来的那部分函数,自然也没有推导对应的返回类型。遇到这种情况我们只能运行时用 Kotlin TypeResolver 进行一次推导。

file

file

这就是 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> 等等。

Not by AI

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇