1. 项目概述自定义Inspector面板的“显示/隐藏”陷阱在Unity编辑器扩展开发中自定义Inspector面板几乎是每个工具开发者都会接触的核心技能。它能让我们为组件或ScriptableObject创建更直观、更高效的配置界面。然而很多开发者在初次尝试或进阶使用自定义Inspector时都会遇到一个看似简单却令人困惑的问题为什么我脚本里用[HideInInspector]或[SerializeField] private标记的字段在自定义Inspector里“显示/隐藏”的逻辑失效了或者为什么我精心设计的折叠面板Foldout在切换显示状态时数据没有被正确保存这个“坑”的本质在于对Unity序列化系统、SerializedObject/SerializedProperty工作流以及UI Toolkit数据绑定机制的理解偏差。今天我们就来彻底拆解这个“显示隐藏没了”的经典问题并分享一套从原理到避坑的完整解决方案。2. 核心原理Unity的序列化与Inspector渲染机制要理解自定义Inspector中显示隐藏的“坑”首先必须厘清Unity底层是如何处理脚本字段与Inspector UI之间的关系的。2.1 默认Inspector的“魔法”当你为一个普通的MonoBehaviour脚本不提供任何自定义Editor类时Unity会使用其内置的默认Inspector绘制器。这个默认绘制器会做以下几件事反射与序列化检查通过反射读取目标脚本对象的所有公共字段以及标记了[SerializeField]的私有字段。属性特性解析检查字段上是否有如[HideInInspector]、[Range]、[Tooltip]等属性Attribute。[HideInInspector]会直接告诉Unity“不要在默认Inspector中绘制这个字段”。按类型绘制根据字段类型int, float, string, Object引用等生成对应的UI控件如IntField, FloatField, ObjectField。直接值修改当你在Inspector中修改这些控件的值时Unity会直接通过反射将新值赋给脚本实例的对应字段。这个过程是“直连”的。关键在于[HideInInspector]这个属性是由Unity的默认Inspector绘制流程来尊重和执行的。它不是一个数据层面的标记而是一个UI绘制指令。2.2 自定义Inspector的“接管”与责任转移一旦你为脚本创建了自定义的Editor类使用[CustomEditor(typeof(YourScript))]情况就完全变了。你通过重写CreateInspectorGUI()或OnInspectorGUI()方法完全接管了该组件在Inspector中的UI绘制责任。Unity的默认绘制逻辑包括对[HideInInspector]的自动处理就此失效。此时Inspector里显示什么、怎么显示、如何与数据交互全部由你的代码决定。如果你在自定义UI中直接使用serializedObject.FindProperty(“myField”)来获取一个属性并为其创建PropertyField那么无论这个字段在原始脚本中是否标记了[HideInInspector]它都会被绘制出来。因为SerializedProperty代表的是序列化数据流中的一个节点而PropertyField是一个“通用绘制器”它只负责根据属性类型生成UI并不关心源字段上有什么Attribute。核心认知[HideInInspector]是给Unity默认Inspector的指令不是给你自定义Inspector的指令。当你接管绘制后你必须自己实现所有的显示/隐藏逻辑。2.3 SerializedObject与直接修改的差异自定义Inspector的最佳实践是使用SerializedObject和SerializedProperty来操作数据而非直接通过target你的脚本实例进行赋值。原因如下撤销/重做支持serializedObject.Update()和serializedObject.ApplyModifiedProperties()会自动集成Unity的撤销系统。多对象编辑可以同时编辑多个选中对象的相同属性。Prefab覆盖能正确处理Prefab实例上被覆盖的属性值。但这也引入了一个关键点SerializedProperty反映的是序列化数据。一个字段只要被序列化是public或带有[SerializeField]它就会存在于SerializedObject中。[HideInInspector]不会阻止字段被序列化它只影响默认绘制。因此通过serializedObject.FindProperty你总能找到它。3. 自定义Inspector实现详解与“显示/隐藏”陷阱让我们通过一个具体的工具类案例来一步步实现自定义Inspector并揭示其中关于显示/隐藏的典型陷阱。3.1 基础脚本与自定义Editor框架假设我们有一个工具类AdvancedTool它有一些配置参数其中部分我们希望在默认情况下隐藏。// AdvancedTool.cs using UnityEngine; public class AdvancedTool : MonoBehaviour { // 希望始终显示的公共配置 public string toolName My Tool; public int executionPriority 0; // 希望在某些条件下才显示的“高级”配置 [SerializeField, HideInInspector] // 注意这里我们标记了HideInInspector private float advancedThreshold 0.5f; [SerializeField] private bool enableAdvancedMode false; // 这个开关控制高级设置的显示 // 一个结构体数组 [System.Serializable] public struct ConfigEntry { public string key; public Vector3 value; public bool isActive; } public ConfigEntry[] entries; // 方法省略... }接着我们创建自定义的Editor脚本。务必将其放在项目的Editor文件夹或其子目录下否则UnityEditor命名空间无法访问编译会失败。// AdvancedToolEditor.cs using UnityEditor; using UnityEditor.UIElements; using UnityEngine.UIElements; using UnityEngine; [CustomEditor(typeof(AdvancedTool))] public class AdvancedToolEditor : Editor { public override VisualElement CreateInspectorGUI() { // 1. 创建根VisualElement VisualElement root new VisualElement(); // 2. 从序列化对象中获取属性 SerializedProperty toolNameProp serializedObject.FindProperty(toolName); SerializedProperty priorityProp serializedObject.FindProperty(executionPriority); SerializedProperty advancedThresholdProp serializedObject.FindProperty(advancedThreshold); // 能找到 SerializedProperty enableAdvancedModeProp serializedObject.FindProperty(enableAdvancedMode); SerializedProperty entriesProp serializedObject.FindProperty(entries); // 3. 创建并添加基础UI控件 root.Add(new PropertyField(toolNameProp, 工具名称)); root.Add(new PropertyField(priorityProp, 执行优先级)); // 4. 添加一个Toggle来控制高级设置的显示 Toggle advancedToggle new Toggle(启用高级模式); advancedToggle.BindProperty(enableAdvancedModeProp); // 绑定到序列化属性 root.Add(advancedToggle); // 5. 为高级阈值创建一个容器初始状态根据enableAdvancedMode隐藏 VisualElement advancedContainer new VisualElement(); advancedContainer.Add(new PropertyField(advancedThresholdProp, 高级阈值)); // 关键陷阱直接设置style.display if (!enableAdvancedModeProp.boolValue) { advancedContainer.style.display DisplayStyle.None; } root.Add(advancedContainer); // 6. 监听Toggle的变化动态显示/隐藏容器 advancedToggle.RegisterValueChangedCallback(evt { // 陷阱直接修改UI样式但序列化对象未更新 advancedContainer.style.display evt.newValue ? DisplayStyle.Flex : DisplayStyle.None; }); // 7. 添加数组字段 root.Add(new PropertyField(entriesProp, 配置条目列表)); return root; } }3.2 第一个陷阱静态判断与动态更新看上面代码的第5步和第6步。我们在第5步根据enableAdvancedModeProp.boolValue的初始值来设置advancedContainer的显示状态。在第6步我们监听了Toggle的值改变事件并动态修改了容器的style.display。问题来了当你在运行时Play Mode或通过其他脚本修改了AdvancedTool实例的enableAdvancedMode字段然后回到编辑器模式并选中该物体Inspector面板中的Toggle开关状态可能会更新因为PropertyField绑定了序列化属性但那个advancedContainer的显示/隐藏状态却不会变因为它只在CreateInspectorGUI()创建UI时判断了一次。解决方案我们需要将显示/隐藏逻辑与序列化属性的当前值动态绑定而不是一次性判断。我们可以利用Bind方法或创建一个响应式更新机制。// 改进方案使用Bind方法将显示状态与属性绑定 advancedContainer.Bind(serializedObject); // 但style.display不能直接绑定到一个bool属性我们需要一个转换 // 更优方案使用条件显示逻辑或在每次Inspector更新时检查更健壮的做法是将显示逻辑封装在一个方法中并在CreateInspectorGUI中调用同时确保在Inspector刷新时也能更新。但对于UI Toolkit一个常见的模式是使用TrackPropertyValue来监听属性变化并更新UI。public override VisualElement CreateInspectorGUI() { VisualElement root new VisualElement(); SerializedProperty enableAdvancedModeProp serializedObject.FindProperty(enableAdvancedMode); SerializedProperty advancedThresholdProp serializedObject.FindProperty(advancedThreshold); // 创建Toggle并绑定 Toggle advancedToggle new Toggle(启用高级模式); advancedToggle.BindProperty(enableAdvancedModeProp); root.Add(advancedToggle); // 创建高级设置容器和字段 VisualElement advancedContainer new VisualElement(); PropertyField thresholdField new PropertyField(advancedThresholdProp, 高级阈值); advancedContainer.Add(thresholdField); root.Add(advancedContainer); // 定义一个更新容器显示状态的函数 void UpdateAdvancedContainerVisibility() { // 直接从绑定的属性或序列化对象获取当前值 // 注意需要先调用serializedObject.Update()确保数据最新但通常在渲染前Unity会自动处理 advancedContainer.style.display enableAdvancedModeProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None; // 同时可以控制子字段的禁用/启用状态提供更好的视觉反馈 thresholdField.SetEnabled(enableAdvancedModeProp.boolValue); } // 初始设置一次 UpdateAdvancedContainerVisibility(); // 关键监听序列化属性值的变化 // 当enableAdvancedModeProp的值因任何原因改变时包括Undo/Redo重新评估可见性 advancedToggle.TrackPropertyValue(enableAdvancedModeProp, (sp) UpdateAdvancedContainerVisibility()); // 注意TrackPropertyValue是UIElements的一个扩展方法它会在属性变化时触发回调。 // 这比只监听Toggle的UI事件更全面因为它也响应通过其他方式如脚本、动画对属性的修改。 return root; }3.3 第二个陷阱Foldout的展开状态丢失另一个常见需求是使用Foldout来分组属性。但如果你只是简单地创建Foldout添加内容然后期望它的展开/折叠状态能自动保存你会失望的。默认情况下Foldout的value布尔值表示是否展开是临时的不会随项目保存。// 有问题的简单实现 Foldout configFoldout new Foldout { text 详细配置, value true }; // 初始展开 configFoldout.Add(new PropertyField(someProp)); root.Add(configFoldout);当你折叠这个Foldout然后取消选择物体再重新选中或者重启Unity编辑器后你会发现它又变回初始状态展开了。解决方案将Foldout的展开状态也进行序列化保存。通常的做法是将状态保存在Editor脚本的一个序列化字段中或者利用EditorPrefs/SessionState进行存储。对于工具类更优雅的方式是使用SerializedProperty来绑定一个专门用于保存UI状态的“虚拟”字段。假设我们在工具脚本中添加一个字段来保存Foldout状态// AdvancedTool.cs 补充 public class AdvancedTool : MonoBehaviour { // ... 其他字段 [SerializeField, HideInInspector] private bool _editorFoldoutExpanded true; // 用于保存编辑器UI状态 }然后在Editor中绑定// AdvancedToolEditor.cs 补充 SerializedProperty foldoutStateProp serializedObject.FindProperty(_editorFoldoutExpanded); Foldout configFoldout new Foldout { text 详细配置 }; // 将Foldout的value绑定到序列化属性 configFoldout.BindProperty(foldoutStateProp); configFoldout.Add(new PropertyField(someProp)); root.Add(configFoldout);这样Foldout的展开/折叠状态就会随组件数据一起被序列化保存在Prefab、场景中都能持久化。3.4 第三个陷阱数组/列表内部的显示控制对于数组或列表ListTPropertyField会为其生成一个默认的列表视图包含添加/删除元素和折叠功能。但如果你想自定义列表内每个元素的显示比如根据某个条件隐藏元素内的某些字段情况会更复杂。你不能直接通过[HideInInspector]在元素结构体/类中标记因为那会影响所有地方。你需要在自定义的PropertyDrawer针对该元素类型或是在遍历数组属性手动创建UI时实现条件逻辑。例如为ConfigEntry创建自定义PropertyDrawer// ConfigEntryPropertyDrawer.cs (放在Editor文件夹) using UnityEditor; using UnityEngine.UIElements; [CustomPropertyDrawer(typeof(AdvancedTool.ConfigEntry))] public class ConfigEntryPropertyDrawer : PropertyDrawer { public override VisualElement CreatePropertyGUI(SerializedProperty property) { VisualElement container new VisualElement(); SerializedProperty keyProp property.FindPropertyRelative(key); SerializedProperty valueProp property.FindPropertyRelative(value); SerializedProperty isActiveProp property.FindPropertyRelative(isActive); container.Add(new PropertyField(keyProp, Key)); container.Add(new PropertyField(valueProp, Value)); // 只有当isActive为true时才显示一个额外的提示标签 Label activeHint new Label(此项当前活跃) { style { color Color.green } }; container.Add(activeHint); // 监听isActive的变化控制提示标签的显示 PropertyField isActiveField new PropertyField(isActiveProp, 是否激活); isActiveField.RegisterCallbackChangeEventbool(evt { activeHint.style.display evt.newValue ? DisplayStyle.Flex : DisplayStyle.None; }); // 初始状态设置 activeHint.style.display isActiveProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None; container.Add(isActiveField); return container; } }这样每个ConfigEntry在Inspector中渲染时都会根据其自身的isActive字段来动态控制“此项当前活跃”标签的显示。这个逻辑是封装在Drawer内部的与主Inspector的显示控制分离更加模块化。4. 完整实战一个健壮的自定义Inspector工具类结合以上所有点我们构建一个更健壮、功能更完整的AdvancedToolEditor。using UnityEditor; using UnityEditor.UIElements; using UnityEngine.UIElements; using UnityEngine; [CustomEditor(typeof(AdvancedTool))] public class AdvancedToolEditor : Editor { private SerializedProperty _toolNameProp; private SerializedProperty _priorityProp; private SerializedProperty _advancedThresholdProp; private SerializedProperty _enableAdvancedModeProp; private SerializedProperty _entriesProp; private SerializedProperty _foldoutStateProp; // 用于保存Foldout状态 private VisualElement _advancedSettingsContainer; private Foldout _detailsFoldout; private void OnEnable() { // 在OnEnable中查找属性比在CreateInspectorGUI中每次创建都查找更高效 _toolNameProp serializedObject.FindProperty(toolName); _priorityProp serializedObject.FindProperty(executionPriority); _advancedThresholdProp serializedObject.FindProperty(advancedThreshold); _enableAdvancedModeProp serializedObject.FindProperty(enableAdvancedMode); _entriesProp serializedObject.FindProperty(entries); _foldoutStateProp serializedObject.FindProperty(_editorFoldoutExpanded); } public override VisualElement CreateInspectorGUI() { VisualElement root new VisualElement(); // 1. 基础属性 root.Add(new PropertyField(_toolNameProp, 工具名称)); root.Add(new PropertyField(_priorityProp, 执行优先级)); // 2. 高级模式开关与条件显示区域 Toggle advancedToggle new Toggle(启用高级模式) { tooltip 开启后显示更多配置选项 }; advancedToggle.BindProperty(_enableAdvancedModeProp); root.Add(advancedToggle); _advancedSettingsContainer new VisualElement(); _advancedSettingsContainer.Add(new PropertyField(_advancedThresholdProp, 高级阈值)); root.Add(_advancedSettingsContainer); // 3. 可持久化展开状态的Foldout _detailsFoldout new Foldout { text 详细配置列表 }; _detailsFoldout.BindProperty(_foldoutStateProp); // 状态绑定到序列化字段 PropertyField entriesField new PropertyField(_entriesProp); // 可以自定义列表的标题这里使用默认 _detailsFoldout.Add(entriesField); root.Add(_detailsFoldout); // 4. 一个自定义的按钮用于执行工具操作 Button executeButton new Button(() { // 确保所有修改已应用 serializedObject.ApplyModifiedProperties(); // 调用工具方法 (target as AdvancedTool)?.ExecuteTool(); // 如果需要可以强制刷新Inspector // EditorUtility.SetDirty(target); }) { text 执行工具, style { height 30, unityFontStyleAndWeight FontStyle.Bold } }; root.Add(executeButton); // 5. 初始化高级区域的可见性 UpdateAdvancedSettingsVisibility(); // 监听高级模式属性的变化包括通过Undo/Redo advancedToggle.TrackPropertyValue(_enableAdvancedModeProp, _ UpdateAdvancedSettingsVisibility()); return root; } private void UpdateAdvancedSettingsVisibility() { // 从序列化对象获取最新值虽然TrackPropertyValue回调时可能已更新但这是好习惯 serializedObject.UpdateIfRequiredOrScript(); bool isAdvancedMode _enableAdvancedModeProp.boolValue; _advancedSettingsContainer.style.display isAdvancedMode ? DisplayStyle.Flex : DisplayStyle.None; // 也可以设置子字段的启用状态提供更清晰的视觉反馈 // 注意PropertyField内部控件可能已经根据绑定属性处理了Enable状态但容器隐藏是更彻底的“不显示” } }5. 常见问题排查与调试技巧即使按照最佳实践编写了代码自定义Inspector仍然可能出现各种诡异问题。下面是一个问题排查清单。5.1 问题字段完全不显示可能原因及排查步骤脚本编译错误检查Unity Console是否有任何编译错误。一个错误可能导致整个程序集不被加载你的Editor脚本也就失效了。Editor脚本位置错误确认你的AdvancedToolEditor.cs文件在Assets/Editor或Assets/AnyFolder/Editor目录下。放在Assets/根目录或Resources/等文件夹中是无效的。CustomEditor特性不匹配检查[CustomEditor(typeof(AdvancedTool))]中的类型名称是否完全正确包括命名空间。如果AdvancedTool在命名空间MyTools中则需要写[CustomEditor(typeof(MyTools.AdvancedTool))]。属性名拼写错误serializedObject.FindProperty(“toolName”)中的字符串必须与脚本中的字段名完全一致包括大小写。字段未序列化查找的字段必须是public或标记了[SerializeField]。普通的private字段不会被序列化FindProperty会返回null。5.2 问题修改值后不保存或撤销Undo无效可能原因及排查步骤缺少ApplyModifiedProperties如果你在IMGUI的OnInspectorGUI()中使用了EditorGUILayout.PropertyField需要在绘制结束后调用serializedObject.ApplyModifiedProperties()。在UI Toolkit的CreateInspectorGUI()中如果你使用了BindProperty或TrackPropertyValue数据绑定通常是自动的但通过代码直接修改SerializedProperty的xxxValue属性后仍需调用ApplyModifiedProperties。直接修改了target避免使用(target as AdvancedTool).toolName “newName”这种方式。这绕过了序列化系统会导致撤销功能失效且在多对象编辑时只会修改第一个对象。始终坚持通过SerializedProperty来修改数据。未调用Update在读取SerializedProperty的值之前最好调用serializedObject.Update()来确保获取的是磁盘上的最新数据。不过在CreateInspectorGUI的生命周期中Unity通常会在渲染前自动调用。5.3 问题自定义Inspector导致性能卡顿可能原因及排查步骤在OnInspectorGUI或CreateInspectorGUI中执行昂贵操作这些方法每帧都可能被调用多次。避免在其中进行复杂的计算、查找资源如Resources.Load、遍历场景所有对象等操作。将结果缓存到成员变量中并在OnEnable中初始化。过度复杂的UI结构成百上千个UI元素嵌套会导致布局计算缓慢。对于长列表考虑使用ListView或TableView等虚拟化列表控件它们只渲染可见部分。频繁的FindProperty在CreateInspectorGUI中反复调用FindProperty查找同一个属性是低效的。应该在OnEnable中查找一次并缓存到成员变量中。5.4 问题多对象编辑时行为异常可能原因及排查步骤使用了target而非targets当多个对象被选中时target只代表第一个对象。如果你需要基于某个条件如第一个对象的某个值来决定所有对象的UI状态可以使用target。但如果你要对所有选中对象进行操作必须遍历targets数组并对每个对象获取其SerializedObject。SerializedObject已自动处理多对象serializedObject代表所有选中对象的共享序列化数据。当你使用PropertyField并绑定到一个属性时它会自动处理多对象编辑——如果所有对象的该属性值相同则显示该值如果不同则显示混合值通常为破折号或默认值。修改该字段会同时应用到所有选中对象。这是使用SerializedObject的最大优势之一无需手动处理。5.5 调试技巧使用Debug.Log在OnEnable和CreateInspectorGUI中打印属性名和值确认查找是否正确回调是否触发。检查SerializedProperty是否为null在创建UI前检查FindProperty的返回值。利用EditorGUI.BeginChangeCheck和EndChangeCheck(IMGUI)在IMGUI中这对方法可以包裹你的UI代码检测是否有任何值被修改。观察序列化数据在Inspector右上角菜单中开启“Debug”模式可以查看组件所有序列化字段的原始数据帮助你理解SerializedObject内部的结构。简化测试当遇到复杂UI问题时创建一个最小可复现示例。移除所有不相关的代码和UI元素逐步添加直到问题再次出现从而定位问题根源。自定义Inspector是提升开发效率和工具专业度的利器但理解其背后的序列化、数据绑定和UI生命周期是避免踩坑的关键。记住当你接管了绘制权你就承担了包括显示逻辑、数据持久化、撤销重做、多对象编辑在内的所有责任。从SerializedObject出发善用TrackPropertyValue、Bind等数据绑定机制并谨慎处理UI状态持久化你就能构建出既强大又稳定的编辑器扩展。