Unity 编辑器扩展和序列化踩坑记(一)

Unity 编辑器扩展和序列化踩坑记(一)
最新回答
紫南

2026-10-27 08:16:59

本篇文章未经作者本人授权,禁止任何形式的转载,谢谢!如果在第三方阅读发现公式等格式有问题,请到个人博客地址或知掘闷乎地址阅读。

最近两三周在写 Unity 的编辑器扩展,主要是为项目开发地图编辑器,过程中遇到了一些问题,因此决定记录下来,形成系列文章渗散悄。这是第一篇,但真心希望不要有第二篇。

目前我使用的 Unity 版本是 2017.1.3.f1。

在编写编辑器扩展时,如果需要将数据保存到文件,序列化数据是必须的。我尝试过两种方案:Unity 自身的序列化系统配合 ScriptableObject,以及 C# 的序列化 API。两者各有优缺点。

C# 序列化虽然方便快捷,但在 Unity 中使用可能并不理想。如果使用 C# 序列化,class 或 struct 必须加上 Serializable 属性。然而,大多数你想序列化的 C# 内置 class 或 struct 都没有这个属性,例如 VectorX 系列,还有 ScriptableObject。在需要使用 C# 序列化时,我不得不自己实现一个可序列化版本,例如 Vector2 和 Vector3。

C# 序列化在 Unity 中的另一个问题是,它不如内置序列化系统方便。虽然 Unity 的序列化几乎每时每刻都在进行,但有相应接口,你必须确保在必须序列化的时机进行序列化,以免数据被清空。

Unity 内置序列化系统以及一些配套组件(例如 ScriptableObject 等)在 Unity 中能保证序列化和反序列化数据的正确性,并且几乎不需要关心序列化的时机丛渣,只需要指明哪些数据需要序列化即可。一般来说,在会被 Unity 进行序列化的内置类中,public 字段和被 SerializeField 属性修饰的字段会被序列化,例如 MonoBehaviour 中的字段,ScriptableObject 也是,其他自定义类我没验证过,但是 private 的我都加了 SerializeField 属性没有问题,public 的也可以正确序列化。

但是并非所有类型都可以序列化!在常用的需要序列化的类中,Dictionary、二维数组以及其他有嵌套关系的容器(目前看来是这样,不能保证所有这种容器一定不能被序列化)不能被序列化。幸运的是,都有相应的解决办法。

虽然 Dictionary 不能被 Unity 序列化,但 List 是可以的。我们可以把 Dictionary 的 keys 和 values 保存在两个 List 里,这样就可以序列化和反序列化字典了。请注意,我并不是说要用两个 List 完全代替 Dictionary 这个数据结构,这样就失去了 Dictionary 的数据结构特性了。我们只需要在 Unity 进行序列化和反序列化的时候把 Dictionary 搬到两个 List 里,再从两个 List 中复原 Dictionary 就行了。Unity 进行序列化和反序列化的时机是可以知道的,关键在于一个 interface。

这个 interface 有两个方法:OnBeforeSerialize 在将要序列化的时候执行,OnAfterDeserialize 在反序列化完成后执行。因此,我们只需要在 OnBeforeSerialize 中把 keys 和 values 保存到两个 List 里,在 OnAfterDeserialize 中根据两个 List 中的数据重建一个 Dictionary 就行了。需要注意的是,这个 interface 目前不支持 struct。

如果是类似这样的数据,里面的 X[] 是不能被序列化的。但是如果你将 X[] 写在一个类里,外部容器保存这个 XWrapper,就可以正确序列化数组中的数据了。因此,如果你在使用嵌套容器时出现问题,可以考虑这种方法。

在编写编辑器代码时,会有一些情况导致内存中的数据被清空。我判断内存数据被清空重置的方式是观察静态构造函数在控制台的输出。目前我遇到的情况有:退出运行不会进行内存重置,但很重要,因为退出也相当于加载了场景,会执行 Awake 等相关函数,如果你有一些初始化函数在这时候执行,你需要注意它是否会影响到你。

ScriptableObject 类看似很简单,但第一次使用却花费了我很长时间来与其搏斗。这个类主要用来进行数据交互,它不需要挂在 GameObject 上,可以作为单独的数据存储来使用。在编写编辑器扩展时使用它,有不少地方需要注意。

ScriptableObject 可以保存为 Asset,而且实际使用时发现,如果你不保存成 Asset,相关的引用就会在某些时候变为空。例如使用 ScriptableObject.CreateInstance 创建一个 ScriptableObject 后,把它保存在容器里,然后函数结束。当你下次再用,就会发现这个引用变为 null 了。如果 ScriptableObject.CreateInstance 之后把这个 ScriptableObject 保存为 Asset,则没有问题。

如果使用诸如 AssetDatabase.LoadAsset<> 之类的函数把 ScriptableObject Asset 加载进来,然后保存在一个 MonoBehaviour 的变量里,当你删除这个 Asset 时,这个变量也为空了。也就是说,如果是直接加载这个 ScriptableObject Asset,当 Asset 被删除,加载进来的对象也没了(我原本以为挂在物体上的数据还会保留)。这个不注意可能会有一些致命的问题,例如你有个 ScriptableObject Asset,里面可能还有很多子 ScriptableObject Asset,策划想要用你的编辑器继续做上次没完成的工作,于是把上次保存好的 ScriptableObject Asset 加载进来进行操作,操作完毕后进行保存,可能会直接选择上次的路径,代码里可能会使用 AssetDatabase.CreateAsset 等方法,这实际上会覆盖原先的资源,也就是说之前的资源被删掉了。这时问题就来了,资源被删除后,Scene 中的数据也没了,这时保存代码执行,自然什么也保存不到。

解决方案是加载后使用 Instantiate<> 去复制出一个 ScriptableObject,之后都操作这个复制出来的,或者干脆用 ScriptableObject.CreateInstance 创建一个新的(要注意上面提到的问题,最好创建完后立即使用或者挂在物体上)。

保存数据时,尽可能不要用枚举作为 key,因为一旦枚举因为某些原因数值发生变化,数据可能取不出来。可以考虑将其转换为字符串。参考了 C# 的源码,枚举在进行 GetHashCode 时,好像是用它的数值进行操作的。

这次暂时写这么多,再次希望不要有第二篇。以上如果有朋友知道更准确的原因,可以留言。感谢大家阅读。