前阵子项目里策划丢过来一个问题:“你这个speed到底是移动速度还是攻击速度?我在Inspector里改来改去总怕改错。”我打开编辑器一看,字段名是m_MoveSpeed,确实是直接暴露出来了。代码命名规范要求字段带前缀没问题,可Inspector面板是给策划、美术甚至音频同事用的,他们没义务去理解m_、_xxx这种内部命名。很多人的第一反应是重命名字段,但序列化字段一改名,场景和预制体里已经配置好的数据就像换了身份证一样对不上号。正确做法是给Inspector上的显示名做一次“换壳”,让面板上显示的名字和代码里的字段名彻底解耦。今天这篇文章,就围绕我实际落地的一套方案来聊:自定义 CustomLabel 特性,重写 Inspector 的变量显示名称。
1. 先搞清楚一件事:Inspector 上的名字到底从哪来
1.1 序列化字段与显示名的对应关系
Unity 的 Inspector 面板显示的并不是 C# 字段名本身,而是SerializedProperty的displayName。默认情况下,这个displayName是序列化字段名经过“人性化”处理后的结果:
| 代码字段名 | Inspector 默认显示名 |
|---|---|
moveSpeed | Move Speed |
m_MoveSpeed | M Move Speed |
_chargeLevel | Charge Level |
isPlayerAlive | Is Player Alive |
这里面的关键点在于:displayName的改动不会影响 YAML 中的数据键名。Unity 在持久化场景、预制体时,靠的是属性路径(propertyPath),而不是显示名。你可以在 Inspector 层随便“换壳”,只要别把序列化字段本身重命名,场景和预制体里已有的数据就都是安全的。
所以第一条铁律先立在这:能改显示名,绝不改字段名。
很多团队推行字段命名规范(m_前缀、下划线前缀、驼峰),但从策划美术的视角看,这些前缀没有任何意义。这也是为什么“显示壳”和“代码名”分离,几乎是大中型项目的必选项。你代码里可以继续用m_MoveSpeed保持规范,但 Inspector 上给业务同事看的是“移动速度”,两边各取所需,互不干扰。
1.2 为什么不能直接改字段名来“换个好名字”
说个我见过很多次的惨案:有个同事嫌字段moveSpeed不够“语义化”,直接把它改名成了movementSpeed。代码编译过了,他以为万事大吉。结果跑起来一测,角色移动速度变成了 0,场景里满地的 NPC 全在原地罚站。
原因很简单。场景里一个 NPC 本来配置了m_MoveSpeed: 3.5,YAML 里存的键是m_MoveSpeed。你字段一改名,Unity 序列化系统找不到原来的键了,它会把新名字当成一个全新的字段来处理,原来的配置值自然就丢了。尤其是配置量大的项目,这种“顺手改名”会造成非常隐蔽的数据丢失,而且往往要等跑到具体逻辑时才能发现。
那有同学会问:“我改名之后手动把数值再填一遍不就行了?”小项目可以,大项目不行。一个预制体可能在十个场景里被引用,每个场景里的覆盖值还不一样,你根本不知道哪些地方引用了旧字段,逐个排查的工作量远大于在显示层做“换壳”。所以到了这一步,问题的解就收敛到“如何在不动字段名的前提下,改变 Inspector 上的显示文字”。
2. 官方自带的 InspectorName:能用,但别对它期待太多
2.1 最朴素的做法
Unity 从 5.x 时代就提供了一个内置特性InspectorName,用法极其简单:
[InspectorName("移动速度")] public float m_MoveSpeed;不加任何编辑器脚本,编译完打开 Inspector 就能看到字段名变成了“移动速度”。对单个字段的静态命名来说,这确实是成本最低的方案,我早期项目里也是这么用的。
2.2 我在实际项目里遇到的 InspectorName 的三个问题
但用得多了,问题也就暴露出来了。
第一,命名是静态的。游戏有中英繁三语版本,策划希望编辑器跟着当前文案语言走,InspectorName 直接满足不了。你写死成一个字符串,想换语言就得改代码重新编译,这在日常调参的过程中非常不友好。
第二,旧版本对数组元素的显示名支持不稳定。印象里 2019.2 之前,数组元素不会正确显示自定义名,会退化成 “Element 0” 这种默认样式。如果你的数据结构里嵌套了数组,用起来会比较难受。
第三,某些版本和 Prefab Overrides 配合有显示刷新不及时的问题。我遇到过一次,切换选中对象后名字还是上一次的,要重新点一下对象才能刷新,虽然不致命,但很打断工作流。
另外,InspectorName只改名字,不能给字段单独加悬停提示。所以你在很多项目里会看到[InspectorName("...")]和[Tooltip("...")]一起堆在字段上,代码行又长又碎,维护体验一般。
2.3 和 Header、Tooltip 的配合
有同学可能会问,既然要分组展示,为什么不用Header把字段包起来?我的看法是:Header 是“分组标题”,负责的是视觉分区;InspectorName 是“字段名”,负责单字段的显示;Tooltip 是“悬停提示”,负责补充说明。三者可以共存,也能组合出不错的效果:
[Header("移动参数")] [InspectorName("移速")] [Tooltip("角色每秒移动的单位数")] public float m_MoveSpeed;但请注意,这套组合依然没有解决“动态显示名”这个核心诉求。Header、Tooltip 都是固定文案,InspectorName 也是固定文案。只要需求里出现“名字要根据状态变化”或“名字要走本地化表”,内置三件套就撑不住了。项目规模上来以后,很多人都会走到第三步:自己写特性。
3. 手写 CustomLabel:基于 PropertyDrawer 的完整实现
3.1 设计思路:Attribute 管配置,Drawer 管绘制
在动手之前,先把“自定义显示名”这个需求拆到正确的层级。Inspector 的绘制流程是:Unity 拿到一个SerializedProperty,根据字段上的 Attribute 找到对应的PropertyDrawer,然后调用OnGUI绘制这一行。所以我们要做两件事:
- 定义一个
PropertyAttribute,用来在字段上写配置(显示名、提示文本、动态数据源); - 定义一个
CustomPropertyDrawer,在OnGUI里读取配置,替换掉默认的label,然后调用EditorGUI.PropertyField完成实际绘制。
这两个东西职责分开:Attribute 不写任何绘制逻辑,Drawer 不写业务配置。以后要扩展什么能力,只要改一边就行,代码结构非常清爽。
3.2 完整代码
Attribute 定义,建议放在非 Editor 目录下,这样运行时脚本也能引用到:
using UnityEngine; public class CustomLabelAttribute : PropertyAttribute { public string Label { get; private set; } public string Tooltip { get; private set; } public string DynamicField { get; private set; } public CustomLabelAttribute(string label, string tooltip = "") { Label = label; Tooltip = tooltip; } public CustomLabelAttribute(string label, string dynamicField, string tooltip = "") { Label = label; DynamicField = dynamicField; Tooltip = tooltip; } }Drawer 实现,必须放在 Editor 目录下:
using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(CustomLabelAttribute))] public class CustomLabelDrawer : PropertyDrawer { private GUIContent _cachedContent; private string _cachedDynamicField; private string _cachedRawLabel; private string _cachedTooltip; public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { var attr = (CustomLabelAttribute)attribute; if (NeedsRebuild(attr)) { _cachedContent = BuildContent(attr, property); _cachedDynamicField = attr.DynamicField; _cachedRawLabel = attr.Label; _cachedTooltip = attr.Tooltip; } EditorGUI.PropertyField(position, property, _cachedContent, true); } private bool NeedsRebuild(CustomLabelAttribute attr) { if (_cachedContent == null) return true; if (_cachedDynamicField != attr.DynamicField) return true; if (_cachedRawLabel != attr.Label) return true; if (_cachedTooltip != attr.Tooltip) return true; return false; } private GUIContent BuildContent(CustomLabelAttribute attr, SerializedProperty property) { string text = attr.Label; // 动态命名:从目标对象上反射读取一个字符串字段的值 if (!string.IsNullOrEmpty(attr.DynamicField)) { var target = property.serializedObject.targetObject; if (target != null) { var fieldInfo = target.GetType().GetField(attr.DynamicField, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); if (fieldInfo != null && fieldInfo.GetValue(target) is string dynamicText) { if (!string.IsNullOrEmpty(dynamicText)) { text = dynamicText; } } else { Debug.LogWarning($"[CustomLabel] 找不到字段 {attr.DynamicField},使用默认名 {attr.Label}"); } } } return new GUIContent(text, string.IsNullOrEmpty(attr.Tooltip) ? label.tooltip : attr.Tooltip); } }这里有一个兼容性细节:fieldInfo.GetValue(target) is string dynamicText这种写法要求 C# 7 以上。Unity 2018.3 之后默认的 Scripting Runtime Version 是 .NET 4.x Equivalent,没问题;但如果你的项目还停留在 2018.2 之前的古董配置,请改成先var obj = fieldInfo.GetValue(target);再判断类型的写法。
3.3 代码逐段拆解:每个细节都不是白写的
attribute是 PropertyDrawer 从字段上取到的特性实例。OnGUI里通过(CustomLabelAttribute)attribute拿到配置,这个操作没有额外开销,因为它就是同一个字段的 Attribute 对象。label参数是 Unity 默认生成的 GUIContent,里面包含默认的 displayName 和 tooltip。我们只替换 text,保留 tooltip 的回退逻辑:自定义 Tooltip 为空时,回退到默认 tooltip。这样不会丢掉编辑器里已有的提示信息。- 缓存的三个
_cachedXxx字段分别对应配置项。只要配置没变,就不重复创建 GUIContent,这是为了减少 OnGUI 高频重绘时的 GC 压力。后面 5.4 节还会细说。 - 动态命名的实现依赖
targetObject加反射。这在编辑器脚本里是可接受的,因为只在 Inspector 重绘时触发,不会进入运行时热路径。编辑器反射的代价远低于你想象,真正要警惕的是“每帧都在跑”的热路径,Inspector 重绘不算。
3.4 实际使用效果
public class PlayerConfig : MonoBehaviour { [CustomLabel("移动速度", "角色每秒移动的单位数")] public float m_MoveSpeed; [CustomLabel("技能冷却", "_skillCooldown")] public float m_Cooldown; [SerializeField] [CustomLabel("当前显示的技能名")] private string _skillCooldown; }这样在 Inspector 里,策划看到的是“移动速度”,鼠标悬停会看到“角色每秒移动的单位数”;“技能冷却”的显示名会实时取_skillCooldown字段的字符串值,实现“显示名跟着状态走”。这个能力是 InspectorName 给不了的。
4. 进阶玩法:动态命名、本地化、条件显示与枚举显示名
4.1 运行时动态改名:让显示名跟随数据状态
刚才的DynamicField方案,本质是“显示名绑定到某个字符串字段”。它最典型的应用场景是技能编辑器:同一个字段在不同技能类型下需要显示不同名字。
举个例子,技能配置里有伤害、治疗、增益三类,数值字段只用一个m_Value,但策划希望看到的是“伤害系数”或“治疗量”,而不是一个干巴巴的Value。此时可以这样设计:
public enum SkillType { Damage, Heal, Buff } public class SkillConfig : MonoBehaviour { public SkillType Type; [CustomLabel("参数", nameof(Type))] public float m_Value; }但注意,DynamicField拿到的是字段的原始值,Type是枚举,不是字符串。所以需要在BuildContent里做一次转换。实际项目中我会用一个字典维护枚举值和显示文案的映射:
private static readonly Dictionary<SkillType, string> SkillParamNames = new Dictionary<SkillType, string> { { SkillType.Damage, "伤害系数" }, { SkillType.Heal, "治疗量" }, { SkillType.Buff, "增益数值" }, };然后在 Drawer 里根据枚举值查表,查不到再用默认名。这样字段名保持m_Value不变,但策划每切换一次Type,下面那行字段的名字就会跟着变。这种联动体验,对配置效率的提升非常明显。
4.2 多语言本地化:Inspector 名字也能走文案表
中大型项目经常有“编辑器语言跟随文案配置”的需求。比如游戏本身支持中英繁,策划在配置时希望编辑器里的字段名也切换到对应语言。实现思路不复杂:Attribute 里不直接存文案,而是存一个 key,Drawer 绘制时从全局的本地化表里取:
public class LocLabelAttribute : PropertyAttribute { public string Key; public LocLabelAttribute(string key) { Key = key; } }Drawer 里就变成:
string text = Localization.Get(attr.Key); if (string.IsNullOrEmpty(text)) text = attr.Key;这里有一个注意事项:本地化表通常在运行时才加载完毕,而 Inspector 在编辑器阶段就会绘制。所以 Drawer 里查不到时不要直接报错,先回退到 key 本身。等本地化系统加载完成后,发一个OnLanguageChanged事件,在事件里对所有当前选中的对象执行一次EditorUtility.SetDirty和重绘,就能让 Inspector 立刻刷新成目标语言。这个方案我在项目里跑得很稳,唯一的成本是本地化系统要有“编辑器内查询”的接口。
4.3 字段级装饰:在名字后面补充单位或状态
还有一种很实用的变体:不在名字前做动态变化,而是在后面拼单位。比如“攻击速度 (次/秒)”、“背包容量 (格)”。实现方式就是在 Drawer 的BuildContent里做字符串拼接:
if (!string.IsNullOrEmpty(attr.Unit)) { text = $"{text} ({attr.Unit})"; }这里要奉劝一句:这类拼接要放在缓存逻辑里,不要在OnGUI里每次都拼。尤其是在中文项目里,string.Format和插值都会产生额外分配,Inspector 频繁重绘时 GC 会被放大。写成缓存只在配置变化时执行一次,几乎没有开销。
4.4 枚举字段的自定义显示名
枚举字段在 Inspector 默认下拉框里显示的是枚举项名。SkillType.Damage显示成 “Damage”,如果业务方想要中文枚举名,或者想对某些枚举项做隐藏,就得自定义枚举绘制器。这个需求和字段显示名是两回事,但经常被放在一起问,所以我顺手给一套实现:
public class EnumLabelAttribute : PropertyAttribute { public string[] Labels; public EnumLabelAttribute(params string[] labels) { Labels = labels; } }[CustomPropertyDrawer(typeof(EnumLabelAttribute))] public class EnumLabelDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { var attr = (EnumLabelAttribute)attribute; var enumType = fieldInfo.FieldType; if (!enumType.IsEnum) { EditorGUI.PropertyField(position, property, label); return; } var enumNames = System.Enum.GetNames(enumType); var displayNames = attr.Labels != null && attr.Labels.Length == enumNames.Length ? attr.Labels : enumNames; var selectedIndex = property.enumValueIndex; if (selectedIndex < 0 || selectedIndex >= displayNames.Length) selectedIndex = 0; EditorGUI.BeginChangeCheck(); var newIndex = EditorGUI.Popup(position, label.text, selectedIndex, displayNames); if (EditorGUI.EndChangeCheck()) { property.enumValueIndex = newIndex; } } }这个方案的核心价值在于:枚举数值和序列化数据完全没变,变的只有显示文本。相比在代码里给枚举成员加中文注释,这个方案能让策划在下拉框里直接看到中文名,也方便后续扩展“隐藏某个枚举项”“显示为多列”之类的需求。
5. 落地前必须知道的坑:多选、撤销、预制体、刷新与性能
5.1 多对象编辑:targetObject 只是第一个对象
如果一次性选中多个预制体或场景物体,property.serializedObject.targetObject只会拿到第一个选中对象。这意味着动态命名在“多选编辑”时,所有选中的对象都会显示第一个对象的显示名。如果每个对象的显示名不同,策划很可能会误以为字段值也有问题。
我的建议是:在 Drawer 里判断serializedObject.isEditingMultipleObjects,为 true 时直接显示默认名,或者显示“(多选)”标签,明确告诉使用者当前不是单对象查看模式。眼神确认比口头解释可靠得多。
5.2 撤销与预制体覆盖:修改值以后谁来 Apply
EditorGUI.PropertyField内部已经帮你做了 Undo 和ApplyModifiedProperties,所以用内置控件时不需要额外处理。但如果你在 Drawer 里手动修改属性值(比如前面枚举 Popup 的方案),一定要用serializedObject.Update()开头,serializedObject.ApplyModifiedProperties()结尾,并且通过EditorGUI.BeginChangeCheck/EndChangeCheck捕获改动。
漏掉 Apply 的话,Inspector 里的数字看起来变了,但底层对象其实没变,一保存场景,一切回到原样。这种问题特别难排查,因为它没有任何报错,只是“改了没保存”。
至于预制体覆盖:GameObject 字段加了 CustomLabel 后,预制体覆盖依然有效,因为覆盖记录基于 propertyPath 而不是显示名。这一点可以放心,显示层改动不会影响已经存在的预制体变体。
5.3 刷新问题:为什么改了动态字段,Inspector 名字不更新
DynamicField 方案有一个天然问题:Inspector 不会因为你改了某个字符串字段就自动重绘整个面板。尤其是这个字符串是代码里改的,而不是在 Inspector 里手动改的,面板很可能一直显示旧名字。
解决办法有两种:
- 在该字段的
OnValidate里调用EditorApplication.QueuePlayerLoopUpdate()或者SceneView.RepaintAll(),强制刷新; - 给 MonoBehaviour 重新实现
OnValidate,在字段变化时设置EditorUtility.SetDirty(this),并在OnInspectorGUI里做Repaint。
如果你用了本地化方案,最简单是做一个工具菜单[MenuItem("Tools/Refresh Inspector")],遍历当前选中对象并 Repaint。实际用下来,我建议把“刷新 Inspector”绑定成一个全局快捷键,开发期非常顺手,比找菜单快得多。
5.4 性能与 GC:GUIContent 缓存是底线
Unity 的 OnGUI 每帧可能被调用多次,尤其是 Inspector 带实时预览、或者被外部工具强制 Repaint 时。如果每次绘制都new GUIContent(...),字符串拼接和对象分配会累积成零碎 GC。所以我在前面的代码里只让缓存随配置项变化重建,这是底线。
那动态字段值每次变化时缓存不就不准了吗?折中方案是给缓存加一个版本号,用一个字符串字段记录最后一次读取动态值时的结果,变化了才重建。字段值本身是字符串,直接比较字符串是否相等也可以,性能开销在一个 Inspector 里完全可接受。
5.5 不是所有“变量”都能被这个方案覆盖
最后提醒一下:[CustomPropertyDrawer]只能作用于序列化字段。static字段、[NonSerialized]字段、普通属性(除了 C# 属性序列化开启的字段支持)、const常量、readonly字段都不会显示在 Inspector,自然也无法通过 PropertyDrawer 改显示名。
如果你是想给一个静态工具类做编辑器面板,那就不是 CustomLabel 的活了,得去写[CustomEditor]或者继承Editor重写面板布局,那是另一套方案,不在本文范围。
还有一类特殊对象:[SerializeReference]的多态字段,在部分 Unity 版本里 PropertyDrawer 的 label 会被其他绘制器吞掉一部分。遇到时先升级 Unity 版本再看,如果版本已经比较新还出问题,就需要考虑用EditorGUI.BeginProperty手动维护 label 区域,这块以后有空再单独写一篇。
我在做编辑器扩展这几年,最大的感受是:Inspector 不只是调试工具,它是策划、美术、音频甚至 PM 日常接触最多的“产品界面”。字段命名是给代码读的,显示命名是给人读的,这两者解耦,省下的沟通成本远比写一个 Drawer 的成本高。CustomLabel 这套方案我大概在四年前开始用,前后迭代了几个版本,从最初的简单替换 label,到后来的动态命名、本地化、枚举显示名,现在已经成为项目编辑器基础库的一部分。如果你的项目还停留在用[InspectorName]硬编码的阶段,不妨从本文的 CustomLabel 开始,把 Inspector 的“壳”真正掌握在自己手里。代码不长,但价值不小。