作者声明:创作过程中使用了AI辅助
搞 Fabric 开发的小伙伴可能都遇到过一个头疼的问题:Minecraft 每个大版本更新,底层代码就像重写了一样。如果你想弄一个一套代码兼容 1.18 到 26.1+ 的模组,还不能用 Mixin,甚至编译的时候连 Minecraft 的 jar 包都引用不了,还怎么玩?
今天咱就来聊聊,在这种极端约束下,怎么纯靠反射搭起一套稳健的多版本兼容体系。
一、为什么非得用反射?
MC 版本更新对底层类的重构可以说是毫不留情。举个例子,跨几个版本你会发现类名、包名全变了:
| 逻辑含义 | 1.18–1.21 (Yarn 映射) | 26.1+ (Mojang 映射) |
|---|---|---|
| 文本组件 | net.minecraft.text.Text | net.minecraft.network.chat.Component |
| 方块坐标 | net.minecraft.util.math.BlockPos | net.minecraft.core.BlockPos |
| 服务端世界 | net.minecraft.server.world.ServerWorld | net.minecraft.server.level.ServerLevel |
| 服务端玩家 | net.minecraft.server.network.ServerPlayerEntity | net.minecraft.server.level.ServerPlayer |
| 命令源 | net.minecraft.server.command.ServerCommandSource | net.minecraft.commands.CommandSourceStack |
有时候我们的开发环境非常受限,比如:
- 编译期压根没有 MC 的环境:你可能在写一个 Bukkit/Fabric 双端通用的插件,主项目是基于 Bukkit API 编译的,没法直接
import net.minecraft.*。 - 没法用 Mixin:只能走 Fabric 暴露出来的公开 API 表面(比如事件、注册表、命令)。
- 单 JAR 走天下:打出来的一个包,得在各种版本的服务器上都能跑。
遇到这种“戴着镣铐跳舞”的情况,反射就成了咱们唯一的救命稻草。
二、别乱写反射,分层是关键
几万行代码如果到处塞满 Class.forName 和 Method.invoke 绝对会让人抓狂。比较靠谱的做法是把代码拆成三层:
这样分层最大的好处是:脏活累活全被常量层扛了。多版本的差异被死死锁在底层,业务代码看着依旧清爽。
三、找类:named 怎么转 intermediary?
Fabric 生产环境把类名改成了 intermediary,但到了 MC 26+,官方又给改回了 named/mojang 名。所以第一步,你得知道现在的运行环境到底用哪套名字。
我们去找一个只在 named 运行时才存在的类:
拿到环境状态后,咱就可以弄个回退机制了。先试 named 名,不行就查表回退到 intermediary 名:
为啥要查表?因为 intermediary 名(class_NNNN)在所有大版本更新时是跨版本稳定的,用它做兜底最合适不过。
四、找方法:参数扫描与别名表
类名搞定了,方法和字段更恶心。同一个方法,在这叫 method_123,到另一个版本可能就换地方了,比如disconnect。解决这个得靠两步走战略。
1. 必须带描述符(descriptor)
方法名解析如果不带描述符,很容易翻车。比如找个 getUUID(),混淆后谁知道返回的是 UUID 还是 String?必须精确匹配:
2. 搞个方法名重定向表 很多逻辑在不同版本不仅混淆名变了,连语义名都变了。我们可以在表里把它们都指到一个标准名上,这样业务层就不用操心了:
五、事件监听怎么破?用动态代理
碰到 Fabric 的事件回调接口(比如 PlayerBlockBreakEvents$After),里面的方法参数全是 MC 原生类,编译期根本没法 import。怎么办?这里有个黑科技:JDK 动态代理。
咱们不直接 implements 这个接口,而是在运行时动态捏一个代理对象传进去:
这招直接斩断了编译期的类型依赖,完美绕过限制!当然,不同版本的参数个数可能不一样,业务层拆包的时候记得判断一下 args.length。
六、坑点:重载方法的匹配陷阱
很多插件或模组的 API 都有重载方法,比如权限检查:一个接 CommandSourceStack,一个接 Entity。
如果你傻乎乎地只缓存第一个拿到的 Method,等运行的时候,传了个 Entity 进去,人家实际上要的是 CommandSourceStack 的那个方法,当场就会给你糊一脸 IllegalArgumentException。
正确的玩法是:把所有重载全找出来缓存好,每次调用前,拿 param[0].isInstance(source) 挨个验一下身:
谁匹配得上就用谁,这样就稳如老狗了。
七、多策略兜底:拿方块 ID 的玄学
在跨版本环境中做操作,最稳妥的方式永远是“多策略兜底”。以获取方块 ID 为例:
创建文本组件也是同理:老版本发消息用 new TextComponent(),新版本这玩意直接没了,变成了 Component.literal()。写兼容方法的时候,先试新 API,抛异常了再试旧 API,确保能在不同版本平滑过渡。
八、榨干性能:缓存与哨兵
都说反射慢,那是没做缓存。把找出来的 Class、Method、Field 全塞进 ConcurrentHashMap 里就行了。
不过要注意,ConcurrentHashMap 里不能存 null。如果某个版本确实没这个方法,每次查找都会失败,每次都会重新走一遍全量扫描。所以咱们得整个 NOT_FOUND 的哨兵对象当占位符:
总结
盘点一下,这套纯反射的兼容玩法其实就几个核心套路:
- 把恶心的版本差异全关进常量层,业务代码该怎么写怎么写。
- 多策略兜底,新 API 走不通走旧 API,旧 API 走不通走字符串硬抠。
- 动态代理大法好,绕开编译期依赖全靠它。
- 遇到重载方法,用
isInstance动态匹配,别死磕一个。 - 缓存 + 占位符,把反射的性能开销降到最低。
这套体系搭下来,哪怕编译期连一个 MC 的原生类都见不到,一样能把 Fabric 的多版本兼容拿捏得死死的。虽然前期写起来比直接用 Mixin 折腾点,但在特定场景下(比如单 JAR 双端通吃),这确实是最灵活的方案。
希望能帮大家少掉几根头发!