1. 项目概述:为什么Unity开发者必须掌握C#特性
如果你在Unity里写过脚本,大概率用过[SerializeField]或者[Range(0, 100)]这样的标记。你有没有想过,为什么在变量前加这么一行“魔法”代码,就能让私有变量出现在Inspector面板,或者让一个数字输入框变成滑块?这背后就是C#的**特性(Attributes)**在起作用。很多Unity新手,甚至写了一两年项目的开发者,对特性的理解可能还停留在“照着官方文档用”的阶段,知其然不知其所以然。
实际上,特性是C#中一种强大的**元数据(Metadata)**声明机制。你可以把它理解为给代码元素(类、方法、属性、参数等)贴上的“标签”或“注解”。编译器、运行时环境(如Unity编辑器)或者其他工具(如序列化系统、测试框架)可以读取这些标签,并据此改变它们的行为。在Unity开发中,特性无处不在:它控制着Inspector的显示逻辑、自动化序列化流程、定义编辑器扩展行为,甚至是实现一些高级框架(如依赖注入、网络同步)的基石。
掌握自定义特性,意味着你不再仅仅是Unity引擎的“使用者”,而是能更深入地定制和扩展工作流,写出更干净、更可维护、更强大的代码。比如,你可以创建一个[ReadOnly]特性,让Inspector中的某些字段在Play模式下不可编辑;或者创建一个[AssetPath]特性,自动将字符串字段转换为项目内的资源引用选择器。这些都能极大提升开发效率和代码质量。
2. 特性基础:从内置特性到运行机制
2.1 内置特性的实战应用解析
在深入自定义之前,我们必须先吃透Unity和.NET中那些最常用、最核心的内置特性。它们是我们理解特性工作机制的最佳范例。
[SerializeField]:打破私有壁垒的序列化桥梁这是Unity开发者最早接触的特性之一。C#的默认序列化规则(指Unity的序列化系统,而非.NET的BinaryFormatter)只序列化公有字段。但面向对象设计原则告诉我们,数据应尽可能私有,通过属性暴露。这就产生了矛盾。
public class PlayerData : MonoBehaviour { // 公有字段,会显示在Inspector并被序列化,但破坏了封装性。 public float health; // 私有字段,符合封装原则,但不会显示在Inspector,也不会被序列化(默认情况下)。 private float _maxHealth; // 使用[SerializeField]标记的私有字段,既保持了封装性,又能被Unity序列化和在Inspector中编辑。 [SerializeField] private float _currentStamina; }注意:
[SerializeField]只对Unity的序列化系统有效。如果你尝试用JsonUtility.ToJson()序列化一个带有[SerializeField]的私有字段,它默认是不会被包含进去的,除非该字段同时也是public的。这是两个不同的序列化系统。
[Range(min, max)]与[Tooltip(“description”)]:提升编辑器友好度这两个特性是改善Inspector用户体验的利器。[Range]不仅提供了滑块UI,更重要的是它隐式地进行了输入验证。你不再需要在Update或属性设置器里写Mathf.Clamp来防止数值越界。
[Range(0, 1)] [Tooltip(“角色的基础移动速度,通常介于0.5到1之间进行微调。”)] public float baseMoveSpeed = 0.8f;[Tooltip]则是一种廉价的文档。当项目交接或自己隔了很久再回头看代码时,悬停在字段上的提示文字能快速唤醒记忆,说明这个参数的用途、单位或注意事项,避免了去翻代码注释的麻烦。
[Header(“Title”)]与[Space]:组织Inspector布局当脚本的公开变量越来越多时,Inspector面板会变得杂乱无章。这两个特性没有任何程序逻辑上的影响,纯粹是为了视觉管理。
public class Weapon : MonoBehaviour { [Header(“基础属性”)] public float damage; public float fireRate; [Space(10)] // 添加10像素的垂直间距 [Header(“高级设置”)] [Tooltip(“连发模式下的射击间隔”)] public float burstInterval; }合理使用它们,能让你的组件在Inspector里看起来像官方组件一样专业、清晰。
[System.Serializable]:自定义类的序列化通行证这是让自定义类或结构体能被Unity序列化的关键。Unity的序列化系统无法自动处理任意自定义类型。
// 没有[Serializable],这个类即使作为公有字段,其内容也不会被序列化或显示。 [System.Serializable] public class RewardItem { public string itemId; public int count; public Sprite icon; // Unity内置类型,本身可序列化 } public class LootBox : MonoBehaviour { public RewardItem[] potentialRewards; // 现在这个数组可以被完整序列化和在Inspector中编辑了 }实操心得:如果你发现一个自定义类的字段在Inspector中显示为灰色(不可编辑)或者其值在运行后无法保存,第一件事就是检查这个类是否加上了
[System.Serializable]特性。这是新手常踩的坑。
2.2 特性在编译与运行时的幕后原理
理解特性的工作机制,能帮你避免很多迷惑行为。特性本质上是一段声明性信息,它被编译进程序集的元数据中,但本身不执行任何操作。
编译期:当你写下[SerializeField] private int _value;时,编译器只是将“这个字段被SerializeFieldAttribute修饰过”这一信息,记录到生成程序集(.dll文件)的元数据表里。编译器不会因此生成任何额外的CIL(中间语言)代码来改变_value的访问权限。
运行期:特性的“魔法”发生在运行时,由特定的消费者(Consumer)来读取并做出反应。
- Unity编辑器:当你在Editor中选中一个GameObject时,Unity的Inspector窗口代码会通过反射(Reflection)读取该物体上所有MonoBehaviour脚本的字段元数据。如果发现某个字段带有
[SerializeField]特性,即使它是私有的,Inspector也会尝试绘制它并允许编辑。保存场景或预制体时,Unity的序列化系统同样会读取这个特性,决定将哪些字段写入.scene或.prefab文件。 - Unity运行时序列化:
JsonUtility.ToJson()和FromJson()在默认情况下并不尊重[SerializeField]。它们是独立的系统。如果你想用JsonUtility序列化私有字段,需要配合[System.NonSerialized]的反面特性吗?不,JsonUtility默认只处理公有字段。对于复杂需求,你可能需要转向Newtonsoft.Json(需导入)并配合其[JsonProperty]特性。 - 自定义工具代码:这是自定义特性的主战场。你可以写一个编辑器脚本,遍历项目中的所有脚本,寻找带有你自定义
[Version]特性的类,然后自动生成版本报告。
关键结论:特性本身是被动的。一个[MyCustomAttribute]如果没有任何代码去查找和使用它,那么它就只是一个无用的装饰。赋予特性生命力的,永远是另一段主动去“查询”它的代码。
3. 自定义特性全流程:从设计到应用
3.1 定义自定义特性类
创建自定义特性,本质上是定义一个继承自System.Attribute的类。类名通常以 “Attribute” 结尾,但使用时可以省略。
第一步:创建特性类我们以一个实际需求为例:为游戏中的配置表数据类添加一个[ExcelColumn]特性,用于标记属性对应Excel表中的哪一列,以便自动化导入数据。
// 1. 继承自 System.Attribute // 2. 类名以 Attribute 结尾是约定俗成的做法。 public class ExcelColumnAttribute : System.Attribute { // 3. 定义特性所需的字段或属性,通常是只读的。 public string ColumnName { get; } public int ColumnIndex { get; } // 4. 通过构造函数强制要求提供必要信息。 public ExcelColumnAttribute(string columnName) { ColumnName = columnName; ColumnIndex = -1; // 用-1表示未指定索引 } public ExcelColumnAttribute(int columnIndex) { ColumnName = null; ColumnIndex = columnIndex; } // 5. 可以提供更丰富的构造函数,但保持简单。 public ExcelColumnAttribute(string columnName, int columnIndex) { ColumnName = columnName; ColumnIndex = columnIndex; } }第二步:使用 AttributeUsage 限定目标上面的特性可以被用到类、方法、字段等任何地方,但这可能不是我们想要的。[AttributeUsage]特性(没错,特性本身也用特性来修饰)用于限制自定义特性的使用范围。
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, // 只允许用在属性和字段上 Inherited = false, // 不被派生类继承 AllowMultiple = false)] // 同一元素上不允许使用多个此特性 public class ExcelColumnAttribute : System.Attribute { // ... 同上 ... }- AttributeTargets:这是一个枚举,指定了特性可以应用的目标。常用值有
Class,Struct,Property,Field,Method,Parameter等。可以通过|操作符组合多个目标。 - Inherited:默认为
true。如果为true,当特性应用于一个类时,其派生类也会被认为拥有此特性。对于数据标记类特性(如ExcelColumn),通常设为false。 - AllowMultiple:默认为
false。如果为true,则允许在同一代码元素上重复应用多个相同的特性。例如,[Conditional(“DEBUG”)]特性就允许多个。
3.2 在代码中应用自定义特性
定义好特性后,就可以像使用内置特性一样使用它了。
public class MonsterConfig { // 应用自定义特性,指定对应Excel的“ID”列 [ExcelColumn(“ID”)] public int Id { get; set; } [ExcelColumn(“名称”)] public string Name { get; set; } [ExcelColumn(“血量”)] public float Health { get; set; } // 也可以使用列索引 [ExcelColumn(3)] // 假设攻击力在第4列(索引从0开始) public int Attack { get; set; } // 这个字段没有特性,数据导入工具可能会忽略它 public string InternalComment { get; set; } }应用特性本身不会产生任何即时效果。它只是为Id,Name这些成员贴上了“我是Excel数据”的标签。
3.3 通过反射读取与使用特性
特性价值的体现,完全依赖于另一段代码去读取它。这个过程离不开反射(Reflection)。
下面是一个模拟的Excel数据读取器,演示如何利用[ExcelColumn]特性:
using System; using System.Reflection; public static class ExcelDataLoader { public static T LoadFromRow<T>(Dictionary<string, string> rowData) where T : new() { // 创建目标对象实例 T obj = new T(); Type type = typeof(T); // 获取类型的所有属性 PropertyInfo[] properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); foreach (PropertyInfo prop in properties) { // 1. 查找属性上的 ExcelColumnAttribute ExcelColumnAttribute columnAttr = prop.GetCustomAttribute<ExcelColumnAttribute>(); if (columnAttr != null) { string valueToSet = null; // 2. 根据特性信息,从行数据中查找值 if (!string.IsNullOrEmpty(columnAttr.ColumnName)) { // 优先按列名查找 if (rowData.ContainsKey(columnAttr.ColumnName)) { valueToSet = rowData[columnAttr.ColumnName]; } } else if (columnAttr.ColumnIndex >= 0) { // 或者按列索引查找(假设rowData的Key是索引的字符串形式) string indexKey = columnAttr.ColumnIndex.ToString(); if (rowData.ContainsKey(indexKey)) { valueToSet = rowData[indexKey]; } } // 3. 如果找到了值,尝试转换并赋值 if (valueToSet != null) { try { object convertedValue = Convert.ChangeType(valueToSet, prop.PropertyType); prop.SetValue(obj, convertedValue); } catch (Exception ex) { Debug.LogError($“无法将值 ‘{valueToSet}’ 转换为属性 ‘{prop.Name}’ 的类型 {prop.PropertyType}. 错误: {ex.Message}”); } } } // 没有特性的属性会被跳过 } // 同样的逻辑也可以用于字段 (type.GetFields) return obj; } }使用示例:
// 假设从CSV解析出一行数据 var csvRow = new Dictionary<string, string> { {“ID”, “1001”}, {“名称”, “史莱姆”}, {“血量”, “50.5”}, {“3”, “10”} // 攻击力列,用索引3表示 }; MonsterConfig monster = ExcelDataLoader.LoadFromRow<MonsterConfig>(csvRow); Debug.Log($“加载怪物: {monster.Name}, HP: {monster.Health}, ATK: {monster.Attack}”); // 输出:加载怪物: 史莱姆, HP: 50.5, ATK: 10这段代码的核心是GetCustomAttribute<T>()方法。它检查一个特定的成员(PropertyInfo)是否装饰了指定的特性,如果有,就返回该特性对象的实例,我们可以读取它的ColumnName或ColumnIndex属性来指导后续操作。
注意事项:反射操作在性能上是有开销的,因为它涉及运行时类型检查和方法调用。切忌在每帧执行的代码(如
Update)中频繁进行复杂的反射查询。正确的做法是在初始化时(如Awake、Start或静态构造函数中)一次性收集并缓存这些信息。例如,可以为每种配置类型创建一个Dictionary<PropertyInfo, ExcelColumnAttribute>的缓存,以后每次加载数据都从缓存中读取特性信息,而不是反复调用GetCustomAttribute。
4. 在Unity编辑器中的高级应用实战
Unity Editor是自定义特性大展拳脚的舞台。通过结合UnityEditor命名空间下的API,你可以创建强大的 Inspector 扩展和编辑器工具。
4.1 创建自定义PropertyDrawer
PropertyDrawer用于为特定类型或带有特定特性的字段定制其在Inspector中的绘制方式。这是最常用的编辑器扩展方式之一。
案例:实现一个[ReadOnly]特性我们想实现一个特性,让字段在Inspector中显示为灰色,不可编辑,常用于显示运行时计算的结果或配置。
// 第一步:定义特性 [AttributeUsage(AttributeTargets.Field)] public class ReadOnlyAttribute : PropertyAttribute { } // 这是一个空特性,仅作为标记。所有逻辑在Drawer中实现。// 第二步:创建对应的PropertyDrawer // 必须放在Editor文件夹下的脚本中! using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(ReadOnlyAttribute))] public class ReadOnlyDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { // 1. 保存原始的GUI启用状态 bool previousGUIState = GUI.enabled; // 2. 禁用GUI交互,使其变灰 GUI.enabled = false; // 3. 使用默认的属性字段绘制逻辑,但现在是禁用的 EditorGUI.PropertyField(position, property, label, true); // 4. 恢复GUI状态 GUI.enabled = previousGUIState; } }使用:
public class PlayerUI : MonoBehaviour { public float currentHealth; public float maxHealth; [ReadOnly] public float healthPercentage; // 在Inspector中只读 void Update() { healthPercentage = currentHealth / maxHealth; } }现在,healthPercentage在Inspector中会实时更新,但无法直接修改,完美地展示了运行时状态。
更复杂的案例:[MinMaxSlider]范围滑块Unity有[Range],但有时我们需要一个同时定义最小值和最大值的区间滑块(比如随机生成的范围)。
// 特性定义 [AttributeUsage(AttributeTargets.Field)] public class MinMaxSliderAttribute : PropertyAttribute { public float Min { get; private set; } public float Max { get; private set; } public MinMaxSliderAttribute(float min, float max) { Min = min; Max = max; } }// PropertyDrawer实现 [CustomPropertyDrawer(typeof(MinMaxSliderAttribute))] public class MinMaxSliderDrawer : PropertyDrawer { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { if (property.propertyType != SerializedPropertyType.Vector2) { EditorGUI.LabelField(position, label.text, “Use MinMaxSlider with Vector2.”); return; } MinMaxSliderAttribute minMax = attribute as MinMaxSliderAttribute; Vector2 value = property.vector2Value; Rect controlRect = EditorGUI.PrefixLabel(position, label); Rect[] rects = SplitRect(controlRect, 3); // 将区域分成三份:左值、滑块、右值 // 绘制最小值字段 EditorGUI.BeginChangeCheck(); float leftVal = EditorGUI.FloatField(rects[0], value.x); leftVal = Mathf.Clamp(leftVal, minMax.Min, value.y); // 限制最小值 if (EditorGUI.EndChangeCheck()) { value.x = leftVal; } // 绘制滑块 EditorGUI.BeginChangeCheck(); EditorGUI.MinMaxSlider(rects[1], ref value.x, ref value.y, minMax.Min, minMax.Max); if (EditorGUI.EndChangeCheck()) { // 滑块可能让左值大于右值,需要钳制 value.x = Mathf.Clamp(value.x, minMax.Min, value.y); value.y = Mathf.Clamp(value.y, value.x, minMax.Max); } // 绘制最大值字段 EditorGUI.BeginChangeCheck(); float rightVal = EditorGUI.FloatField(rects[2], value.y); rightVal = Mathf.Clamp(rightVal, value.x, minMax.Max); if (EditorGUI.EndChangeCheck()) { value.y = rightVal; } property.vector2Value = value; } private Rect[] SplitRect(Rect rect, int count) { // 简单的分割矩形逻辑 Rect[] rects = new Rect[count]; float width = rect.width / count; for (int i = 0; i < count; i++) { rects[i] = new Rect(rect.x + i * width, rect.y, width, rect.height); rects[i].width -= 2; // 加一点间隙 rects[i].x += 1; } return rects; } }使用:
public class Spawner : MonoBehaviour { [MinMaxSlider(0, 10)] public Vector2 spawnInterval = new Vector2(1, 3); // x是最小间隔,y是最大间隔 }这样就在Inspector中创建了一个直观的区间配置工具。
4.2 创建自定义Inspector
当需要对整个组件(而非单个字段)的Inspector外观进行大刀阔斧的改造时,就需要用到CustomEditor。
案例:为技能系统组件美化Inspector假设我们有一个简单的技能脚本:
public class Skill : MonoBehaviour { public string skillName; public Sprite icon; [TextArea(3, 5)] public string description; public float cooldown; public float manaCost; public bool isPassive; }默认的Inspector很普通。我们可以创建一个自定义Inspector来分组、美化。
using UnityEditor; using UnityEngine; [CustomEditor(typeof(Skill))] public class SkillEditor : Editor { private SerializedProperty _skillNameProp; private SerializedProperty _iconProp; private SerializedProperty _descProp; private SerializedProperty _cdProp; private SerializedProperty _manaProp; private SerializedProperty _isPassiveProp; void OnEnable() { // 在OnEnable中查找并缓存SerializedProperty,比在OnGUI中反复查找高效。 _skillNameProp = serializedObject.FindProperty(“skillName”); _iconProp = serializedObject.FindProperty(“icon”); _descProp = serializedObject.FindProperty(“description”); _cdProp = serializedObject.FindProperty(“cooldown”); _manaProp = serializedObject.FindProperty(“manaCost”); _isPassiveProp = serializedObject.FindProperty(“isPassive”); } public override void OnInspectorGUI() { // 开始检查变化 EditorGUI.BeginChangeCheck(); // 绘制一个标题区域 EditorGUILayout.LabelField(“技能配置”, EditorStyles.boldLabel); EditorGUILayout.Space(); // 基本信息组 EditorGUILayout.LabelField(“基本信息”, EditorStyles.miniBoldLabel); EditorGUILayout.PropertyField(_skillNameProp); EditorGUILayout.PropertyField(_iconProp); EditorGUILayout.PropertyField(_descProp); EditorGUILayout.Space(10); // 数值属性组,根据是否被动技能动态显示 EditorGUILayout.LabelField(“数值属性”, EditorStyles.miniBoldLabel); EditorGUILayout.PropertyField(_isPassiveProp); if (!_isPassiveProp.boolValue) // 如果不是被动技能,显示冷却和蓝耗 { EditorGUILayout.PropertyField(_cdProp); EditorGUILayout.PropertyField(_manaProp); } else { EditorGUILayout.HelpBox(“被动技能无冷却和消耗。”, MessageType.Info); } EditorGUILayout.Space(10); // 预览按钮 if (GUILayout.Button(“预览技能图标”)) { Skill skill = target as Skill; if (skill.icon != null) { // 这里可以弹出一个窗口显示大图,简单起见用Debug Debug.Log($“预览技能: {skill.skillName}”); } } // 如果GUI有变化,应用修改到序列化对象 if (EditorGUI.EndChangeCheck()) { serializedObject.ApplyModifiedProperties(); } } }这个自定义Inspector将字段分组,增加了标题和间距,并根据isPassive复选框动态隐藏/显示相关字段,还添加了一个功能按钮,使得组件的配置界面更加友好和专业。
重要原则:在
CustomEditor的OnGUI中,必须使用SerializedProperty和EditorGUILayout.PropertyField()来绘制字段,而不是直接访问脚本的公有字段(如(target as Skill).cooldown)。因为SerializedProperty与Unity的撤销(Undo)、预制体覆盖(Prefab Override)和序列化系统是深度集成的。直接修改字段值会绕过这些系统,导致无法撤销、预制体连接断开等问题。
5. 性能考量、最佳实践与疑难排查
5.1 性能优化与缓存策略
反射是强大的,但也是有代价的。在性能关键的路径上,必须谨慎使用。
问题场景:假设我们有一个属性监听系统,每个MonoBehaviour启动时,都要检查自己所有带有[WatchValue]特性的字段,并在其变化时触发事件。如果在每个脚本的Update里都去用反射获取特性,帧率会惨不忍睹。
解决方案:初始化时缓存
// 定义特性 [AttributeUsage(AttributeTargets.Field)] public class WatchValueAttribute : Attribute { } // 缓存结构 public class WatchedFieldInfo { public FieldInfo Field; public object LastValue; public Action<object, object> OnChanged; // 值变化时的回调 } public class ValueWatcher : MonoBehaviour { private List<WatchedFieldInfo> _watchedFields = new List<WatchedFieldInfo>(); void Start() { // 1. 只在初始化时进行一次反射 CacheWatchedFields(); } void Update() { // 2. 每帧只进行高效的字段值获取和比较,没有反射开销 foreach (var info in _watchedFields) { object currentValue = info.Field.GetValue(this); if (!Equals(currentValue, info.LastValue)) { info.OnChanged?.Invoke(info.LastValue, currentValue); info.LastValue = currentValue; } } } private void CacheWatchedFields() { Type type = this.GetType(); FieldInfo[] allFields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); foreach (FieldInfo field in allFields) { if (field.GetCustomAttribute<WatchValueAttribute>() != null) { _watchedFields.Add(new WatchedFieldInfo { Field = field, LastValue = field.GetValue(this) }); } } } }更进一步:对于大型框架,可以考虑在游戏启动时,为所有可能用到的类型预扫描并生成静态的查找字典,将反射开销完全转移到加载期。
5.2 特性使用的常见陷阱与最佳实践
特性不是条件编译指令:
[Conditional(“DEBUG”)]是一个特例,它确实会影响编译器。但绝大多数自定义特性不会。不要指望用特性来让一段代码在某种条件下不编译或不执行。特性的消费者代码决定是否、何时执行逻辑。保持特性类轻量:特性类通常只在反射时被实例化,其构造函数和属性应尽可能简单,避免执行复杂的逻辑或依赖外部服务。
明确使用目标:始终使用
[AttributeUsage]来明确你的特性设计用于什么目标(类、方法、属性等)。这可以避免误用,并在编译时提供一定保护。命名清晰:特性类名应明确表达其用途,并以 “Attribute” 结尾。使用时可以省略后缀,但定义时加上是好习惯。
编辑器代码隔离:所有使用了
UnityEditorAPI的代码(如CustomEditor,PropertyDrawer)都必须放在名为Editor的文件夹中,否则在项目构建(Build)时会报错,因为这些API在运行时(玩家端)是不存在的。
5.3 常见问题排查实录
问题1:自定义PropertyDrawer不生效
- 检查点1:Drawer脚本是否放在了
Editor文件夹下? - 检查点2:特性类是否继承自
PropertyAttribute? - 检查点3:
CustomPropertyDrawer特性中传入的类型是否正确?是否是特性类本身的typeof(YourAttribute),而不是使用该特性的字段的类型。 - 检查点4:Unity编辑器是否已经编译并刷新?有时需要手动点击一下Inspector面板或等待几秒。
问题2:应用了特性的字段,在Inspector中显示为默认样式
- 可能原因:你的
PropertyDrawer.OnGUI方法没有正确调用EditorGUI.PropertyField或者没有处理property参数。确保你最终将用户的输入赋值回了property(对于SerializedProperty)或者调用了serializedObject.ApplyModifiedProperties()(对于CustomEditor)。
问题3:在运行时(非编辑器模式)读取特性失败
- 检查点:确保你读取特性的代码路径在构建后的游戏中依然存在。一些编辑器专用的特性(如
HideInInspector)在运行时可能没有意义,但你的自定义数据标记特性(如ExcelColumn)的读取逻辑必须是运行时代码的一部分。 - 注意:构建时,Unity可能会对代码进行代码裁剪(Code Stripping)。如果一段代码(比如你的特性查询逻辑)在引擎看来“没有被任何地方引用”,它可能会被优化掉。确保你的特性消费者代码被显式地调用(例如,在一个不会被裁剪的初始化方法中),或者通过
[Preserve]特性来标记相关类型和方法。
问题4:特性影响了序列化,但JsonUtility不认
- 根本原因:
JsonUtility是Unity自带的轻量级JSON工具,它只序列化公有字段和标记了[SerializeField]的字段。它不识别其他自定义特性。如果你需要基于自定义特性来控制JSON序列化,需要换用其他序列化库(如Newtonsoft.Json),并实现自己的JsonConverter,或者在序列化前手动将对象转换为一个中间DTO(Data Transfer Object)。
掌握C#特性,尤其是自定义特性,是Unity开发者从中级迈向高级的关键一步。它让你从“写游戏逻辑”进化到“设计开发工具和框架”。开始时可以从模仿内置特性(如ReadOnly)做起,然后尝试解决自己项目中遇到的特定配置、自动化或编辑器美化问题。记住,特性的力量不在于特性本身,而在于那些主动去寻找并解释这些“标签”的代码。