ADR 0002:目标是一套资源可插拔的开源系统,不是一堆适配器
- 日期:2026-08-20
- 状态:已采纳
- 修订:
0001-自研内核而非微前端框架.md的决定二 - 相关:
AIO-ARCHITECTURE.md、VIEWER-REFACTOR.md
问题
ADR 0001 决定二说「查看器不重写,基于现有代码改造」,VIEWER-REFACTOR.md 据此 得出「四个查看器一个都不用重写,只有三种活:换一个参数、加一个入口、拦一层请求」。
按那条路走下去,终局是一堆适配器:每个上游站点包一层,宿主把它们摆在一起。 能用,但那不是一套系统——它是一个集成层,上游各自的形状原样透过来。
维护者的目标不是这个:
把这些现有的网站/资源仓库全部抽象成接入现代 UI 框架和运行框架的、 资源可插拔的全新开源系统。
区别是实的,不是措辞:适配器模型下,「换一个 3D 实现」意味着改宿主; 系统模型下,它意味着换一个满足同一份契约的包,宿主一行不动。
决定
目标状态是一套开源系统,适配器降级为迁移路径。
三层,自下而上:
一、资源可插拔层
资源怎么来,是可替换的实现,不是写死的 CDN 假设。
ResourceProvider ← 接口
├─ ManifestCdnProvider ← 现有:清单 + 多源回退 + sha256(COS/EdgeOne)
├─ LocalDirectoryProvider← 本地目录,离线研究用
├─ OfflineBundleProvider ← 打包分发,无网可用
└─ MirrorProvider ← 第三方镜像判据:换 provider,插件与宿主零改动。 这是铁律 3(插件只经 host.resources 拿资源)的必然延伸——既然插件已经不碰 URL,那 URL 后面是 CDN、是本地目录、 还是一个离线包,本来就该无关。
现有的 @magireco/resource 是 ManifestCdnProvider 这一个实现,不是接口本身。 这是要拆的第一件事。
二、能力契约层(这一层才是「全新的系统」)
每个能力有一份与渲染技术无关的行为契约,外加一份一致性测试套件 (conformance suite)。契约不描述「怎么画」,只描述「能被怎么用」:
| 能力 | 契约里有什么 |
|---|---|
model3d.show | 装载 / 切角色 / 动画列表与播放控制 / 相机 / 挂起恢复 |
live2d.show | 装载 / 换装 / 动作 / 表情 / 口型同步开关 |
sprite.show | unit 与变体切换 / 播放控制 / 帧回调 |
adv.play | 定位到行 / 播放控制 / progress 与 entity.focused 回流 |
search.query | 查询 / 分面 / 结果 ref 化 |
一致性套件是这一层的实质产物,判据可证伪:
能力的测试套件不 import 任何具体实现;任何实现装进去都必须全绿。
有了它,「换一个实现」才是一句可验证的话,而不是愿望。plugin-model-3d 现在那 10 个测试已经是雏形——它们跑在 node 上,上游两个类是注入的, 所以那套判据天然不绑定 three.js。
三、宿主与 UI 层
React + Next.js(已定,见 apps/station)。但共享 UI 原语仍然不能是 React 组件库 ——ADR 0001 最后一行的理由不变:插件内部各自带运行时,绑定单一框架的组件库会把 其中几个排除在外。分工是:
- 宿主外壳、CMS 后台、路由、布局 → React
- 跨插件共享的 UI 原语(按钮、加载态、错误降级) → CSS 自定义属性 + Web Components
与 ADR 0001 的关系:修订,不是推翻
ADR 0001 决定二的理由仍然成立,只是它回答的是另一个问题。
它论证的是「现在要不要重写」,答案是不要——因为那些代码的价值不在代码本身, 在沉淀的结论:那个 3D 查看器的 shader 是 45 个 CI 提交逆向出来的 (RDToon、SoftMetallic MatCap、ReDrive GLSL 公式),Exedra 有 91 个测试把行为 钉死在真机 AArch64 原生实现上。丢掉它们等于在真机上重新踩一遍。
本 ADR 回答的是「终局是什么」。两者的合成是:
适配器 → 是迁移桥,不是终点
契约 → 是终点具体地:
- 先定契约与一致性套件;
- 把既有查看器适配成第一个实现(就是 VIEWER-REFACTOR 那三种活), 让它通过一致性套件;
- 之后可以逐个能力换成现代实现,换的时候宿主与其它插件零改动—— 因为一致性套件在,替换是可验证的。
第 2 步不是浪费。没有一个能跑的实现,契约就是凭空设计的,一定设计错。 先有一个能过套件的实现,契约才算被证伪过一次。
铁律 6 的解释随之收紧
铁律 6 说「上游仓库改造后必须仍能独立运行」。在适配器模型下这是全部; 在系统模型下它是过渡期的要求:只要某个上游还是某个能力的唯一实现, 它就必须保持独立可跑(否则我们把别人的项目搞坏了,而且失去调试基线)。
当某个能力有了不依赖该上游的实现之后,这条对那个上游自动解除—— 但解除要在契约文件里写明,不是默认发生。
明确未决:渲染层要不要重写
这是本 ADR 不替维护者决定的一件事。
「抽象成新系统」有两种强度:
| 做什么 | 代价 | |
|---|---|---|
| A:契约化 | 定契约 + 一致性套件,既有查看器作为实现接进来 | 保住全部逆向结论;上游仍是依赖 |
| B:契约化 + 重写渲染 | 再用现代栈重新实现渲染层 | 丢掉 45 个 CI 提交的 shader 逆向与 91 个真机核对测试,要在真机上重新踩 |
本 ADR 采纳的是 A,因为 A 是 B 的前提:没有契约与套件,重写出来的东西 无法验证「行为一样」。B 是否要做、对哪个查看器做,等一致性套件跑起来、 能拿它衡量新实现之后再定,那时这个决定才有依据。
落地状态
| 状态 | |
|---|---|
第一层:ResourceProvider 接口 | ✅ 已拆(packages/resource/src/provider.ts)。ManifestCdnProvider 与 StaticProvider 两个实现,一致性套件 9 条判据 × 2 实现全绿 |
| 内核与插件依赖接口而非实现 | ✅ PluginHost.resources 与 KernelOptions.resources 已改为 ResourceProvider |
| 第二层:能力契约与一致性套件 | ✅ 已落地。@magireco/capability 定 5 份契约,@magireco/conformance 出套件;model3d.show 已有两个实现同时过同一套判据 |
adv.play 有了不依赖上游的实现 | ✅ @magireco/plugin-adv:worksheet 解析器(按表头名解析,不依赖列序)+ 渲染无关播放引擎,舞台注入。与参考实现同过一套判据 |
sprite.show 有了不依赖上游的实现 | ✅ @magireco/plugin-sprite:CocosStudio ExportJson 解析器(动作清单来自数据,dr/lp/sc 逐条校验)+ 渲染无关帧播放器,舞台注入。上游那几百个引擎文件与几千组素材留在原地 |
search.query 有了不依赖上游的实现 | ✅ @magireco/plugin-search:跨字段匹配 + 片假名折叠。实测发现上游那份角色目录没有任何 ID(按显示名索引),且显示名跨源对不上(「环彩羽」vs「环伊吕波」)——所以语料条目的 ref 是可选的,没有就不发 entity.focused,绝不按名字凑 |
live2d.show 有了不依赖上游的实现 | ✅ @magireco/plugin-live2d:Cubism model3.json 解析器(Physics/DisplayInfo 实测是 null 而非缺键)+ 渲染无关会话。契约里的 costume 参数因此删除——换服装是换模型文件,属于另一条 ref |
model3d.show 有了不依赖上游的实现 | ✅ @magireco/plugin-gltf:glTF 2.0 解析器(拒收 1.x 与 GLB、区分内嵌 data: 与外部依赖、无名动画按 #下标 显式指)。至此五个能力全部有自有实现,model3d.show 更是有了三个实现同过一套判据 |
| 第三层:宿主装上真实现 | ✅ apps/station 一个占位都不剩:sprite(canvas2d 舞台)、adv(DOM 舞台)、model3d(glTF 解析),并把 provider 从 ManifestCdnProvider 换成 StaticProvider(data: URL 送合成骨骼)。插件与宿主一行没改,一致性套件也没动——两条判据同时兑现 |
补充理由(2026-08-20,维护者):不改上游
原本第二层排在「等第一个真实替换需求」。这条排序被一个更硬的约束改掉了:
这是开源仓库。直接去改上游,第一不太好,第二容易引出许可证问题。
这句话把契约层从「将来好替换」变成这套系统能独立成立的前提:
- 有的上游未授予任何开源许可,有的是他人仓库—— 以「改上游加一个插件入口」为主路径,等于把项目建在别人是否接受我们改动、 以及那些改动的许可状态上;
- 契约先行则反过来:我们自己的实现不依赖任何一个上游而存在, 上游若愿意接,它是契约的又一个实现;不接也不影响这套系统可用。
packages/conformance/test/reference.test.ts 是这条推理的可执行证据: 两个从零写、一行上游代码都不碰的实现,与 plugin-model-3d 适配器跑同一套判据。
StaticProvider 不是凑数的第二个实现——它同时是「反向检查」那条的答案: 没有第二个实现,接口就是在为尚未发生的事付钱。现在有了,而且用途都是真的 (离线包、本地研究、测试造数据)。
后果
已完成。@magireco/resource要拆出ResourceProvider接口,现有实现降为其一。- 新增
@magireco/conformance:每个能力一套不 import 具体实现的测试。 (资源层的那份先落在packages/resource/test/conformance.ts, 等第二个能力也需要时再抽包——现在抽是在造一个只有一个用户的包。) VIEWER-REFACTOR.md的定位改为迁移手册,不再是终局描述。契约文件(已完成,但没有加进contracts/*.source.json)需要能表达「某能力有几个实现、当前用哪个」。*.source.json:那些文件按上游仓库分(一个仓库一份), 而「某能力有几个实现」是横着切的——sprite.show的两个候选分别躺在example-sprite-mirror.source.json与packages/plugin-sprite里,谁也看不出全貌。 所以横着另记一份contracts/capabilities.json, 由tools/check-sources.py与竖着的契约双向对账(10 个坏样本自测)。 它当场查出了一件事:chart.height只有上游一个实现,且本仓库没有它的能力契约。- 上游仓库从「被包装的成品」变成「某个能力的一个实现」——
repository-policy.json里的vendor语义不变,但依赖方向的叙述要跟着改。
反向检查:什么情况下这个决定是错的
如果最终只会有一个实现、且永远不打算换,那契约与一致性套件就是纯开销, 适配器模型更省。判据是「有没有第二个实现」——第二个实现出现之前, 这套抽象是在为一个尚未发生的事付钱。
所以落地顺序上,一致性套件要跟着第一个真实替换需求走, 而不是一次性给五个能力都写完。