1. 项目概述:为什么Unity开发者必须搞懂序列化?
如果你在Unity编辑器里拖拽过一个GameObject,为它设置了一个速度值,然后点击运行,发现这个速度值被完美地保留了下来——恭喜你,你已经体验过Unity序列化的魔力了。但这背后到底发生了什么?为什么有些变量在Inspector面板里能显示并修改,有些却不行?为什么我辛辛苦苦调好的参数,一运行游戏就变回默认值了?这些问题,都指向了Unity引擎一个核心但常被忽视的机制:序列化。
简单来说,序列化就是把内存中的对象状态(比如一个脚本里public float speed = 5.0f;这个5.0)转换成一种可以存储或传输的格式(比如Unity场景文件.unity或预制体文件.prefab中的一段数据)。反序列化则是反过来,从存储的格式重建出对象状态。在Unity的工作流中,序列化无处不在:保存场景、编辑预制体、在Inspector中显示和修改组件属性,甚至AssetBundle的打包与加载,底层都依赖这套系统。
而[SerializeField]属性,就是开发者与这套自动化系统进行“对话”的关键工具。默认情况下,Unity只序列化public字段。但面向对象设计原则告诉我们,应该尽量使用private字段并通过属性(Property)来访问,以封装数据。这就产生了矛盾:我需要数据私有以保证代码安全,但又需要它在编辑器里可配置以便于设计和调试。[SerializeField]正是为了解决这个矛盾而生,它告诉Unity:“嘿,虽然这个字段是private的,但请你也把它序列化,让我能在Inspector里看到和修改它。”
不理解序列化,你可能会遇到一堆令人头疼的“灵异事件”:改好的数据没保存、预制体引用莫名丢失、自定义的类数据在Inspector里不显示。理解它,你就能驾驭Unity编辑器,实现更高效的数据驱动设计,甚至能玩出一些高级花样,比如编辑器工具开发、自定义数据持久化方案。接下来,我们就一层层剥开序列化的外壳,看看它究竟如何工作,以及如何用[SerializeField]把它变成我们的得力助手。
2. Unity序列化机制深度解析
2.1 序列化在Unity引擎中的角色与流程
Unity的序列化系统并非一个独立的、只在保存时运行的模块,而是一个深度集成到编辑器运行时和播放器运行时的核心数据管道。我们可以把它想象成一个连接“编辑器态”和“运行时态”的桥梁。
核心流程可以拆解为以下几个关键环节:
编辑器序列化(保存时):当你在编辑器中点击保存场景或预制体时,Unity会遍历场景中的所有GameObject及其附加的组件。对于每个组件,引擎会检查其脚本中定义的所有字段,根据序列化规则(哪些能序列化,后面详述)收集字段的当前值,并将这些数据转换为一种紧凑的二进制格式(对于
.asset文件可能是YAML文本格式),写入到项目文件(如.unity,.prefab)中。这个过程是增量式的,通常只序列化发生变化的部分。反序列化与资源加载(进入播放模式或加载场景时):当你点击播放按钮,Unity会加载场景文件。引擎读取文件中的序列化数据,并据此在内存中重新创建GameObject和组件。关键的一步来了:它会为每个组件的新实例分配内存,然后将序列化数据中对应的值,一一填充到这些新创建组件的对应字段中。这就是为什么你编辑器中设置的值,在游戏运行时能“记住”的原因。预制体的实例化过程也完全类似。
运行时序列化(特定情况):虽然不常见,但Unity也支持在游戏运行时进行序列化,例如使用
JsonUtility.ToJson或BinaryFormatter(注意:.NET的BinaryFormatter在安全性上有问题,Unity已不推荐用于网络等场景)。但这里讨论的核心是Unity编辑器用于场景/预制体保存的那套内置系统。
一个常见的误解是:Inspector面板直接编辑的就是脚本的字段。实际上,Inspector是Unity编辑器的一个视图,它读取的是当前选中对象上组件序列化后的数据,并提供一个友好的界面让你修改。当你修改Inspector中的值,这个修改会写回到该组件对应的序列化数据存储中,并标记该对象为“脏”(已修改),等待下一次保存操作将其持久化到磁盘文件。
2.2 哪些类型可以被Unity序列化?
Unity的序列化系统并非无所不能,它对可序列化的类型有明确的限制。理解这份“白名单”是避免踩坑的第一步。主要分为以下几类:
基本数据类型:这是最直接的。包括
int,float,double,bool,string。这些类型有明确的二进制或文本表示形式,序列化和反序列化过程简单可靠。Unity内置的“值类型”结构体:这些是Unity数学库的核心,也被深度支持。包括:
Vector2,Vector3,Vector4Quaternion(用于旋转)Color,Color32Rect,RectIntBounds,BoundsIntLayerMaskAnimationCurve(注意:这是一个类,但被特殊处理为可序列化)Gradient(同上)
继承自
UnityEngine.Object的类型:这是Unity资源系统的基石。序列化时,Unity存储的不是这个对象实例的全部数据,而是一个引用(通常包含文件GUID和本地ID)。这确保了资源(如材质、纹理、音频剪辑、其他预制体)的实例在项目中是共享的,而不是被复制。GameObject,Component,MonoBehaviour,ScriptableObjectTexture2D,Material,ShaderAudioClip,AnimationClip,Mesh- 任何你自定义的、继承自
ScriptableObject的类。
可序列化类型的数组(Array)和列表(
List<T>):只要T是上述可序列化类型,那么T[]和List<T>也可以被序列化。这是组织结构化数据(如敌人波次、物品列表)的常用手段。标记了
[System.Serializable]的自定义结构体(struct)或类(class):这是扩展序列化能力的关键。通过给一个自定义的类或结构体加上[System.Serializable]属性,你就可以让Unity尝试序列化它的所有公共字段(以及标记了[SerializeField]的私有字段)。这让你可以创建复杂的数据结构,如[System.Serializable] public class Item { public string name; public int id; public Sprite icon; },并在Inspector中直接编辑。
重要限制与陷阱:
- 不支持
Dictionary<TKey, TValue>:这是新手最常遇到的坑。Unity的内置序列化器无法直接处理字典。常见的解决方案是使用两个并列的List分别存储键和值,然后在Awake()或Start()中手动构建字典。或者使用第三方序列化方案如JsonUtility、Newtonsoft.Json(需导入)来序列化字典,但这部分数据不会显示在默认的Inspector中。 - 不支持属性(Property):
public int Health { get; set; }这样的属性不会被序列化。序列化只针对字段(Field)。 - 静态(
static)字段和常量(const):不会被序列化,因为它们属于类而非实例。 - 只读(
readonly)字段:不会被序列化。 - 非公开字段:默认情况下,
private、protected、internal字段不会被序列化,除非它们被标记了[SerializeField]。
2.3 序列化数据的存储与版本管理
Unity的序列化数据最终存储在哪里?对于场景和预制体,主要保存在对应的.unity和.prefab文件中(文本格式,可读)。对于ScriptableObject,则保存在.asset文件中。
版本兼容性是一个严肃的问题。当你修改了一个脚本(例如,重命名字段、改变字段类型、删除字段),然后打开一个包含旧版本该脚本实例的场景或预制体时,Unity的反序列化过程可能会失败或产生意外结果。
- 字段改名:旧数据会“丢失”,因为序列化数据中存储的是字段名,改名后找不到对应字段,Unity会将其视为新字段并用默认值初始化。可以使用
[FormerlySerializedAs(“oldFieldName”)]属性来告诉Unity:“这个字段以前叫那个名字,请把旧数据迁移过来。” - 改变字段类型:如果类型变更不兼容(如
float改为string),通常会导致反序列化错误,该字段值会丢失或变为默认值。 - 删除字段:对应的旧数据会被简单地忽略。
因此,在项目开发中,尤其是团队协作或长期维护的项目,对已序列化的数据结构进行修改需要格外谨慎,最好有相应的数据迁移策略。
3. [SerializeField]属性的实战应用与高级技巧
3.1 基础用法:为什么以及何时使用它?
[SerializeField]最经典的应用场景就是封装与编辑器可配置性的平衡。面向对象设计鼓励我们隐藏内部实现细节(私有字段),但游戏开发又极度依赖在编辑器中快速迭代和配置参数。
一个典型例子:角色控制器
public class PlayerController : MonoBehaviour { // 公有字段,Inspector可见,但破坏了封装性。任何外部脚本都可以直接修改speed,可能导致意外。 // public float moveSpeed = 5.0f; // 更佳实践:私有字段 + [SerializeField] [SerializeField] private float moveSpeed = 5.0f; [SerializeField] private float jumpForce = 10.0f; [SerializeField] private AudioClip jumpSound; // 对资源(AudioClip)的引用也需要序列化 // 通过属性提供受控的访问(如果需要) public float MoveSpeed => moveSpeed; void Update() { // 使用 moveSpeed 进行移动逻辑... } }通过这种方式,moveSpeed和jumpForce在代码内部是私有的,外部类不能随意修改,保证了数据的安全性。同时,策划或美术同学可以在Inspector中方便地调整这些数值,无需触碰代码。jumpSound的引用也能被保存,在预制体中不会丢失。
何时使用?遵循这个原则:如果一个字段需要在编辑器中被配置或调试查看,但它又不应该被其他脚本随意修改,就为它加上[SerializeField]。
3.2 进阶用法:控制序列化行为与自定义绘制
仅仅显示在Inspector里还不够,有时我们需要更精细的控制。
[HideInInspector]属性:与[SerializeField]相反,这个属性用于序列化但不显示。一个字段被标记为public,默认会在Inspector显示。如果你希望某个公共字段被序列化(值被保存),但又不想它在Inspector中杂乱显示(可能因为它只用于内部逻辑,或由其他工具计算),就可以使用[HideInInspector]。[HideInInspector] public int internalStateCache; // 这个值会被保存,但不会在Inspector里干扰你。[NonSerialized]属性:这个属性来自System命名空间,用于强制不序列化一个公有字段。默认情况下,所有public字段都会被序列化。但有些字段可能是运行时临时计算的(如缓存、引用),不需要也不应该被保存到场景/预制体中。使用[NonSerialized]可以避免无用的数据被持久化,减小文件体积。[System.NonSerialized] public GameObject cachedTarget; // 这个引用不会被保存,每次加载场景都是null。结合
[SerializeField]与[Range],[Tooltip]等属性:Unity提供了许多PropertyAttribute,可以极大地提升Inspector的易用性。[SerializeField] [Range(0.1f, 10.0f)] // 在Inspector中显示为一个滑块,限制输入范围 [Tooltip("这是角色的移动速度,单位是米/秒。")] // 鼠标悬停时显示提示文字 private float moveSpeed = 5.0f; [SerializeField] [Header("攻击属性")] // 在Inspector中创建一个分组标题 private int attackDamage = 10; [SerializeField] [Space(20)] // 在上面创建一个20像素的空白间隔 private bool canFly = false;这些属性只影响编辑器中的显示,不影响序列化本身,但能让你的组件更加专业和友好。
3.3 实战案例:构建一个可配置的敌人生成系统
让我们用一个综合案例来串联所学知识。假设我们要做一个敌人波次生成器,每波敌人有不同的类型、数量和生成间隔。
首先,定义可序列化的敌人数据单元:
[System.Serializable] // 关键!让这个自定义类可序列化 public class EnemyWave { public string waveName; // 波次名称 public GameObject enemyPrefab; // 敌人预制体引用 public int enemyCount; // 数量 public float spawnInterval; // 生成间隔 [Range(0f, 1f)] public float healthMultiplier = 1.0f; // 生命值倍率 }然后,创建生成器脚本,使用
[SerializeField]的列表:public class EnemySpawner : MonoBehaviour { // 一个可序列化的 EnemyWave 列表。在Inspector中会显示为一个可折叠、可增减元素的列表。 [SerializeField] private List<EnemyWave> waves = new List<EnemyWave>(); [SerializeField] private Transform spawnPoint; [SerializeField] private bool autoStart = true; // 非序列化的运行时变量 private int currentWaveIndex = 0; private float timer = 0f; private int spawnedCount = 0; void Start() { if (autoStart && waves.Count > 0) { StartWave(0); } } void Update() { // 波次生成逻辑... if (currentWaveIndex >= 0 && currentWaveIndex < waves.Count) { EnemyWave currentWave = waves[currentWaveIndex]; // ... 计时和生成逻辑 } } public void StartWave(int index) { if (index < waves.Count) { currentWaveIndex = index; spawnedCount = 0; timer = 0f; Debug.Log($"开始波次: {waves[index].waveName}"); } } // 在Inspector中提供一个按钮,用于测试生成 [ContextMenu("Test Spawn First Wave")] void TestSpawnFirstWave() { if (waves.Count > 0) { StartWave(0); } } }将这个脚本挂载到一个空物体上。在Inspector中,你会看到一个
Waves列表,你可以点击“+”号添加新的波次,并为每个波次配置所有参数(名称、预制体、数量等)。所有配置好的数据都会随着预制体或场景被保存下来。
这个案例的威力在于:策划人员可以完全在编辑器内设计复杂的敌人波次,无需程序员介入修改代码。数据与逻辑分离,迭代效率极高。这正是深入理解序列化和[SerializeField]所带来的生产力提升。
4. 序列化相关的常见问题与深度排查
即使理解了原理,在实际开发中你依然会遇到各种稀奇古怪的序列化问题。下面是一些“血泪教训”总结出来的常见坑点及解决方案。
4.1 预制体引用丢失(Missing Reference)
这是Unity开发中最令人沮丧的问题之一。你明明在预制体A中为某个public GameObject target;字段拖入了预制体B作为引用,保存后一切正常。但某次打开项目,或者将预制体A实例化到场景后,这个引用变成了(None),并显示“Missing”状态。
根本原因:Unity通过元文件(.meta)的GUID和文件内部的本地ID来唯一标识和引用资源。引用丢失,意味着这个链接断开了。
排查与解决步骤:
- 检查.meta文件:确保被引用的资源(如预制体B)的.meta文件存在且未被改动。版本控制系统(如Git)在处理二进制文件时有时会出问题,导致.meta文件丢失或GUID变化。确保将
.meta文件一并提交。 - 检查资源移动或重命名:在Unity编辑器外部(如Windows资源管理器或Mac Finder)移动或重命名资源文件,是导致引用丢失的最常见原因。永远在Unity编辑器内的Project窗口中进行资源管理操作。
- 使用“查找依赖关系”:在Project窗口右键点击疑似丢失引用的资源,选择“Find References In Scene”。如果找不到引用,说明链接确实断了。可以尝试重新拖拽赋值。
- 脚本序列化错误:如果脚本本身有编译错误,或者你修改了脚本中字段的类型(比如从
GameObject改成了Transform),Unity在反序列化旧数据时会失败,导致引用显示为Missing。修复编译错误或回退脚本更改。 - 资产数据库刷新问题:有时Unity的资产数据库(Asset Database)没有及时更新。尝试菜单栏
Assets -> Refresh或按Ctrl+R(Cmd+R on Mac) 强制刷新。
重要提示:对于团队项目,确保所有成员都使用相同的Unity版本,并且
.meta文件被正确纳入版本管理,是预防引用丢失的基石。
4.2 自定义类数据不显示或不被保存
你定义了一个[System.Serializable]的类,在脚本中声明了一个public List<MyClass> myList;,但在Inspector里要么不显示,要么显示为空,或者数据无法保存。
可能的原因:
- 没有无参数的构造函数:Unity的反序列化系统在创建你的类实例时,需要调用一个无参数的构造函数。如果你的类定义了带参数的构造函数,必须显式地再添加一个无参数的公共构造函数。
[System.Serializable] public class MyData { public int id; public string name; // 如果定义了带参数的构造函数,必须加上这个 public MyData() {} public MyData(int id, string name) { this.id = id; this.name = name; } } - 类定义在非
MonoBehaviour脚本内部:如果你的可序列化类是嵌套在另一个类中定义的,特别是如果外层类不是MonoBehaviour,有时会出现序列化问题。尽量将可序列化类定义在单独的脚本文件中,或者放在命名空间下。 - 字段类型不可序列化:检查你的自定义类里的所有字段,确保它们都是Unity可序列化的类型(基本类型、Unity类型、其他可序列化类)。如果包含了一个不可序列化的类型(比如某个第三方库的类),整个类都可能无法被正确序列化。
- 脚本编译顺序:在极少数情况下,如果引用自定义类的脚本比定义该类的脚本先编译,可能会导致序列化问题。确保项目结构清晰,或者使用
Assembly Definition文件来管理程序集编译顺序。
4.3 版本升级与数据迁移策略
随着项目迭代,修改已序列化的数据结构是不可避免的。如何安全地进行?
- 添加新字段:这是最安全的操作。新字段会使用其默认值初始化。旧数据中不存在的字段,在反序列化时会被忽略并赋予默认值。
- 重命名字段:使用
[FormerlySerializedAs("oldName")]属性。这个属性告诉Unity序列化系统:“当前这个字段,在旧版本的数据中是以oldName这个名字存储的,请把旧数据读进来。”这能实现平滑的数据迁移。using UnityEngine.Scripting; // 需要引用这个命名空间 [SerializeField] [FormerlySerializedAs("legacySpeed")] private float moveSpeed = 5.0f; - 删除字段:直接删除即可,旧数据中对应的字段会被忽略。但要注意,如果那些旧数据还有用,你需要先通过脚本(比如一个编辑器工具)将其导出或迁移到新的结构中。
- 改变字段类型:高风险操作。如果类型变化不兼容(如
int到string),数据会丢失。如果必须修改,考虑编写一个一次性迁移脚本。这个脚本可以放在[InitializeOnLoad]的静态构造函数中,在编辑器启动时检查所有相关资产,将旧格式的数据读取出来,转换成新格式,再写回去。 - 使用
ISerializationCallbackReceiver接口:这是一个强大的工具,允许你在序列化前和反序列化后执行自定义代码。你可以用它来进行复杂的数据验证、格式转换或初始化。
这个接口是实现复杂数据序列化(如字典)的官方推荐方法之一。using UnityEngine; using System.Collections.Generic; public class MyComponent : MonoBehaviour, ISerializationCallbackReceiver { // 我们想用Dictionary,但Unity不能直接序列化它 public Dictionary<int, string> myDictionary = new Dictionary<int, string>(); // 用于序列化的辅助列表 [SerializeField] private List<int> serializedKeys = new List<int>(); [SerializeField] private List<string> serializedValues = new List<string>(); // 在序列化前,将Dictionary的数据存入两个列表 public void OnBeforeSerialize() { serializedKeys.Clear(); serializedValues.Clear(); foreach (var kvp in myDictionary) { serializedKeys.Add(kvp.Key); serializedValues.Add(kvp.Value); } } // 在反序列化后,用两个列表的数据重建Dictionary public void OnAfterDeserialize() { myDictionary.Clear(); int count = Mathf.Min(serializedKeys.Count, serializedValues.Count); for (int i = 0; i < count; i++) { myDictionary[serializedKeys[i]] = serializedValues[i]; } } }
5. 性能考量与最佳实践
序列化看起来是后台自动完成的,但如果使用不当,也可能成为性能瓶颈或内存浪费的源头。
5.1 序列化对性能的影响
- 启动/加载时间:场景越复杂,预制体越大,需要反序列化的数据就越多,加载时间就越长。一个包含成千上万个组件、每个组件又有大量序列化字段的场景,其加载速度会明显慢于一个精简的场景。
- 内存占用:序列化数据不仅存在于磁盘文件中,在编辑器模式下,它们也会被加载到内存中以便快速访问和修改。过大的序列化数据会增大内存开销。
- 垃圾回收(GC)压力:频繁地创建和销毁包含大量序列化字段的
MonoBehaviour或ScriptableObject,可能会产生可观的GC开销,因为反序列化过程会分配新对象来填充这些字段。
5.2 优化序列化数据的最佳实践
- 精简序列化字段:只序列化真正需要持久化或在编辑器中配置的字段。对于运行时计算的临时变量、缓存引用,使用
[System.NonSerialized]或直接声明为无属性的私有字段。// 优化前:所有字段都序列化 public class BadExample : MonoBehaviour { public float speed; public float maxSpeed; public float acceleration; private GameObject lastTarget; // 这个不需要序列化 private float cachedDistance; // 这个也不需要 } // 优化后:明确区分 public class GoodExample : MonoBehaviour { [SerializeField] private float speed; // 需要配置 [SerializeField] private float maxSpeed; // 需要配置 [NonSerialized] public float acceleration; // 可能由其他系统计算,不需要保存 private GameObject lastTarget; // 运行时缓存,不需要序列化 private float cachedDistance; } - 慎用大型数组/列表:一个序列化了包含1000个元素的
List<Vector3>的组件,其序列化数据量是巨大的。考虑是否真的需要将所有数据都序列化?能否在运行时动态生成或从外部文件(如ScriptableObject、JSON)加载? - 使用
ScriptableObject管理共享数据:如果一个数据配置(如武器属性、角色成长表)被多个预制体或场景共享,不要在每个使用它的组件里都序列化一份副本。将其创建为ScriptableObject资产,组件中只保存一个对该资产的引用。这样既减少了重复数据,也方便集中修改。// 创建一个ScriptableObject作为数据容器 [CreateAssetMenu(fileName = "NewWeaponData", menuName = "Game/Weapon Data")] public class WeaponData : ScriptableObject { public float damage; public float fireRate; public GameObject projectilePrefab; public AudioClip fireSound; } // 在组件中引用它 public class Weapon : MonoBehaviour { [SerializeField] private WeaponData data; // 只存储一个轻量级的引用 // ... 使用 data.damage, data.fireRate 等 } - 对默认值保持敏感:如果一个字段的默认值(在代码中初始化的值)就是它99%情况下的值,那么是否真的需要将它序列化?有时,不序列化它,而是在
Awake()或Start()中初始化,可以节省存储空间。但这会牺牲编辑器中的可配置性,需要权衡。 - 定期检查预制体:使用Unity Profiler的Memory窗口,或者一些第三方工具,检查项目中预制体的序列化数据大小。寻找那些异常庞大的预制体,并分析其臃肿的原因。
5.3 编辑器扩展中的序列化应用
理解序列化是进行Unity编辑器扩展开发的基础。当你创建自定义的EditorWindow或PropertyDrawer时,你需要处理的就是对象的序列化数据(通过SerializedObject和SerializedProperty)。
SerializedProperty提供了对序列化字段的通用访问方式,无论它是公有、私有(带SerializeField)还是继承自父类。这使得编写能处理多种类型对象的通用编辑器代码成为可能。
using UnityEditor; using UnityEngine; [CustomEditor(typeof(EnemySpawner))] public class EnemySpawnerEditor : Editor { public override void OnInspectorGUI() { // 获取目标对象的序列化表示 SerializedObject so = new SerializedObject(target); // 找到我们关心的序列化属性 SerializedProperty wavesProp = so.FindProperty("waves"); SerializedProperty autoStartProp = so.FindProperty("autoStart"); EditorGUILayout.PropertyField(wavesProp, true); // ‘true‘ 表示绘制子属性 EditorGUILayout.PropertyField(autoStartProp); // 应用修改回目标对象 if (so.ApplyModifiedProperties()) { // 数据已修改,可以在这里触发一些自定义逻辑 Debug.Log("EnemySpawner data modified!"); } // 添加一个自定义按钮 EnemySpawner spawner = (EnemySpawner)target; if (GUILayout.Button("立即生成测试敌人")) { spawner.TestSpawnFirstWave(); } } }通过SerializedObject/PropertyAPI,你可以安全地读取和修改任何可序列化字段的值,并且修改会自动标记对象为“脏”,确保能正确保存。这是构建健壮、可维护的编辑器工具的关键。
6. 总结与核心心法
走完这一趟序列化之旅,你会发现它远不止是一个“保存数据”的功能。它是Unity编辑器驱动开发模式的核心支柱,连接着代码逻辑与可视化配置。对[SerializeField]的熟练运用,是区分Unity新手与熟手的一道分水岭。
最后,分享几条我总结的、在实战中非常管用的心法:
- 默认私有,按需公开:养成习惯,所有字段先声明为
private。只有确定需要外部脚本访问时,才改为public或通过属性暴露。需要编辑器配置时,加上[SerializeField]。这条原则能极大提升代码的健壮性。 - 复杂数据,ScriptableObject:当遇到需要反复配置、多处共享的复杂数据结构时,第一时间想到
ScriptableObject。它比直接序列化在组件里更优雅、更高效、更易管理。 - 时刻警惕引用丢失:任何在编辑器外对项目文件的操作都是危险的。移动、重命名、删除资源,务必在Unity编辑器内完成。将
.meta文件视为资源的一部分,妥善进行版本管理。 - 修改序列化结构要三思:在项目中期修改一个已被大量使用的类的序列化字段,是一场冒险。如果必须做,一定要制定并测试数据迁移方案,
[FormerlySerializedAs]是你的好朋友。 - 善用PropertyDrawer定制Inspector:如果某个
[SerializeField]字段在默认Inspector里显示得不够直观(比如一个枚举显示成下拉框,但你想要按钮组),不要硬着头皮用。花点时间写一个自定义的PropertyDrawer,可以极大提升你和团队的使用体验。
理解序列化,就是理解Unity编辑器如何与你的代码“对话”。掌握了它,你就能更自如地驾驭Unity这座强大的引擎,让编辑器真正成为你创意实现的加速器,而不是绊脚石。