重载监听器

重载监听器

某些情况下,仅接入 Minecraft 或 NeoForge 提供的现有资源系统 无法满足需求,让自己的系统直接从资源包或数据包加载文件更为合适。为此,可以注册自定义重载监听器,实现 PreparableReloadListener 或它的某个子接口、子类。

重载监听器的思路很简单:资源包或数据包发生重载时,会调用监听器,让它从新的一组资源包或数据包中读取内容。监听器会保留这些内容,直到下次重载时丢弃,然后重新开始这一循环。

创建自定义重载监听器时,需要考虑两个问题:

  1. 重载监听器运行在什么上下文或侧?可选方式见添加和获取重载监听器。
  2. 重载监听器加载什么类型的数据(或者根本不加载数据)?可选方式见重载监听器的类层次结构。

前者决定使用哪个事件注册,以及重载监听器实例存放在哪里;后者决定使用哪个基类或接口,以及需要重写哪些方法。

建议 在服务端侧,许多场景可能更适合使用数据包注册表 或数据映射 系统。

重载

资源包和数据包的重载在原理上类似,区别仅在于关联的物理侧与逻辑侧,以及由此决定的重载时间和文件加载位置。

客户端重载监听器 服务端重载监听器
加载来源 资源包(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 拆分如下:

  • assets
  • mymod 是命名空间。
  • mymod/my_listener 是 FileToIdConverter 的前缀。
  • example_1 是路径。
  • .json 是 FileToIdConverter 的扩展名。

因此得到的 Identifier 是 mymod:example_1。其他示例:

  • assets/mymod/mymod/my_listener/subfolder/example_2.json -> mymod:subfolder/example_2
  • assets/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。