开发指南
生命周期与阶段职责
2.1 四阶段总表
宿主按 onPreInit → 冻结注册表 → onInit → onPostInit → …(运行期)… → onUnload 推进,全部回调在 Mod 中都有默认空实现。四个阶段的职责不能混:
| 阶段 | 何时 | 允许做什么 | 禁止 / 注意 | 抛异常的后果 |
|---|---|---|---|---|
onPreInit(ModContext) | 模组加载时最先调用,注册表开放 | 经 ctx.registries() 注册全部自有内容(音符类型 / 游戏模式 / 判定等级 / 谱面扩展段 / 渲染滤镜 / 背景渲染器 / 打击特效样式 / 判定策略 / 判定调整器 / 收集品 / 皮肤 / 层覆盖 / 位姿调整器等) | 不能把注册写到别的回调或运行期;窗口内不要做 IO / 耗时操作;回调顺序按依赖拓扑 | 该模组不被激活,记入加载失败(LOAD_FAILED),其余模组继续;注册类错误(重复注册 IllegalArgumentException、冻结后写入 RegistryFrozenException)同样表现为该模组加载失败,不要捕获吞掉 |
| 宿主冻结注册表 | 全部模组的 onPreInit 结束后立即执行 | 宿主内部动作 | 模组不得依赖该动作作为自己的回调 | 不适用(宿主动作) |
onInit(ModContext) | 注册表已冻结、全部模组的 onPreInit 已完成之后 | 读配置、建缓存、订阅事件、创建运行期资源、读取其它模组已注册的内容(此时注册表内容已定型,可安全遍历) | 任何注册写入都会抛 RegistryFrozenException | 该模组不被激活,记入加载失败(不加入激活列表) |
onPostInit(ModContext) | 全部模组的 onInit 已完成之后 | 发布初始事件、自检、建立跨模组交互(此时可确信依赖模组已完成初始化)、读取自有译文 | 任何注册写入 | 保持激活,仅记录错误(此时运行期订阅已经建立,判成失败会让订阅变成孤儿) |
| 运行期 | onPostInit 之后直至卸载 | 事件回调、渲染、快捷键、网络等 | 任何注册写入 | 事件总线对每个监听器独立 try/catch(Throwable):单个监听器抛异常只记日志,不影响其它监听器,也不冒泡到宿主循环 |
onUnload() | 模组卸载时调用一次 | 释放资源 | — | 异常被宿主隔离,仅记录日志,不阻塞其它模组卸载 |
要点:
- 注册窗口一次性:宿主在全部模组的
onPreInit结束后立即冻结所有注册表,此后(含onInit、运行期、渲染回调内)任何注册都抛RegistryFrozenException。把注册写在别处是错误用法,也不是靠 try/catch 能绕过的。 - 注册不撤销:注册内容在进程生命周期内稳定存在,所以玩家在模组列表里启用 / 停用模组需要重启才生效。
- 事件订阅不在入口接口里:统一走
ctx.eventBus(),在onInit中订阅即可同时收到游玩事件与跨场景事件(只有一条总线,无需区分事件来源)。宿主按模组归属托管订阅,模组停用时自动退订。 - 内容注册只有一条路径:全部自有内容都在
onPreInit内经ctx.registries()注册,没有 per-模组的registerXxx(registry)回调。
2.2 阶段推进粒度
宿主在每一个阶段内部都按依赖拓扑顺序遍历全部模组,即:
text
全部模组的 onPreInit(按依赖序) → 冻结注册表 → 全部模组的 onInit(按依赖序) → 全部模组的 onPostInit(按依赖序)而不是「逐个模组把三段跑完」。这条粒度直接决定了两件事:
- 在
onInit里可以确信所有模组的注册内容都已定型,因此可以放心遍历注册表、读取别的模组注册了什么; - 在
onPostInit里可以确信所有模组的onInit都已完成,因此跨模组交互、依赖他人初始化结果的自检都放在这里。
阶段的遍历顺序由依赖关系决定(ModInfo.dependsOn() 声明硬依赖,缺失即加载失败;loadOrder() 声明加载阶段与软约束,目标不存在时自动失效)。
2.3 常见坑:在 onInit 里读自己的译文是空的
宿主在本模组的 onInit 返回之后才自动扫描并加载其 jar 内的语言文件(assets/<模组id>/lang/<locale>.json)。因此在 onInit 里读自己的译文会读到空值——这不是加载失败,只是时机没到。
正确做法是把「读自有译文」放到 onPostInit:
java
@Override
public void onInit(ModContext ctx) {
// 这里读 ctx.language().get("hello_mod.greeting") 会得到空 Optional
}
@Override
public void onPostInit(ModContext ctx) {
// 语言文件已在 onInit 返回后由宿主扫描加载,这里才是读自有译文的时机
String greeting = ctx.language().get("hello_mod.greeting").orElse("(no lang entry)");
ctx.getLogger().info("译文就绪:" + greeting);
ctx.notifier().info(greeting);
}同类道理:任何「依赖宿主在 onInit 之后才完成的装配动作」都要放到 onPostInit。
2.4 onInit 抛异常与 onPostInit 抛异常的区别
两者都做了异常隔离(不让一个模组崩掉游戏),但后果完全不同:
onInit抛异常 → 模组不被激活。它还没有进入激活列表,运行期订阅也不该建立,所以判成加载失败是安全的。onPostInit抛异常 → 模组保持激活,只记录错误。原因很明确:走到这个阶段,模组的运行期订阅已经建立起来了,如果此时把它判成「加载失败」,那些已经生效的订阅就会变成无人负责的孤儿。所以onPostInit不是「激活前的最后一道关卡」,它已经是激活之后的自检阶段——不要指望在这里抛异常能回滚什么。