以下是作者github仓库介绍中译:(含有技术性内容)


Manningham Mills


在一个由Mixin和可怕的反射Hack组成的和谐世界中,Fabric常驻其中。对于附加代码的简单注入,和方法调用的重定向,Mixin是最合适的,它为可能发生的事提供了明确的准则,并在运行时检查正在做的事是否正常。当需要进行其他更改时,无论是更改if的判断或是循环中的break,Mixin都会被证明不太合适。单单使用简单的注释,便利性已经不复存在了。你要么照抄大部分的方法体,然后在笨拙的@Inject中取消,要么是用更糟糕的@Overwrite来解决一切。


暂离一下去想另一件事,你正试图将它注入一个以私有类为参数的方法中,这种情况是最具灾难性的。如果它是一个接口反射,以经典的不安全InvocationHandler的形式来拯救你,让接口“假装”被继承。如果摊上类中更大的麻烦,那还是来个@Coerce或者祈个祷。


如果你需要继承私有类。Mixin已经完全抛弃你自生自灭了。当然,理论上你可以@Accessor<init>,但当Java不把你的假<init>作为一个合适的超类构造函数来调用时,这对继承完全没有用。重包装黑客(repackaging hack)可能会尽力让你进入一个包中的私有类,但在那一刻,你已经离开了安全的港湾,进入了危险的公开水域。即使这样,一个合适的私有类也是无法访问的。你能做的不是一大堆重新包装,而是一艘访问转换器(access transformer)的战舰。




驾驭危险


定义战舰


Mod的访问转换器是由Loom在开发期间定义的。对于生产代码,访问转换器会自动从Yarn重新映射到Intermediary名称,并放到生产jar根中的silky.at。MM将在运行时自动加载和应用任何转换,如果不存在,则抛出IllegalAccessException。Loom应用任何转换进行开发,因此,除非将带有AT(access transformer 访问转换器)的另一个mod添加到类路径,否则不需要MM。

提供的示例的AT在build.gradle中定义。




合理构造


在Fabric中,访问转换器只需要让类公开(允许直接引用并继承)和方法公开(允许继承,特别是构造函数)。因此,MM中使用了更简洁的访问转换器格式,以考虑不需要处理字段或非公有转换。


# Any line starting with a hash are ignored 
# 忽略任何以 hash 开头的行
# As are any empty lines
# 还有空行

# Classes defined alone are interpreted to mean the class is to be made public and definalised
# 单独定义的类被解释为该类是公有的并已被定义
net/minecraft/item/ItemStack

# Classes defined with a method description are interpreted to mean the method is to be made public and definalised
# 用方法的描述符定义的类被解释为该方法是公有的并已被定义
net/minecraft/item/ItemStack <init>(Lnet/minecraft/item/ItemProvider;)V

# Loom will crash when any wrongly or ambiguously defined transformations are provided
# 提供任何错误或定义不明确的转换,Loom将崩溃
# MM will merely fail to transform invalid methods, whilst invalid classes will crash
# MM仅能无法转换无效的方法,而无效的类则会崩溃
#net/minecraft/item/ItemStack <init>
# Will go bang if uncommented  ^^^
# 如果取消了上方的注释,那么就会崩溃

此处定义了所提供示例的AT




安全航行


现在你已经离开了安全区,Mixin提供的保护程度并不完全相同。虽然存取访问器的引发的错误相对较少,但始终值得记住的黄金法则是:


  • 避免转换任何不需要的东西

    每一次转换都会变成你mod中另一个脆弱的更新

  • 小心将受保护的(protected)方法转换为公有方法

    如果他们保留了protected(他们完全可以这样做),任何其他继承该方法的mod都会崩溃。虽然这在理论上是可以修复的,但它需要嗅探所有加载的mod来修复类层次结构。如果你想实现这一点,那就累死自己吧,如果成功了,我会接受的。

  • 确保在开发中进行的转换正确地导出到生产环境中

    如果单个项目导出多个mod jar,则应注意任何需要转换的mod都能拿到。一个项目只能定义一个AT,默认情况下,它将添加到jarsourcesJar任务以及所有具有RemapJarTaskRemappingJar类型的任务中。这可以配置为:


禁用重映射和导出AT的主jar任务……

jar {
    AT.include = false
}

禁用导出AT的sourcesJar任务……

sourcesJar {
    AT.include = false
}

RemapJarTaskRemappingJar任务可以同时通过includeAT属性禁用AT重映射和导出:

task exampleJar(type: RemappingJar, dependsOn: exampleClasses) {
    from sourceSets.example.output
    includeAT = false
}

注意,对于纯粹用于@Mixin目标的AT,它们不需要在运行时出现,因为Mixin不直接对类型进行类加载。但是,如果其他AT需要(在运行时需要),它们的存在也无伤大雅。


访问转换器非常整洁,但仍然有局限性。您可以将enum的构造函数转换为公共构造函数,但这并不能添加新条目,或在此取得较大的进展。为此,需要绕行至扩展的海湾。




利用扩展的海湾


等待涨潮


在mod加载过程中,必须在早期就注册想要添加的内容。因此,期望给定mod的初始化程序尚未运行是非常合理的。为了解决这个问题(以及下面描述的类加载陷阱),MM有一个“Early Risers”系统,“Early Risers”的类实现Runnable,并在需要时提早调用。


Early Riser是以一个入口点定义的,该入口点在mod的fabric.mod.json中像一样定义。Early Riser入口点名称为mm:early_risers,使用Runnable类型,因此在初始化时会调用run方法。提供的示例的Early Riser定义在此




导航入口


扩展所要求的早期性带来了类加载的潜在陷阱。考虑到主启动类将被解释,意外引用一个将要应用的Mixin类,肯定并不是很好的。因此,对于可能由Early Riser加载的类,非常需要小心。


为了扩展枚举类ClassTinkerers中提供了四种方法:

虽然表面上看,采用ClassStringObject数组之间可能存在细微的差异,但这种差异对于避免不希望的类加载来说至关重要。




快速的方式


第一种方法相对来说是最简单的,但也是最不灵活的。在所需枚举构造函数不带参数的情况下,可以使用此方法来防止将空数组传递到其他三个方法中。事实上,将空数组传递给其他三种方法中的任何一种,在功能上都等同于使用此方法。但是,如果构造函数确实接收了参数,那么此方法并不适用(也不会起作用),因此应使用其中的另一个方法。




简单的方式


第二种方法实际上更容易使用。变量实参表示所需枚举类的构造函数要使用的参数类型,因此传入Class实参是很下意识的。对于Java或库中的类型,并没有什么不妥,但当涉及到Minecraft类型时,这会带来一个大问题。因为提及该类,那么它就必须被加载,这样Java才可以明确地知道它的存在(以及定义的属性)。因此,ClassTinkerers会检查传入的类是否在net.minecraft包中,如果有,就会崩溃。考虑到像GlStateManager这样的类不在包中,但仍然可以Mixin,这当然有点粗糙,但保留兼容性总比什么都没有好。




安全的方式


第三种方法需要付出更大的工作量。与第二个一样,变量实参表示所需构造函数的参数类型。但与第二个不同的是,它不使用Class对象,而是使用内部名称(类似于Mixin)。这显然不太安全,因为与正常运行的游戏相比,类在开发环境中有不同的名称,但这就是它的代价。好处(正如你可能已经猜到的)是,使用内部名称可以绕过类加载,因为Java就算在寻找构造函数时,也不需要知道类型的存在。


重要的是要记住(有点混淆)第一个参数不是枚举类的内部名称,而是普通类名。这部分是为了方法之间的一致性,部分是因为内部名称会有些用,因为实际上只需要类名就可以找到要扩展的枚举类




懒惰的方式


第四种方法实际上是前两种方法的结合。与它们一样,变量参数表示所需构造函数的参数类型。但是,这些类型可以指定为Class、内部名称String或直接指定为ASM Type对象。这种方法两全其美,因为可以直接指定非Minecraft类,而无需强制指定Minecraft类。有选择总是好的。




停靠码头


无论使用哪种方法,生成的返回对象都将是所选枚举构造函数的EnumAdder。它理论上允许添加需要的任意多个值,并且可以再次选择执行此操作的方法:

第一个直接将值的名称和潜在参数作为预先创建的对象。这对于可能不需要任何额外参数的构造函数或只使用Java/Library类型的构造函数很有用。第二个也采用值的名称,但使用一个工厂类,该工厂类根据需要返回一组参数。这保护了原本会在lambda之后加载的类型,从而避免了传递更多没用的字符串。


提供的示例使用这两种方法在此


添加所需的值后,必须调用EnumAdder#build才能实际应用更改。虽然使用EnumAdder作为builder的操作看起来更像Java-y,但也将更改注册为单个块,而不是逐段添加,这大大提高了类转换速度。一旦调用build,再添加更多的值将以失败告终。




错装木筏


如果指定并尝试使用无效(通常是定义错误)的构造函数,那么就会在转换过程中会发现这个错误的构造函数,让游戏因NoSuchMethodError而崩溃。沉默的例外毕竟是在制造麻烦。重复或无效的值名称将抛出ClassVerifyError,最终JVM将决定什么是该用的,什么不是。


给定的参数对象实际上与构造函数所期望的类型匹配也是有一定程度的信任的,如果它们不匹配,那么就会抛出ClassCastException。对于构造函数所采用的参数,提供错误数量的参数将抛出IllegalArgumentException




两栖登陆


有时,码头本身不足以完成手头的扩展任务,这会给任何附加带来很大的问题。此时,就可以在海湾岸边进行全面的两栖登陆。


枚举类中的方法得是子类的情况下,例如抽象的枚举类,当为添加的值调用方法时,直接添加值将导致AbstractMethodError。构造函数本身无法提供解决方案,因此MM会让添加的条目转为子枚举类。这些子类是通过一个额外的结构类(构造函数的参数)定义的,该结构类定义了子类想要对枚举进行的覆盖。与普通加法一样,根据普通构造函数参数的传递方式,有两个选择:

第一个和第三个参数的作用就像普通加法一样。第二个参数则是子类的结构类的内部名称。这样的结构类本身可以扩展其他类(因此在技术上支持典型的类继承,尽管它是一个枚举类),但最终应该使用需要带有@Shadow注解的抽象方法,将未注册的抽象Mixin扩展到目标枚举类上。虽然这是一种不同寻常的方法,但它允许Mixin注解处理器处理模糊处理,而无需在运行时进行任何注入。相反,在运行时,Mixin类从结构类的层次结构中删除,以替换Object(因为它必须是最深的父类)和直接从子类调用的方法实现。


结构类可以用于多次添加,每次添加都会创建一个实例。它存储在子类中,用于处理方法调用。之后,如果需要,结构类可以通过字段存储自己的状态。出于类加载和一般健全性的原因,强烈建议在枚举扩展之外使用结构类。


这是一个带有注册使用的示例结构类,演示扩展EnchantmentTarget的实际用法。




掠夺物资


由于添加到枚举类中是在类加载期间完成的,因此获取条目可能需要在代码中的其他地方进行。事实上,它确实应该发生在其他地方,因为类加载你添加的枚举的行为是非常愚蠢的。MM添加了一个用于获取新加条目的方法:ClassTinkerers#getEnum(Class, String)。它很快就会失败,所以在转换过程中没有发现的任何添加到枚举中的问题,都会变得清晰了当。如果你在多个地方使用它,那也值得缓存结果,因为它的时间复杂度是O(n),n代表给定的枚举类有多大。


所提供的示例在这里使用了这个。




扩展地图


有时,现有的类和资源映射不够,需要对其进行扩展。这些补丁可以在运行时根据需要动态附加,以允许加载额外的jar。所有添加都是以URL的形式添加的,以便mod的URLClassLoader通过ClassTinkerers#addURL(URL)进行查找。如果调用得太早(即在Early Risers运行之前),它会执行失败并返回false,否则它会把给定的URL添加到类路径并返回true


假设你对这个没有Mixin的世界有了感觉,并想走得更深。危险的海洋(原始的ASM)是通往更大的解放海洋的通道。把任何安全和谨慎的概念抛到九霄云外,代表着彻底打破一切,充分发挥Fabric的潜力。




扔掉地图


类的生成


ASM完全理解的第一步,是让你能够生成你想要的任何类。当然,尝试重新定义已经存在的类是行不通的,但你可以想出一个几乎无限的替代类名库来生成你想要的任何东西。更重要的是,类可以在任何时候生成,只要注册了定义,就可以加载和使用它们。


定义一个类很简单,只需给出名称,然后将名称和类的字节码传入ClassTinkerers#define(String,byte[])。类的字节码可以使用标准的ASM ClassWriter生成,你并不需要手动计算出所需的内容。如果已经使用该方法定义了给定名称的类,那么它将跳过附加定义并返回false。如果类路径上已经存在具有相同名称的类,那么该行为是个未定义的行为(不要这样做)。




类的修改


现在可以随意定义类,离ASM的完全理解肯定更近了。但是,创建一个新类并不像根据需要更改现有类那样强大。当然,如果类已经加载,那就太晚了,但在不受Mixins限制的情况下转换类(在此之前)是最终目标。


类的转换是通过向ClassTinkerers#addTransformation(String,Consumer)注册给定类的ClassNode Consumer来完成的。这意味着可以为任何类添加任意数量的转换。就像添加到枚举类中一样,这需要从Early Riser中完成,这样在游戏开始之前,所有被转换的类都会及时得知。




类的替换


在极少数的,对类的转换如此广泛的情况下,之前可能进行的更改的任何即时兼容性都是不切实际的。在这种情况下,有一个更果断的选择,那就是只修改现有的类,直接替换它。替换不应掉以轻心,因为一个类只能注册一个替换。


类的替换是通过向ClassTinkerers#addReplacement(String,Consumer)注册给定类的ClassNode Consumer来完成的。像普通的类转换一样,这需要在Early Riser中完成。如果已经为给定名称注册了替换,则会抛出IllegalStateException


有了这些,您现在可以在Fabric的生态系统中尽可能自由地更改任何你喜欢的内容。现在,在充满ASM的原始海洋的开阔水域上,你所做的一切都是不受约束的,任何出了问题(或者确实没有出问题)都可能由你决定。祝你旅途愉快!


PS:如果你迷失在海上,被生成和/或修改的类弄糊涂了,请记住Mixin可以帮助你的,使用虚拟机参数-Dmixin.debug.export=true。MM完全支持它更改和生成的所有类。




融入我心


Manningham Mills(或Lister Mills,当想掩盖它在Manningham的事实时)曾经是世界上最大的丝绸和天鹅绒纺织厂。这座现在被列为二级建筑的建筑占地27英亩,可容纳11000多名生产高质量纺织品的员工。据估计,这座249英尺高的烟囱重约8000英吨,是吸引购房者购买豪华公寓的灯塔,因为自1999年工厂关闭并改建为公寓楼以来,它几乎无能为力。并不是说Manningham是一个你现在应该向往的地方。或者来真的。