问题与结论
Unity 框架常见的两种极端,一种是所有系统都通过全局单例访问,另一种是在项目开始时就要求完整构造注入、显式 Scope 和组合根。前者接入快但所有权模糊,后者边界清楚却可能把尚未出现的复杂度提前压给小项目。
Sakura 的关键判断是:访问方式、组合根和生命周期不应被当成互相竞争的架构信仰,而应成为同一组服务合同上的迁移阶梯。
先说结论:消费复杂度应随所有权问题升级
运行时消费模式(后文简称“框架使用档位”):项目访问服务、登记模块、建立组合根并管理生命周期时采用的一组统一规则。
| 模式 | 主要入口 | 适用状态 | 所有权要求 |
|---|---|---|---|
simple-facade |
Sakura.* Facade |
原型、小型项目、单一 Owner | 允许受限兼容创建 |
modular-installer |
IProjectInstaller + TryGet |
多场景、多团队、模块化项目 | App 与 Gameplay 生命周期分开 |
strict-scoped-di |
构造注入 + 显式 Scope | 大型项目、发布加固 | 禁止隐式回退与创建 |
三种模式共享服务合同。迁移改变的是注册、解析和所有权规则,而不是把 Audio、Event、UI、Save 等能力换成完全不同的实现。
证据与分析
Facade 是接入入口,不是隐藏的全局所有权
Simple 模式允许业务通过 SakuraEvent、SakuraPooling、SakuraAudio、SakuraUi、SakuraSave 和 SakuraCommand 等门面进入当前 Runtime Service Context。
业务代码
→ Sakura.* Facade
→ Runtime Service Context
→ 容器服务或受控兼容实例
TryGet* 必须保持观察语义:它只查询已有服务,不创建 Manager、GameObject 或新的隐藏状态。Simple 允许的是一条明确、可诊断的兼容通道,而不是任何地方都能随意创建单例。
组合根负责装配,不负责吞并模块
组合根(后文简称“服务总装入口”):集中决定服务注册、依赖关系和生命周期的启动边界。
组合根应该知道哪些模块属于 App Scope、哪些可选包已安装、项目有哪些覆盖实现、启动任务按什么顺序执行,以及 Root 销毁时怎样释放所有权;它不应该知道具体战斗技能、页面导航、音频表或场景敌人的业务细节。
默认 Bootstrap 的基础闭包应保持最小,通常只直接装配 Event 与 Pooling。Input、UI、Audio、Timeline 等能力在自身包存在并满足版本条件时,通过 条件 Contribution(后文简称“装了才贡献”) 加入既有 Root。
flowchart TD
A["GameRootLifetimeScope"] --> B["Event"]
A --> C["Pooling"]
D["Optional Package"] --> E["Conditional Contribution"]
E --> A
F["Project Installer"] --> A
这样可以避免核心依赖膨胀,也避免让服务器工具、编辑器工具、纯模拟测试或无 UI 场景被迫安装完整客户端闭包。
Modular 把服务来源交给 Installer
当多个场景共享服务、测试需要替换实现、团队需要明确模块 Owner,或项目出现默认实现与覆盖实现时,应迁移到 Modular。
项目通过 IProjectInstaller 或等效入口把服务登记到组合根。业务仍可使用 Facade,但服务身份来自当前 Resolver,而不是场景搜索或隐式创建。
Installer 必须按稳定的 Phase、Priority、程序集全名和类型全名排序。多实现注册应明确 Default、Override、Multi 或普通独占意图;不能依赖“后注册覆盖前注册”的容器细节。
**组合提交边界(后文简称“整套注册一起生效”)**要求先发现 Installer、稳定排序、隔离执行、收集 Descriptor、验证冲突,全部成功后才转换并构建最终容器。
发现 Installer
→ 稳定排序
→ 隔离执行
→ 收集 Descriptor
→ 验证冲突与意图
→ 一次构建容器
如果中途类型加载、构造、DI 转换或注册意图检查失败,不能留下半套注册。
自动 Root 与项目覆盖可以共存
小项目可以在首场景前检测不到 Root 时创建默认组合根;项目已经提供自定义 GameRootLifetimeScope 时,自动启动器必须跳过,不得创建第二个 Bootstrap Host。
自定义 Root 仍然复用既有服务上下文和生命周期合同,项目覆盖通过正式 Installer 或派生 Root 表达。**唯一 Host 所有权(后文简称“一个模块只归一个启动器”)**要求同一模块在同一生命周期中只能被一个容器、Manager 或项目 Host 接管。
重复接管不能用“最后创建的覆盖前一个”掩盖。无法证明唯一所有权时,应跳过或拒绝启动,并产生结构化诊断。
Root 身份还需要 代际绑定(后文简称“旧根不能清新根”):每次 Resolver 绑定记录 Owner 与 generation,释放时只有当前绑定仍属于自己才允许清理。否则 Root A 延迟销毁可能清空 Root B 已建立的全局 Resolver。
Scope 把服务、任务与资源归给同一个 Owner
App、Scene、Battle 和 UI 是常见的四类生命周期:
| Scope | 典型服务 |
|---|---|
| App | 配置、事件、资源、对象池、音频、存档 |
| Scene | 导航、局部资源句柄、房间状态 |
| Battle | 战斗编排、临时实体、技能上下文、随机流 |
| UI | 页面 ViewModel、窗口路由、输入拦截 |
当 Battle Scope 结束时,释放它应同时取消战斗任务、注销事件、归还对象池、释放资源句柄、清理命令历史、关闭战斗 UI,并阻止旧回调继续写入下一局。
Bootstrap FSM、异步启动任务和主线程 Dispatcher 也应绑定到共同的 App Owner,并且只保留一个 Pump。Root 销毁时,任务监管器能够统一取消启动任务、观察失败并防止旧回调进入新一轮运行。
App Scope 不应默认持有当前战斗实体、关卡导航、房间状态、页面 ViewModel 或临时输入拦截。把所有对象都放入 DontDestroyOnLoad Root,只会让场景切换更换画面而不真正结束旧玩法生命周期。
Strict 把隐式回退变成组合错误
Strict 模式适合服务注册完整、Scope 边界已经建立、项目准备禁止隐式创建的阶段。它要求:
- Root 中未注册的服务不会回退到场景单例;
- 无 Root 时不查询 Legacy Manager;
- 强制入口不会创建缺失对象;
- 业务对象优先通过构造函数接收接口;
- Scene、Battle、UI 临时模块使用显式 Scope;
- 兼容路径触发时记录
StrictProfileViolation。
Strict 增加的不是 DI 语法,而是可证明性。项目需要能够回答谁创建了对象、它属于哪个 Scope、Scope 结束时谁释放,以及未注册时系统在哪里失败。
Root ready 后,Facade 必须只认当前 Resolver:
| 运行状态 | TryGet* |
强制入口 |
|---|---|---|
| Root ready | 只查询 Resolver,缺项失败 | 只返回 Resolver 身份 |
| 无 Root + Simple | 只读取已有兼容实例 | 可按明确策略受限创建兼容实例 |
| 无 Root + Modular | 只读取已有兼容实例 | 不创建缺失 Manager |
| 无 Root + Strict | 不查询兼容实例 | 返回空并记录拒绝 |
Root ready 后的 Resolver miss 是组合缺口,不是继续寻找另一个对象的信号。
Legacy 只能是迁移通道
旧 XxxManager.Instance、MonoSingleton 和兼容包装器可以暂时存在,但必须被标记为 Compatibility,并拥有适用范围、观测方式和退出条件。它不能被描述成第四种正式架构模式,否则 Modular 和 Strict 永远没有完成迁移的终点。
Runtime Monitor 应记录当前 Profile、Resolver 是否绑定、解析成功与失败次数、Facade 回退次数、访问来源、活动 Scope 和模块生命周期摘要。**回退预算(后文简称“还剩多少旧路”)**让项目可以从 Simple 逐步减少兼容访问,并在进入 Strict 前把关键路径回退归零。
适用边界与实践
迁移阶梯由边界信号触发
从 Simple 到 Modular 的信号包括:多个场景共享服务、团队需要明确 Owner、服务需要替换、场景开始出现重复 Manager。从 Modular 到 Strict 的信号包括:关键服务已正式登记、App/Scene/Battle/UI Scope 已有明确创建与释放点、非预期 Facade 回退接近零,以及发布前需要禁止隐式创建。
没有这些信号时提前升级,只会增加注册、Scope 和测试样板;但当边界问题真实出现时,继续停留在 Simple 又会让所有权和失败状态不可证明。
我的判断:组合根是治理边界,消费模式是治理强度
一套可长期使用的 Unity 框架,不应强迫所有项目从同一个复杂度起点出发。更重要的是在同一服务合同上提供:
方便接入
→ 明确注册
→ 唯一 Host
→ 显式 Scope
→ 禁止隐式回退
Facade、Installer、组合根和严格 DI 只有在连接到同一套所有权、注册和失败合同后才构成真正的迁移路径。组合根越成熟,知道的业务细节应越少;它只负责把模块按确定性规则装配起来,并把生命周期交给正确的 Scope。
设计检查表
TryGet*是否保持无创建副作用;- 默认 Root 的硬依赖是否最小;
- 可选模块是否通过条件 Contribution 加入;
- 同一模块是否可能被两个 Host 接管;
- Root 替换时旧对象是否可能清理新绑定;
- Installer 是否稳定排序并声明注册意图;
- 注册失败是否会留下半套容器;
- Bootstrap FSM 与异步任务是否共享 Owner;
- App、Scene、Battle、UI Scope 是否边界清晰;
- Root ready 后是否仍可能回退到场景单例;
- Legacy 入口是否有可观测的回退预算;
- Strict 启用前关键路径回退是否归零;
- 项目自定义 Root 是否会触发默认 Root 重复创建。
术语对照
| 正式术语 | 通俗称呼 | 含义 |
|---|---|---|
| 运行时消费模式 | 框架使用档位 | 服务访问、注册和生命周期的统一规则 |
| 组合根 | 服务总装入口 | 统一登记并创建项目运行时服务的位置 |
| 条件 Contribution | 装了才贡献 | 可选包满足条件后向既有 Root 增加服务 |
| 唯一 Host 所有权 | 一个模块只归一个启动器 | 防止重复初始化、Tick 和清理 |
| Root 代际绑定 | 旧根不能清新根 | 旧 Owner 不能释放后继 Root 的全局身份 |
| 组合提交边界 | 整套注册一起生效 | 全部描述验证成功后才构建最终容器 |
| 显式 Scope | 有名字的生命周期 | 由明确 Owner 创建和释放的一组服务、任务与资源 |
| 回退预算 | 还剩多少旧路 | 项目仍允许存在的兼容访问范围 |
来源与验证状态
内部资料依据
本文重新组织原 05-运行时消费模式与架构升级阈值 与原 06-Bootstrap组合根与生命周期所有权。新增结构把 Facade、Installer、组合根、Host 唯一性和 Scope 所有权放进同一条迁移链;它不是把两篇文章按章节简单拼接。
2026-10-26 只读核对 Framework checkout 07f712c04236488879ec44d281695ff0705710c2 的 docs/reference/runtime-consumption-modes.json,确认三个模式、Legacy 非正式模式与迁移触发条件。本文的具体 Installer / Scope 机制仍按原历史研究说明;本轮未执行目标项目生命周期验收。