最近在Unity项目里和带[Flags]的位掩码枚举Flags打交道比较多遇到一个很现实的问题Inspector 默认的枚举下拉框对位掩码非常不友好。比如一个技能系统里要配置“适用场景”想同时勾选野外、副本、竞技场三块默认控件却只能在下拉框里挑一个选项剩下的组合只能靠手打数字效率低还容易错。后来我给这些字段加了一个自定义[EnumFlags]标记再用自定义PropertyDrawer接管绘制把 Flags 枚举在 Inspector 上显示成一组多选 Toggle这个问题就彻底解决了。这篇拆解适合两类人一类是刚开始研究 Unity 编辑器扩展的新手可以照着代码直接抄另一类是已经在用 Flags 枚举但还没优化面板的开发者文里很多细节是官方文档不会写的。我会先分析默认行为的痛点再从一个可运行的Attribute说起逐步完成一个带折叠功能的多选绘制器最后补充显示名、全选/清空按钮、只读模式这些进阶功能以及实际踩过的坑。1. 默认Flags枚举在Inspector上的显示差在哪1.1 默认编辑器只会给枚举画下拉框Unity 的 Inspector 遇到枚举类型字段时默认调用的是EditorGUI.Popup一类的控件。它把枚举定义里每个值当作一个选项选中哪一个序列化字段就存哪一个。这件事本身没有问题因为普通枚举本来就是单一选项。但加上[Flags]之后枚举值的含义变了它是一个整数的若干二进制位组合理应支持多选。[Flags]特性在编译层面只是允许位运算并没有告诉 Inspector “这个枚举要按位显示”。所以默认下拉框依然是单选项。你想选两个以上的位在下拉框里是做不到的。Unity 后续版本虽然对带[Flags]的枚举做了一点改进比如会显示一个Everything的快捷选项但本质上仍然没有解决多选操作的问题。下拉框的选项数量还会随枚举项增多而爆炸一个二十位的掩码下拉列表就有二十来项即使你有耐心一项项翻也无法同时选中其中多项。1.2 位掩码枚举的核心痛点是“组合”位掩码枚举的值不是简单序号每一位都代表一个独立开关。配置面板里真正需要的是把每个开关显示成独立的勾选项用户按需勾选最后组合成整数存进序列化文件。如果只能用下拉框用户面临两个选择要么找到预先定义好的组合项假如你恰好枚举里定义了A|B|C要么理解二进制自己填数字。前者强行扩充了枚举元素数量组合数量一多根本列不完。一个三位的 Flags 枚举不算None也有七种组合四个位就是十五种组合你要是把这些全写进枚举定义维护成本直接起飞。后者更反人类而且非常容易配错。我在某项目里排查过一例配置错误配置面板里填了253这种数字代码运行时倒没问题但后续维护的人完全看不出它原本想开哪些位。换成多选勾选框之后这类低级问题几乎绝迹。1.3 什么时候值得自己写Drawer我个人的判断标准很简单只要 Flags 枚举字段在项目里出现超过三次或者配置字段会被多人长期修改就值得写一个通用 Drawer。写一次可以覆盖项目里所有 Flags 枚举字段成本其实很低。如果你用着完整的 Inspector 增强插件那另说但如果只为了一个字段就引入整套插件反而有点重。从实现思路上说自定义一个PropertyAttribute和配套的PropertyDrawer是 Unity 编辑器扩展里最标准、最不容易出问题的路径。它不会侵入项目运行时逻辑也不依赖具体业务模块写完以后就是一个纯编辑器工具随时可以搬进下一个项目。2. 手写EnumFlagsAttribute和PropertyDrawer5分钟跑起来2.1 自定义Attribute给字段打上标记先写一个标记字段用的Attributeusing System; using UnityEngine; [AttributeUsage(AttributeTargets.Field)] public class EnumFlagsAttribute : PropertyAttribute { public bool showNone true; public bool showAll true; public bool readOnly false; public EnumFlagsAttribute() { } }这里继承的是PropertyAttribute不是System.Attribute。这一点很关键Unity 的编辑器管线只会对带有PropertyAttribute子类的字段查找对应的PropertyDrawer。如果你直接继承了普通AttributeInspector 不会自动识别你还要额外写CustomEditor复杂度完全不同。AttributeUsage限定在Field上因为这个绘制器只处理字段。如果你后面想用在类、方法或者属性上需要再作扩展但普通配置场景用字段就够了。2.2 设计目标折叠收起一行展开后逐个勾选拿到一个 Flags 枚举字段我们希望它在 Inspector 里长这样第一行是一个带展开箭头的字段名折叠状态下只占一行展开后每个枚举值占一行的 Toggle最后一行放“全选 / 清空”按钮。这样做的好处是显而易见的字段多的时候不会把 Inspector 撑爆默认折叠状态也能通过摘要看到当前选中的组合。用户需要调整时再展开逐项勾选。摘要我选择放进标签里效果类似适用场景: 野外, 副本这样不用展开就能看到内容。2.3 动态计算高度PropertyDrawer最容易翻车的地方在PropertyDrawer里最容易翻车的地方是高度。Unity 会先调用GetPropertyHeight用返回值来决定给这个字段留多少绘制区域。如果返回的高度太小绘制内容就会被截断Toggle 会被切掉一半如果返回太大界面又会多出大片空白。所以高度必须根据当前展开状态动态计算折叠返回一行高度。展开返回一行折叠行 N 行枚举项 一行按钮行。用一行一行singleLineHeight叠加是最稳的做法。后面如果你改成了每行放四个 Toggle那这里也要同步改否则界面会立刻错乱。2.4 核心绘制代码可折叠的多选Toggle下面是一段可以直接粘贴运行的完整 Drawer。为了保持可读性我把辅助函数放在代码后面解释主流程已经注释清楚using System; using System.Collections.Generic; using System.Reflection; using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(EnumFlagsAttribute))] public class EnumFlagsDrawer : PropertyDrawer { private class EnumItem { public string Name; public int Value; public GUIContent Content; } private DictionaryType, ListEnumItem _cache new DictionaryType, ListEnumItem(); public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { if (!property.isExpanded) { return EditorGUIUtility.singleLineHeight; } ListEnumItem items GetVisibleItems(property); return EditorGUIUtility.singleLineHeight * (items.Count 2); } public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { EnumFlagsAttribute attr attribute as EnumFlagsAttribute; Type enumType GetEnumType(property); if (enumType null || !enumType.IsEnum) { EditorGUI.LabelField(position, label, new GUIContent(不是有效的枚举类型)); return; } ListEnumItem items GetVisibleItems(property); float lineHeight EditorGUIUtility.singleLineHeight; int currentMask property.intValue; using (new EditorGUI.PropertyScope(property, label, position)) { bool previousEnabled GUI.enabled; if (attr.readOnly) { GUI.enabled false; } // 第一行折叠标签 当前组合摘要 Rect foldRect new Rect(position.x, position.y, position.width, lineHeight); string summary BuildSummary(items, currentMask); property.isExpanded EditorGUI.Foldout(foldRect, property.isExpanded, ${label.text}: {summary}, true); if (property.isExpanded) { int row 1; // 枚举项逐个绘制成 Toggle foreach (EnumItem item in items) { Rect toggleRect new Rect( position.x 16, position.y lineHeight * row, position.width - 16, lineHeight); bool isOn; if (item.Value 0) { isOn currentMask 0; } else { isOn (currentMask item.Value) ! 0; } EditorGUI.BeginChangeCheck(); bool newValue EditorGUI.ToggleLeft(toggleRect, item.Content, isOn); if (EditorGUI.EndChangeCheck()) { if (item.Value 0) { currentMask newValue ? 0 : currentMask; } else { currentMask newValue ? (currentMask | item.Value) : (currentMask ~item.Value); } property.intValue currentMask; } row; } // 按钮行全选 / 清空 Rect buttonLine new Rect( position.x 16, position.y lineHeight * row, position.width - 16, lineHeight); if (GUI.Button(new Rect(buttonLine.x, buttonLine.y, 60, lineHeight), 全选)) { int allMask 0; foreach (EnumItem item in items) { if (item.Value ! 0) { allMask | item.Value; } } property.intValue allMask; currentMask allMask; } if (GUI.Button(new Rect(buttonLine.x 70, buttonLine.y, 60, lineHeight), 清空)) { property.intValue 0; currentMask 0; } } GUI.enabled previousEnabled; } property.serializedObject.ApplyModifiedProperties(); } private Type GetEnumType(SerializedProperty property) { if (fieldInfo ! null fieldInfo.FieldType.IsEnum) { return fieldInfo.FieldType; } // 如果标记在数组或嵌套容器的字段上需要沿 propertyPath 反射真实元素类型。 // 普通字段直接通过 fieldInfo 获取即可这里做了最简处理。 return null; } private ListEnumItem GetVisibleItems(SerializedProperty property) { Type enumType GetEnumType(property); if (enumType null) { return new ListEnumItem(); } if (_cache.TryGetValue(enumType, out ListEnumItem cached)) { return cached; } ListEnumItem result new ListEnumItem(); string[] names Enum.GetNames(enumType); int[] values (int[])Enum.GetValues(enumType); HashSetint seen new HashSetint(); for (int i 0; i values.Length; i) { int value values[i]; // 过滤组合值只保留 0 和单个二进制位。 if (value ! 0 (value (value - 1)) ! 0) { continue; } // 过滤重复值避免同一个位出现多次。 if (!seen.Add(value)) { continue; } string displayName GetDisplayName(enumType, names[i], names[i]); result.Add(new EnumItem { Name names[i], Value value, Content new GUIContent(displayName) }); } // 按数值排序保证 None 在最前面。 result.Sort((a, b) a.Value.CompareTo(b.Value)); _cache[enumType] result; return result; } private string BuildSummary(ListEnumItem items, int mask) { if (mask 0) { foreach (EnumItem item in items) { if (item.Value 0) { return item.Content.text; } } return 0; } Liststring selected new Liststring(); foreach (EnumItem item in items) { if (item.Value ! 0 (mask item.Value) ! 0) { selected.Add(item.Content.text); } } return selected.Count 0 ? string.Join(, , selected) : 未定义; } private string GetDisplayName(Type enumType, string name, string fallback) { FieldInfo field enumType.GetField(name); if (field ! null) { EnumDisplayNameAttribute attr field.GetCustomAttributeEnumDisplayNameAttribute(); if (attr ! null !string.IsNullOrEmpty(attr.name)) { return attr.name; } } return fallback; } }2.5 辅助方法背后的三个细节上面代码里有些细节值得单独说。GetVisibleItems里我做了缓存用枚举类型当 key。不使用缓存的话GetPropertyHeight和OnGUI每帧都会调用每次都要走一遍反射和排序编辑器会明显卡顿。缓存以后虽然内存多占一点但换来的是流畅体验值得。GetEnumType只处理了最简单的情况fieldInfo.FieldType直接就是枚举类型。如果你的热键标记在数组元素上或者标记在泛型容器的内部字段上这里需要拿到SerializedProperty的类型并做反射。因为EnumFlagsAttribute标注的目标是普通字段所以这段最简实现覆盖了绝大多数项目场景但你要心里有数它不是一个万能版本。组合值过滤的逻辑是用(value (value - 1)) ! 0判断的。这个位运算很经典一个整数如果只有一个二进制位是 1那么value (value - 1)一定等于 0。比如1 0结果是 11 0等于 0但如果值是33 2等于 2不是 0说明不是单个位会被过滤掉。3. 让位掩码面板更好用显示名、折叠、快速按钮3.1 怎样显示中文或自定义短名原版枚举名在配置面板上不一定直观。比如CanFly、CanBeTargeted策划同学可能更希望看到“可飞行”“可作为目标”。做法是在枚举值的字段上定义一个显示名特性using System; [AttributeUsage(AttributeTargets.Field)] public class EnumDisplayNameAttribute : Attribute { public string name; public EnumDisplayNameAttribute(string name) { this.name name; } }然后在枚举里这样用[Flags] public enum SkillScene { [EnumDisplayName(无)] None 0, [EnumDisplayName(野外)] Wild 1 0, [EnumDisplayName(副本)] Dungeon 1 1, [EnumDisplayName(竞技场)] Arena 1 2 }上面的GetDisplayName方法就是做这件事的通过枚举名字反射FieldInfo拿到EnumDisplayNameAttribute并读取自定义文案没有就回退成原始枚举名。这里有一个实际经验如果你项目里已经有公共的DescriptionAttribute可以直接复用它尽量避免再造一个轮子。Unity 自带一个Tooltip属性但那是挂在字段上用的作用范围和显示强度都不太适合枚举内部的单项说明。3.2 为什么要过滤组合值[Flags]枚举里经常有人这样写public enum SkillTarget { None 0, Self 1 0, Ally 1 1, Enemy 1 2, AllCombatant Self | Ally | Enemy, }AllCombatant是给代码用的便捷宏但如果把它当成一个勾选项多选面板会出现很奇怪的逻辑勾了Self和Ally没勾EnemyAllCombatant却显示未勾选勾了AllCombatant实际写入了三个位然后你再单独取消某个位时AllCombatant的状态又会变。这会让用户非常困惑。所以在GetVisibleItems里默认只保留None和单个二进制位。一键选中某个组合的需求用“全选”按钮就能覆盖大部分场景。如果你确实需要更多快捷组合可以在这个基础上扩展“自定义组合按钮”不要直接把它变成 Toggle。3.3 给面板加点快捷操作除了全选、清空我还实际遇到过“要先清掉某一位再把某一位打开”的操作场景所以按钮不需要太复杂。全选按钮直接遍历所有单个位做或运算清空直接把 0 写进property.intValue。在实现上我把按钮放在最后一行的固定 Rect 里。这样做比GUILayout.Button可控不会因为 Inspector 宽度变化产生奇怪的换行。如果你想控制按钮显示在右侧也可以按buttonLine.width - 130之类的坐标算但要注意和左侧Toggle的区域不能重叠否则点击容易误触。如果你担心误触比如清空按钮不小心被点到可以改成弹出确认菜单。不过在我自己项目里清空位掩码的代价并不高重新勾选几下就回来了所以确认弹窗属于锦上添花不是必需品。3.4 只读开关与禁用灰显有些配置字段是运行时自动维护的策划或者程序不应该在 Inspector 里直接改。这种情况下可以用GUI.enabled false把整个 Drawer 区域置灰。代码里已经处理了这一点根据readOnly参数决定是否关闭GUI.enabled用完以后通过previousEnabled恢复原值。这里有个很容易踩的坑如果你忘记复位当前整个 Inspector 的所有后续控件都会变成禁用状态而且不是报错就是界面一片灰你排查半天才有可能发现是GUI.enabled没恢复。如果你用的是更高版本的 Unity也可以改用EditorGUI.BeginDisabledGroup和EndDisabledGroup这两个方法支持嵌套语义更清晰。新版 Unity 对这个写法没有兼容问题老版本才需要考虑降级方案。4. 位掩码枚举Drawer常见问题与避坑技巧4.1 控件重叠、被截断十有八九是高度算错了新手最容易遇到的就是面板显示不全或者 Toggle 叠在一起。原因基本是GetPropertyHeight返回了固定高度或者没有考虑可展开状态的变化。解决办法很简单在修改isExpanded之后像上面代码一样动态计算高度。如果你用的是EditorGUILayout布局也要注意PropertyDrawer里没有自动布局概念必须自己用Rect算坐标。混用GUILayout和绝对Rect也容易出问题。我见过有人在一个 Drawer 里先调用EditorGUILayout.LabelField再用绝对坐标画 Toggle最后在折叠状态下完全错位。建议统一使用Rect计算别图省事混用。4.2 枚举项太多面板撑爆了怎么办如果有十几个 Flags 位全展开确实很长尤其在一屏只能显示两三个字段的情况下体验很差。我建议做两件事第一默认折叠用户主动点开才显示完整选项。这一点上面代码已经实现了。第二把 Toggle 分行排列比如一排放四个减少纵向占位。实现方式是在GetPropertyHeight里把行数改成每行多少个的计算结果同时在绘制时按列索引切分Rect。核心计算大概是每行数量 4总行数 ceil(items.Count / 4)每一列的宽度按position.width / 4划分。这个优化代码量不大但能明显改善长列表的体验。有需要的可以自己加。4.3 枚举值不是从10开始或者有重复值时怎么办有些项目枚举为了兼容老序列化数据会从 1 开始递增或者包含重复义项。对 Flags 枚举来说这是反模式但现实里确实存在。处理原则是多选 Toggle 只应显示真正的二进制位。如果某个值既不是 0 也不是 2 的幂直接过滤。重复值要保证每个位只出现一次上面代码里用了HashSetint来去重。如果你使用long类型定义枚举property.intValue会截断数据这点要特别注意位掩码枚举我一般统一建议使用int不要用uint或long因为 Inspector 和序列化对无符号、长整型的支持都不够友好。如果你真的遇到1 31这种高位int 会变成负数。位运算在补码下依然能用但(value (value - 1))可能产生溢出不定的行为最好加一步value 0的判断。我的代码目前没有处理负数依赖的前提是项目内 Flags 枚举都使用非负的单个位值。4.4 修改后没保存、没刷新切对象后配置丢失PropertyDrawer 里修改property.intValue只是修改了临时拷贝通常要serializedObject.ApplyModifiedProperties()才会写回对象。如果漏掉这行你会发现面板上勾选有反应切换对象再切回来后修改全丢了。我在核心代码末尾显式调用了ApplyModifiedProperties()这样写虽然简单但每一帧都会触发一次序列化写回。如果你更在意性能可以用EditorGUI.BeginChangeCheck()和EditorGUI.EndChangeCheck()包住绘制区域只在真正改变时才调用ApplyModifiedProperties()。两个方案都能用项目面板字段不多时直接调用问题不大。另一个被忽略的问题是无操作回滚。Unity 编辑器的普通字段修改默认会记录在 Undo 栈里但自动绘制的 Drawer 不会自动帮你Undo.RecordObject。如果想支持 CtrlZ你需要在修改前调用Undo.RecordObject(property.serializedObject.targetObject, Change Flags)。编辑器扩展一旦要长时间被人使用这一步建议加上。4.5 和其他Inspector插件撞车一些增强 Inspector 的插件会在绘制前介入导致自定义 Drawer 不生效或者被二次绘制。排查思路是先在OnGUI里加Debug.Log确认 Drawer 有没有被调进来再到插件配置里找“排除字段”或者“禁用自定义绘制”相关的选项。这里不展开具体插件因为不同插件处理方式差别很大。记住排查顺序最重要先确认自己的OnGUI是否有被执行再确认是不是多个 Drawer 叠加最后才考虑是不是插件冲突。很多时候你以为插件冲突实际上是自己GetPropertyHeight算错了或者多个CustomPropertyDrawer特性重复造成绘制顺序混乱。4.6 常见问题速查表问题现象常见原因解决办法Toggle 显示一半或被截断GetPropertyHeight返回高度不够根据展开状态动态计算行数和高度Toggle 重叠错位混用Rect和GUILayout布局全部统一用Rect计算修改后切换对象又还原缺少ApplyModifiedProperties修改后调用写回所有后续控件都灰了GUI.enabled未恢复保存旧值绘制完恢复勾选一个组合项后状态混乱把组合值也当成了 Toggle 项过滤非单二进制位值面板被一个字段撑得特别高每个枚举项都占一行多列布局或默认折叠枚举值有重复定义Enum.GetNames出现同名项用HashSetint去重4.7 关于序列化int与枚举的值域SerializedProperty.intValue是 32 位 int和枚举底层类型强相关。如果你的枚举声明成了byte或shortintValue也基本能处理但边界情况比较多。Flags 枚举建议统一使用int避免无符号和长整型的坑。如果项目中确实有超过 32 位的位掩码需求建议拆成多个 int 字段分别配置而不是硬塞进一个长整型枚举里。Inspector 对long枚举的序列化支持本来就一般自定义 Drawer 还要手动处理高位截断问题性价比太低。5. 个人实操体会写这个 Drawer 之前我也试过直接用现成的 Inspector 插件功能确实丰富但为了一个字段引入整套插件总觉得太重。后来抽出小半天时间自己写发现真正核心的代码不到一百行而且可以根据项目需求自由扩展。编辑器扩展这个东西真的不神秘关键是理解PropertyDrawer的生命周期和Rect布局规则。我自己现在把所有通用 Drawer 放在同一个编辑器类库里后续项目直接复用。枚举项上加中文显示名这个习惯也一直保留着否则策划同学改配置时理解成本会高不少。如果你项目里已经有公共的DescriptionAttribute或类似的本地化方案可以直接复用避免再造一个轮子。最后分享一个小技巧折叠摘要里的组合值文本建议用逗号拼接不要用顿号。因为有些策划会在枚举显示名里写“副本、野外”这类带顿号的文案如果再拼进摘要整行会变得很难阅读。逗号分隔配合英文逗号后面加一个空格既清晰又不会有歧义。