你编写的Mod,代码是从何处开始执行的?

本文作者zzzz(@ustc-zzzz),使用CC-BY-SA 4.0协议授权。

对于一段程序来说,需要关注的无非就两件事:程序管理的数据,以及程序操控数据的方式。对于Java来说,前者对应的是对象及其字段(Field),而后者对应的自然是对象的方法(Method)了。但是,什么样的方法,会在什么时候执行呢?对于Forge Mod来说,这并不是一件可以立刻回答上来的问题。本篇文章就将一步一步地阐述,Forge会让你编写的Mod,在什么时机,执行什么样的代码,以及Forge为什么要这样做。

依赖注入

凡是写过比Hello World更高级的代码的普通Java开发者都一定知道,Java程序是从一个类的main方法开始执行的。至于这个类是什么,它通常会定义在JAR的manifast(位于META-INF目录下的MANIFEST.MF文件中)中。这样,在你直接执行JAR的时候,被指定的类的main方法便开始工作,从而启动整个应用程序。

Minecraft当然也是这样的,它的代码入口位于net.minecraft.client.main.Main下。当然了,Forge利用了一个名为LaunchWrapper的工具,从而将代码入口修改为net.minecraft.launchwrapper.Launch下。根据包名以及GitHub上的项目所有者,我们可以猜到LaunchWrapper本身,其实也是Mojang官方的工具。

言归正传。不管是Minecraft本身,还是LaunchWrapper,一个Java应用程序,当然只能有一个代码执行入口,也就是说,Forge需要手动执行Mod的代码。Forge首先需要做的是,在启动后搜索mods或者其他的什么目录,然后把所有Mod的JAR加载进Java虚拟机中,然后Forge接着要做的事,很多人可能都已经清楚了——这项技术被称为依赖注入(Dependency Injection)。

依赖注入,简单地来说,就是内部代码由外部框架准备好资源,然后再通过构造方法、Setter等方式带入内部代码中,从而不需要内部代码主动获取资源。在Java中,一个最常见的声明依赖注入的方式是使用注解声明,当然了,有的框架(比如Bukkit、BungeeCord等)声明依赖注入,有时还可以在配置文件中声明,不过Forge没有采用这种做法。

Mod主类

Forge约定,Mod需要执行的内部代码,其所处类应该使用@net.minecraftforge.fml.common.Mod注解标记。我们通常会称呼被@Mod注解的类为一个Mod的主类。Forge会检查Mod代码中所有添加有这一注解的类,并自动实例化,从而创建一个代表该Mod的对象。

Forge还规定了一些其他的注解,有的被用于监听Mod的不同生命周期事件(@Mod.EventHandler)、有的被用于根据不同的物理端实例化对象(@SidedProxy)、有的被用于获取Mod主类实例本身(@Mod.Instance)、有的被用于指定创建Mod主类实例的方式(@Mod.InstanceFactory)等等。这些注解分使用情况的不同,在不同的场合被用于不同的字段和方法上。使用这些注解对于有一定经验的Modder来说,应该再熟悉不过了,这里就不再赘述了。

游戏事件

游戏中的事件监听是如此重要,以至于说整个Modding都建构在游戏中的事件监听上,也一点都不为过。Modder基本上都知道,想要监听游戏中的事件,首先需要定义一个类,作为事件监听器的集合。然后在这个类上定义若干方法,方法的参数代表想要监听的事件类型,从而在相应的事件触发时有机会执行Mod的代码。同时,为区分事件监听器和类的其他方法,只有添加了@net.minecraftforge.fml.common.eventhandler.SubscribeEvent注解的方法才会作为事件监听器处理。最后,再将这个类注册到某个特定的事件总线(Event Bus)上,触发事件同时也由特定的事件总线监管。

Forge定义的事件总线非常之少,其中绝大部分事件都由MinecraftForge.EVENT_BUS这一事件总线监管。不过,对于接触过1.8及更早版本的Minecraft的Modder来说,常用的事件总线其实有两个,而另一个事件总线可以通过FMLCommonHandler.instance().bus()这一方式获得。不过,在新版本的Minecraft中我们可以看到,这一方法已经被弃用了,它的返回值实际上就是MinecraftForge.EVENT_BUS。这是因为,在很久以前,Forge和FML是两套不同的东西,你甚至可以只使用FML加载Mod,而不使用Forge,因为Forge只是一套方便Modder的API。不过在数年后的今天,已经没有人会只使用FML而不使用Forge了。两套代码也融合到了一起,两个分立的事件总线,自然也没有分开存在的必要了。

管理监听器

对于将包含有事件监听器的类注册到事件总线这一行为来说,Forge允许的方式也不断添加。最初的方式是将一个类实例化,然后再调用事件总线的register方法注册这一实例,也就是说,Mod中,这样的代码会经常出现:

public class XXXEventHandler {  
    @SubscribeEvent
    public void onItemCrafted(PlayerEvent.ItemCraftedEvent event) {
        // DO SOMETHING
    }
}

MinecraftForge.EVENT_BUS.register(new XXXEventHandler());  

后来人们发现——XXXEventHandler这样的类,从头到尾只会实例化一次啊,那我为什么不实例化它呢——答案是肯定的,Minecraft 1.10.2的某个Forge版本,引入了静态方法作为事件监听器这一使用方式,在注册时只需要向register方法传入一个Class对象,也就是说,我们的代码可以这么写:

public class XXXEventHandler {  
    @SubscribeEvent
    public static void onItemCrafted(PlayerEvent.ItemCraftedEvent event) {
        // DO SOMETHING
    }
}

MinecraftForge.EVENT_BUS.register(XXXEventHandler.class);  

这看起来就好多了,因为我们根本不需要实例化一个新的对象了,这甚至节省了内存空间。

但是,Forge觉得做的还不够,我们仍然需要在某个时刻(通常是在Mod的FMLPreInitializationEvent事件触发时)调用一次register方法,能否连这个方法的调用都省了呢?Forge在随后引入了@Mod.EventBusSubscriber注解,从而通过通过为类添加这一注解的方式自动将该类注册到MinecraftForge.EVENT_BUS中:

@Mod.EventBusSubscriber
public class XXXEventHandler {  
    @SubscribeEvent
    public static void onItemCrafted(PlayerEvent.ItemCraftedEvent event) {
        // DO SOMETHING
    }
}

Forge会自动检索所有包含有@Mod.EventBusSubscriber注解的类,并将它们对应的Class注册进事件总线中。实际上, 我个人而言更喜欢最后一种监听事件的方法。

为什么有两套系统?

现在我们可以大概整理一下了。

  • Mod主类的部分方法通过添加@Mod.EventHandler的方式,监听FMLPreInitializationEvent等和Mod的生命周期有关的事件。
  • 一些类中的部分方法通过添加@SubscribeEvent的方式,监听PlayerEvent.ItemCraftedEvent等游戏中的相关事件。

同样都是监听事件,为什么是两套注解,两套系统?合并在一起有理论上的困难吗?事实上没有——一个有一定知名度的框架Sponge,采用的就是把两套事件的监听方法统一的方式。这其实更可以说,是历史遗留的原因。

刚刚我们提到,在数年前,Forge和FML是两套不同的系统——Forge更倾向于形成一套API,而FML才真正负责加载Mod。那么,在很早以前,FML是没有游戏事件监听这一选项的,只需要管理和Mod的生命周期有关的事件,相关的游戏事件监听由Forge负责,Forge也为此重写了一套系统。后来,Forge的事件监听系统由于非常好用,其实现底层也基于一套非常高效的方法——因此被FML手动搬运了过来,并添加了几个FML自己的游戏事件——这也是FMLCommonHandler.instance().bus()的由来。这套系统沿用至今,于是就出现了监听Mod生命周期事件和监听游戏事件采用两套不同方法的现象。

不过,据说在即将到来的Minecraft 1.13,对应的Forge版本中,相应的代码会被重写,@Mod.EventHandler也将走向历史。到时候会发生什么,让我们拭目以待。