很多 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 引用赋值的特殊处理
字段是 Material、Texture、GameObject 等 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 与讨论。