
1. 项目概述为什么我们需要自定义Inspector在Unity开发中Inspector窗口是我们与游戏对象组件交互最频繁的界面。默认情况下一个public字段会直接以变量名经过驼峰命名转换显示在Inspector中。这很方便但远远不够。当你需要给策划或美术同事配置参数或者构建一个复杂的编辑器工具时原始的Inspector布局往往显得笨拙、不直观甚至容易引发错误。举个例子你有一个Weapon脚本里面有个public float attackRange;字段。在Inspector里它就叫“Attack Range”。但策划可能更希望看到“攻击范围米”甚至旁边带一个滑块限制在1到50米之间。又或者你有一个public string displayName;字段但你想在Inspector里把它重命名为“显示名称”并且当它为空时显示一个警告。这些需求都指向了Unity Inspector的自定义能力。自定义Inspector的核心就是通过特性Attribute和自定义Editor脚本来增强或完全重绘组件的Inspector界面。这不仅能提升开发效率更能构建出专业、易用、防错的工具链。今天要深入探讨的正是这个过程中的两个关键且容易被混淆的环节如何优雅地重命名属性在Inspector中的显示名以及当使用自定义特性时如何解决其布局渲染的深层次问题。很多开发者止步于使用[Header]、[Tooltip]等内置特性一旦需要自己写一个带复杂UI的特性就会遇到各种布局错乱、渲染异常的坑。这篇文章就是带你从“会用”到“精通”彻底搞懂这套机制。2. 核心概念解析Attribute与PropertyDrawer在深入自定义之前我们必须厘清几个核心概念这是避免后续踩坑的基础。2.1 Unity中的特性Attribute是什么在C#中特性是一种为代码元素类、方法、属性、字段等添加元数据的声明性标签。在Unity的上下文中特性主要用于控制序列化字段在Inspector中的表现行为而非其数据本身。Unity内置了大量实用特性例如[SerializeField]: 强制私有字段被序列化并显示在Inspector。[Range(min, max)]: 将数值字段显示为一个滑块。[Header(“title”)]: 添加一个标题头。[Tooltip(“info”)]: 添加鼠标悬停提示。[Space]: 添加垂直间距。这些特性之所以能改变Inspector的绘制是因为Unity Editor内部有对应的**绘制器Drawer**来处理它们。当我们创建自定义特性时目标就是仿照这个模式实现自己的逻辑。2.2 PropertyDrawer特性的画笔PropertyDrawer是一个专门的编辑器类它的唯一职责就是在Inspector中绘制被特定特性修饰的单个序列化属性SerializedProperty。你可以把它理解为某个特性的“专属UI设计师”。PropertyDrawer与特性的关联是通过[CustomPropertyDrawer(typeof(YourAttribute))]特性建立的。这种设计实现了关注点分离特性类只负责存储数据比如滑块的最小最大值、标题文字等而PropertyDrawer负责如何绘制。一个常见的误解是我写了一个特性它就能自动改变布局。实际上如果没有配套的PropertyDrawer自定义特性除了能被反射读取外在Inspector上不会有任何视觉效果。这就是很多自定义特性“失灵”的根本原因。2.3 SerializedProperty 与 EditorGUI这是自定义绘制时的两个核心工具类。SerializedProperty: 它是Unity序列化系统中的一个属性的抽象表示。通过它我们可以安全地读取、写入字段的值并处理撤销/重做操作。永远不要直接通过反射修改字段值来实现Inspector交互必须使用SerializedProperty。EditorGUI/EditorGUILayout: Unity Editor的GUI绘制类。EditorGUI要求你手动指定绘制位置Rect而EditorGUILayout会自动进行布局。在PropertyDrawer中我们通常使用EditorGUI因为我们需要在给定的Rect区域内进行绘制。注意EditorGUI和运行时用的GUI类完全不同切记只能在Editor目录下的脚本中使用。3. 实战一自定义属性名特性DisplayName第一个需求重命名字段在Inspector中的显示标签。Unity默认会拆分变量名如playerHealth显示为“Player Health”但有时我们需要完全不同的名字比如显示为中文“玩家生命值”。3.1 创建DisplayNameAttribute这个特性只负责存储我们想要显示的名字。// DisplayNameAttribute.cs // 可以放在任何Runtime目录下 using UnityEngine; public class DisplayNameAttribute : PropertyAttribute { public readonly string displayName; public DisplayNameAttribute(string name) { this.displayName name; } }非常简单它继承自PropertyAttribute并在构造时接收一个显示名称。3.2 创建对应的PropertyDrawer这是关键步骤绘制器需要读取这个特性并用自定义的标签进行绘制。// DisplayNameDrawer.cs // 必须放在Editor目录下 using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(DisplayNameAttribute))] public class DisplayNameDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { // 1. 获取我们自定义的特性实例 DisplayNameAttribute displayNameAttr (DisplayNameAttribute)attribute; // 2. 创建一个新的GUIContent将标签文本替换为自定义名称 // 同时保留原有的工具提示tooltip这是一个好习惯 GUIContent customLabel new GUIContent(displayNameAttr.displayName, label.tooltip); // 3. 使用自定义标签绘制属性 // EditorGUI.PropertyField 是绘制任何序列化属性的通用方法 EditorGUI.PropertyField(position, property, customLabel); } }3.3 使用方式与效果在MonoBehaviour脚本中你可以这样使用using UnityEngine; public class TestScript : MonoBehaviour { [DisplayName(玩家生命值)] public float playerHealth 100f; [DisplayName(攻击速度(次/秒))] [Range(0.5f, 10f)] public float attackRate 1f; }在Inspector中playerHealth字段的标签将显示为“玩家生命值”而attackRate则会显示为“攻击速度(次/秒)”并且它依然受[Range]特性的影响显示为一个滑块。这说明多个PropertyDrawer可以协同工作DisplayNameDrawer只改变了标签实际的字段绘制交给了后续逻辑这里是RangeDrawer。实操心得EditorGUI.PropertyField方法非常强大它会自动处理字段类型的绘制如浮点数、整数、字符串、枚举、甚至其他对象引用并且会自动应用该属性上可能存在的其他特性的绘制器。这意味着我们的DisplayNameDrawer具有良好的兼容性。4. 实战二特性自定义布局的深水区现在我们来解决更复杂的问题创建一个不仅改名还要改变整个UI布局的特性。例如一个Vector3字段我们想把它拆分成三个独立的、带有单位标签的浮数字段来编辑。4.1 创建带参数的Attribute假设我们创建一个Vector3AsThreeFieldsAttribute它可以为X/Y/Z轴分别指定显示名和单位。// Vector3AsThreeFieldsAttribute.cs using UnityEngine; public class Vector3AsThreeFieldsAttribute : PropertyAttribute { public readonly string labelX; public readonly string labelY; public readonly string labelZ; public readonly string unit; // 例如 “m”, “km/h” public Vector3AsThreeFieldsAttribute(string labelX “X”, string labelY “Y”, string labelZ “Z”, string unit “”) { this.labelX labelX; this.labelY labelY; this.labelZ labelZ; this.unit unit; } }4.2 绘制器的核心挑战与GetPropertyHeight当我们想要自定义布局时PropertyDrawer有两个必须重写的方法OnGUI和GetPropertyHeight。GetPropertyHeight是第一个大坑。Unity在渲染Inspector前会先调用它来询问这个属性需要占用多少垂直空间。如果你不重写它或者返回的高度不正确会导致布局重叠、挤压甚至不可见。对于我们要绘制三行的情况高度应该是单行高度的三倍加上行间距。// Vector3AsThreeFieldsDrawer.cs using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(Vector3AsThreeFieldsAttribute))] public class Vector3AsThreeFieldsDrawer : PropertyDrawer { // 单行高度使用Unity的标准行高 private const float SingleLineHeight EditorGUIUtility.singleLineHeight; private const float VerticalSpacing EditorGUIUtility.standardVerticalSpacing; // 通常是2px public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { // 我们需要绘制1个主标签行 3个分量行 // 总高度 标签行高度 3 * 分量行高度 3 * 行间距 // 注意第一行标签和后面行之间也有间距 return SingleLineHeight * 4 VerticalSpacing * 3; } }4.3 在OnGUI中实现复杂布局接下来是OnGUI方法我们需要手动计算每一行Rect的位置并绘制。public override void OnGUI(Rect totalPosition, SerializedProperty property, GUIContent label) { // 0. 参数检查 if (property.propertyType ! SerializedPropertyType.Vector3) { // 如果属性不是Vector3类型绘制错误信息并回退到默认绘制 EditorGUI.LabelField(totalPosition, label.text, “Use [Vector3AsThreeFields] with Vector3 only.”); return; } Vector3AsThreeFieldsAttribute attr (Vector3AsThreeFieldsAttribute)attribute; // 1. 绘制主标签第一行 Rect labelRect new Rect(totalPosition.x, totalPosition.y, totalPosition.width, SingleLineHeight); EditorGUI.LabelField(labelRect, label); // 2. 为三个分量创建Rect // 计算起始Y坐标标签行下方 间距 float fieldStartY totalPosition.y SingleLineHeight VerticalSpacing; // 每个字段的宽度可以均分这里我们简单处理 float fieldWidth totalPosition.width; float fieldHeight SingleLineHeight; Rect rectX new Rect(totalPosition.x, fieldStartY, fieldWidth, fieldHeight); Rect rectY new Rect(totalPosition.x, fieldStartY (SingleLineHeight VerticalSpacing), fieldWidth, fieldHeight); Rect rectZ new Rect(totalPosition.x, fieldStartY (SingleLineHeight VerticalSpacing) * 2, fieldWidth, fieldHeight); // 3. 开始绘制字段并处理撤销操作 EditorGUI.BeginChangeCheck(); // 开始检查值变化 // 获取当前Vector3值 Vector3 vectorValue property.vector3Value; // 为每个分量创建带单位的新标签 GUIContent labelX new GUIContent($”{attr.labelX} ({attr.unit})”); GUIContent labelY new GUIContent($”{attr.labelY} ({attr.unit})”); GUIContent labelZ new GUIContent($”{attr.labelZ} ({attr.unit})”); // 使用EditorGUI.FloatField绘制浮点字段 vectorValue.x EditorGUI.FloatField(rectX, labelX, vectorValue.x); vectorValue.y EditorGUI.FloatField(rectY, labelY, vectorValue.y); vectorValue.z EditorGUI.FloatField(rectZ, labelZ, vectorValue.z); // 4. 如果值发生改变应用回SerializedProperty if (EditorGUI.EndChangeCheck()) { property.vector3Value vectorValue; } }4.4 使用示例与效果在脚本中应用这个特性public class AdvancedTestScript : MonoBehaviour { [Vector3AsThreeFields(“长度”, “宽度”, “高度”, “m”)] public Vector3 size; [Vector3AsThreeFields(“速度X”, “速度Y”, “速度Z”, “m/s”)] public Vector3 velocity; }在Inspector中原本单个Vector3字段会展开成三个独立的、带有自定义标签和单位的浮点输入框可读性和易用性大大提升。核心避坑点GetPropertyHeight必须准确这是自定义布局的基石。计算错误会导致严重的渲染问题。使用EditorGUIUtility.singleLineHeight和standardVerticalSpacing来保证与Unity原生UI的一致性。使用EditorGUI.BeginChangeCheck()和EndChangeCheck()这是实现撤销/重做支持的标准模式。直接修改vectorValue然后赋值如果没有这个检查框撤销操作将无法正确工作。属性类型检查在OnGUI开始处检查property.propertyType。如果你的特性只适用于特定类型如Vector3对于错误类型应给出友好提示或回退到默认绘制避免运行时错误。5. 进阶处理嵌套、数组与自定义类现实项目中的数据结构要复杂得多。你的特性可能需要应用到数组元素上或者应用到自定义Serializable类的字段上。5.1 在数组或列表上使用自定义特性这是完全支持的。Unity会为数组中的每一个元素分别调用你的PropertyDrawer.OnGUI方法。你不需要做任何特殊处理。例如public class SpawnPoints : MonoBehaviour { [DisplayName(“生成点位置”)] public Vector3[] points; }DisplayNameDrawer会尝试应用到points数组本身上。但更常见的是特性应用于数组内部的元素类型。例如如果我们有一个自定义类SpawnConfig的数组并且SpawnConfig里用了我们的特性那么绘制器会对每个数组元素的对应字段生效。5.2 为自定义序列化类创建PropertyDrawer如果你想为一个完整的类而不仅仅是单个字段创建自定义Inspector你需要的是CustomEditor而不是PropertyDrawer。PropertyDrawer用于修饰字段的特性。但是如果你的自定义类用[System.Serializable]标记的某个字段使用了自定义特性那么当这个类作为另一个类的成员出现在Inspector时该字段的PropertyDrawer依然会正常工作。这是因为Unity的序列化系统会递归地处理所有可序列化的字段。一个关键区别PropertyDrawer的attribute成员可以直接转换为你的特性类型。而在CustomEditor中你需要通过serializedObject.FindProperty(“fieldName”)来找到对应的SerializedProperty然后再尝试获取其上的特性过程更复杂。5.3 布局协调当多个特性共存时一个字段可以附加多个特性。Unity会按照一个特定的顺序来应用它们的绘制器。通常与标签显示相关的如DisplayName会先应用然后是与输入控件相关的如Range。我们的绘制器在调用EditorGUI.PropertyField时相当于把后续的绘制工作“委托”了出去保证了兼容性。但是如果你完全重写了OnGUI像我们的Vector3AsThreeFieldsDrawer没有调用EditorGUI.PropertyField那么其他特性的效果就会丢失。如果你需要同时支持[Range]就必须在自己的绘制器代码中手动实现滑块逻辑或者进行更复杂的绘制器链式调用这通常需要更高级的技巧。6. 常见问题排查与性能优化在实际使用中你可能会遇到一些棘手的问题。6.1 问题一特性绘制器完全不起作用检查文件位置确保PropertyDrawer脚本放在任意名为Editor的文件夹下。Unity不会编译Editor目录下的脚本到运行时程序集。检查特性继承自定义特性类必须直接或间接继承自PropertyAttribute。检查关联确保PropertyDrawer类上方有[CustomPropertyDrawer(typeof(YourAttribute))]并且typeof里的类型名称完全正确。编译与重启修改PropertyDrawer后等待Unity编译完成。有时需要重启Unity编辑器才能完全刷新编辑器脚本的缓存。6.2 问题二布局错乱、重叠或超出边界首要检查GetPropertyHeight99%的布局问题源于此。确保你返回的高度等于你在OnGUI中实际绘制的所有控件的高度总和。使用EditorGUIUtility.singleLineHeight作为计算基准。手动计算Rect仔细检查每个Rect的x,y,width,height参数。y坐标需要累加之前行的高度和间距。画个草图有助于理解。使用EditorGUI.IndentedRect如果你的绘制器在有一定缩进层级如在数组或嵌套类内部中被调用使用EditorGUI.IndentedRect(position)可以获取到已考虑缩进的正确矩形区域避免绘制到面板外面。6.3 问题三撤销Undo功能无效必须使用SerializedProperty永远通过SerializedProperty来读写值。直接使用反射如fieldInfo.SetValue会绕过Unity的撤销系统。必须包裹在变化检查块中使用EditorGUI.BeginChangeCheck()和EditorGUI.EndChangeCheck()来检测更改。只有在EndChangeCheck返回true时才将修改后的值赋回property.xxxValue。6.4 性能优化建议避免在OnGUI中频繁分配内存例如不要在OnGUI中频繁new GUIContent或new Rect。可以考虑在绘制器类中将它们缓存为成员变量前提是它们不会随每次绘制改变。简化复杂绘制如果一个自定义绘制器包含大量控件如一个包含10个字段的折叠式结构考虑将其拆分成更小的单元或者使用EditorGUI.Foldout来默认收起提升Inspector的渲染效率。谨慎使用EditorGUILayout在PropertyDrawer中由于你接收的是一个确定的Rect通常使用EditorGUI更合适。EditorGUILayout是基于自动布局的在复杂的自定义绘制中混合使用可能导致不可预知的布局结果。7. 超越PropertyDrawer更强大的CustomEditor当PropertyDrawer无法满足需求时例如你需要完全重新组织一个组件所有字段的布局或者添加按钮等交互控件就需要用到CustomEditor。CustomEditor通过[CustomEditor(typeof(YourComponentType))]关联到一个具体的组件类型如MonoBehaviour,ScriptableObject等。在它的OnInspectorGUI方法中你可以完全自由地绘制整个Inspector界面。在CustomEditor中你仍然可以利用我们上面编写的PropertyDrawer。你可以使用EditorGUI.PropertyField来绘制某个特定属性如果该属性上有自定义特性Unity会自动调用对应的PropertyDrawer。这提供了一种分层自定义的能力用CustomEditor进行宏观布局用PropertyDrawer处理微观的、可复用的字段级UI。例如为你团队的“角色状态”组件写一个CustomEditor将生命、魔法、体力等核心属性用醒目的进度条显示在顶部而其他琐碎属性则通过PropertyField按默认方式绘制在下方其中某些字段可能已经应用了DisplayName或Vector3AsThreeFields特性。这样既保证了关键信息的突出又保留了字段级自定义的灵活性。自定义Inspector是一个从“提升个人效率”到“构建团队工具”的进阶过程。从简单的重命名特性开始逐步深入到自定义布局、处理复杂数据类型最终能够设计出符合项目需求的专用编辑器界面这本身就是Unity引擎向开发者开放强大定制能力的一个体现。理解Attribute与PropertyDrawer的协作机制是掌握这套工具链的关键。