Files
XCEngine/docs/api/_guides/Scripting/Scripting-Runtime-And-Field-Model.md

193 lines
7.2 KiB
Markdown
Raw Normal View History

# Scripting Runtime And Field Model
## 这份指南解决什么问题
2026-04-08 16:07:03 +08:00
单看 API 页,很难一下子理解当前脚本系统里为什么同时存在:
- `ScriptComponent`
- `ScriptFieldStorage`
- `ScriptEngine`
- `IScriptRuntime`
- `MonoScriptRuntime`
2026-04-08 16:07:03 +08:00
这份指南的目标,是把它们放回同一条真实运行链路里解释清楚,并重点说明“类默认值、场景存储覆盖值、活体托管值”这三层数据如何叠加。
## 一句话理解当前架构
当前脚本系统采用的是“原生场景数据层 + 运行时调度层 + 可替换托管后端”三段式设计:
2026-04-08 16:07:03 +08:00
1. 场景和对象层保存脚本绑定与字段存储。
2. `ScriptEngine` 负责生命周期调度和实例跟踪。
3. `IScriptRuntime` 的实现负责真正执行托管代码,当前实现是 [MonoScriptRuntime](../../XCEngine/Scripting/Mono/MonoScriptRuntime/MonoScriptRuntime.md)。
2026-04-08 16:07:03 +08:00
这种设计和商业引擎常见的脚本体系很接近。它的核心收益不是“更抽象”,而是把三个原本会互相缠绕的问题拆开:
- 场景序列化
- 运行时生命周期
- 托管语言桥接
## 为什么 `ScriptComponent` 不直接等于“脚本实例”
2026-04-08 16:07:03 +08:00
`ScriptComponent` 更像“脚本绑定描述 + 字段持久化容器”,而不是托管对象本身。
2026-04-08 16:07:03 +08:00
这是因为:
2026-04-08 16:07:03 +08:00
- 场景在未运行时也要保存脚本类绑定
- Inspector 和序列化层在未启动 Mono 时也要工作
- 运行时实例应当可以被创建、销毁和重建,而不污染场景层数据结构
2026-04-08 16:07:03 +08:00
这和 Unity 的理念一致:序列化壳与运行时实例不是同一个对象。
2026-04-08 16:07:03 +08:00
## 字段到底有哪几层
2026-04-02 22:23:29 +08:00
当前字段值至少可能来自三层:
1. 类默认值
2026-04-08 16:07:03 +08:00
- 来自 `MonoScriptRuntime::TryGetClassFieldDefaultValues()`
- 它反映的是 C# 初始化后的真实默认值
2026-04-02 22:23:29 +08:00
2. 存储覆盖值
2026-04-08 16:07:03 +08:00
- 来自 `ScriptFieldStorage`
- 它是场景/本地持久化层
2026-04-02 22:23:29 +08:00
3. 活体托管值
2026-04-08 16:07:03 +08:00
- 来自当前正在运行的托管实例
2026-04-02 22:23:29 +08:00
2026-04-08 16:07:03 +08:00
这三层的意义不同:
2026-04-02 22:23:29 +08:00
2026-04-08 16:07:03 +08:00
- 类默认值回答“如果没有任何场景覆盖,这个字段原本该是什么”
- 存储覆盖值回答“场景目前记住了什么”
- 活体托管值回答“本次运行到现在,这个字段被脚本改成了什么”
2026-04-08 16:07:03 +08:00
## 当前哪些字段会进入字段模型
2026-04-08 16:07:03 +08:00
当前 Mono 后端的真实规则不是“所有字段都进来”,而是经过一层非常明确的筛选:
2026-04-08 16:07:03 +08:00
- 排除:
- `static`
- `const / literal`
- `readonly / init-only`
- 接受:
- `public` 实例字段
- `[SerializeField] private` 字段
- 最后还要满足当前支持的字段类型
2026-04-08 16:07:03 +08:00
这意味着:
2026-04-08 16:07:03 +08:00
- `public` 不是进入序列化/字段模型的唯一方式
- `private` 也不是天然不行,关键在于它是否被显式声明为可序列化字段
2026-04-08 16:07:03 +08:00
## 为什么支持 `[SerializeField] private`
2026-04-08 16:07:03 +08:00
这是当前文档里必须讲清楚的一条设计原则。
2026-04-08 16:07:03 +08:00
如果只允许 `public` 字段进入字段模型,会有两个直接问题:
2026-04-08 16:07:03 +08:00
- 脚本作者为了让字段能进 Inspector / 场景存储,只能把本来应该私有的实现细节也暴露成公开 API
- 类的封装边界会被序列化需求反向破坏
2026-04-08 16:07:03 +08:00
支持 `[SerializeField] private` 的好处是:
2026-04-08 16:07:03 +08:00
- 你可以保留封装,把类对外 API 面控制住
- 你仍然能让字段参与场景存储和 Inspector
- 字段重构时更安全,不会因为“想序列化”就把内部状态长期公开
2026-04-08 16:07:03 +08:00
这正是 Unity 一类商业引擎长期采用这套规则的核心原因。
2026-04-08 16:07:03 +08:00
## 为什么未标注的 private 字段仍然要忽略
2026-04-08 16:07:03 +08:00
因为不是所有 private 字段都应该持久化。
2026-04-08 16:07:03 +08:00
很多 private 字段只是:
2026-04-08 16:07:03 +08:00
- 临时缓存
- 运行时计数器
- 中间态标记
- 纯内部实现细节
2026-04-08 16:07:03 +08:00
如果把它们也自动纳入字段模型,会导致:
2026-04-08 16:07:03 +08:00
- 场景数据被临时状态污染
- Inspector 暴露出不该改的内部细节
- 运行时偶发状态被错误回写到持久化层
2026-04-08 16:07:03 +08:00
因此当前策略很明确:
2026-04-08 16:07:03 +08:00
- 想进入字段模型,就显式标 `[SerializeField]`
- 不标,就保持私有实现细节身份
2026-04-08 16:07:03 +08:00
## 为什么 `readonly` 也被排除
2026-04-02 22:23:29 +08:00
2026-04-08 16:07:03 +08:00
当前字段同步模型要求:
2026-04-02 22:23:29 +08:00
2026-04-08 16:07:03 +08:00
- 运行前把存储覆盖值写回托管实例
- 运行后把托管值同步回 `ScriptFieldStorage`
2026-04-02 22:23:29 +08:00
2026-04-08 16:07:03 +08:00
`readonly` 字段不适合承担这个双向同步职责,因此当前实现选择直接排除它。这是偏工程安全的取舍,不是功能缺失。
2026-04-02 22:23:29 +08:00
2026-04-08 16:07:03 +08:00
## 一个最直接的例子:`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` 字段是如何真正走完整个同步链的。
当前测试场景做了这几步:
2026-04-08 16:07:03 +08:00
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)
4. [ScriptFieldStorage](../../XCEngine/Scripting/ScriptFieldStorage/ScriptFieldStorage.md)
5. [ScriptEngine](../../XCEngine/Scripting/ScriptEngine/ScriptEngine.md)
6. [IScriptRuntime](../../XCEngine/Scripting/IScriptRuntime/IScriptRuntime.md)
7. [MonoScriptRuntime](../../XCEngine/Scripting/Mono/MonoScriptRuntime/MonoScriptRuntime.md)
2026-04-02 22:23:29 +08:00
8. [Project Script Assembly And Field Sync](Project-Script-Assembly-And-Field-Sync.md)
## 相关 API
- [SceneRuntime](../../XCEngine/Scene/SceneRuntime/SceneRuntime.md)
- [ScriptComponent](../../XCEngine/Scripting/ScriptComponent/ScriptComponent.md)
- [ScriptEngine](../../XCEngine/Scripting/ScriptEngine/ScriptEngine.md)
- [MonoScriptRuntime](../../XCEngine/Scripting/Mono/MonoScriptRuntime/MonoScriptRuntime.md)