“我明明给基类字段赋了一个FireballSkill,Inspector 里也看到了火球伤害、爆炸半径这些参数,保存 Prefab 再重开工程,全没了,字段上只剩一个SkillBase。”
这个场景,做过 Unity 技能系统、道具系统或者战斗配置的人多半都遇到过。我自己第一次碰到是在做角色技能编辑器的时候:策划调了半天的数值,第二天打开工程发现所有派生技能字段全部回到默认值,整个人血压当场拉满。查了一圈,问题根源就是标题这行字——Unity 的序列化系统在默认情况下就是不认多态的。这篇东西就把我踩过的坑、看过的引擎行为,以及最终采用的几个绕行方案一次性讲清楚。不管你是在用纯 C# 写玩法逻辑,还是在给策划做配置面板,这件事都值得花十分钟彻底弄明白。
1. 一次多态丢失事故的全过程
1.1 事故现场:策划配置好的技能数据全部归零
先还原一下事故现场。假设你要做一套技能系统,抽象一个基类SkillBase,下面挂两个具体技能:火球术FireballSkill和治愈术HealSkill。代码长这样:
using UnityEngine; [System.Serializable] public abstract class SkillBase { public string skillName; public float cooldown; } [System.Serializable] public class FireballSkill : SkillBase { public float burnDamage; public float radius; } [System.Serializable] public class HealSkill : SkillBase { public float healAmount; public bool revive; }然后在角色身上挂一个PlayerSkills组件:
public class PlayerSkills : MonoBehaviour { public SkillBase primarySkill; public SkillBase secondarySkill; }问题就从这里开始了。你在 Inspector 里把primarySkill拖成FireballSkill,填好skillName = 火球术、cooldown = 5、burnDamage = 20、radius = 3,一切看起来都很正常,Inspector 里也把火球术的专属字段全部显示出来了。
然后你 Ctrl+S 保存 Prefab,关掉编辑器,第二天再打开工程。你发现primarySkill的字段虽然还挂着,但里面只剩skillName和cooldown,burnDamage和radius全部变成 0。更诡异的是,运行时你访问primarySkill,它的GetType()返回的还是FireballSkill,但数据已经残缺。
这不是某个版本才有的 bug,也不是你代码写错了,而是默认的 Unity 序列化行为就是这样:它用字段的声明类型去序列化,声明类型是SkillBase,那就只序列化SkillBase里定义的字段。子类新增的字段,序列化器根本不关心。
1.2 从 Inspector 追到 YAML,问题浮出水面
如果你不信这个结论,可以亲自验证一下。项目保存之后,用文本编辑器打开 Prefab 对应的.prefab文件,搜索primarySkill。正常的、能保留多态数据的结构,应该能在 YAML 里看到完整的burnDamage和radius。但默认情况下,你看到的只有:
primarySkill: skillName: FireballSkill cooldown: 5注意看,这里连类型标签都没有,更别说子类字段了。burnDamage和radius压根没有出现在 YAML 里,那编辑器重启之后自然就丢了。如果你给字段加一个[SerializeReference]特性,再保存一次,YAML 结构会变成带references区块的引用形式,里面会出现type: {class: FireballSkill}这种明确标识,数据才会完整保留下來。
我当时为了确认这个问题,专门在一个空工程里做了对照组实验。同样的类、同样的字段,一个加了[SerializeReference],一个没加。保存、退出、重开、对比 YAML 内容和运行时字段值,结果一目了然。从那天起我就养成了一个习惯:写完序列化字段,先保存一次,再用文本编辑器看一眼 YAML 到底写了什么,别等到第二天,更别等到策划反馈“数值怎么丢了”。
1.3 结论:序列化器只认“声明类型”,不认“实际类型”
这里可以总结出一条硬规则:Unity 默认序列化在遍历字段时,用的是你在代码里写的那个字段类型,而不是运行时对象实例的真实类型。
public SkillBase primarySkill,那默认情况下 Unity 序列化器就只把primarySkill当成SkillBase处理。它不会去读变量实际指向的FireballSkill对象,也不会去枚举FireballSkill里的字段。哪怕你明确知道这个对象就是FireballSkill,哪怕 Inspector 在那一瞬间也把它当成FireballSkill显示了,但序列化程序不会主动做这件事。
原因也不复杂:Inspector 的显示是编辑器通过反射拿到的临时信息,而序列化写入 YAML 的过程发生在引擎底层,它走的是 C++ 那套序列化管线,遵循的规则是“编译期确定类型布局”,不是“运行时反射实例类型”。这两套机制各管各的,于是就有了“看到的和存下来的不一样”这种灵异事件。
2. Unity 序列化到底是怎么工作的
2.1 Unity 的序列化不是 C# 的序列化
很多刚接触 Unity 的人会把“序列化”理解成 C# 里的二进制序列化或者 JSON 序列化,这其实是个天大的误会。传统 C# 序列化(比如BinaryFormatter、Newtonsoft.Json)是构建在 CLR 反射机制上的,它可以枚举一个对象的所有字段、属性、类型信息,然后把“这个对象是什么类型”连同数据一起写进流里。反序列化时再根据类型信息重建对象,所以天然支持多态。
Unity 的序列化完全不是这套路。它是一套由引擎 C++ 层实现的序列化系统,主要服务于 Editor、Prefab、Scene、AssetBundle、Inspector 显示和源码控制。它从 C# 代码里拿到一个类的字段布局之后,就直接用这套固定布局去分配内存、写入文件、恢复数据。它不是“运行时去看对象是什么”,而是“编译期就定好这个字段占多大空间、有哪些子字段”。
这里有一个很形象的类比:Unity 序列化就像一个只认固定菜单的厨师。菜单上写“饮品”,他就给你一杯白水。哪怕你心里想要的是一杯拿铁,哪怕吧台小哥都看到你点了拿铁,但厨师的菜单上没有“拿铁”这个条目,他就不会给你做。传统 C# 序列化则像一个能看到完整配方的大厨,你想喝拿铁,他能现场从柜子里翻出咖啡豆、牛奶、糖浆,给你做一杯出来。Unity 在默认情况下,就是那个只认菜单的厨师。
2.2 一张清单看清楚什么能序列化,什么不能
既然 Unity 的序列化有自己的规则,那它到底支持哪些类型?我整理了一份个人项目里最常用的对照表,基本覆盖日常工作:
| 类型 | 能否序列化 | 说明 |
|---|---|---|
| int, float, bool, string, double, long 等基础类型 | 支持 | 不用额外标记,字段直接序列化 |
| Vector2, Vector3, Quaternion, Color 等原生结构体 | 支持 | 引擎内置的原生容器 |
自定义[Serializable]结构体或类 | 支持 | 字段必须可序列化,且字段为实例字段 |
UnityEngine.Object及其派生类 | 支持 | GameObject、ScriptableObject、Component 都是引用序列化 |
数组T[] | 支持 | 元素必须是可序列化类型 |
List<T> | 支持 | 底层是数组,天然支持 |
Dictionary<K, V> | 默认不支持 | 引擎没有内置字典序列化 |
| 接口字段 | 默认不支持 | 序列化结果通常为 null |
| 抽象类字段 | 只序列化基类字段 | 子类专属字段丢失 |
| 普通多态字段 | 不支持 | 只认声明类型 |
| 属性(getter/setter) | 不支持 | 序列化器只处理字段 |
| 静态字段 | 不支持 | 静态成员不属于实例布局 |
| readonly 字段 | 不支持 | 反序列化重写不会生效 |
| 泛型类 | 有限支持 | 常规[Serializable]泛型可以,[SerializeReference]泛型基本不可用 |
| C# 事件、方法 | 不支持 | 不参与序列化布局 |
注意,这个清单是“序列化器认为可序列化”的能力。字段默认要标记[SerializeField]或者public才能真正参与序列化。private字段即使类型支持,如果不加特性也不会被序列化。
2.3 YAML 资源必须保持“布局稳定”的工程原因
很多人不理解,为什么 Unity 不能把序列化做得更“智能”一点?明明加个反射,把真实类型写进文件,多态问题不就解决了吗?技术上当然可行,但工程上代价巨大。
Unity 的 Prefab、Scene、ScriptableObject 本质上都是 YAML 文本文件。这些文件会被放在 SVN/Git 里做版本管理,可能同时被策划、程序、美术多人修改。如果序列化格式依赖运行时的真实类型信息,那么同一个文件在不同平台、不同 Unity 版本下就可能生成完全不同的 YAML。今天你存一份 Prefab,明天别人用另一个版本打开,整个文件变成一千行 diff,这在团队协作里是不可接受的。
Unity 选择把序列化布局做成“编译期确定”,核心目的就是保证资源文件在不同环境下生成的 YAML 结构完全一致。只要类定义不变,同一份 Prefab 在任何机器上打开、保存,diff 都是干净的。而且这种固定布局对性能也更友好:引擎可以在加载资源时直接按照布局拷贝内存,不需要反射、不需要查类型、不需要走虚方法分派。尤其在 IL2CPP 和 WebGL 这种 AOT 环境下,反射能力受限,固定布局几乎是唯一稳靠的序列化方案。
所以,Unity 不是不知道多态好,而是它为了“资源稳定性”和“跨平台一致性”选择了牺牲默认多态。理解了这一点,你就不会再骂“这功能怎么会没有”,而是会去想,在保证资源稳定的前提下有没有更聪明的绕法。答案就是后面要讲的[SerializeReference]。
2.4 不了解这些原理会写出什么“假序列化”代码
我见过很多新人在用JsonUtility序列化继承结构时踩坑。比如一个战斗存档系统,里面有List<SkillBase> skills,想用JsonUtility.ToJson(player)把整个对象存到本地,结果发现反序列化出来的 List 里全是空的SkillBase,而且元素个数都变了。
原因和前面的 Prefab 问题一模一样:JsonUtility是 Unity 序列化器的一个文本封装,它不是独立的 JSON 框架。官方文档里写得很清楚,JsonUtility的处理范围就是 Unity 能序列化的那些类型,它不会为接口、抽象基类或字段的真实类型做额外处理。所以哪怕你在代码里把SkillBase赋值为FireballSkill,JsonUtility.ToJson序列化时也只读取SkillBase声明的那几个字段,FireballSkill的数据一样会丢。
这个知识点在很多场景都会复现:存档、网络同步、编辑器数据交换、ScriptableObject 缓存……只要你想把对象持久化,而且对象里有多态需求,就必须先问一句:我用的序列化器支持多态吗?是 Unity 原生序列化,还是自定义 JSON 方案?如果用的是 Newtonsoft.Json,那没问题,它支持多态;如果用的是 Unity 自带那一套,那就得走后面说的几个方案。
3. 多态到底触犯了哪条“天规”
3.1 C++ 层分配内存,没有“运行时类型推断”
我们要把“多态为什么被拒绝”这个问题继续往下挖一挖。
Unity 序列化器的核心实现在引擎的 C++ 层。当它反序列化一个MonoBehaviour的时候,流程大概是:解析 YAML 中的字段数据 → 根据类型信息在 C++ 侧分配对象槽位 → 填充字段。问题是,C++ 侧怎么知道该分配多少内存?怎么知道有哪些字段需要填充?它只能通过 C# 侧生成的元数据表来查。
默认情况下,这个元数据表是针对“每个具体类”生成的。比如PlayerSkills这个类,元数据表里记录的就是:primarySkill的类型是SkillBase,SkillBase有skillName和cooldown两个字段。于是序列化系统就按照这个表去写入和恢复数据。它根本不会去看primarySkill运行时指向的实际对象是什么——因为在 C++ 层,那个字段只是一个SkillBase槽位,没有虚表、没有运行时类型信息,它只知道“这个字段的类型是 SkillBase”。
如果 Unity 要在默认序列化里支持多态,它就必须在 C++ 层为每一个被引用的真实类型动态生成元数据,并在反序列化时先读取“真实类型名”,再跳转到对应的类型描述。这一套方案可以做成,但代价是每一处序列化数据都要多存一个类型标签、每次反序列化都要做一次类型查找和虚函数式分派。Unity 觉得不值得为默认路径付出这个成本,于是选择把多态交给开发者显式声明。这就是[SerializeReference]存在的意义:你告诉它,这个字段我要保存真实类型,请额外处理。
3.2 默认构造函数不参与,布局全靠元数据
还有一个很多人不知道的冷知识:Unity 反序列化普通 C# 类时,是直接按元数据布局在非托管层分配的,它不会调用 C# 的构造函数。
这句话意味着什么?举个例子:
[System.Serializable] public class ItemData { public int stack = 99; public string itemName; public ItemData() { stack = 99; itemName = "未命名"; } }你新建一个ItemData时,构造函数会把stack设成 99、itemName设成“未命名”。但如果你把这个类挂在某个[Serializable]字段上,并在 Inspector 里创建了一个实例,序列化保存后再反序列化,stack和itemName的值完全取决于 YAML 里存了什么。如果 YAML 里没有那个字段,值就是类型默认值(0 或 null),构造函数里的初始化逻辑根本不会跑。
这让很多人排查了半天:“为什么我构造函数里设置了初始血量 100,序列化回来变成 0?”原因就在这。Unity 的序列化不是“先 new 对象再填字段”,而是“直接在内存里划一块区域,把数据按布局填进去”。构造函数在这个流程里完全被跳过了。
这个现象也进一步解释了为什么多态这么难支持。多态要求反序列化时先识别真实类型,然后创建对应类型的实例。而创建实例到底走不走构造函数?走的话就破坏了“不调用构造函数”的性能优势;不走的话,子类字段怎么初始化?这些都是要额外定义规则的。Unity 选择最简单的方式:默认字段类型必须能静态确定,用元数据布局直接分配,不给运行时类型推断留空间。
3.3 接口和抽象类字段是最严重的“重灾区”
在序列化多态问题上,抽象类字段和接口字段是两个不同级别的坑。
抽象类字段,比如public SkillBase primarySkill,默认情况下还能保存SkillBase自身的字段,至少skillName和cooldown不会丢。真正惨的是接口字段:
public interface IUsable { void Use(); } public class Player : MonoBehaviour { public IUsable usableItem; }这个usableItem在 Inspector 里通常直接显示为 None,你拖任何实现了IUsable的对象进去都会被拒绝。即使在运行时赋值了,序列化保存后也会变成 null。因为接口不是具体类型,序列化器连“这个字段应该占用多大内存”都无法确定,它只能处理引用类型里的UnityEngine.Object和[Serializable]普通对象。接口在它眼里相当于一个空类型外壳,没有字段列表可以序列化。
抽象类稍微好一点,因为抽象类里至少声明了一些字段,序列化器可以读取到字段表。但它只会读取抽象类自己的字段。如果没加[SerializeReference],子类字段在 YAML 里完全缺席,这也就是第一节那种事故的根因。
3.4 性能、AOT 和跨平台的一致性考量
最后再补一个宏观视角:为什么 Unity 不干脆换个“支持多态的序列化器”?
你去看 IL2CPP 的工作机制就明白了。IL2CPP 在打包时会把 C# 代码转换成 C++,然后进行裁剪(code stripping)。没有实际用到的类型、字段、方法,会在裁剪时被删掉。如果一个序列化器想在运行时通过反射读取任意真实类型,那这个类型必须提前保住在裁剪列表里,否则一打包就没了。Unity 的序列化系统恰好是在打包前就能确定所有字段布局的——因为它只看类的声明,不去依赖运行时动态类型。这样连裁剪都可以做得很激进,不用担心序列化漏数据。
再考虑跨平台场景。同一个 Prefab,在 Windows 编辑器、macOS 编辑器、iOS 真机、Android 真机上都要能加载出完全一致的数据。如果序列化格式里掺入了运行时反射结果,不同平台、不同运行时环境可能会生成不同的类型描述,资源文件的一致性就崩了。Unity 选择“声明类型即唯一真相”的模式,本质上是把数据格式钉死,用牺牲多态换来全平台统一。
所以,把“Unity 序列化为何拒绝多态”这个问题翻译一下,其实就是:Unity 为了让 Prefab 和 Scene 在任何环境、任何版本中都能稳定加载,选择了一套“编译期定死布局”的序列化方案,而多态天生需要“运行时动态识别类型”,这两者天然冲突。想绕过去,就要显式告诉引擎“这里请动态处理”,也就是[SerializeReference],或者干脆自己接管序列化流程。
4. 三个绕开限制的成熟方案
4.1 首选方案:用 [SerializeReference] 让序列化器“存档类型”
[SerializeReference]是 Unity 2019.3 起的官方解。加在字段上之后,序列化器不再按照“声明类型”处理字段,而是把字段的真实类型连同数据一起写进 YAML。反序列化时,它会读取类型信息,创建对应实例,再填充子类字段。先看用法:
public class PlayerSkills : MonoBehaviour { [SerializeReference] public SkillBase primarySkill; [SerializeReference] public SkillBase secondarySkill; [SerializeReference] public List<SkillBase> skillList = new List<SkillBase>(); }就加了这么一行特性,FireballSkill的burnDamage、radius就能正常保存和恢复。在 Inspector 里,字段右侧会出现一个类型选择下拉框,可以选择SkillBase的所有可实例化子类。选完类型之后,填参数,保存,重开,数据都在。
但[SerializeReference]不是银弹,有几个坑必须先打在预防针里:
- 不支持泛型类。如果你的
FireballSkill<T>带泛型参数,Unity 序列化器无法稳定描述这个类型,通常直接用不了。 - 类型改名/改命名空间会让数据失联。YAML 里存的是类的完整名称和程序集名,一旦改了类名,旧数据找不到对应类型,Unity 会报 warning,数据变成 null。
- 不会调用构造函数。这一点和默认序列化一致,反序列化创建实例时,构造函数不执行。如果字段有默认值初始化,可能不生效。
- Inspector UI 在早期版本比较简陋。2019.3 到 2020.1 之间,选择子类型的交互不够直观;到 2020.3 之后,鼠标放在引用字段上会出现替换类型的操作按钮,才算好用。
要验证[SerializeReference]是否真的把多态数据存进去了,打开 Prefab 的 YAML 文件,搜索类名。你会看到类似这样的结构(不同 Unity 版本细节有差异,但核心特征一致):
primarySkill: rid: 4213966834722305672 references: version: 1 Resource: - rid: 4213966834722305672 type: {class: FireballSkill, ns: , asm: Assembly-CSharp} data: skillName: 火球术 cooldown: 5 burnDamage: 20 radius: 3看到rid和references,说明这个字段已经被当成可引用对象保存了。这种情况下,类型信息被显式写进文件,反序列化时 Unity 就能按图索骥,重建出FireballSkill实例。这也是我之前排查问题最常用的方法:先看 YAML 有没有references区块,有就是多态生效,没有就是默认序列化丢了数据。
4.2 老版本方案:ISerializationCallbackReceiver 手动接管序列化
如果你的项目还停留在 2019.3 之前,或者你需要在序列化里支持泛型、需要更精细地控制数据格式,那就得用ISerializationCallbackReceiver手动接管。
这个接口给了两个回调:OnBeforeSerialize在序列化前调用,OnAfterDeserialize在反序列化后调用。思路是:把真正需要保存的多态数据拆分成的“类型字符串 + 数据字符串”,用可序列化的普通字段存下来;读取时再根据类型字符串反序列化重建对象。
using System; using UnityEngine; public class PlayerSkills : MonoBehaviour, ISerializationCallbackReceiver { public SkillBase primarySkill; [SerializeField, HideInInspector] private string primarySkillType; [SerializeField, HideInInspector] private string primarySkillData; public void OnBeforeSerialize() { if (primarySkill == null) { primarySkillType = null; primarySkillData = null; return; } primarySkillType = primarySkill.GetType().AssemblyQualifiedName; primarySkillData = JsonUtility.ToJson(primarySkill); } public void OnAfterDeserialize() { if (string.IsNullOrEmpty(primarySkillType) || string.IsNullOrEmpty(primarySkillData)) { primarySkill = null; return; } Type type = Type.GetType(primarySkillType); if (type == null) { Debug.LogWarning($"找不到技能类型: {primarySkillType}"); primarySkill = null; return; } primarySkill = (SkillBase)JsonUtility.FromJson(primarySkillData, type); } }这段代码的原理是:Unity 序列化器只负责保存两个字符串,不直接处理多态。字符串里记录了技能真实类型和完整 JSON 数据。反序列化完成之后,OnAfterDeserialize会执行,从字符串中重新构造出FireballSkill对象。
优点很明显:兼容老版本 Unity、支持带泛型的自定义类型、开发者可以完全控制存储格式。缺点同样明显:代码量变大、Inspector 里不能直接看到FireballSkill的字段、每次新增一个技能类型都要确保序列化/反序列化对称,漏一个环节数据就崩。所以这个方案更适合做存档系统,不适合做策划向的 Prefab 配置面板。
4.3 资源化方案:用 ScriptableObject 替代多态字段
还有一种思路,很多人没意识到:既然ScriptableObject本身是UnityEngine.Object,而 Unity 对UnityEngine.Object的引用序列化是天然支持的,那为什么不把“多态”拆成“多个资源引用”呢?
做法很简单,把技能制造成不同类型的ScriptableObject资源:
public abstract class SkillSO : ScriptableObject { public string skillName; public float cooldown; public abstract void Execute(Transform target); } [CreateAssetMenu(fileName = "Fireball", menuName = "Skills/Fireball")] public class FireballSkillSO : SkillSO { public float burnDamage; public float radius; public override void Execute(Transform target) { // 火球术逻辑 } } [CreateAssetMenu(fileName = "Heal", menuName = "Skills/Heal")] public class HealSkillSO : SkillSO { public float healAmount; public bool revive; public override void Execute(Transform target) { // 治愈术逻辑 } }然后在MonoBehaviour里声明public SkillSO primarySkill。这个字段虽然写的是抽象类型SkillSO,但因为它本身就是一个UnityEngine.Object的引用,Unity 序列化时走的是“引用资源”路径,天然支持指向任何SkillSO子类资源。策划可以直接把FireballSkillSO资产拖进去,字段的数据保存在资产文件里,不可能丢。
用这个方案之后,技能配置从“修改一个字段”变成了“维护一个资产”。好处是数据可以复用,同一个火球术资产可以被多个角色引用;坏处是如果你的树形结构很复杂,资产数量会膨胀,管理成本上升。另外要注意,这本质上是用组合替代了多态字段,但它解决了“我要在 Inspector 里存多种类型”的 90% 实际需求。如果不需要把多态对象直接嵌在 Prefab 里,我更推荐优先考虑这个方案,工程上最干净。
4.4 三个方案到底怎么选:一张决策表
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| Unity 2019.3+,配置直接放在 Prefab 里,需要 Inspctor 可视化编辑多态字段 | [SerializeReference] | 官方支持,数据与 Prefab 一体,还原度最高 |
| 老项目、低版本 Unity,或者需要序列化泛型类型 | ISerializationCallbackReceiver | 手动接管格式,兼容性好,代价是自己维护序列化逻辑 |
| 数据类型稳定、需要复用、数据量较大 | ScriptableObject 资源化 | 利用引擎引用序列化,稳定性最好,适合策划配置资源 |
| 存档系统,要求离线存储和运行时读写 | ISerializationCallbackReceiver+ JSON | 安全,可控,不污染 Prefab |
| 大型战斗编辑器,还要支持撤销重做,多态层级很深 | [SerializeReference]+ 编辑器扩展 | 官方支持最完整,配合 Odin 等插件体验更佳 |
选型的关键在于,你想让“序列化数据”保存在哪里。数据跟着 Prefab 走,就用[SerializeReference];数据想独立成资源,就用 ScriptableObject;数据要变成存档字符串,就自己写回调。三个方案相互不冲突,实际项目里也经常混用。
5. 实战:用 [SerializeReference] 搭一套技能系统
5.1 数据定义与技能逻辑
光讲理论容易飘,我直接给一套可以复制到工程里跑的 Demo。假设我们要做一个“技能槽系统”,玩家身上挂了若干技能,按数字键施法。技能类型有火球术和治愈术。
先把技能数据和行为定义好:
using UnityEngine; [System.Serializable] public abstract class SkillBase { public string skillName; public float cooldown; public abstract void Apply(); } [System.Serializable] public class FireballSkill : SkillBase { public float burnDamage; public float radius; public override void Apply() { Debug.Log($"{skillName} 造成 {burnDamage} 点火焰伤害,半径 {radius} 米"); } } [System.Serializable] public class HealSkill : SkillBase { public float healAmount; public bool revive; public override void Apply() { Debug.Log($"{skillName} 恢复 {healAmount} 点生命,可复活:{revive}"); } }然后定义玩家组件。关键点:skillList必须加[SerializeReference],这样列表里的每个元素都会记录自己的真实类型。
using System.Collections.Generic; using UnityEngine; public class SkillSlot : MonoBehaviour { [SerializeReference] public List<SkillBase> skills = new List<SkillBase>(); private float[] cooldownTimers; private void Awake() { cooldownTimers = new float[skills.Count]; } private void Update() { if (Input.GetKeyDown(KeyCode.Alpha1) && skills.Count > 0) { UseSkill(0); } if (Input.GetKeyDown(KeyCode.Alpha2) && skills.Count > 1) { UseSkill(1); } for (int i = 0; i < cooldownTimers.Length; i++) { if (cooldownTimers[i] > 0) { cooldownTimers[i] -= Time.deltaTime; } } } private void UseSkill(int index) { if (skills[index] == null) { Debug.LogWarning($"技能槽 {index} 为空"); return; } if (cooldownTimers[index] > 0) { Debug.LogWarning($"{skills[index].skillName} 还在冷却中"); return; } skills[index].Apply(); cooldownTimers[index] = skills[index].cooldown; } }这套代码里,SkillBase是抽象类,FireballSkill和HealSkill是它的可实例化子类。[SerializeReference]加在List<SkillBase>上,意味着整个列表的多态状态都会被序列化保存。你不需要再写任何手动 JSON 转换代码,剩下的都交给 Unity 处理。
5.2 在编辑器中完成配置闭环
代码写完之后,在场景里创建一个空物体,挂上SkillSlot。然后看 Inspector,skills列表右侧会有一个“+”号。点“+”添加一个元素,接着你会看到元素栏里有一个类型选择区域。不同 Unity 版本的入口略有差异:2019.3 到 2020.1 之间,通常是点击元素类型名弹出一个下拉菜单;2020.3 之后,同一区域会显示当前类型名,鼠标悬停会出现替换或清除的按钮。
在弹出的类型列表里选择FireballSkill,元素就会变成火球术的字段布局:skillName、cooldown、burnDamage、radius。接着再添加一个元素,选择HealSkill,填好治愈术的数值。配置完毕后,把整个 GameObject 保存成 Prefab。
如果一切正常,你重启 Unity 再打开这个 Prefab,skills列表依然会显示两个技能,火球术和治愈术的专属字段都还在。这一步就是整个方案的核心价值:多态数据真正被持久化了。
5.3 用 YAML 文件验证多态是否真正保存
保存完 Prefab 之后,我建议你执行一次“验尸检查”。在 Project 窗口里右键 Prefab 文件,选择Show in Explorer或Show in Finder,用文本编辑器打开.prefab文件。搜索SkillBase或FireballSkill,如果文件里出现了类似下面的结构:
references: version: 1 Resource: - rid: 1025743801981628160 type: {class: FireballSkill, ns: , asm: Assembly-CSharp} data: skillName: 火球术 cooldown: 5 burnDamage: 20 radius: 3那就说明 Unity 已经按照[SerializeReference]的规则,把技能的真实类型和数据都写进了 YAML。以后再打开工程,反序列化器读到type: {class: FireballSkill},就知道要创建一个FireballSkill实例,然后按字段表填充所有数据。这个验证步骤花不了半分钟,但能帮你省掉很多“我明明保存了怎么又丢了”的折腾。
如果你在 YAML 里只看到skillName和cooldown,甚至只有一行skills:空列表,那说明你的字段类型、类声明或 Inspector 配置有问题,需要回头检查。
5.4 运行时读取和容错
上面的SkillSlot代码在运行时直接遍历skills列表,调用Apply()。因为Apply()是抽象方法,每个子类都有自己的实现,所以多态在运行时代码层面没有任何障碍,真正受限的只有序列化环节。只要序列化数据完整,运行时读取就是水到渠成的事。
容错方面,我的习惯是在每次使用技能前都做三层检查:第一层检查元素是否为 null,因为类型改名、程序集变更、序列化数据损坏都可能产生 null 引用;第二层检查冷却时间,这只是游戏逻辑层面的常规判断;第三层调用Apply()时用 try-catch 包起来,避免某个技能实现抛异常导致整个 Update 崩掉。一个[SerializeReference]字段丢了类型引用时,Unity 会输出类似“Unable to find type”的警告,这时候如果后续代码直接访问该字段,很容易空引用崩溃。所以我宁可多写几个防御性判断,也不在线上环境赌序列化数据永远健康。
6. 常见问题与避坑指南
6.1 子类字段保存后直接丢失
现象:Inspector 里配置好的FireballSkill字段,重启工程后burnDamage和radius变回 0。
原因:字段没有加[SerializeReference],Unity 默认按声明类型序列化,子类字段不被写入 YAML。
解决:在字段或列表声明上添加[SerializeReference]。注意,如果你已经保存过一次 Prefab,旧数据已经丢了,加完特性后要重新配置一次。多态序列化从来不是“加上就自动恢复之前丢的东西”,它只管未来的保存。
额外提示:如果字段是一个数组或 List,[SerializeReference]加在声明的这一行即可,它会递归应用到列表中的每个元素。
6.2 改了类型名或命名空间,数据全部叛逃
现象:重构时把FireballSkill改成FireballSkillV2,打开工程后所有引用这个类型技能的 Prefab 都变成 null,或者报“type not found”警告。
原因:[SerializeReference]在 YAML 里记录的是包含类名和程序集名的类型描述,改了名称之后,旧数据里的类型描述指向了一个不存在的类型,Unity 只能放弃恢复。
解决:最稳妥的办法是尽量保持类型名稳定;如果必须改名,写一个编辑器迁移工具,在OnValidate或ExecuteInEditMode阶段扫描场景和 Prefab,读取旧的 YAML 类型描述,替换成新类名。常见做法是直接用文本编辑器全局搜索替换.prefab文件里的type: {class: FireballSkill, ns: , asm: Assembly-CSharp},改成FireballSkillV2。这种替换只影响类型引用,不会破坏数据块,算是最快的修复方式。
6.3 [SerializeReference] 不支持泛型怎么办
现象:声明了[SerializeReference] public Holder<Weapon> holder;,Inspector 报错或者干脆不显示任何类型选择。
原因:Unity 序列化系统无法稳定描述泛型构造类型。序列化器从 C++ 侧看到的是类似Holder``1[[System.String, mscorlib]]的CLR类型,这种描述在跨平台和版本控制下不稳定,官方默认不支持。
解决:有三个常用路子。一是用一个非泛型的中间类包一层,比如定义一个WeaponHolder : Holder<Weapon>,对序列化器来说WeaponHolder是普通类型,能正常处理;二是放弃[SerializeReference],改用ISerializationCallbackReceiver自己存类型字符串;三是把泛型数据拆成 object 字段加自定义元数据。实际项目里我大部分时候用第一种,简单直接,改动量小。
6.4 反序列化后构造函数没执行
现象:类的构造函数里初始化了maxHp = 100,但序列化回来maxHp是 0。
原因:Unity 反序列化普通[Serializable]类时不会调用构造函数,而是直接按布局在内存里填充数据。如果序列化数据里没有maxHp这个字段,它就不会被赋值。
解决:不要依赖构造函数做初始化。给字段写默认值初始化器(public int maxHp = 100;)也不是绝对可靠,因为反序列化过程中字段值可能会被覆盖。最稳的做法是在ISerializationCallbackReceiver.OnAfterDeserialize中做缺失字段的兜底。如果用的是[SerializeReference],可以在引用字段的OnAfterDeserialize回调里检查并补默认值。还有一个折中方案:把默认值写进序列化数据,批量创建对象后运行一次Reset()或OnValidate()修正空值。
6.5 Dictionary 多态要怎么解
现象:想用一个Dictionary<string, SkillBase>保存技能配置,发现Dictionary根本不进 Inspector,更不用谈多态。
原因:Unity 原生序列化不支持Dictionary。常见的解决方案是自实现一个序列化字典,底层用两个List存放键和值,并在ISerializationCallbackReceiver里完成双向同步。如果值需要多态,就在值的 List 上加上[SerializeReference]:
[System.Serializable] public class SkillDictionary : ISerializationCallbackReceiver { [SerializeField] private List<string> keys = new List<string>(); [SerializeField] private List<SkillBase> values = new List<SkillBase>(); private Dictionary<string, SkillBase> runtimeDict = new Dictionary<string, SkillBase>(); public void Set(string key, SkillBase value) { runtimeDict[key] = value; } public bool TryGet(string key, out SkillBase value) { return runtimeDict.TryGetValue(key, out value); } public void OnBeforeSerialize() { keys.Clear(); values.Clear(); foreach (var pair in runtimeDict) { keys.Add(pair.Key); values.Add(pair.Value); } } public void OnAfterDeserialize() { runtimeDict.Clear(); for (int i = 0; i < keys.Count && i < values.Count; i++) { runtimeDict[keys[i]] = values[i]; } } }如果你不想自己写这套,也可以直接去找第三方库,比如某些开源的自定义可序列化字典,但要注意它们的底层实现是否支持[SerializeReference]。
6.6 Prefab 合并引发的 YAML 冲突
现象:多人协作时,两个人同时改同一个 Prefab,上游拉下来之后 YAML 冲突,rid编号错位,技能引用数据丢失。
原因:[SerializeReference]会在 YAML 里生成rid(reference ID)和references列表。这个rid是 Unity 在保存时自动生成的局部唯一标识,不同分支上的编号可能不一致。合并时如果有人整体替换了references区块,另一边的字段引用rid就对不上了。
解决:工程层面的经验是,把容易冲突、经常多人改的数据拆成独立 ScriptableObject,Prefab 里只保存资源引用,这样每次修改的 diff 范围会小很多。如果必须多人改同一个 Prefab,尽量避免并行修改同一段序列化列表,而且合并后一定要在编辑器中打开 Prefab 再做一次保存,让 Unity 自动修复rid。另外,Unity 自带的 YAML Merge 工具在处理普通字段时表现不错,但对[SerializeReference]的引用块兼容性一般,不要盲目依赖。
说到底,“Unity 序列化为何拒绝多态”不是一个无理取闹的问题,它是 Unity 为资源确定性和跨平台一致性付出的代价。理解这套机制之后,你就不会再问“为什么不能直接存”,而是会主动选择[SerializeReference]、ISerializationCallbackReceiver或 ScriptableObject 资源化方案,根据数据形态做正确的设计。
最后分享一个小技巧:我每次写完带序列化字段的类,都会先保存一次 Prefab,然后用文本编辑器打开 YAML 扫一眼,确认关键字段真的出现在文件里。这个习惯救了我很多次。只要记住了“YAML 里没有的东西,Unity 重启之后必然没有”,你在序列化这条路上就能少走 80% 的弯路。