Unity自定义属性绘制器:从PropertyAttribute到PropertyDrawer的完整指南
1. 项目概述:为什么你需要掌握自定义属性绘制器?
如果你在Unity里写过一段时间的脚本,尤其是那些需要频繁在Inspector面板上调整参数的脚本,你大概率已经对[SerializeField]、[Range]、[Header]这些内置属性(Attribute)习以为常了。它们确实方便,能让你快速组织Inspector的显示。但当你面对一个自定义的类、一个结构体,或者一个枚举,而Unity默认的绘制方式又丑又难用时,那种无力感就上来了。比如,你定义了一个WeaponData类,里面有攻击力、射速、特效预制体等一堆字段,在Inspector里它默认展开成折叠列表,每次修改都要点开,找起来非常麻烦。又或者,你想让一个float字段不仅显示滑动条,还能在旁边实时显示一个根据该值计算出的伤害公式结果。这些,就是自定义属性绘制器(PropertyDrawer)和属性特性(PropertyAttribute)大显身手的地方。
简单来说,PropertyAttribute是你贴在字段或类上的“标签”,它本身不做事,只负责“声明意图”——“我这个字段需要特殊对待”。而PropertyDrawer则是这个“标签”对应的“执行者”或“画家”,它负责在Inspector里实际画出这个字段的样子。Unity内置的那些[Range]、[Tooltip],背后都对应着一个内置的PropertyDrawer。我们今天要做的,就是创建自己的“标签”和“画家”,把Inspector面板的布局和交互逻辑,牢牢掌握在自己手里。这不仅能极大提升你(和你的团队)在编辑器下的工作效率,更能让复杂系统的配置变得直观、安全且优雅。无论是制作技能编辑器、关卡配置工具,还是管理庞大的游戏数据,这都是Unity进阶路上必须点亮的技能树。
2. 核心概念拆解:Attribute与Drawer是如何协作的?
在深入代码之前,我们必须彻底理清PropertyAttribute和PropertyDrawer的关系与工作流程。很多教程只教怎么写,却不讲为什么这么写,导致一旦遇到复杂情况就无从下手。
2.1 PropertyAttribute:不仅仅是元数据
PropertyAttribute继承自System.Attribute。它的核心作用是“标记”。你可以把它想象成贴在变量上的一个便利贴,上面写着给Unity编辑器的“指令”或“提示信息”。这个便利贴本身不会改变变量的值,也不会影响运行时的逻辑(除非你刻意去读取它)。它的生命周期始于编译时,并被序列化到程序集的元数据中。
创建一个自定义Attribute非常简单:
using UnityEngine; // 1. 必须继承自PropertyAttribute // 2. 通常以Attribute结尾,但使用时可以省略 // 3. 需要添加[System.Serializable]以便序列化,但PropertyAttribute本身已可序列化 public class MyCustomAttribute : PropertyAttribute { // 可以在这里定义一些配置参数 public string DisplayName { get; private set; } public float MaxValue { get; private set; } // 构造函数,用于初始化这些参数 public MyCustomAttribute(string displayName = “Default”, float maxValue = 100f) { DisplayName = displayName; MaxValue = maxValue; } }这个MyCustomAttribute现在就是一个合法的“标签”了。你可以把它贴在任何支持序列化的字段上:
public class MyComponent : MonoBehaviour { [MyCustomAttribute(“生命值”, 500f)] public float health; // 使用时的简写,省略“Attribute”后缀 [MyCustom(“攻击力”, 999f)] public float attackPower; }此时,如果你运行游戏,Inspector里这两个字段的显示不会有任何变化。因为Unity只知道这里有个MyCustom标签,但并不知道该如何根据这个标签来绘制字段。这就是PropertyDrawer出场的时候了。
注意:
PropertyAttribute的构造函数参数只能是基本常量类型(如int,float,string,bool)或者System.Type。不能传递对象实例、数组等复杂数据。这是因为Attribute的信息是在编译时就被确定的。
2.2 PropertyDrawer:Inspector的画笔
PropertyDrawer继承自UnityEditor.PropertyDrawer(注意命名空间是UnityEditor,这意味着它只在编辑器下生效)。它的核心使命是:给定一个序列化属性(SerializedProperty)和一个区域(Rect),在这个区域里画出你想要的UI。
Unity编辑器内部有一个“绘制器查找表”。当你打开一个包含被标记字段的Inspector时,Unity会:
- 找到这个字段对应的
SerializedProperty。 - 检查该字段上是否有
PropertyAttribute。 - 如果有,就去查找注册的
PropertyDrawer中,有没有哪个能处理这个特定的Attribute类型。 - 找到后,就将绘制工作委托给这个
PropertyDrawer的OnGUI方法。
关联PropertyDrawer和PropertyAttribute的关键,是给PropertyDrawer类添加[CustomPropertyDrawer(typeof(YourAttribute))]这个Attribute。
using UnityEditor; using UnityEngine; // 关键:使用CustomPropertyDrawer特性关联到我们自定义的Attribute [CustomPropertyDrawer(typeof(MyCustomAttribute))] public class MyCustomDrawer : PropertyDrawer { // 核心方法:如何绘制属性 public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { // 1. 获取我们定义的Attribute实例 MyCustomAttribute myAttr = attribute as MyCustomAttribute; // 2. 修改标签文本 label.text = myAttr.DisplayName; // 3. 根据字段类型进行绘制 if (property.propertyType == SerializedPropertyType.Float) { // 使用Slider绘制,最大值来自Attribute参数 property.floatValue = EditorGUI.Slider(position, label, property.floatValue, 0f, myAttr.MaxValue); } else { // 如果不支持的类型,回退到默认绘制并给出警告 EditorGUI.LabelField(position, label.text, “不支持的字段类型”); // 或者调用基类方法绘制默认UI // base.OnGUI(position, property, label); } } // 可选:返回此属性绘制所需的高度 public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { // 通常返回一行的高度,如果需要多行UI,则计算总高度 return EditorGUIUtility.singleLineHeight; } }现在,你再去看Inspector,health和attackPower字段就会显示为带有自定义标签名和最大值的滑动条了。整个协作流程可以概括为:Attribute定义“要做什么”(What),Drawer定义“怎么做”(How)。
2.3 SerializedProperty:编辑器与数据的桥梁
在PropertyDrawer的OnGUI方法中,第三个参数SerializedProperty property是核心操作对象。它不是字段的运行时值,而是Unity序列化系统对那个字段的一个“引用”或“代理”。通过它,你可以安全地读取和修改序列化字段的值,并且能自动处理撤销(Undo)、预制件覆盖(Prefab Override)等编辑器功能。
你需要根据property.propertyType来判断字段的底层类型,然后使用对应的方法:
property.intValue,property.floatValue,property.stringValue:用于基本类型。property.objectReferenceValue:用于引用类型(如GameObject,ScriptableObject)。property.vector3Value:用于Vector3等Unity结构体。property.enumValueIndex:用于枚举(获取的是索引,通常需要转换)。- 对于数组或列表,使用
property.arraySize和property.GetArrayElementAtIndex(index)。
一个至关重要的细节:在OnGUI中,你应该使用EditorGUI或EditorGUILayout提供的方法来绘制UI并直接赋值给property.XXXValue。例如property.floatValue = EditorGUI.Slider(...)。这样赋值,Unity的序列化系统才能捕获到变化。切忌先从一个临时变量读取,绘制完再赋值,这可能会丢失中间帧的编辑状态。
3. 实战进阶:从简单到复杂的绘制器实现
理解了基本原理后,我们通过几个由浅入深的例子,来看看如何应对实际开发中的各种需求。我会在每个例子中穿插我踩过的坑和总结的技巧。
3.1 案例一:为枚举添加颜色标签与图标
Unity默认的枚举下拉菜单是黑白的,当枚举项很多且代表不同状态时(比如物品品质:普通、稀有、史诗、传说),辨识度很低。我们想为每个枚举值附加一个颜色和图标。
首先,定义Attribute,用于存储颜色和图标路径(或直接引用):
public class EnumStyleAttribute : PropertyAttribute { public readonly string[] Colors; public readonly string[] IconNames; // 假设图标在特定Resources路径下 public EnumStyleAttribute(string[] colors, string[] iconNames) { Colors = colors; IconNames = iconNames; } }在组件上使用:
public class Item : MonoBehaviour { [EnumStyle(new string[] { “#808080”, “#1E90FF”, “#9370DB”, “#FFD700” }, new string[] { “icon_common”, “icon_rare”, “icon_epic”, “icon_legend” })] public ItemRarity rarity; } public enum ItemRarity { Common, Rare, Epic, Legendary }接下来是复杂的Drawer实现。我们不能简单地画一个下拉菜单,而是要自己绘制一个带颜色的按钮式选择器。
[CustomPropertyDrawer(typeof(EnumStyleAttribute))] public class EnumStyleDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { EnumStyleAttribute styleAttr = attribute as EnumStyleAttribute; if (property.propertyType != SerializedPropertyType.Enum) { EditorGUI.HelpBox(position, “EnumStyleAttribute 只能用于枚举类型”, MessageType.Error); return; } // 获取当前枚举类型和值 Type enumType = fieldInfo.FieldType; string[] enumNames = property.enumDisplayNames; int currentIndex = property.enumValueIndex; // 计算每个按钮的宽度 float buttonWidth = position.width / enumNames.Length; Rect buttonRect = new Rect(position.x, position.y, buttonWidth, position.height); EditorGUI.LabelField(new Rect(position.x, position.y, EditorGUIUtility.labelWidth, position.height), label); // 绘制标签后的区域 Rect buttonsRect = new Rect(position.x + EditorGUIUtility.labelWidth, position.y, position.width - EditorGUIUtility.labelWidth, position.height); buttonWidth = buttonsRect.width / enumNames.Length; buttonRect = new Rect(buttonsRect.x, buttonsRect.y, buttonWidth, buttonsRect.height); // 为每个枚举值绘制一个带颜色的按钮 for (int i = 0; i < enumNames.Length; i++) { // 解析颜色 Color originalColor = GUI.color; if (ColorUtility.TryParseHtmlString(styleAttr.Colors[i], out Color btnColor)) { GUI.color = btnColor; } // 设置按钮样式,当前选中的高亮 GUIStyle buttonStyle = new GUIStyle(EditorStyles.miniButtonMid); if (i == currentIndex) { buttonStyle.fontStyle = FontStyle.Bold; buttonStyle.normal.textColor = Color.white; } // 绘制按钮 if (GUI.Button(buttonRect, enumNames[i], buttonStyle)) { property.enumValueIndex = i; property.serializedObject.ApplyModifiedProperties(); } // 这里可以添加加载并绘制图标的逻辑(略复杂,需缓存Texture2D) // ... GUI.color = originalColor; buttonRect.x += buttonWidth; } } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { // 只需要一行高度 return EditorGUIUtility.singleLineHeight; } }实操心得:
- 性能注意:在
OnGUI中频繁加载资源(如Resources.Load图标)是性能杀手。正确的做法是在Drawer的静态构造函数或初始化方法中加载并缓存这些资源,或者使用EditorGUIUtility.Load配合AssetDatabase。 - 布局计算:手动计算
Rect是精细控制UI布局的基础。EditorGUIUtility.labelWidth是Unity为字段标签预留的宽度,使用它能让你的自定义绘制器与内置字段对齐,看起来更协调。 - 撤销支持:我们的代码在按钮点击时直接修改了
property.enumValueIndex并调用了ApplyModifiedProperties()。但更规范的做法是使用EditorGUI.BeginChangeCheck()和EditorGUI.EndChangeCheck()包裹赋值逻辑,这样能获得更好的撤销/重做支持。
3.2 案例二:创建可折叠的复杂结构体绘制器
默认情况下,自定义结构体(struct)在Inspector里会展开所有字段,占用大量空间。我们希望为某些复杂的结构体(如AttackModifier,包含伤害类型、倍率、持续时间等)提供一个可折叠、且内部布局更紧凑的绘制方式。
首先定义结构体和Attribute:
[System.Serializable] public struct AttackModifier { public DamageType damageType; public float multiplier; public float duration; public bool isPercentage; } public enum DamageType { Fire, Ice, Lightning, Physical } // 这个Attribute用于标记希望用自定义折叠方式绘制的结构体字段 public class FoldoutStructAttribute : PropertyAttribute { public string DisplayName { get; private set; } public FoldoutStructAttribute(string displayName = null) { DisplayName = displayName; } }Drawer的实现需要处理折叠状态和递归绘制子属性:
[CustomPropertyDrawer(typeof(FoldoutStructAttribute))] public class FoldoutStructDrawer : PropertyDrawer { // 使用字典来保存每个属性实例的折叠状态,Key由对象实例ID和属性路径生成,确保唯一性 private static Dictionary<string, bool> s_foldoutStates = new Dictionary<string, bool>(); public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { FoldoutStructAttribute attr = attribute as FoldoutStructAttribute; string displayName = string.IsNullOrEmpty(attr.DisplayName) ? label.text : attr.DisplayName; // 生成一个唯一Key来存储折叠状态 string key = $“{property.serializedObject.targetObject.GetInstanceID()}.{property.propertyPath}”; if (!s_foldoutStates.ContainsKey(key)) { s_foldoutStates[key] = false; // 默认折叠 } // 1. 绘制折叠标题栏 Rect foldoutRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); s_foldoutStates[key] = EditorGUI.Foldout(foldoutRect, s_foldoutStates[key], displayName, true); if (!s_foldoutStates[key]) { // 如果折叠,只显示标题,高度就是一行 return; } // 2. 展开后,绘制结构体内部的所有子属性 EditorGUI.indentLevel++; float lineHeight = EditorGUIUtility.singleLineHeight; float verticalSpacing = EditorGUIUtility.standardVerticalSpacing; float currentY = position.y + lineHeight + verticalSpacing; // 遍历结构体的所有子属性 SerializedProperty child = property.Copy(); SerializedProperty end = property.GetEndProperty(); bool enterChildren = true; while (child.NextVisible(enterChildren) && !SerializedProperty.EqualContents(child, end)) { enterChildren = false; float childHeight = EditorGUI.GetPropertyHeight(child, true); Rect childRect = new Rect(position.x, currentY, position.width, childHeight); EditorGUI.PropertyField(childRect, child, true); currentY += childHeight + verticalSpacing; } EditorGUI.indentLevel--; } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { FoldoutStructAttribute attr = attribute as FoldoutStructAttribute; string key = $“{property.serializedObject.targetObject.GetInstanceID()}.{property.propertyPath}”; if (!s_foldoutStates.ContainsKey(key) || !s_foldoutStates[key]) { // 折叠状态,高度为一行 return EditorGUIUtility.singleLineHeight; } // 展开状态,计算所有子属性的总高度 float totalHeight = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; SerializedProperty child = property.Copy(); SerializedProperty end = property.GetEndProperty(); bool enterChildren = true; while (child.NextVisible(enterChildren) && !SerializedProperty.EqualContents(child, end)) { enterChildren = false; totalHeight += EditorGUI.GetPropertyHeight(child, true) + EditorGUIUtility.standardVerticalSpacing; } // 减去最后一个多余的间距 totalHeight -= EditorGUIUtility.standardVerticalSpacing; return totalHeight; } }踩坑记录与技巧:
- 折叠状态管理:折叠状态(
bool)不能直接作为Drawer的成员变量,因为同一个Drawer实例可能用于绘制场景中多个对象的同一个字段。必须使用一个以属性路径和目标对象实例ID组合成的唯一键来存储在静态字典中。property.propertyPath给出了从根对象到该属性的完整路径,结合对象ID,可以精确区分每一个被绘制的属性实例。 - 遍历子属性:
SerializedProperty.Copy()和NextVisible()是遍历所有子属性的标准模式。GetEndProperty()获取一个“结束标记”,用于判断遍历何时停止。enterChildren参数控制是否进入数组或嵌套结构的内部。 - 高度计算:
GetPropertyHeight必须与OnGUI中的绘制逻辑严格对应。在展开状态下,需要递归计算所有子属性的高度。使用EditorGUI.GetPropertyHeight(child, true)可以自动获得包含子项的高度,这是最可靠的方法。 - 缩进管理:在绘制子属性前增加
EditorGUI.indentLevel++,绘制完后恢复,可以让子属性正确缩进,符合Unity的视觉习惯。
3.3 案例三:实现一个关联资源与预览的Asset引用绘制器
Unity默认的ObjectField可以拖入资源,但有时我们想增强它。例如,一个“音效”字段,拖入AudioClip后,希望在Inspector里直接显示一个播放/停止按钮,或者一个“纹理”字段,旁边能显示一个小预览图。
这个例子我们实现一个带预览图的纹理引用绘制器。
public class PreviewTextureAttribute : PropertyAttribute { public float PreviewHeight { get; private set; } public PreviewTextureAttribute(float previewHeight = 64f) { PreviewHeight = previewHeight; } } [CustomPropertyDrawer(typeof(PreviewTextureAttribute))] public class PreviewTextureDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { if (property.propertyType != SerializedPropertyType.ObjectReference) { EditorGUI.HelpBox(position, “PreviewTextureAttribute 只能用于Object引用类型”, MessageType.Error); return; } PreviewTextureAttribute attr = attribute as PreviewTextureAttribute; float lineHeight = EditorGUIUtility.singleLineHeight; float spacing = EditorGUIUtility.standardVerticalSpacing; // 第一行:标签和Object字段 Rect objectFieldRect = new Rect(position.x, position.y, position.width, lineHeight); EditorGUI.ObjectField(objectFieldRect, property, typeof(Texture), label); // 如果当前有引用,并且是Texture类型,则绘制预览 Texture texture = property.objectReferenceValue as Texture; if (texture != null) { float previewY = position.y + lineHeight + spacing; Rect previewRect = new Rect(position.x, previewY, position.width, attr.PreviewHeight); // 绘制一个背景框 EditorGUI.DrawRect(previewRect, new Color(0.15f, 0.15f, 0.15f, 0.9f)); // 计算保持比例的预览区域 float aspectRatio = (float)texture.width / texture.height; float previewWidth = attr.PreviewHeight * aspectRatio; if (previewWidth > position.width) { previewWidth = position.width; attr.PreviewHeight = previewWidth / aspectRatio; } Rect imageRect = new Rect( position.x + (position.width - previewWidth) * 0.5f, previewY + (attr.PreviewHeight - attr.PreviewHeight) * 0.5f, // 垂直居中 previewWidth, attr.PreviewHeight ); // 绘制纹理 GUI.DrawTexture(imageRect, texture, ScaleMode.ScaleToFit); // 可选:在预览图上方或下方显示纹理信息 Rect infoRect = new Rect(position.x, previewY + attr.PreviewHeight + 2f, position.width, lineHeight); EditorGUI.LabelField(infoRect, $“{texture.width}x{texture.height} | {GetTextureFormatInfo(texture)}”, EditorStyles.centeredGreyMiniLabel); } } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { PreviewTextureAttribute attr = attribute as PreviewTextureAttribute; float baseHeight = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; if (property.objectReferenceValue as Texture != null) { // 基础高度 + 预览图高度 + 信息标签高度 + 间距 return baseHeight + attr.PreviewHeight + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; } return baseHeight - EditorGUIUtility.standardVerticalSpacing; // 没有预览时,只有一行 } private string GetTextureFormatInfo(Texture texture) { // 注意:Texture.format是运行时API,在编辑器下可能无法直接获取。 // 更可靠的方式是通过AssetDatabase和TextureImporter来获取信息。 // 这里简化处理,实际使用时需要更复杂的逻辑。 return “RGB24”; // 示例 } }注意事项:
- 编辑器与运行时API:在
PropertyDrawer中,你处于编辑器环境下,可以自由使用EditorGUI、AssetDatabase等UnityEditor命名空间下的类。但要注意,像Texture.format这样的属性在未加载的纹理上可能无法访问。对于复杂的资源信息获取,可能需要通过AssetImporter(如TextureImporter)来查询。 - 性能与重绘:在
OnGUI中绘制纹理预览是相对昂贵的操作,尤其是预览图较大时。要确保GetPropertyHeight计算准确,避免不必要的布局变化导致频繁重绘。对于非常复杂的预览(如模型预览),可以考虑使用EditorGUI.DrawPreviewTexture或更底层的Handles/GL绘图,但这需要更深入的知识。 - 泛用性:这个Drawer目前只检查
Texture。你可以修改ObjectField的接受类型和检查逻辑,使其适用于AudioClip(显示波形图)、Material(显示小球预览)等多种资源,原理是相通的。
4. 高级技巧与性能优化
当你开始大规模使用自定义绘制器时,一些高级技巧和性能考量就变得至关重要。
4.1 绘制器缓存与序列化数据管理
PropertyDrawer的实例由Unity编辑器管理,其生命周期并不完全由你控制。因此,切忌在Drawer中存储与特定属性实例相关的状态(比如输入框的临时字符串、滚动位置等)。这些状态应该存储在序列化系统能够管理的地方。
正确做法:使用SerializedProperty本身来存储状态,或者使用SerializedObject的FindProperty找到相关的“隐藏”属性。例如,如果你想为一个字符串字段添加一个“编辑”按钮,点击后展开一个大文本框,这个“是否展开”的状态,应该存储在该组件脚本的另一个bool序列化字段中,并通过property.serializedObject.FindProperty(“myFoldoutState”)来获取和修改。
对于需要复杂计算的数据(如预览图的渲染结果),可以考虑使用静态字典配合唯一键进行缓存,但要注意及时清理无效的缓存项,防止内存泄漏。键的生成可以结合property.serializedObject.targetObject.GetInstanceID()和property.propertyPath。
4.2 处理数组与列表中的自定义绘制
当你把自定义Attribute应用在数组或List<T>的元素上时,绘制器需要能正确处理。好消息是,Unity会自动为数组中的每个元素调用对应的Drawer。但这里有个关键点:property参数代表的是数组中的那个元素属性。
在Drawer的OnGUI里,property就是序列化数组中当前索引处的元素。你可以像操作单个字段一样操作它。GetPropertyHeight也同样被用于计算每个元素的高度。这使得为数组内的复杂结构创建美观的UI成为可能,比如一个List<AttackModifier>,每个元素都显示为可折叠的卡片。
一个常见需求:自定义数组标题行。你可能想在整个数组上方添加一个自定义的按钮。这需要通过一个自定义的PropertyDrawer来绘制整个数组(property.propertyType == SerializedPropertyType.Generic且property.isArray为true),但这属于更高级的CustomEditor范畴,通常结合ReorderableList来实现,已经超出了基础PropertyDrawer的范围。对于数组内元素的自定义,PropertyDrawer完全胜任;对于整个列表容器的自定义,则需要考虑Editor脚本。
4.3 与CustomEditor的协作与区分
PropertyDrawer和CustomEditor(为整个MonoBehaviour或ScriptableObject自定义Inspector)都是扩展编辑器UI的工具,但分工不同:
PropertyDrawer:字段级。它只关心某一个特定的字段如何绘制。职责单一,可复用性极高。一旦定义好,任何使用了该Attribute的字段,无论在哪个脚本里,都会自动应用这个绘制方式。CustomEditor:组件/资源级。它控制整个Inspector的布局。你可以在OnInspectorGUI中完全重排所有字段,添加按钮、绘制曲线、创建复杂的标签页系统等。CustomEditor功能更强大,但它是针对特定组件类型的,复用性不如PropertyDrawer。
最佳实践:优先使用PropertyDrawer。只有当需要对整个Inspector进行大刀阔斧的改造,或者需要协调多个字段之间的联动UI时,才使用CustomEditor。在CustomEditor的OnInspectorGUI中,你仍然可以调用EditorGUILayout.PropertyField(serializedObject.FindProperty(“myField”), true)来绘制字段,而这个字段如果带有自定义的PropertyDrawer,它的自定义绘制效果依然会显示出来!两者可以和谐共存。
5. 调试、排查与常见问题实录
即使理解了所有原理,在实际编写复杂Drawer时,你依然会遇到各种诡异的问题。下面是我在项目中积累的一些典型问题及其解决方法。
5.1 绘制器不生效?检查清单
- 脚本编译:确保你的Drawer脚本放在
Editor文件夹下(或任意以Editor命名的文件夹中)。只有放在Editor文件夹下的脚本才会在Unity编辑器中被编译,运行时不会包含。这是最常见的错误。 - Attribute与Drawer关联:检查
[CustomPropertyDrawer(typeof(YourAttribute))]中的typeof参数是否完全匹配你的Attribute类名,包括命名空间。 - 字段类型匹配:在Drawer的
OnGUI开头,检查property.propertyType是否是你期望的类型。一个为float设计的Drawer被用在int字段上就会出错。可以使用EditorGUI.HelpBox显示友好的错误信息。 - 序列化支持:确保你应用Attribute的字段是可序列化的(
public或带有[SerializeField]的private/protected字段)。非序列化字段不会出现在Inspector中,Drawer自然无效。 - 编辑器刷新:有时修改了Drawer代码后,Inspector没有立即更新。尝试点击一下Inspector窗口的标题栏,或者在Project窗口中点击任意一个脚本,强制编辑器刷新所有自定义绘制器。
5.2 布局错乱与高度计算陷阱
GetPropertyHeight返回的高度必须与OnGUI中实际绘制的区域高度严格一致。如果高度计算偏小,部分UI会被裁剪;如果偏大,会出现难看的空白区域,并可能影响后续元素的布局。
调试技巧:在开发阶段,可以临时在GetPropertyHeight中返回一个固定值(比如100),然后在OnGUI中用GUI.Box或EditorGUI.DrawRect为你的绘制区域画上不同颜色的背景,直观地看到Unity为你分配的区域和你实际使用的区域是否匹配。
另一个常见问题是未考虑间距。EditorGUIUtility.singleLineHeight是标准行高,EditorGUIUtility.standardVerticalSpacing是标准垂直间距。在计算多行布局的总高度时,别忘了在行与行之间加上间距,但最后一行之后通常不加。
5.3 撤销(Undo)操作失效
如果你在Drawer中直接修改了property.XXXValue,Unity的序列化系统通常会为你自动处理撤销。但是,如果你在修改前后进行了复杂的逻辑判断,或者修改了多个关联属性,为了确保撤销操作的正确性,应该使用标准模式:
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { EditorGUI.BeginChangeCheck(); // 开始变更检查 // ... 你的绘制逻辑,最终会修改property的值 ... float newValue = EditorGUI.Slider(position, label, property.floatValue, 0, 10); if (EditorGUI.EndChangeCheck()) // 如果检测到变更 { property.floatValue = newValue; // 赋值 property.serializedObject.ApplyModifiedProperties(); // 应用修改 } // 如果还有修改其他关联属性,也放在EndChangeCheck块内 }EditorGUI.BeginChangeCheck()和EditorGUI.EndChangeCheck()会记录UI操作,确保任何修改都能被正确地纳入一次撤销操作中。
5.4 处理多对象编辑(Multi-Object Editing)
当在场景中同时选中多个具有相同脚本的GameObject进行编辑时,Inspector会进入多对象编辑模式。你的Drawer也需要能正确处理这种情况。
- 值不一致显示:当选中的多个对象在该字段上的值不同时,
property会显示一个“混合值”。EditorGUI控件(如Slider、TextField)大多能自动处理,显示为空白或特殊标识。你在绘制时,应避免在这种情况下显示一个确定的值。 - 修改操作:当你通过UI修改了字段,这个修改会应用到所有选中的对象上。这是由
property.serializedObject.ApplyModifiedProperties()自动完成的,你一般不需要额外处理。 - 自定义控件的注意事项:如果你自己绘制了一个非标准的控件(比如一套按钮),在修改值之前,最好检查
property.hasMultipleDifferentValues。如果为true,你可能需要设计特殊的交互,比如点击按钮后,是清除混合状态设置为统一值,还是弹出一个对话框让用户选择。
5.5 性能问题分析与优化
复杂的Drawer,尤其是那些包含纹理预览、实时计算或大量布局计算的,可能会拖慢Inspector的响应速度。
- 避免在OnGUI中执行昂贵操作:如数据库查询、复杂的物理计算、频繁的
Resources.Load或AssetDatabase.LoadAssetAtPath。这些操作的结果应该被缓存。 - 减少不必要的重绘:
OnGUI每帧都可能被调用多次。确保你的绘制逻辑是轻量级的。对于基于时间的动画或预览,考虑使用EditorApplication.update委托来驱动,并只在数据变化时请求重绘(EditorUtility.SetDirty)。 - 优化GetPropertyHeight:这个方法的调用频率可能比
OnGUI还高。确保它的计算尽可能快,避免在这里进行复杂的遍历或计算。如果高度是固定的或依赖于一个简单属性,直接返回缓存的值。 - 使用GUILayout的权衡:我们的例子大多使用基于
Rect的EditorGUI,因为布局精确。EditorGUILayout虽然方便,但它在幕后也需要计算布局,对于复杂或动态高度的UI,有时反而更难控制性能。在自定义Drawer中,我通常更推荐使用EditorGUI进行显式布局。
最后,分享一个我个人的习惯:为每一个复杂的自定义Drawer创建一个专门的测试脚本和测试场景。在这个测试脚本中,暴露各种边界情况(空值、极值、数组、嵌套)的字段,然后观察Drawer的行为。这比在真实项目中调试要高效得多,也能确保你的绘制器足够健壮。自定义属性绘制器是Unity编辑器扩展的基石,花时间掌握它,你打造的工具和工作流将产生质的飞跃。
