7.3 KiB
Scripting Runtime And Field Model
这份指南解决什么问题
只看单个 API 页,很难一下子理解当前脚本系统为什么同时存在:
ScriptComponentScriptFieldStorageScriptEngineIScriptRuntimeMonoScriptRuntime
这份指南的目标,是把它们放回同一条真实运行链路里解释清楚,并明确“类默认值、存储覆盖值、活体托管值”这三层数据是怎么叠加的。
一句话理解当前架构
当前脚本系统采用的是“原生场景数据层 + 运行时调度层 + 可替换托管后端”三段式设计:
- 场景和对象层只保存脚本绑定与字段缓存。
ScriptEngine负责生命周期和实例追踪。- 具体脚本代码执行交给
IScriptRuntime的实现,当前实现是MonoScriptRuntime。
这种分层和商业引擎里常见的脚本体系很接近。它的核心收益,不是“代码显得更抽象”,而是让三个原本会互相缠死的问题被拆开:
- 场景序列化
- 运行时生命周期
- 托管语言桥接
为什么 ScriptComponent 不直接等于“脚本实例”
很多用户第一反应会是:既然组件名叫 ScriptComponent,为什么它不自己持有托管对象?
原因很现实:
- 场景在未运行时也需要保存脚本绑定信息。
- 场景加载、复制、序列化、反序列化时,不应该强依赖 Mono 已经初始化。
- 编辑器或工具链可能只想改字段,不想真正启动托管运行时。
所以当前 ScriptComponent 更像“脚本实例描述与持久化容器”,而不是托管对象本身。真正的托管实例是在运行期由 ScriptEngine + IScriptRuntime 动态创建的。
为什么还要有 ScriptFieldStorage
ScriptFieldStorage 的存在,是为了让脚本字段有一个不依赖托管运行时的落点。它解决了三类问题:
- 场景序列化时,字段值需要写进文本数据。
- 运行前,用户仍然需要能编辑脚本字段默认值。
- 运行中,托管对象的字段变化需要有机会回写到本地缓存。
这和 Unity Inspector 里的“脚本字段序列化层”在理念上很接近,但当前实现更轻量,也更直接。
运行时里字段值到底有几层
当前字段值至少可能来自三层:
- 类默认值
来自运行时
TryGetClassFieldDefaultValues();Mono 实现会真实构造一个托管对象并读取字段初始化结果。 - 存储覆盖值
来自
ScriptFieldStorage;它会在场景序列化、运行前编辑和生命周期回写之间持续存在。 - 活体托管值
来自当前运行中的托管实例;
ScriptEngine::TryGetScriptFieldModel()和TryGetScriptFieldValue()会优先读这一层。
ScriptFieldSnapshot 里的 valueSource 就是在说明当前展示值到底来自哪一层。
当前生命周期是怎么串起来的
真实顺序如下:
SceneRuntime::Start()调用ScriptEngine::OnRuntimeStart(scene)。ScriptEngine收集场景里的ScriptComponent,并订阅Scene::OnGameObjectCreated(),保证运行中生成的新对象也会被继续追踪。- 对满足运行条件的组件,调用运行时
CreateScriptInstance()。 - 然后按顺序补发
Awake、OnEnable。 - 第一次
OnUpdate()前,会先补发一次Start。 - 每个生命周期调用后,运行时会把托管字段同步回
ScriptFieldStorage。 - 停止运行时,按
OnDisable -> OnDestroy -> DestroyScriptInstance回收。
这里最关键的一点,是当前引擎把生命周期顺序集中收口在 ScriptEngine,而不是让 Mono 运行时自己决定。这样未来即便更换脚本后端,生命周期语义仍然可以保持一致。
IScriptRuntime 的真正意义
IScriptRuntime 不是为了“为了抽象而抽象”。它解决的是一个很实际的问题:
- 引擎主流程需要操心“什么时候创建实例、什么时候调用生命周期、字段怎么读写”。
- 但它不应该知道 Mono API、GCHandle、程序集加载这些实现细节。
因此 IScriptRuntime 只暴露引擎真正关心的契约:
- 运行时启停
- 类字段元数据查询
- 托管字段读写
- 实例创建/销毁
- 生命周期方法调用
这样 ScriptEngine 才能成为“脚本系统总调度器”,而不是某个具体运行时的包装类。
当前 Mono 方案做到了哪一步
MonoScriptRuntime 当前已经具备以下实际能力:
- 初始化 Mono 域并加载核心/游戏程序集。
- 发现继承
MonoBehaviour的非抽象脚本类。 - 枚举支持类型的公共实例字段,并读取字段默认值。
- 创建托管实例,并注入
GameObject/ScriptComponent上下文。 - 通过 internal call 桥接基础引擎 API。
- 生命周期调用后把已存储字段同步回本地缓存。
但也要明确它还没有做到什么:
- 没有热重载和程序集增量刷新。
- 没有完整的脚本调试器集成。
- 没有更细的域隔离和编辑器运行时分层。
SyncManagedFieldsToStorage()当前只回写“本地已经存在的字段”,不会自动持久化运行时临时新增字段。
为什么字段同步策略这么保守
当前字段同步只回写 ScriptFieldStorage 里已经存在且类型匹配的字段。这种设计看上去保守,但它有明确好处:
- 不会因为运行时临时字段把场景数据层污染得越来越不可控。
- 可以保住“场景里声明了哪些持久化字段”这条边界。
- 对编辑器和序列化格式更安全。
代价也很明显:
- 运行中动态产生的临时计数器、缓存值,不会自动进场景。
- 如果字段类型改了,系统会把它标成 mismatch,而不是偷偷帮你转换。
这是一种更偏工程安全的选择。
类切换为什么要走 SetScriptClass()
当前运行时重绑定逻辑并不是“任意改三个字符串都能触发”。
ScriptComponent::SetScriptClass()/ClearScriptClass()会通知ScriptEngine。- 运行时中的
ScriptEngine::OnScriptComponentClassChanged()会停掉旧实例,再按新类重新创建和跟踪。 SetAssemblyName()/SetNamespaceName()/SetClassName()只是原始字段 setter,不会触发这条流程。
也就是说,真正要做脚本类切换时,必须走显式重绑定 API。
推荐阅读顺序
如果你第一次接触这个模块,建议按下面顺序看:
- Scripting
- ScriptComponent
- ScriptField
- ScriptFieldStorage
- ScriptEngine
- IScriptRuntime
- MonoScriptRuntime
- Project Script Assembly And Field Sync