插槽与实现位
大插件对外只暴露有限几种扩展点。理解它们的差别,比记住具体名字更重要:
- 服务让扩展调用大插件提供的能力,也可通过
inject获取。 - 通知型插槽让扩展知道「发生了什么」,返回值不参与流程,挂它永远是插件行为。
- 改写型插槽(机制插槽)让扩展改变「结果是什么」,挂它一律是 Mod。
- 实现位让扩展登记「由谁来做」,同时可以登记多个条目,按规则选择一个或按名字分派。
服务与通知型插槽都不产生写边;改写型插槽产生写边;实现位是可替换实现的登记处。
四类扩展点
| 扩展点 | 谁提供 | 谁使用 | 数据流向 | 是否写边 |
|---|---|---|---|---|
| 服务 | 大插件 provide | 任意单元 inject | 调用与返回 | 否 |
| 通知型插槽 | 大插件广播 | 插件订阅 | 事件单向广播 | 否 |
| 改写型插槽 | 大插件在机制内部调用 | Mod 注册 hook | 输入 → 改写 → 输出 | 是 |
| 实现位 | 大插件登记内置条目,Plugin 注册新条目 | 大插件按名字取用 | 注册表查询与选择 | 否 |
注册实现位虽然也经过装载器的声明核对,但它不改变任何既有节点的行为,因此不计入写边。这是「换实现」能保持为 Plugin 的关键。
通知型插槽
通知型插槽通过广播发送事件。订阅者拿到载荷,可以做日志、统计、联动播报等副作用,但返回值不会进入本体流程,也不能阻止流程继续。广播前会复制订阅者列表;某个监听器同步抛错或异步拒绝时只记录告警,其他监听器仍会收到通知。
| 大插件 | 通知型插槽示例 |
|---|---|
| Kernel | kernel:on-unit-loaded、kernel:on-unit-rejected、kernel:on-unit-unloaded |
| ScriptPlay | script:on-command、script:on-scene-enter、script:on-choice、script:on-advance |
| Stage | stage:on-transition、stage:on-cast-change |
Kernel 的三个通知载荷如下:
| 通知 | 载荷 | 触发时机 |
|---|---|---|
kernel:on-unit-loaded | UnitInfo | 单元完成当前装载流程 |
kernel:on-unit-rejected | { id, code } | 单元被拒绝,或装载后核验失败 |
kernel:on-unit-unloaded | UnitInfo | 单元已卸载 |
通知的接收方不需要在清单里声明「我要监听」,因为它不产生写边;但监听本身仍然受作用域生命周期管理,单元卸载后订阅随之撤销。
常见误解
「在通知回调里返回 false 就能取消装载」是错的。装载判定在广播之前完成,通知只报告结果;返回值没有约定,也不参与判定。
改写型插槽(机制插槽)
改写型插槽在机制执行到某个决策点时,把输入交给已注册的 hook,按阶段合成最终结果。
| 阶段 | 语义 | 叠加规则 |
|---|---|---|
before | 改写输入 | 按装载顺序依次执行,前一个的输出是后一个的输入 |
replace | 整体替换 | 同一插槽同时只允许一个;第二个申请者以 GUARD_SLOT_REPLACE_TAKEN 拒绝 |
after | 改写输出 | 按装载顺序依次执行,可同时看到输出与改写后的输入 |
执行顺序可以写成这样:
input
-> before[0] -> before[1] -> ... -> before[n]
-> replace ? replace(input) : 默认实现(input)
-> after[0] -> after[1] -> ... -> after[m]
-> outputbefore 与 after 构成顺序改写(waterfall);replace 是短路替换(bail),它存在时默认实现不再执行。三者都是叠加层:每个 hook 都挂在注册者的 effect 上,卸载时从对应列表移除或清空替换位,后续调用立即回到之前的行为。
机制插槽的规则
- 只对 Mod 开放。 在改写型插槽上挂 hook,就产生写边,单元按 Mod 判定,与插槽 policy 是
public还是trusted无关。 - 不存在
closed档。 不允许外部改写的目标不应声明为机制插槽。 - 必须绑定契约版本。 Mod 的
targets必须包含插槽所属大插件的契约版本,例如scriptplay@1;不匹配时拒绝加载,不做静默跳过。 replace同槽唯一。 第二个replace在准入阶段就被拒绝,不会先装载再覆盖。- 多 Mod 叠加有确定顺序。
before与after按装载顺序合成;顺序不是由扩展自报的优先级决定。
当前契约中的例子
| 大插件 | 机制插槽 | 典型用途 |
|---|---|---|
| ScriptPlay | script:resolve-command | 解析指令 |
| ScriptPlay | script:resolve-condition | 求值条件 |
| ScriptPlay | script:resolve-presentation | 决定演出如何执行 |
| ScriptPlay | script:resolve-flow | 决定流程如何推进 |
| Stage | stage:resolve-effect | 解析演出效果 |
| Stage | stage:resolve-composition | 解析画面合成方式 |
| Battle | battle:resolve-turn-order | 返回可行动单位的排列 |
| Battle | battle:resolve-targeting | 返回候选集合中的目标 |
| Battle | battle:resolve-formula | 返回有序的结算事件 |
这些插槽的 policy 与具体签名以各插件页和代码为准,见 ScriptPlay、Stage 与 Battle。Battle 已提供具体类型与显式输出校验;默认盘策略仍未调用这些插槽,不能仅凭插槽已登记就认为默认玩法已交付。
实现位
实现位是「可替换实现」的登记处,本质是一个 Map<实现名, 实现> 加选择规则,而不是「一个服务名只能被一个 provider 占据」。这是换实现能够不改变既有节点行为的原因。
| 概念 | 含义 |
|---|---|
| 实现名 | 注册键;在同一个实现位内唯一 |
| 内置条目 | 由所属大插件直接登记,随框架存在,不因扩展卸载而消失 |
| 第三方条目 | 由 Plugin 注册,随该单元的作用域撤销 |
| 基数 | single:同一时刻一个生效;multi:全部生效,按名字分派 |
| 卸载行为 | fallback:回落到声明的默认条目;vacant:该位空缺 |
single 与 multi
| 基数 | 注册 | 生效规则 | 取用方式 | 卸载后 |
|---|---|---|---|---|
single | 可登记多个条目 | 同一时刻只有一个生效 | 按选择指针或默认条目 | 回落 default,或变为空缺 |
multi | 可登记多个条目 | 全部生效 | 实现名即分派键 | 该条目消失,其余条目不受影响 |
通用 single 实现位的当前解析顺序:
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | 用户显式选择 | 选择指针指向哪个条目就用哪个 |
| 2 | 宿主能力档匹配 | 条目的 tiers 与当前宿主档位相符时参与选择 |
| 3 | 默认条目 | 契约声明的 default |
通用实现位尚未接入场景或数据侧的选择声明。界面实现位 ui:screen@1 使用专门的解析逻辑:同一界面名下按「用户选择 → ui:screen-defaults 数据包声明 → 内置条目 → 最早登记条目」取用,不经过宿主能力档匹配;所选实现缺失时继续向后回落。它本身是 multi,不能把这套界面选择逻辑理解为所有 single 位的行为。详见 Platform。
用户显式选择只属框架与外壳控制面。插件和 Mod 可以登记实现,不能通过 select、界面的 choose/restore,或 platform:host.select 代用户选择;这些单元调用会被拒绝。界面选择可由外壳保存,所选实现卸载后保留选择记录,重新装载后恢复采用。
single 的实现位必须声明默认条目(若它的卸载行为是 fallback),否则契约本身不合法。
multi 没有「生效一个」的概念:所有条目同时可用,调用方按实现名取用。例如包读取按 form 分派,形态名就是实现名。
注册、重名与撤销
一次实现位注册包含四步:
- 单元在清单
registers中声明{ slot, name };translate:codec@1的登记项还必须声明formats。 - 单元通过服务代理调用实现位的
register(name, impl)。 - 装载器核对该登记是否已在清单中声明;未声明直接抛出
SlotError。 - 注册挂在调用方的 effect 上;单元卸载时自动删除该条目。
重名一律拒绝,不会「后注册的覆盖先注册的」:
| 情况 | 结果 |
|---|---|
| 与内置条目重名 | 拒绝,GUARD_SLOT_NAME_TAKEN |
| 与已装载的第三方条目重名 | 拒绝,GUARD_SLOT_NAME_TAKEN |
| 同一单元重复登记同名条目 | 拒绝 |
| 卸载后再次登记同名条目 | 允许,前一条目已随作用域撤销 |
未在清单中声明却调用 register,会在调用点抛错;装载后核验还会检查「声明了却没有登记」的情况,由核验卸载该单元。这条双向检查让 registers 成为承诺,而不是提示。
卸载回落
卸载行为由大插件在契约中声明,Plugin 不能自行选择:
onUnload | 卸载后的状态 | 适用 |
|---|---|---|
fallback | 回落到契约声明的 default 条目;默认条目也被撤销时该位可能空缺 | 有稳定默认实现的位 |
vacant | 该实现名消失,依赖它的调用需要处理「没有这个条目」 | 按需扩展、无默认实现的位 |
内置条目不随扩展卸载。以 kernel:pack-reader@1 为例:它是 multi、onUnload: vacant,内置 directory、zip、builtin 三种形态;第三方可以新增一个形态名,卸载后该形态不再可用,三个内置形态仍然存在。细节见 Kernel。
常见误解
- 「
single表示只能注册一个实现。」 它表示同一时刻只有一个生效;多个实现可以同时登记,通过选择指针切换。 - 「重名注册会覆盖旧实现,这就是换实现。」 重名会被拒绝。覆盖在用实现属于 Mod 行为,不是 Plugin 的注册。
- 「实现位可以随插件新增。」 实现位的数量与名称由大插件固定;新增实现位属于改大插件结构。
换实现与改机制
| 做法 | 行为 | 档位 |
|---|---|---|
| 注册到实现位,再由用户或控制面选择 | 多个实现并存;单元只负责登记,不能代用户选择 | Plugin |
| 卸载当前 provider,换成自己的 | 既有节点的可观测行为被改变 | Mod |
| 在机制插槽上挂 hook | 改写机制结果 | Mod |
决策时可以只问一句:我要改的是「这个功能怎么运作」,还是「这个分项由谁实现」? 前者挂机制插槽,后者注册实现位。
与装载流程的关系
插槽登记、写边核对与装载后核验都在装载管线中完成。准入阶段会检查:
- 落点是否在根插件名单声明范围内,否则
GUARD_SLOT_UNDECLARED; - 目标 policy 是否
closed,否则GUARD_POLICY_CLOSED; replace位是否已被占用,否则GUARD_SLOT_REPLACE_TAKEN;- 实现名是否重复,否则
GUARD_SLOT_NAME_TAKEN; - Mod 是否绑定了插槽所属大插件的契约版本,否则
GUARD_CONTRACT_INCOMPATIBLE。
判定顺序与失败后的卸载行为见装载管线与 guard,原因码清单见装载原因码。
相关页面
- 写边的定义与 policy:policy 与写边。
- 大插件为什么固定这些名字:大插件与小插件。
- 根插件名单如何固定落点:根插件名单(RPM)。
- 具体插槽与实现位清单:大插件总表。