类隔离机制
原版模组的问题
Section titled “原版模组的问题”Mindustry 原版其实有基础的类加载隔离:每个 Java 类模组(jar/zip/目录)都由一个独立的类加载器加载(Mods.loadMod() 里对每个模组执行 platform.loadJar(...) 并 mainLoader.addChild(loader)),桌面端默认实现是 child-first——模组自己 JAR 里的类优先从自己的 JAR 加载。游戏本体类也始终由游戏自身的类加载器提供:把 mindustry.* 打进模组 JAR 的模组,其主类的 Mod/Plugin 超类会来自模组自己的加载器,Mods 检测到后直接拒绝加载,并提示不要打包 Mindustry 依赖。
但原版的隔离只到”每个模组一个加载器”这一层,类可见性完全不受控:所有模组的加载器都注册在同一个共享的 ModClassLoader(mainLoader)之下,它的 findClass() 在游戏类加载器找不到类时,会按加载顺序遍历全部模组加载器查找。由此带来两个经典问题:
-
同名类冲突: 两个模组各自打包了同名类(例如都内置了某个第三方库的不同版本,或都写了
util.Helper)时,由于 child-first,双方在各自内部用的是自己 JAR 里的那份——但它们是两个互不相同的Class对象。一旦实例经由游戏 API 跨模组传递,instanceof或强转就会抛ClassCastException,静态状态也不共享;而引用自己没有打包的类时,解析结果完全由加载顺序决定:先加载的模组里的实现生效,后加载者只能被动接受,对方更新或移除后便莫名崩溃。模组作者只能靠改名、避免同名来规避,治标不治本。 -
依赖泄露: 任何模组都能解析到任何其他模组的类——自己 JAR 里没有的类,会沿着共享加载器链在别的模组里找到,即使没有声明任何依赖。
mod.json的dependencies/softDependencies只用来决定加载顺序(resolveDependencies()拓扑排序)与缺失依赖检测,与类可见性无关。后果有两个方向:一方面,你的模组可能在不知情时用上了另一个模组的内部类,一旦对方更新或移除,你的模组就莫名其妙崩溃;另一方面,你想隐藏的内部实现也根本藏不住,别人随时可以引用。依赖关系形同虚设。
Copper 的解法
Section titled “Copper 的解法”Copper 把原版”加载器级”的浅隔离升级为可见性级的完整隔离:每个模组、游戏本体、加载器、每个原版 Mindustry 模组都是一个独立的容器,拥有自己的类加载器、资源与规则。容器之间查找类只沿显式声明的依赖边进行,没有依赖边就没有任何可见性。因此默认情况下,一个 Copper 模组看不到任何其他模组的类。类要想被看见,必须满足三条显式规则:
- 依赖声明:声明依赖,建立访问通道;
- 导出规则:被依赖方把类”公布”出来(
exports); - 导入规则:访问方针对某个依赖额外放行(
imports)。
三条规则环环相扣,下面逐一展开。
容器是”墙围起来的类空间”。声明依赖就是把两堵墙之间打开一扇门——但门后能看见什么,由对方的导出规则决定。
每个模组容器自动获得以下依赖,无需声明:
| 依赖 | 内容 |
|---|---|
| 核心模组 | Copper 核心模组(copper.core.* API) |
mindustry | 游戏本体全部类 |
loader | 加载器全部类 |
也就是说,任何 Copper 模组都能直接使用游戏 API 与 copper.core.* 工具类,与原版体验一致。你在 dependencies 中声明的每个模组,同样成为访问通道。
默认情况下,一个模组的类对外是不可见的。exports 字段把类”公布”出去:
"exports": [ "exclude example.examplemod.internal.*", "include example.examplemod.*"]- 每条规则:
"include <模式>"或"exclude <模式>"; - 模式匹配全限定类名,支持通配符:
*:任意字符序列(含空);?:恰好一个字符;
- 规则从上到下逐条匹配,第一条匹配的规则生效(
include表示可见,exclude表示不可见); - 所有规则都不匹配时,默认不可见;
- 系统会自动在你的规则末尾追加
include <你的包>.*(基于id生成),保证主类所在的顶层包至少对他人可见。
// 只导出 API 包,隐藏实现细节"exports": [ "exclude example.examplemod.impl.*", "include example.examplemod.*"]// 什么都不写:只导出自动追加的 <包>.*"exports": []- 其他模组只能引用通过
exports导出的类; - 未导出的类即使存在于 Jar 中,对其他模组也不可见(加载时报
ClassNotFoundException); - 不影响你自己的模组:本模组内部始终能使用自己的所有类。
exports 的匹配粒度是类,但一次导出实际暴露的是该类的完整公有表面:父类链、接口、公有/受保护成员签名的类型等,都会被依赖方以某种方式引用到。因此”只导出 API 入口类、不导出它的父类或签名类型”虽然在运行时可能侥幸可用,却会让 d8、Mixin 这类静态工具在编译期直接失败。原因在于两者的类解析方式完全不同:
运行期:解析跟随定义类加载器,导出规则通常不参与
Section titled “运行期:解析跟随定义类加载器,导出规则通常不参与”按照 JVM 规范(JVMS 5.4.3),解析一个符号引用时,使用触发解析的字节码所在类的定义类加载器:
Foo(由模组 A 的容器CL_A加载)继承Bar。CL_A加载Foo时就用CL_A自己解析Foo的父类Bar,并把内存中的super_class指针直接指向Bar的Class实例;- 模组 B(
CL_B)拿到Foo实例后调用其方法,JVM 走的是方法分派:沿Foo实例内存里已有的super_class指针找到Bar的方法,根本不会调用CL_B.loadClass("Bar"); - 同理,
Foo方法体内部的new Bar()、字段访问等解析,也全部在CL_A上下文完成(指令存放在哪个类、就用哪个类的定义类加载器解析)。
所以运行时:只要 Bar 能被 CL_A 自己解析到(public 且存在于模组 A),Bar 是否对 CL_B 导出并不影响执行。导出规则的拦截只发生在一种场景:CL_B 自己的字节码里直接书写了 Bar 的名字(强转 (Bar) obj、直接调用 Bar 的静态成员等),此时才由 CL_B 发起解析、命中 loadPublicClass 拦截。
编译期:静态工具只有单一扁平视界,必须完整导出
Section titled “编译期:静态工具只有单一扁平视界,必须完整导出”d8(Android dex 构建)、Mixin 注入器这类工具在编译期不是运行时的 JVM——它们没有”每个类身上烙印着定义类加载器”的内存指针网,只有一个全局的、模拟某一容器视界的字节码供给源(Provider):
- d8 编译模组 B 的源码时,看到
invokevirtual Foo.method(),向 Provider 请求Foo; - d8 拿到
Foo的字节流,读到文件头的super_class字符串"Bar"(Foo的父类); - d8 想知道
Bar长什么样时,唯一途径是再次向同一个 Provider 请求"Bar"; - Provider 按导出规则回答:
Bar未导出 → 返回null→ 缺类报错。
Mixin 注入器在解析目标类层级(父类、接口、方法签名)时同理。这就是为什么”运行时能跑、d8 却报缺类”——编译期解析没有”定义类加载器”这一维度,一切类都从单一视界去要。
// 错误:Foo extends example.base.Bar,但 Bar 未导出"exports": [ "include example.api.Foo"]正确做法是把”API 类 + 其公有成分涉及的类”作为整体导出:
// 正确:同时导出 API 类及其父类/签名涉及的包"exports": [ "include example.api.*", "include example.base.*"]同时不要滥用导出:
- 不要为省事直接
include *导出全部类——这会失去隔离意义,把内部实现也暴露出去; - 也不要过度裁剪,把公有成分藏掉——运行时也许侥幸能跑,但 d8 / Mixin 编译期会失败,且依赖方一旦在自有字节码中直接引用就会触发解析拦截;
- 合理粒度:导出”顶层 API 包 + API 签名依赖的类型”,内部实现留在未导出的包中;确需访问隐藏类的场景,应让依赖方通过
imports显式放行,而不是扩大exports。
依赖模组的未导出类,默认对你的模组也不可见。imports 用于针对某个依赖模组额外打开可见性:
{ "dependencies": { "dep:mod": ">=1.0.0" }, "imports": { "dep:mod": ["include dep.sub.*", "include dep.extra.*"] }}imports的键必须是已声明在dependencies中的模组 id;- 值可以是单条规则或规则数组,语法与
exports相同(从上到下匹配); - 规则中的模式匹配目标模组的类,
include表示”即使目标模组未导出,我也能看到”。
典型使用场景
Section titled “典型使用场景”- 依赖模组没有导出你需要的类(作者忘了导出,或你正在为其做补丁/调试);
- 访问原版 Mindustry 模组的类——注意,原版模组不使用
exports机制,默认不导出任何类。即使你声明了"dependencies": { "mindustry:some-mod": "=1.0.0" },如果不显式imports,你依然访问不到它的类:
{ "dependencies": { "mindustry:new-horizon": "=2.2.1" }, "imports": { "mindustry:new-horizon": ["include newhorizon.*"] }}类加载的查找顺序
Section titled “类加载的查找顺序”在模组容器内查找一个类时,按以下顺序:
- 自己的类(优先);
- Mixin 目标容器的公开类(如果你声明了 Mixin 目标,见下文);
- 依赖容器的公开类。
Mixin 目标与可见性
Section titled “Mixin 目标与可见性”声明 Mixin 目标(mixins 字段)时,目标容器的公开类会进入你的查找范围。因此给其他模组写补丁时,即使不声明 dependencies,只要声明了 mixins 目标,也能看到目标模组的公开类。Mixin 注入本身不受导出规则限制——注入是字节码层面的操作,发生在目标容器内部。
我引用了别的模组的类,运行时报 ClassNotFoundException
Section titled “我引用了别的模组的类,运行时报 ClassNotFoundException”- 确认该模组已声明为
dependencies; - 确认该类在目标模组的
exports中,或你通过imports显式放行; - 原版 Mindustry 模组的 id 形如
mindustry:<模组名>,必须用imports显式导入它的类。
我的模组被别人引用时报 ClassNotFoundException
Section titled “我的模组被别人引用时报 ClassNotFoundException”- 在
exports中把该类所在的包 include 出来; - 检查
exclude规则是否意外覆盖了该包(注意规则顺序)。
别人引用了我的 API 类,但报 NoClassDefFoundError(或 d8/Mixin 报缺类)
Section titled “别人引用了我的 API 类,但报 NoClassDefFoundError(或 d8/Mixin 报缺类)”- 通常不是被引用类本身不可见,而是它的公有成分(父类、接口、成员签名中的类型)没有导出——见导出完整性;
- 把父类链、接口与签名涉及的包一并 include 出来。
两个模组有同名类,会冲突吗?
Section titled “两个模组有同名类,会冲突吗?”不会。每个模组在自己的容器中加载自己的类,互不干扰。只有当你通过依赖/导入引用了对方的类时,才会使用对方容器中的定义——这正是容器隔离的初衷:模组之间互不污染,冲突只会在你主动要求访问时发生。