news 2026/9/7 15:57:32

Unity自定义Inspector显示名:用CustomLabel特性实现字段名与代码解耦

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity自定义Inspector显示名:用CustomLabel特性实现字段名与代码解耦

前阵子项目里策划丢过来一个问题:“你这个speed到底是移动速度还是攻击速度?我在Inspector里改来改去总怕改错。”我打开编辑器一看,字段名是m_MoveSpeed,确实是直接暴露出来了。代码命名规范要求字段带前缀没问题,可Inspector面板是给策划、美术甚至音频同事用的,他们没义务去理解m__xxx这种内部命名。很多人的第一反应是重命名字段,但序列化字段一改名,场景和预制体里已经配置好的数据就像换了身份证一样对不上号。正确做法是给Inspector上的显示名做一次“换壳”,让面板上显示的名字和代码里的字段名彻底解耦。今天这篇文章,就围绕我实际落地的一套方案来聊:自定义 CustomLabel 特性,重写 Inspector 的变量显示名称。

1. 先搞清楚一件事:Inspector 上的名字到底从哪来

1.1 序列化字段与显示名的对应关系

Unity 的 Inspector 面板显示的并不是 C# 字段名本身,而是SerializedPropertydisplayName。默认情况下,这个displayName是序列化字段名经过“人性化”处理后的结果:

代码字段名Inspector 默认显示名
moveSpeedMove Speed
m_MoveSpeedM Move Speed
_chargeLevelCharge Level
isPlayerAliveIs 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绘制这一行。所以我们要做两件事:

  1. 定义一个PropertyAttribute,用来在字段上写配置(显示名、提示文本、动态数据源);
  2. 定义一个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 的“壳”真正掌握在自己手里。代码不长,但价值不小。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 15:57:06

从UMD到gRPC:AI训练中GPU指令翻译官的服务化实战

搞AI训练的哥们儿应该都有这种感觉&#xff1a;模型写起来不难&#xff0c;难的是让计算真正跑到GPU上。这一层“让计算跑起来”的衔接&#xff0c;通常不是AI框架直接操作显卡&#xff0c;而是框架调用GPU驱动&#xff0c;驱动再把算子请求翻译成硬件指令。今天聊的就是这条链…

作者头像 李华
网站建设 2026/9/7 15:53:02

Buzz 语音转文字:离线转录快速上手指南

Buzz 语音转文字&#xff1a;离线转录快速上手指南 【免费下载链接】buzz Buzz transcribes and translates audio offline on your personal computer. Powered by OpenAIs Whisper. 项目地址: https://gitcode.com/GitHub_Trending/buz/buzz Buzz 是一款完全本地的离线…

作者头像 李华
网站建设 2026/9/7 15:51:55

Scala样例类与模式匹配:从求面积到工程最佳实践

写了几年代码&#xff0c;看过不少Scala教程&#xff0c;真正让我觉得“这门语言有点东西”的&#xff0c;恰恰是“样例类&#xff08;case class&#xff09; 模式匹配&#xff08;pattern matching&#xff09;”这种看起来很基础、很想当然的组合。很多人入门时写过Circle、…

作者头像 李华
网站建设 2026/9/7 15:51:37

GPU电压噪声根因与压测复现:从di/dt到IR drop的硬核解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 15:51:20

扫地机器人选购技术拆解:导航避障拖地基站四大维度怎么判断

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华