docs: sync api and planning docs

This commit is contained in:
2026-04-08 16:07:03 +08:00
parent 08c3278e10
commit 31756847ab
1826 changed files with 44502 additions and 29645 deletions

View File

@@ -2,122 +2,158 @@
## 这份指南解决什么问题
脚本模块这轮更新之后,已经不只是“Mono 能不能跑起来”的问题,还包括两条更具体的链路:
脚本模块现在关心的不只是“Mono 能不能跑起来”,还包括两条更具体的链路:
1. `project/Assets/**/*.cs` 怎么进入当前运行时发现的 `GameScripts.dll`
2. 字段默认值、场景存储覆盖值、活体托管值到底是怎么互相覆盖和同步
1. `project/Assets/**/*.cs` 怎么进入当前运行时发现的 `GameScripts.dll`
2. 字段默认值、场景存储覆盖值和运行时托管值是怎么通过同一份程序集事实串起来
这份指南就是把这两条链路放在一起说明。
## 为什么商业引擎通常先看程序集而不是直接扫源文件
从工程设计上说,先编译、再发现类和字段,有几个非常实际的好处:
- Inspector 列表和运行时真正能实例化出来的类,来自同一份事实来源
- 字段元数据和字段默认值直接来自编译产物,不需要再维护第二套源代码分析器
- 测试可以直接验证“项目脚本是否真的可用”,而不只是验证目录结构
当前 XCEngine 也是这一路线:`project/Assets/**/*.cs` 必须先进入 `GameScripts.dll`,后续 [MonoScriptRuntime](../../XCEngine/Scripting/Mono/MonoScriptRuntime/MonoScriptRuntime.md) 才能把它们变成脚本类描述、字段元数据和真实实例。
## 项目脚本程序集是怎么生成的
`managed/CMakeLists.txt` 当前会构建两组托管输出:
`managed/CMakeLists.txt` 当前会生成两类托管输出:
- 通用脚本核心程序集:`XCEngine.ScriptCore.dll`
- 游戏脚本程序集:`GameScripts.dll`
- `XCEngine.ScriptCore.dll`
- `GameScripts.dll`
同时它还会扫描 `project/Assets/**/*.cs`,并把这些项目资产脚本编译
同时它还会把项目里的 `project/Assets/**/*.cs` 编译
- `project/Library/ScriptAssemblies/XCEngine.ScriptCore.dll`
- `project/Library/ScriptAssemblies/GameScripts.dll`
- `project/Library/ScriptAssemblies/mscorlib.dll`
如果项目目录下暂时没有任何 `.cs` 文件CMake 会生成一个占位源文件,保证 `GameScripts.dll` 仍然存在
因此“项目脚本能否被运行时发现”的关键,不是文件名像不像脚本,而是它有没有成功进入 `GameScripts.dll`
## 运行时如何接入这份项目程序集
## 运行时如何接入这份程序集
`MonoScriptRuntime::Settings` 可以只指定:
`MonoScriptRuntime::Settings` 当前既可以显式指定:
- `coreAssemblyPath`
- `appAssemblyPath`
也可以只指定:
- `assemblyDirectory`
- `corlibDirectory`
- 或更显式的 `coreAssemblyPath` / `appAssemblyPath`
`ResolveSettings()` 会根据这些字段补全剩余路径。当前 `tests/scripting/test_project_script_assembly.cpp` 的做法就是把 `assemblyDirectory` 指到 `project/Library/ScriptAssemblies`,再验证运行时能发现项目资产脚本
然后由 `ResolveSettings()` 补全路径
已验证的真实行为包括:
实际 editor/runtime 项目产物仍会落到 `project/Library/ScriptAssemblies`
`tests/Scripting/test_project_script_assembly.cpp` 当前并不是直接复用这个工作树目录;它会优先读取 `XCENGINE_TEST_PROJECT_MANAGED_OUTPUT_DIR`,由 `xcengine_test_project_managed_assemblies` target 提供测试专用输出目录,未配置时才 fallback 到 `build/managed/ProjectScriptAssemblies`
这条测试验证的是“项目 `Assets/**/*.cs` 被编译成可发现的 `GameScripts.dll`”这条链路,而不是工作树里现成的 `Library/ScriptAssemblies` 快照。
## 进入 `GameScripts.dll` 之后,哪些类才会真的被发现
编译进程序集只是第一步。当前 Mono 运行时还会继续筛掉:
- 抽象类
-`MonoBehaviour` 子类
- 不在应用程序集这一侧的类型
因此当前真正能进入脚本类列表的,是“应用程序集里的非抽象 `MonoBehaviour` 子类”。
`ProjectScriptProbe.cs` 当前就是这条链路的最小样例:
- 运行时能发现 `ProjectScripts.ProjectScriptProbe`
- 能返回它的字段元数据
- 能返回它的默认字段值:
- 能返回它的真实默认值:
- `EnabledOnBoot = true`
- `Label = "ProjectScriptProbe"`
- `Speed = 2.5f`
项目样例脚本当前位于 [ProjectScriptProbe.cs](../../../../project/Assets/Scripts/ProjectScriptProbe.cs)。
## 字段默认值和字段同步为什么都依赖同一份程序集事实
## 字段值的三层来源
这是当前设计里非常重要的一点。
当前脚本字段至少可能来自三层
如果类发现基于“源文件扫描”,但默认值读取和实例化基于“运行时程序集”,就会出现两套事实来源
1. 类默认值
来自 `MonoScriptRuntime::TryGetClassFieldDefaultValues()`,反映 C# 初始化后的真实默认状态。
2. 存储覆盖值
来自 `ScriptComponent::GetFieldStorage()`,会进入场景序列化。
3. 活体托管值
来自当前运行中的托管实例。
- Inspector 以为这个类和字段存在
- 运行时却可能根本实例化不出来,或者默认值并不一致
`ScriptEngine::TryGetScriptFieldModel()` 会把这三层合并成 `ScriptFieldModel`
当前设计避免了这个问题
- `defaultValue` 表示类默认值
- `storedValue` 表示场景/本地缓存中的覆盖值
- `value` + `valueSource` 表示当前真正应该展示给 UI 的值
- 类发现来自编译后的程序集
- 字段元数据来自编译后的程序集
- 字段默认值来自编译后的程序集
- 真正实例化也来自同一份程序集
## 创建实例时字段怎么进入托管世界
这是商业引擎文档里常见的“单一事实来源”原则。
`MonoScriptRuntime::CreateScriptInstance()` 当前会按这条顺序工作:
## `[SerializeField] private` 在这条链路里意味着什么
1. 查类元数据
2. 创建托管对象
3. 注入 `gameObjectUUID``scriptComponentUUID`
4. 遍历 `ScriptFieldStorage`
5. 只把“字段名存在且类型匹配”的覆盖值写进托管实例
当前字段发现规则已经支持:
这意味着:
- `public` 实例字段
- `[SerializeField] private` 字段
- 场景里保存的覆盖值可以覆盖类默认值
- 存储里已经遗留、但脚本类里不存在的字段,不会被写入托管实例
- 类型不匹配的字段也不会被偷偷应用
这条规则对“项目脚本”和“内置测试脚本”是一致的,因为它们最终都走同一份 `MonoScriptRuntime` 字段发现逻辑。
## 生命周期后为什么还要回写
这样设计的原因很明确:
`ScriptEngine` 每次调用生命周期方法后,都会紧接着调用运行时的 `SyncManagedFieldsToStorage()`
- 你可以把想持久化/想进 Inspector 的字段保留成 private
- 同时又不需要为了序列化而扩大脚本对外 API 面
Mono 当前只会回写
这和 Unity 的设计理念一致,但这里更要强调它背后的工程价值
- 本地已经存在于 `ScriptFieldStorage` 的字段
- 且字段在类元数据里仍然存在
- 且存储类型与类声明类型匹配
- 封装更稳
- 重构更安全
- 序列化范围更可控
这样设计的好处是:
## 哪些 private 字段仍然不会同步
- 不会把运行时临时字段自动污染到场景数据
- 类型漂移会显式表现成 `TypeMismatch`
- 存储层和托管层的职责边界更清楚
当前不会进入字段模型、也不会参与默认值和存储同步的,至少包括:
## 批量编辑和清除覆盖怎么工作
- 未标注 `[SerializeField]``private`
- `readonly / init-only`
- `static`
- `const / literal`
- 当前不支持的字段类型
当前推荐给编辑器用的不是直接操作 `ScriptFieldStorage`,而是:
这不是遗漏,而是当前字段模型的明确边界。
- [ScriptEngine::ApplyScriptFieldWrites](../../XCEngine/Scripting/ScriptEngine/ApplyScriptFieldWrites.md)
- [ScriptEngine::ClearScriptFieldOverrides](../../XCEngine/Scripting/ScriptEngine/ClearScriptFieldOverrides.md)
## 场景存储、默认值和运行时值如何叠加
原因很直接
当前完整链路可以概括为
- 批量写会同时校验类元数据、活体实例和本地存储
- 清除覆盖会把活体托管字段恢复到类默认值,而不是只删一份本地缓存
1. `TryGetClassFieldDefaultValues()`
- 给出类默认值
2. `ScriptFieldStorage`
- 提供场景/本地覆盖值
3. `CreateScriptInstance()`
- 把覆盖值写回托管实例
4. 生命周期运行
- 托管字段在脚本里继续变化
5. `SyncManagedFieldsToStorage()`
- 把字段模型内的值同步回存储
如果脚本类已经丢失,系统仍允许对现有存储字段做有限编辑或删除,但会用 `StoredOnly` / `Missing` 这类状态明确告诉你当前已经脱离类声明
对于 `[SerializeField] private` 字段,这条链路同样成立
`SerializeFieldProbe` 的 round-trip 测试已经证明:
- private 字段可以先吃到场景覆盖值
- 运行后修改过的值会被回写
- 场景序列化和重新加载后仍能保持
- 未标注 private 字段不会被偷偷带进存储
## 当前限制
- 只支持受支持类型的 `public``static` 实例字段
- 只发现应用程序集里的非抽象 `MonoBehaviour` 子类
- 没有热重载或程序集增量刷新
- 不会自动把“运行时新增但本地不存在”的字段持久化
- 项目脚本是否可被发现,依赖的是已编译出的 `GameScripts.dll`
- 当前没有程序集热重载或增量刷新。
- 字段模型只覆盖当前支持的字段类型和显式允许进入的字段访问级别。
- 未进入字段模型的字段,不会出现在默认值查询、字段编辑 UI 或场景持久化链里。
## 推荐阅读
1. [Scripting](../../XCEngine/Scripting/Scripting.md)
2. [MonoScriptRuntime](../../XCEngine/Scripting/Mono/MonoScriptRuntime/MonoScriptRuntime.md)
1. [MonoScriptRuntime](../../XCEngine/Scripting/Mono/MonoScriptRuntime/MonoScriptRuntime.md)
2. [Scripting Runtime And Field Model](Scripting-Runtime-And-Field-Model.md)
3. [ScriptEngine](../../XCEngine/Scripting/ScriptEngine/ScriptEngine.md)
4. [ScriptField](../../XCEngine/Scripting/ScriptField/ScriptField.md)

View File

@@ -2,7 +2,7 @@
## 这份指南解决什么问题
只看单个 API 页,很难一下子理解当前脚本系统为什么同时存在:
单看 API 页,很难一下子理解当前脚本系统为什么同时存在:
- `ScriptComponent`
- `ScriptFieldStorage`
@@ -10,17 +10,17 @@
- `IScriptRuntime`
- `MonoScriptRuntime`
这份指南的目标,是把它们放回同一条真实运行链路里解释清楚,并明“类默认值、存储覆盖值、活体托管值”这三层数据是怎么叠加
这份指南的目标,是把它们放回同一条真实运行链路里解释清楚,并重点说明“类默认值、场景存储覆盖值、活体托管值”这三层数据如何叠加。
## 一句话理解当前架构
当前脚本系统采用的是“原生场景数据层 + 运行时调度层 + 可替换托管后端”三段式设计:
1. 场景和对象层保存脚本绑定与字段存。
2. `ScriptEngine` 负责生命周期和实例踪。
3. 具体脚本代码执行交给 `IScriptRuntime` 的实现,当前实现是 `MonoScriptRuntime`
1. 场景和对象层保存脚本绑定与字段存
2. `ScriptEngine` 负责生命周期调度和实例踪。
3. `IScriptRuntime` 的实现负责真正执行托管代码,当前实现是 [MonoScriptRuntime](../../XCEngine/Scripting/Mono/MonoScriptRuntime/MonoScriptRuntime.md)
这种分层和商业引擎常见的脚本体系很接近。它的核心收益不是“代码显得更抽象”,而是三个原本会互相缠的问题拆开:
这种设计和商业引擎常见的脚本体系很接近。它的核心收益不是“更抽象”,而是三个原本会互相缠的问题拆开:
- 场景序列化
- 运行时生命周期
@@ -28,117 +28,153 @@
## 为什么 `ScriptComponent` 不直接等于“脚本实例”
很多用户第一反应会是:既然组件名叫 `ScriptComponent`,为什么它不自己持有托管对象
`ScriptComponent` 更像“脚本绑定描述 + 字段持久化容器”,而不是托管对象本身。
原因很现实
这是因为
- 场景在未运行时也要保存脚本绑定信息。
- 场景加载、复制、序列化、反序列化时,不应该强依赖 Mono 已经初始化。
- 编辑器或工具链可能只想改字段,不想真正启动托管运行时。
- 场景在未运行时也要保存脚本绑定
- Inspector 和序列化层在未启动 Mono 时也要工作
- 运行时实例应当可以被创建、销毁和重建,而不污染场景层数据结构
所以当前 `ScriptComponent` 更像“脚本实例描述与持久化容器”,而不是托管对象本身。真正的托管实例是在运行期由 `ScriptEngine + IScriptRuntime` 动态创建的
这和 Unity 的理念一致:序列化壳与运行时实例不是同一个对象
## 为什么还要有 `ScriptFieldStorage`
`ScriptFieldStorage` 的存在,是为了让脚本字段有一个不依赖托管运行时的落点。它解决了三类问题:
- 场景序列化时,字段值需要写进文本数据。
- 运行前,用户仍然需要能编辑脚本字段默认值。
- 运行中,托管对象的字段变化需要有机会回写到本地缓存。
这和 Unity Inspector 里的“脚本字段序列化层”在理念上很接近,但当前实现更轻量,也更直接。
## 运行时里字段值到底有几层
## 字段到底有哪几层
当前字段值至少可能来自三层:
1. 类默认值
来自运行时 `TryGetClassFieldDefaultValues()`Mono 实现会真实构造一个托管对象并读取字段初始化结果。
- 来自 `MonoScriptRuntime::TryGetClassFieldDefaultValues()`
- 它反映的是 C# 初始化后的真实默认值
2. 存储覆盖值
来自 `ScriptFieldStorage`;它会在场景序列化、运行前编辑和生命周期回写之间持续存在。
- 来自 `ScriptFieldStorage`
- 它是场景/本地持久化层
3. 活体托管值
来自当前运行的托管实例`ScriptEngine::TryGetScriptFieldModel()``TryGetScriptFieldValue()` 会优先读这一层。
- 来自当前正在运行的托管实例
`ScriptFieldSnapshot` 里的 `valueSource` 就是在说明当前展示值到底来自哪一层。
这三层的意义不同:
## 当前生命周期是怎么串起来的
- 类默认值回答“如果没有任何场景覆盖,这个字段原本该是什么”
- 存储覆盖值回答“场景目前记住了什么”
- 活体托管值回答“本次运行到现在,这个字段被脚本改成了什么”
真实顺序如下:
## 当前哪些字段会进入字段模型
1. `SceneRuntime::Start()` 调用 `ScriptEngine::OnRuntimeStart(scene)`
2. `ScriptEngine` 收集场景里的 `ScriptComponent`,并订阅 `Scene::OnGameObjectCreated()`,保证运行中生成的新对象也会被继续追踪。
3. 对满足运行条件的组件,调用运行时 `CreateScriptInstance()`
4. 然后按顺序补发 `Awake``OnEnable`
5. 第一次 `OnUpdate()` 前,会先补发一次 `Start`
6. 每个生命周期调用后,运行时会把托管字段同步回 `ScriptFieldStorage`
7. 停止运行时,按 `OnDisable -> OnDestroy -> DestroyScriptInstance` 回收。
当前 Mono 后端的真实规则不是“所有字段都进来”,而是经过一层非常明确的筛选:
这里最关键的一点,是当前引擎把生命周期顺序集中收口在 `ScriptEngine`,而不是让 Mono 运行时自己决定。这样未来即便更换脚本后端,生命周期语义仍然可以保持一致。
- 排除:
- `static`
- `const / literal`
- `readonly / init-only`
- 接受:
- `public` 实例字段
- `[SerializeField] private` 字段
- 最后还要满足当前支持的字段类型
## `IScriptRuntime` 的真正意义
这意味着:
`IScriptRuntime` 不是为了“为了抽象而抽象”。它解决的是一个很实际的问题:
- `public` 不是进入序列化/字段模型的唯一方式
- `private` 也不是天然不行,关键在于它是否被显式声明为可序列化字段
- 引擎主流程需要操心“什么时候创建实例、什么时候调用生命周期、字段怎么读写”。
- 但它不应该知道 Mono API、GCHandle、程序集加载这些实现细节。
## 为什么支持 `[SerializeField] private`
因此 `IScriptRuntime` 只暴露引擎真正关心的契约:
这是当前文档里必须讲清楚的一条设计原则。
- 运行时启停
- 类字段元数据查询
- 托管字段读写
- 实例创建/销毁
- 生命周期方法调用
如果只允许 `public` 字段进入字段模型,会有两个直接问题:
这样 `ScriptEngine` 才能成为“脚本系统总调度器”,而不是某个具体运行时的包装类。
- 脚本作者为了让字段能进 Inspector / 场景存储,只能把本来应该私有的实现细节也暴露成公开 API
- 类的封装边界会被序列化需求反向破坏
## 当前 Mono 方案做到了哪一步
支持 `[SerializeField] private` 的好处是:
`MonoScriptRuntime` 当前已经具备以下实际能力:
- 你可以保留封装,把类对外 API 面控制住
- 你仍然能让字段参与场景存储和 Inspector
- 字段重构时更安全,不会因为“想序列化”就把内部状态长期公开
- 初始化 Mono 域并加载核心/游戏程序集
- 发现继承 `MonoBehaviour` 的非抽象脚本类。
- 枚举支持类型的公共实例字段,并读取字段默认值。
- 创建托管实例,并注入 `GameObject` / `ScriptComponent` 上下文。
- 通过 internal call 桥接基础引擎 API。
- 生命周期调用后把已存储字段同步回本地缓存。
这正是 Unity 一类商业引擎长期采用这套规则的核心原因
但也要明确它还没有做到什么:
## 为什么未标注的 private 字段仍然要忽略
- 没有热重载和程序集增量刷新
- 没有完整的脚本调试器集成。
- 没有更细的域隔离和编辑器运行时分层。
- `SyncManagedFieldsToStorage()` 当前只回写“本地已经存在的字段”,不会自动持久化运行时临时新增字段。
因为不是所有 private 字段都应该持久化
## 为什么字段同步策略这么保守
很多 private 字段只是:
当前字段同步只回写 `ScriptFieldStorage` 里已经存在且类型匹配的字段。这种设计看上去保守,但它有明确好处:
- 临时缓存
- 运行时计数器
- 中间态标记
- 纯内部实现细节
- 不会因为运行时临时字段把场景数据层污染得越来越不可控。
- 可以保住“场景里声明了哪些持久化字段”这条边界。
- 对编辑器和序列化格式更安全。
如果把它们也自动纳入字段模型,会导致:
代价也很明显:
- 场景数据被临时状态污染
- Inspector 暴露出不该改的内部细节
- 运行时偶发状态被错误回写到持久化层
- 运行中动态产生的临时计数器、缓存值,不会自动进场景。
- 如果字段类型改了,系统会把它标成 mismatch而不是偷偷帮你转换。
因此当前策略很明确:
这是一种更偏工程安全的选择。
- 想进入字段模型,就显式标 `[SerializeField]`
- 不标,就保持私有实现细节身份
## 类切换为什么要走 `SetScriptClass()`
## 为什么 `readonly` 也被排除
当前运行时重绑定逻辑并不是“任意改三个字符串都能触发”。
当前字段同步模型要求:
- `ScriptComponent::SetScriptClass()` / `ClearScriptClass()` 会通知 `ScriptEngine`
- 运行时中的 `ScriptEngine::OnScriptComponentClassChanged()` 会停掉旧实例,再按新类重新创建和跟踪。
- `SetAssemblyName()` / `SetNamespaceName()` / `SetClassName()` 只是原始字段 setter不会触发这条流程。
- 运行前把存储覆盖值写回托管实例
- 运行后把托管值同步回 `ScriptFieldStorage`
也就是说,真正要做脚本类切换时,必须走显式重绑定 API
`readonly` 字段不适合承担这个双向同步职责,因此当前实现选择直接排除它。这是偏工程安全的取舍,不是功能缺失
## 一个最直接的例子:`FieldMetadataProbe`
`managed/GameScripts/FieldMetadataProbe.cs` 当前几乎就是规则样本:
- `Health` / `Speed` / `Label` / `SpawnPoint` / `State` / `Target`
- 会进入字段元数据
- `HiddenFlag`
- 因为是 `[SerializeField] private`,也会进入字段元数据
- `IgnoredPrivateCounter`
- 因为是未标注的 private不会进入
- `SharedCounter`
- 因为是 `static`,不会进入
- `UnsupportedRotation`
- 因为 `Quaternion` 当前不在支持类型里,不会进入
这条规则已经被 `ClassFieldMetadataListsSupportedPublicInstanceFields` 和相关默认值测试直接覆盖。
## 一个更完整的运行时例子:`SerializeFieldProbe`
`managed/GameScripts/SerializeFieldProbe.cs` 展示了 `[SerializeField] private` 字段是如何真正走完整个同步链的。
当前测试场景做了这几步:
1.`ScriptFieldStorage` 里预先写入:
- `HiddenCounter = 42`
- `HiddenEnabled = false`
2. 启动运行时并创建托管实例
3. `CreateScriptInstance()` 把这两个存储覆盖值写回 private 字段
4. 脚本在 `Start()` 里读取并修改它们
5. `SyncManagedFieldsToStorage()` 再把更新后的值同步回存储
6. 场景序列化并重新加载后,值仍然能 round-trip 保持
同一测试还验证了一个边界:
- `IgnoredPrivateCounter` 这种未标注 private 字段不会被写入 `ScriptFieldStorage`
这正是当前字段模型“支持 `[SerializeField] private`,但保持内部状态边界”的关键体现。
## 为什么这种同步策略是保守但合理的
当前实现并不是“看到托管字段就自动持久化”,而是更保守地只同步已经进入字段模型的字段。
它的好处很现实:
- 不会把运行时临时字段偷偷带进场景数据
- 不会因为类里多了一个内部变量,就让序列化格式悄悄膨胀
- 字段迁移或类型变化时,边界更可控
这是更偏商业引擎工程安全的取舍。
## 推荐阅读顺序
如果你第一次接触这个模块,建议按下面顺序看:
1. [Scripting](../../XCEngine/Scripting/Scripting.md)
2. [ScriptComponent](../../XCEngine/Scripting/ScriptComponent/ScriptComponent.md)
3. [ScriptField](../../XCEngine/Scripting/ScriptField/ScriptField.md)