重载监听器
某些情况下,仅接入 Minecraft 或 NeoForge 提供的现有资源系统 无法满足需求,让自己的系统直接从资源包或数据包加载文件更为合适。为此,可以注册自定义重载监听器,实现 PreparableReloadListener 或它的某个子接口、子类。
重载监听器的思路很简单:资源包或数据包发生重载时,会调用监听器,让它从新的一组资源包或数据包中读取内容。监听器会保留这些内容,直到下次重载时丢弃,然后重新开始这一循环。
创建自定义重载监听器时,需要考虑两个问题:
- 重载监听器运行在什么上下文或侧?可选方式见添加和获取重载监听器。
- 重载监听器加载什么类型的数据(或者根本不加载数据)?可选方式见重载监听器的类层次结构。
前者决定使用哪个事件注册,以及重载监听器实例存放在哪里;后者决定使用哪个基类或接口,以及需要重写哪些方法。
重载
资源包和数据包的重载在原理上类似,区别仅在于关联的物理侧与逻辑侧,以及由此决定的重载时间和文件加载位置。
| 客户端重载监听器 | 服务端重载监听器 | |
|---|---|---|
| 加载来源 | 资源包(assets 文件夹) |
数据包(data 文件夹) |
| 首次重载 | ||
| (物理客户端) | - 启动时 | - 创建新世界时 |
| (“正在准备创建世界……”) |
- 加入已有世界时
- 加入服务器时 | | 后续重载 (物理客户端) | - 在选项菜单中更改资源包时
- 加入服务器时下载服务器的自定义资源包
- 按下 F3+T | -
/reload命令 | | 首次重载 (物理服务端) | 从不 | - 启动时 | | 后续重载 (物理服务端) | 从不 | -/reload命令 |
在物理客户端退出世界时,所有存储的数据都会过期。根据系统的实现方式,这可能导致内存泄漏。避免内存泄漏的机制见下文服务端重载监听器。
说明
/reload命令也会触发其他由数据包驱动的系统重载,例如标签。这是设计行为,无法绕过。
多线程重载
从概念上说,一个重载监听器的工作通常可以分成两个阶段:
- 准备:收集、校验必要文件,并将其解析为下一步可使用的对象。准备阶段使用称为任务执行器的执行器,在多个线程上运行。
- 应用:将上一步的对象“应用”到游戏中,通常是设置某个字段或向某个集合添加内容。应用阶段使用称为重载执行器的执行器,在主线程上运行。
为了确保同步,准备完成后,底层 CompletableFuture 会对 PreparationBarrier 执行 wait。只有所有准备线程都运行完毕后,才允许执行应用阶段。代码大致如下:
@Override
public CompletableFuture<Void> reload(
SharedState currentReload,
Executor taskExecutor,
PreparationBarrier barrier,
Executor reloadExecutor
) {
return CompletableFuture
.supplyAsync(() -> {
// Collect and return the result of the preparation stage here.
}, taskExecutor) // Use the task executor for this call.
.thenCompose(barrier::wait)
.thenAcceptAsync(preparations -> {
// Run the application stage.
// `preparations` is the return value of the `supplyAsync` call above.
}, reloadExecutor); // Use the reload executor for this call.
}
参数说明见下文 PreparableReloadListener。
添加和获取重载监听器
所有重载监听器的注册都遵循相同的基本原则,但会因逻辑侧 而有所不同。首先定义重载监听器类,然后向对应侧的事件 注册,最后通过对应侧的方式获取重载监听器并操作它。
客户端重载监听器
客户端只会在启动时收集一次重载监听器,因此将重载监听器保存为单例实例就足够了,例如:
// Instead of PreparableReloadListener, extend one of its subclasses if applicable, see below.
public class MyClientReloadListener implements PreparableReloadListener {
// The id we're going to use in registration below
public static final Identifier ID =
Identifier.fromNamespaceAndPath("mymod", "my_client_reload_listener");
// The instance through which the listener is accessed
public static final MyClientReloadListener INSTANCE = new MyClientReloadListener();
// Hide the constructor in accordance with the singleton pattern
private MyClientReloadListener() {
}
// other methods added here later
}
接下来,通过 AddClientReloadListenersEvent 添加重载监听器:
@SubscribeEvent // on the game event bus only on the physical client
public static void addClientReloadListeners(AddClientReloadListenersEvent event) {
event.addListener(MyClientReloadListener.ID, MyClientReloadListener.INSTANCE);
}
这样就完成了。要访问重载监听器,直接访问 MyClientReloadListener.INSTANCE 字段即可。
服务端重载监听器
服务端重载监听器根据用途可以采用两种实现方式:
- 如果不需要存储任何数据,只是运行一些代码,应使用一次性监听器,即完全不持有任何可从外部访问的引用。
- 如果需要自行加载和存储数据,应使用保留式监听器。
一次性服务端重载监听器
一次性重载监听器的代码与上面的客户端监听器非常相似,只是完全不保留引用:
// Instead of PreparableReloadListener, extend one of its subclasses if applicable, see below.
public class MyDisposableReloadListener implements PreparableReloadListener {
// The id we're going to use in registration below
public static final Identifier ID =
Identifier.fromNamespaceAndPath("mymod", "my_disposable_reload_listener");
// No instance, since there's no data to access;
// also no hiding the constructor since we'll construct our class directly
// other methods added here later
}
随后,通过 AddServerReloadListenersEvent 添加重载监听器:
@SubscribeEvent // on the game event bus
public static void addServerReloadListeners(AddServerReloadListenersEvent event) {
event.addListener(MyDisposableReloadListener.ID, new MyDisposableReloadListener());
}
保留式服务端重载监听器
存储数据时,理论上可以沿用上面的单例模式,只向 AddServerReloadListenersEvent 注册即可。然而,服务端会在每次数据包重载开始时重新收集重载监听器,如果处理不当,重载期间就很容易发生内存泄漏。此外,这还可能导致客户端代码访问服务端重载监听器,从而出现意外的“跨侧访问”。
为解决这一问题,NeoForge 引入了保留式监听器的概念。保留式监听器通过 ListenerKey<T> 从 MinecraftServer 实例(可以从 ServerLevel 获取)访问,而不是通过单例实例访问。
与前两种方式相比,改动不多,主要是增加了 LISTENER_KEY:
// Instead of PreparableReloadListener, extend one of its subclasses if applicable, see below.
public class MyRetainedReloadListener implements PreparableReloadListener {
// The id we're going to use in registration below
public static final Identifier ID =
Identifier.fromNamespaceAndPath("mymod", "my_retained_reload_listener");
// Create a listener key for use in the event
public static final ListenerKey<MyRetainedReloadListener> LISTENER_KEY = ListenerKey.create(ID);
// other methods added here later
}
接下来,通过 AddServerReloadListenersEvent 注册保留式监听器:
@SubscribeEvent // on the game event bus
public static void addServerReloadListeners(AddServerReloadListenersEvent event) {
event.addRetainedListener(MyRetainedReloadListener.LISTENER_KEY, new MyRetainedReloadListener());
}
最后,可以从 ServerLevel 访问监听器:
MyServerReloadListener listener = serverLevel
.getServer()
.getServerResources()
.managers()
.getListener(MyRetainedReloadListener.LISTENER_KEY);
重载监听器的类层次结构
graph LR;
PreparableReloadListener --> ResourceManagerReloadListener;
PreparableReloadListener --> ContextAwareReloadListener --> SimplePreparableReloadListener --> SimpleJsonResourceReloadListener;
class PreparableReloadListener,ResourceManagerReloadListener green;
class ContextAwareReloadListener,SimplePreparableReloadListener,SimpleJsonResourceReloadListener yellow;
绿色元素是接口,黄色类是 abstract 类。
PreparableReloadListener
PreparableReloadListener 位于层次结构顶端,也是上述事件接受的类型。它定义了返回 CompletableFuture<Void> 的 #reload() 方法,接受四个参数:
SharedState currentReload:此次重载的共享状态。Executor taskExecutor:用于可在多个线程上运行的任务的Executor。PreparationBarrier barrier:线程屏障对象。Executor reloadExecutor:用于必须在主线程上运行的任务的Executor。
此外,它还定义了两个默认方法:
prepareSharedState(SharedState currentReload):默认不做任何事情。参见共享重载状态。getName():返回日志使用的名称,默认返回类名。
PreparableReloadListener 基本上可以加载任何内容。例如,许多加载二进制数据的重载监听器(纹理、字体等,但特别要注意,声音 不在其中)直接实现了它,数据映射 等其他系统也如此。不过,许多系统(例如大量基于 JSON 的系统)会使用下面的某个抽象类。
ContextAwareReloadListener
ContextAwareReloadListener 是一个工具类,它提供数据加载条件 上下文,可以通过 #getContext() 获取。此外,它通过 #getRegistryLookup() 提供注册表访问。
说明 这个类由 NeoForge 添加到层次结构中,因为数据加载条件是 NeoForge 提供的系统。
SimplePreparableReloadListener
SimplePreparableReloadListener<T> 是 PreparableReloadListener 的一种示例实现,将准备阶段与应用阶段(参见多线程重载)拆分为两个完全独立的方法。此时需要重写 #prepare() 和 #apply(),而不是 #reload()。例如:
// The generic type denotes the type of the object that is passed from #prepare() to #apply().
// For example, in many cases, this will be a List<MyObject>, Map<?, MyObject> or similar.
// This is often (but not necessarily) the same as the type of the stored data.
public class MyReloadListener extends SimplePreparableReloadListener<MyObject> {
// We use the singleton pattern of client reload listeners here for the sake of example.
// This part can be adjusted as needed if you're using a server reload listener.
public static final Identifier ID = Identifier.fromNamespaceAndPath("mymod", "my_listener");
public static final MyReloadListener INSTANCE = new MyReloadListener();
private MyReloadListener() {}
// A field to hold our object.
private MyObject myObject;
@Override
protected MyObject prepare(ResourceManager manager, ProfilerFiller profiler) {
// Run whatever logic to collect, parse and validate the files.
return new MyObject();
}
@Override
protected void apply(MyObject preparations, ResourceManager manager, ProfilerFiller profiler) {
// Set the field in our object to the prepared objects, for later use.
this.myObject = preparations;
}
// Provide access to the values in whatever way you deem necessary. For example:
public MyObject getMyObject() {
return myObject;
}
}
SimpleJsonResourceReloadListener
SimpleJsonResourceReloadListener<T> 在 SimplePreparableReloadListener 的基础上进一步专门化,使用 Codec 加载和解析 JSON 文件。它继承 SimplePreparableReloadListener<Map<Identifier, T>>,即将文件名(以标识符 表示)映射到希望将 JSON 解析成的任意类型。
该类还完整实现了准备阶段,遵循大多数 JSON 系统的工作方式:从上到下遍历资源包,并且对每个文件名只保留最上层的条目。这意味着,如果要执行合并(类似标签)或其他需要处理不同资源包、数据包中所有同名文件的操作,就需要自行实现文件夹扫描。
剩下只需在 #apply() 中存储 Map<Identifier, T>。最简单的实现如下:
public class MyReloadListener extends SimpleJsonResourceReloadListener<MyObject> {
// As above.
public static final Identifier ID = Identifier.fromNamespaceAndPath("mymod", "my_listener");
public static final MyReloadListener INSTANCE = new MyReloadListener();
// This map will store our values.
private final Map<Identifier, MyObject> values = new HashMap<>();
private MyReloadListener() {
// Add a super call here. The parameters are the codec (assuming MyObject.CODEC to be a Codec<MyObject>)
// and a FileToIdConverter, see the FileToIdConverter section below.
super(MyObject.CODEC, FileToIdConverter.json("mymod/my_listener"));
}
@Override
protected void apply(Map<Identifier, MyObject> preparations, ResourceManager resourceManager, ProfilerFiller profiler) {
// Clear out the old values and add our new ones.
values.clear();
values.putAll(preparations);
}
// Provide access to the values in whatever way you deem necessary. For example:
@Nullable
public MyObject get(Identifier id) {
return values.get(id);
}
public Map<Identifier, MyObject> getAll() {
return Collections.unmodifiableMap(values);
}
}
然后直接访问其中的值即可(当然,需要确保对应键确实存在):
MyObject myObject = MyReloadListener.INSTANCE.get(Identifier.fromNamespaceAndPath("mymod", "example"));
FileToIdConverter
FileToIdConverter 是一个用于将文件名转换为 Identifier 的工具 record。它定义了命名空间内的前缀和扩展名,转换时会移除这两部分。根据这些信息,转换器会将每个文件路径拆成五部分:
assets或data目录。- 命名空间,由下一级子目录的名称构成。
FileToIdConverter的前缀(可以包含斜杠,因此可能跨越多层目录)。- 文件路径(同样可以包含斜杠,跨越多层目录)。
- 文件扩展名。
然后使用命名空间和文件路径,即第二与第四部分,构造 Identifier。例如,考虑以下 FileToIdConverter:
FileToIdConverter converter = new FileToIdConverter("mymod/my_listener", ".json");
// Equivalent, automatically sets the .json extension:
FileToIdConverter converter = FileToIdConverter.json("mymod/my_listener");
上述转换器会将路径 assets/mymod/mymod/my_listener/example_1.json 拆分如下:
assetsmymod是命名空间。mymod/my_listener是FileToIdConverter的前缀。example_1是路径。.json是FileToIdConverter的扩展名。
因此得到的 Identifier 是 mymod:example_1。其他示例:
assets/mymod/mymod/my_listener/subfolder/example_2.json->mymod:subfolder/example_2assets/othermod/mymod/my_listener/example_3.json->othermod:example_3
除构造方法和 FileToIdConverter#json() 辅助方法外,还有 FileToIdConverter#registry() 辅助方法,它接受 ResourceKey<? extends Registry<?>>,并将注册表键转换为与上例类似的命名空间和路径字符串。
建议 为避免两个模组添加同名注册表时发生冲突,强烈建议在重载监听器的文件夹之前加上以模组 ID 命名的文件夹,如上面的
mymod/my_listener。#registry()辅助方法会自动完成这一操作。
ResourceManagerReloadListener
ResourceManagerReloadListener 是一个特殊的工具接口,在重载本身完成后运行,并通过唯一的方法 #onResourceManagerReload() 提供完整填充的 ResourceManager。实现该接口的类主要用于重载后的清理、缓存构建或类似工作。
共享重载状态
如前所述,重载监听器通常彼此无关,因此重载会在多个线程上进行。如果需要访问另一个重载监听器的值,就必须设置共享状态。为此,在重载监听器中重写默认的 prepareSharedState() 方法:
// Create a record (or class) holding our data to pass to another listener
// Instead of Object, use whatever actual type you need
public record MyPendingResources(CompletableFuture<Object> future) {}
// Can also extend/implement any subclass/subinterface of PreparableReloadListener
public class MyReloadListener implements PreparableReloadListener {
// other stuff here
// Create a StateKey with our record as the type
public static final StateKey<MyPendingResources> STATE_KEY = new StateKey<>();
// Override prepareSharedState() to add our pending resources
@Override
public void prepareSharedState(SharedState sharedState) {
sharedState.set(STATE_KEY, new MyPendingResources(/* any data here */));
}
// Again, instead of Object, use (and return) whatever type you need
private Object prepare(ResourceManager resourceManager) {
return new Object();
}
// Then, use in reload() like so:
@Override
public CompletableFuture<Void> reload(SharedState sharedState, Executor taskExecutor, PreparationBarrier barrier, Executor reloadExecutor) {
// Begin by calling prepare():
return CompletableFuture.supplyAsync(() -> prepare(sharedState.resourceManager()), taskExecutor)
// Set the shared state:
.whenComplete((data, error) -> {
// Again, use whatever type you need instead of Object
CompletableFuture<Object> future = sharedState.get(STATE_KEY).future();
if (data != null) {
future.complete(data);
} else {
future.completeExceptionally(error);
}
})
// Wait for the preparation and proceed to the application stage
.thenCompose(preparationBarrier::wait)
.thenAcceptAsync(preparations -> {
// Application stage here
}, reloadExecutor);
}
}
然后在依赖它的重载监听器中按如下方式访问数据:
public class MyDependentReloadListener implements PreparableReloadListener {
@Override
public CompletableFuture<Void> reload(SharedState sharedState, Executor taskExecutor, PreparationBarrier preparationBarrier, Executor reloadExecutor) {
return sharedState.get(MyReloadListener.STATE_KEY)
.future()
.thenApplyAsync(this::prepare, taskExecutor)
.thenCompose(preparationBarrier::wait)
.thenAcceptAsync(this::apply, reloadExecutor);
}
// Preparation stage; yet again, use whatever type you need instead of Object
private Object prepare(Object data) {
return data;
}
// Application stage
private void apply(Object data) {
// ...
}
}
来源: NeoForged/Documentation(26.1)。中文翻译依据提交
4a680cdd4d15。