Unity Editor 工具不该用反射写组件字段:SerializedObject 在 4 个场景里非用不可

很多 Unity Editor 扩展默认采用反射来读写组件字段------FieldInfo.SetValue(component, value) 一行就能写完,看起来够用。然而一旦工具的使用范围从"在场景里改一个对象"扩展到"覆盖 prefab 资产 / 接 Undo / 同时改多个对象",反射方案会在 4 个具体场景里彻底翻车。

本文记录这 4 个翻车点,以及 Unity 自身 Inspector 同样依赖的统一解法:SerializedObject / SerializedProperty 模型。文中代码以 Funplay Unity MCP 中 ComponentSerializer.cs 的实现为参考------那里的 set_component_property 工具就是为了规避这些坑而完整重写过一遍。

1. 反射的"够用错觉"

反射写法的典型形态:

csharp 复制代码
public static bool SetField(Component target, string fieldName, object value)
{
    var field = target.GetType().GetField(fieldName);
    if (field == null) return false;
    field.SetValue(target, value);
    return true;
}

3 行代码,签名干净,对一个挂在场景里的、字段是 public 的、单选编辑、不需要 Undo 的 Component------一切正常。问题就出在这一连串前提条件上。

2. 翻车 1:[SerializeField] private 字段拿不到

Unity 项目里大量字段是 [SerializeField] private 形态------可序列化但不暴露公共 API。默认 GetField(name) 不带 BindingFlags 时,只匹配 public,private 字段直接返回 null。

csharp 复制代码
// 必须显式指定 BindingFlags 才能拿到 private 字段
var field = target.GetType().GetField(
    fieldName,
    BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);

加 BindingFlags 是底线,但仍不够------继承链上父类的 private 字段还要逐层向上查找。每个用反射的工具都要自己处理这一坨边界,写错就漏字段。

3. 翻车 2:Prefab 实例的修改不持久化

这是反射方案最致命的坑:在 prefab 实例上反射 SetValue,运行时看起来确实生效,但保存场景后再打开,改动消失

原因是 prefab 实例的字段值由两部分构成:

  • 来自原始 prefab 的"基线值"
  • 该实例上的"property override"(Inspector 里那些蓝色高亮)

反射直接 SetValue 修改的是内存里的 C# 对象,但 Unity 序列化时只保存 property override 列表。如果没有显式调用 PrefabUtility.RecordPrefabInstancePropertyModifications(target),Unity 会认为这个字段没有 override,序列化时会回写为 prefab 的基线值。
prefab 实例
未注册
已注册
反射 SetValue
内存对象字段值变化
Unity 序列化
读取 PropertyModifications 列表
回写为基线值
保留覆盖

要修对这个坑,反射方案必须在 SetValue 后追加:

csharp 复制代码
PrefabUtility.RecordPrefabInstancePropertyModifications(target);
EditorUtility.SetDirty(target);

每次都不能漏,且对场景对象(非 prefab 实例)调用是无害的,但对 prefab stage 内编辑的对象又是另一套规则。维护成本陡升。

4. 翻车 3:Undo 不生效

反射 SetValue 直接改内存,Unity 的 Undo 系统对此一无所知。用户按 Ctrl+Z 时 Editor 不会回滚这个字段。

正确的写法是改之前调 Undo.RecordObject

csharp 复制代码
Undo.RecordObject(target, "Set Field");
field.SetValue(target, value);

Undo.RecordObject 只记录调用时刻的 SerializedProperty 状态------它读取的是 Unity 序列化层的字段值,不是 C# 反射层。如果有字段是 [NonSerialized] 或者 setter 有副作用,反射改一份、Undo 记一份,二者不一致。

5. 翻车 4:多对象同时编辑

Unity 的 Inspector 支持一次选中多个对象、改一个字段。这背后是 SerializedObject 接受多个 target 共同操作的能力。

反射方案无法天然支持这一点------你必须手动循环每个 target、检查字段值是否一致、判断显示"---"还是具体值。Inspector 里的"全部值相同"判定、"修改某个 instance 自动应用到其余 N 个"行为,全部要自己实现。

6. 统一解:SerializedObject / SerializedProperty

Unity 提供的 SerializedObject 模型是上述 4 个坑的统一解。它对 Unity 序列化层的字段做抽象,自动处理 prefab override、Undo 集成、多对象协同。

最小用法:

csharp 复制代码
public static bool SetFieldViaSerialized(Component target, string fieldName, object value)
{
    var so = new SerializedObject(target);
    var prop = so.FindProperty(fieldName);
    if (prop == null) return false;

    Undo.RecordObject(target, "Set Field");
    AssignValue(prop, value);
    so.ApplyModifiedProperties(); // 自动 SetDirty + 触发 prefab override 记录
    return true;
}

ApplyModifiedProperties 在 prefab 实例上自动调用 RecordPrefabInstancePropertyModifications,在多选场景下应用到全部 target,且全程参与 Undo。三件事一行代码搞定。

7. 类型分发:AssignValue 的实现要点

SerializedProperty 不是简单的 value setter------按字段类型不同,赋值要走不同的属性。Funplay 的 ComponentSerializer.AssignValue 做了完整分发:

csharp 复制代码
static void AssignValue(SerializedProperty prop, object raw)
{
    switch (prop.propertyType)
    {
        case SerializedPropertyType.Integer:
            prop.intValue = Convert.ToInt32(raw);
            break;
        case SerializedPropertyType.Float:
            prop.floatValue = Convert.ToSingle(raw);
            break;
        case SerializedPropertyType.Boolean:
            prop.boolValue = Convert.ToBoolean(raw);
            break;
        case SerializedPropertyType.String:
            prop.stringValue = raw?.ToString() ?? string.Empty;
            break;
        case SerializedPropertyType.Color:
            prop.colorValue = ParseColor(raw);
            break;
        case SerializedPropertyType.Vector3:
            prop.vector3Value = ParseVector3(raw);
            break;
        case SerializedPropertyType.Quaternion:
            prop.quaternionValue = ParseQuaternion(raw);
            break;
        case SerializedPropertyType.Enum:
            prop.enumValueIndex = ParseEnumIndex(prop, raw);
            break;
        case SerializedPropertyType.ObjectReference:
            prop.objectReferenceValue = ResolveObjectReference(raw);
            break;
        case SerializedPropertyType.ArraySize:
            prop.arraySize = Convert.ToInt32(raw);
            break;
        // ... 其余类型
    }
}

每种类型走专门的赋值通道,比反射"一律 SetValue"更精准------Quaternion 可以接受 xyzw 也可以接受 euler xyz、Color 接受 rgba 也接受 hex 字符串、Enum 接受整数也接受名称,全部封装在分发逻辑里,调用方一致使用 JObject 即可。

8. Object 引用赋值的特殊处理

字段是 MaterialTextureGameObject 等 Object 引用时,赋值需要解析两种来源:

输入形态 来源 解析方式
{"fileID": <instanceId>} 场景或 prefab stage 内已有对象 EditorUtility.InstanceIDToObject(id)
{"assetPath": "Assets/..."} 项目资源 AssetDatabase.LoadAssetAtPath<Object>(path)

Funplay 的 ResolveObjectReference 实现:

csharp 复制代码
static UnityEngine.Object ResolveObjectReference(object raw)
{
    if (raw == null) return null;
    if (raw is JObject obj)
    {
        var fileId = obj["fileID"]?.ToObject<int?>();
        if (fileId.HasValue)
            return EditorUtility.InstanceIDToObject(fileId.Value);

        var assetPath = obj["assetPath"]?.ToString();
        if (!string.IsNullOrEmpty(assetPath))
            return AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
    }
    return null;
}

这种统一约定让 AI 客户端可以无缝传递 instanceId(来自前一次工具调用的返回值)或 assetPath(来自资源浏览结果),不再需要客户端区分对象来源。

9. 实测对比

四个翻车场景下的实际差异:

场景 反射写法 SerializedObject 写法
[SerializeField] private 字段 需手动加 BindingFlags + 父类遍历 FindProperty 自动支持
Prefab 实例字段持久化 需手动 RecordPrefabInstancePropertyModifications ApplyModifiedProperties 自动处理
Undo 集成 需手动 Undo.RecordObject,且与反射层可能不一致 内置参与 Undo
多对象同时编辑 完全自实现 new SerializedObject(targets) 原生支持
代码行数(含完整边界) ~80-120 行 ~30 行

行数差距并非主要------主要是 SerializedObject 把 4 类边界内化成了不写代码就正确的"默认行为"。反射方案每条边界都需要工具开发者主动记得,遗漏就埋雷。

10. 何时仍可用反射

也不是任何场景都不能用反射。下列情况反射依然合适:

  • 运行时(非 Editor)的字段读写------SerializedObject 仅 UnityEditor 程序集可用
  • 读取 [NonSerialized] 字段C# 属性------SerializedProperty 不覆盖
  • 调用方法(不是字段)------反射 InvokeMethod 仍是唯一选择

但凡是 Editor 工具里"修改可序列化字段"这件事,应一律走 SerializedObject。

11. 写在最后

SerializedObject 是 Unity Inspector 的官方实现路径。Editor 扩展工具若希望与 Inspector 行为完全一致------尤其是涉及 prefab override、Undo 注册、多对象编辑------应该直接复用这套模型,而不是重新基于反射拼装一遍边界处理。

Funplay Unity MCP 的 set_component_property / set_component_properties 工具完全基于 SerializedObject 实现,源代码在 Editor/Tools/Helpers/ComponentSerializer.cs。仓库地址:FunplayAI/funplay-unity-mcp,MIT 协议,欢迎 issue 与讨论。

相关推荐
星河耀银海2 小时前
Unity C#入门:变量的定义与访问权限(public/private)
unity·c#·lucene
Black蜡笔小新2 小时前
自动化AI算法训练服务器DLTM企业级AI模型工作站构筑企业AI自主可控新模式
人工智能·算法·自动化
枫叶丹43 小时前
【HarmonyOS 6.0】模拟点击检测:鸿蒙6.0全面狙击自动化作弊行为
开发语言·华为·自动化·harmonyos
郝学胜-神的一滴3 小时前
中级OpenGL教程 005:为球体&平面注入法线灵魂
c++·unity·图形渲染·three.js·opengl·unreal
wzl202612133 小时前
企业微信SCRM系统多账号管理与客户智能分层技术实现
人工智能·自动化·企业微信·ai-native
Splashtop高性能远程控制软件3 小时前
切屏时代终结,Splashtop 统一 IT 运维平台助力 MSP 高效运营
运维·自动化·远程控制·splashtop
隔窗听雨眠3 小时前
读懂AI自动化的两种范式
运维·人工智能·自动化
企业架构师老王3 小时前
跨境电商AI Agent技术拆解:从RPA到智能体,店铺自动化运营的架构与实践
人工智能·自动化·rpa
那个村的李富贵3 小时前
unity编辑器工具,输出使用的字体
unity·编辑器·游戏引擎