Files
XCEngine/docs/api/_guides/Scripting/Project-Script-Assembly-And-Field-Sync.md

6.1 KiB

Project Script Assembly And Field Sync

这份指南解决什么问题

脚本模块现在关心的不只是“Mono 能不能跑起来”,还包括两条更具体的链路:

  1. project/Assets/**/*.cs 是怎么进入当前运行时可发现的 GameScripts.dll
  2. 字段默认值、场景存储覆盖值和运行时托管值是怎么通过同一份程序集事实串起来的

这份指南就是把这两条链路放在一起说明。

为什么商业引擎通常先看程序集而不是直接扫源文件

从工程设计上说,先编译、再发现类和字段,有几个非常实际的好处:

  • Inspector 列表和运行时真正能实例化出来的类,来自同一份事实来源
  • 字段元数据和字段默认值直接来自编译产物,不需要再维护第二套源代码分析器
  • 测试可以直接验证“项目脚本是否真的可用”,而不只是验证目录结构

当前 XCEngine 也是这一路线:project/Assets/**/*.cs 必须先进入 GameScripts.dll,后续 MonoScriptRuntime 才能把它们变成脚本类描述、字段元数据和真实实例。

项目脚本程序集是怎么生成的

managed/CMakeLists.txt 当前会生成两类托管输出:

  • XCEngine.ScriptCore.dll
  • GameScripts.dll

同时它还会把项目里的 project/Assets/**/*.cs 编译进:

  • project/Library/ScriptAssemblies/XCEngine.ScriptCore.dll
  • project/Library/ScriptAssemblies/GameScripts.dll
  • project/Library/ScriptAssemblies/mscorlib.dll

因此“项目脚本能否被运行时发现”的关键,不是文件名像不像脚本,而是它有没有成功进入 GameScripts.dll

运行时如何接入这份程序集

MonoScriptRuntime::Settings 当前既可以显式指定:

  • coreAssemblyPath
  • appAssemblyPath

也可以只指定:

  • assemblyDirectory

然后由 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

字段默认值和字段同步为什么都依赖同一份程序集事实

这是当前设计里非常重要的一点。

如果类发现基于“源文件扫描”,但默认值读取和实例化基于“运行时程序集”,就会出现两套事实来源:

  • Inspector 以为这个类和字段存在
  • 运行时却可能根本实例化不出来,或者默认值并不一致

当前设计避免了这个问题:

  • 类发现来自编译后的程序集
  • 字段元数据来自编译后的程序集
  • 字段默认值来自编译后的程序集
  • 真正实例化也来自同一份程序集

这是商业引擎文档里常见的“单一事实来源”原则。

[SerializeField] private 在这条链路里意味着什么

当前字段发现规则已经支持:

  • public 实例字段
  • [SerializeField] private 字段

这条规则对“项目脚本”和“内置测试脚本”是一致的,因为它们最终都走同一份 MonoScriptRuntime 字段发现逻辑。

这样设计的原因很明确:

  • 你可以把想持久化/想进 Inspector 的字段保留成 private
  • 同时又不需要为了序列化而扩大脚本对外 API 面

这和 Unity 的设计理念一致,但这里更要强调它背后的工程价值:

  • 封装更稳
  • 重构更安全
  • 序列化范围更可控

哪些 private 字段仍然不会同步

当前不会进入字段模型、也不会参与默认值和存储同步的,至少包括:

  • 未标注 [SerializeField]private
  • readonly / init-only
  • static
  • const / literal
  • 当前不支持的字段类型

这不是遗漏,而是当前字段模型的明确边界。

场景存储、默认值和运行时值如何叠加

当前完整链路可以概括为:

  1. TryGetClassFieldDefaultValues()
    • 给出类默认值
  2. ScriptFieldStorage
    • 提供场景/本地覆盖值
  3. CreateScriptInstance()
    • 把覆盖值写回托管实例
  4. 生命周期运行
    • 托管字段在脚本里继续变化
  5. SyncManagedFieldsToStorage()
    • 把字段模型内的值同步回存储

对于 [SerializeField] private 字段,这条链路同样成立。

SerializeFieldProbe 的 round-trip 测试已经证明:

  • private 字段可以先吃到场景覆盖值
  • 运行后修改过的值会被回写
  • 场景序列化和重新加载后仍能保持
  • 未标注 private 字段不会被偷偷带进存储

当前限制

  • 项目脚本是否可被发现,依赖的是已编译出的 GameScripts.dll
  • 当前没有程序集热重载或增量刷新。
  • 字段模型只覆盖当前支持的字段类型和显式允许进入的字段访问级别。
  • 未进入字段模型的字段,不会出现在默认值查询、字段编辑 UI 或场景持久化链里。

推荐阅读

  1. MonoScriptRuntime
  2. Scripting Runtime And Field Model
  3. ScriptEngine
  4. ScriptField