使用纯血C#开发Minecraft Java版mod的劝退指南
这可能是一个长期计划,也可能是一个大饼,也可能是摆烂前的最后一篇文章
因为我大概率坚持不下去了,目前来说C#开发Minecraft Mod只是噱头多一点,要解决的问题是相当多的,所以我把目前我踩过的坑以及我的解决方案贴出来
但是工作量实在是太大,再加上我觉得再把时间花在这上面可能效果还不如Java直接写mod,如果真有人看完我胡扯这一堆能做出来我觉得也算牛逼了哈哈
事先提醒:工作量真的很大,付出与回报不成正比,这篇文章里的坑我踩了两年,纯Java写mod我一个月就写完了
场外知识:Java.Interop
Java.Interop是.NET for Android项目的子项目,原来的地址在https://github.com/dotnet/java-interop ,现在归档然后并入https://github.com/dotnet/android 仓库了,可以在external文件夹里看到
Java.Interop虽然说是独立设计的但是官方没有教程和文档啊,法克,我读源代码读的脑子快炸了
我也曾经尝试借助Java.Interop直接起飞一波,后来失败了,Android目前最高支持JDK才17,现在Java版都25了,而且这个库相当死板,具体感兴趣的可以尝试自己读源码,千万别问我
这个库能给人引入一万倍的工作量原因就在于它的映射元数据是需要人微调的,一旦依赖不全所有的相关类啊方法啊最后全都会被删了,最后看着十多个jar包产出的.cs文件相比Java源文件少了一半你就想想多酸爽吧
Java.Interop归档之前还把以前尝试在桌面运行的样例和依赖代码删了,只给Android用了,老铁们这谁绷得住
从纯血C#谈起
纯血C#意味着有.NET运行时,而目前对我来说唯一能接受的互操作方案就是C#使用JNI调用Java端,Java端使用native函数反向调用C#对象(我们需要JNI的ObjectRef,所以没有用FFM API)
也想过用网络搞互操作,基于延迟等等的问题最终不打算考虑
.NET运行时
目前的两套方案,一套是JVM宿主,C#使用NativeAOT编译成本机库,通过一个工具mod以本机库方式加载C#,我的实现是在NativeLoader中
另一套是.NET宿主,使用JNI_CreateJavaVM直接加载jvm.dll启动JVM进程,这一套不依赖NativeAOT,用起来更爽(改启动器参数和加个mod比起来不太爽,但是对开发很爽),不过我没时间做
不管怎么样,跨进程边界是绝对不行的,不然我们的互操作没得搞了
互操作
类型映射
C#想操作Java对象还要写的爽,重点就在这个类型映射上,将Java对象映射为C#对象,C#对象通过JNI持有Java对象的全局引用,对C#对象的操作都会反映到Java对象上
C#调用Java
C#调用Java是最简单的,把那个样板代码一贴然后JNI调调调就完事了
其实这个样板代码是工作量最大的,你得解析jar依赖,把所有的Java类的API映射到C#类,我之前用的是源生成器,现在想了想可能写个命令行工具更适合
(封装JNI也是工作量哦😇)
Java调用C#
Java调用C#靠的是Java的native函数,对于JVM宿主的情况下C#方可以通过直接匹配Java的native函数符号(通过UnmanagedCallersOnly导出函数),也可以用JNI直接注册函数
.NET宿主的情况下只能用函数指针加动态注册了(因为不是以本机库方式被加载的),本质还是依赖UnmanagedCallersOnly
冷知识,如果没法开启不安全代码使用&取函数指针的话,可以用反射中的MethodInfo.MethodHandle.GetFunctionPointer()方法,效果跟&取指针一样(你不会没加UnmanagedCallersOnly吧)
对象生存期、GC互操作(坑)
这个是纯坑了,我们分两个情况讲
C#直接创建Java类对象
这个情况指的是用户代码没有继承Java类的C#映射类型,比如我直接var obj = new Java.Lang.Object(); Console.WriteLine(obj.ToString());
这个情况下Java端不会反向调用C#方法,所以我们直接JNI持有Java对象的全局引用,使用IDisposable搭配终结器Finalizer确保释放全局引用,这样Java对象的生命周期肯定长于C#对象,C#对象被回收之后Java对象就能被正常回收了,皆大欢喜
C#继承Java类(大坑)
一旦我们继承了Java类就能提供自定义逻辑,这个时候我们就得提供Java调用C#的能力,即使C#对象在用户眼里已经是不可达了我们也得保证能访问到C#对象,所以我们用一个静态字典把C#对象存进去,这个字典可以是全局唯一的也可以是类相关的
同样的我们需要提供一个Java类来将正常的Java调用转发到C#侧,也就是说得生成C#类的Java映射类,这个我们可以用字节码操作工具比如IKVM.ByteCode提前生成.class,也可以用ByteBuddy动态生成,更有甚者(?这词对吗)是生成的.java源文件然后在MSBuild构建流程中用javac现场编译的,(我说的就是Java.Interop)
坑中坑来了,我们的C#对象存活期间就有可能调用Java方法,而Java侧也有可能调用C#对象的方法,我们死循环了
这个时候可以学习Java.Interop的ManagedValueManager(已被删),直接字典强引用C#对象,我鸟都不鸟你,要么用户自己删要么就泄露吧!
也可以试试我今天刚想出来的一个方法:(非常危险)
-
将C#对象存放的字典改成存放WeakReference对象,将C#对象放进WeakReference对象中
-
假设Java侧不再使用Java对象,Java对象只剩JNI的全局引用,C#侧也不再使用C#对象,C#侧只剩WeakReference,这个时候GC尝试回收,调用终结器
-
在C#的终结器中把本地对象存储的JNI全局引用改为弱全局引用,释放原先的全局引用,同时强制Java侧进行GC
-
尝试从弱全局引用重新建立全局引用,如果返回NULL,说明Java对象已被回收,C#对象正常释放
-
如果全局引用建立成功,说明Java对象未被回收,重新建立C#对象的强引用,调用GC.ReRegisterForFinalize重新注册终结器,换句话说就是复活C#对象
可能的示例代码:
// TIndex没想好是啥,反正是能够访问到C#对象的索引
// TJavaObject是继承自Java类的自定义C#类
static Dictionary<TIndex, WeakReference<TJavaObject>> _instances = new();
static void AddPeer(TIndex index, TJavaObject obj)
{
_instances.Add(index, new(obj, true)); // 建立长弱引用
}
~JavaObject()
{
var weakRef = JNI.NewWeakGlobalRef(this._objectRef);
JNI.DeleteGlobalRef(this._objectRef);
this._objectRef = default;
Java.Lang.System.Gc();
if (JNI.NewGlobalRef(weakRef) is { IsValid: true } globalRef) // 非空
{
this._objectRef = globalRef;
// 建立强引用让C#对象复活
GC.ReRegisterForFinalize(this);
}
}
总之,要么必须程序员手动控制对象的释放,要么依靠弱引用但弱引用会带来不安全性(调用的对象已释放,要么像我这种例子,复活对象,但好像更不安全有感觉吗),要么直接摆了
恭喜你已经把最大的坑熬过去了,剩下的都是小坑(也是坑)
类型管理和值管理
顾名思义,你得知道JNI传过来引用的实际类型是什么,不然只知道一个父类或者只知道一个Java.Lang.Object想想就难受
而且你最好还得使用值管理器,不然到时候两个C#对象映射同一个Java对象想想也难受
这两个属于小坑了,Dictionary往里豪就行
Lambda表达式和函数式接口
函数式接口肯定是Java对象,而C#的Lambda表达式是不支持这种自定义类作为目标类型的,所以Java.Lang.Runnable runnable = () => {};这种100%没戏
最好的情况下就是C#在生成方法的样板代码的时候顺手生成一份使用C#委托的版本,以前我的实现就是这么干的,通过Java的InvocationHandler来实现委托到函数式接口,而且因为我在委托里面不存Java的对象引用,我在字典里面只用存委托,这种情况下Java对象先释放C#对象后释放,所以我用Java的Cleaner通知Java对象的释放并把C#的委托移出字典,但可惜对更复杂的情况就没用了
当然也可以实现函数式接口,同时继承Java.Lang.Object,但是这不会更麻烦吗
泛型类
我讲道理,我肯定喜欢具体泛型类,Java.Interop的策略是把所有泛型全改成Java.Lang.Object,如果用具体泛型类的话肯定会出现多个C#对象映射同一个Java对象,而且Java的上下界通配符和C#的泛型有语义不重叠的地方,肯定要舍弃掉什么(现在知道全改成Java.Lang.Object的好了吧)
混淆与反混淆
我当时写的1.16.5和1.20.4,这两个版本还没有取消混淆,当时能力也不强,所以采用的是运行时通过Fabric的API动态获取yarn映射的实际JVM签名,性能肯定差一点,现在想想应该用MSBuild在构建的时候就试试获取yarn映射名,不过现在MC取消混淆了,没有这个坑了
Mixin
通过Mixin修改原游戏逻辑是一个相当重要的功能以及非常普遍的需求,然而mixin是在mod中提前通过json注册的,如果用NativeLoader的话肯定是不可能了,如果是.NET宿主的话应该还有机会,但是我没精力试了
调试
我目前只成功跑起来过JVM当宿主加NativeAOT驱动的项目,我讲道理调试体验还行,C#端抛出异常会直接往上弹,起码堆栈追踪挺清楚的
反编译看Minecraft源码
老铁,你连jar都没有你咋看源码啊
综上,基本上就是C#写MC Mod遇到的所有坑了,我的牢项目随时可以来我GitHub首页看,当然我觉得用处也不大
C#写MC MOD就是坑特别多,成本特别大,成果又很少,整个C#和Java只能说看起来比较像,搞起互操作来能把人折磨的不轻,C#的爽还没享受多少,苦倒是受了不少
我就写到这里了,后面可能不会再搞这个相关的东西了,如果这篇文章能帮到你,我会觉得我这两年青春没白费

浙公网安备 33010602011771号