
“我明明给基类字段赋了一个FireballSkillInspector 里也看到了火球伤害、爆炸半径这些参数保存 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 里也把火球术的专属字段全部显示出来了。然后你 CtrlS 保存 Prefab关掉编辑器第二天再打开工程。你发现primarySkill的字段虽然还挂着但里面只剩skillName和cooldownburnDamage和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[]支持元素必须是可序列化类型ListT支持底层是数组天然支持DictionaryK, 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序列化继承结构时踩坑。比如一个战斗存档系统里面有ListSkillBase skills想用JsonUtility.ToJson(player)把整个对象存到本地结果发现反序列化出来的 List 里全是空的SkillBase而且元素个数都变了。原因和前面的 Prefab 问题一模一样JsonUtility是 Unity 序列化器的一个文本封装它不是独立的 JSON 框架。官方文档里写得很清楚JsonUtility的处理范围就是 Unity 能序列化的那些类型它不会为接口、抽象基类或字段的真实类型做额外处理。所以哪怕你在代码里把SkillBase赋值为FireballSkillJsonUtility.ToJson序列化时也只读取SkillBase声明的那几个字段FireballSkill的数据一样会丢。这个知识点在很多场景都会复现存档、网络同步、编辑器数据交换、ScriptableObject 缓存……只要你想把对象持久化而且对象里有多态需求就必须先问一句我用的序列化器支持多态吗是 Unity 原生序列化还是自定义 JSON 方案如果用的是 Newtonsoft.Json那没问题它支持多态如果用的是 Unity 自带那一套那就得走后面说的几个方案。3. 多态到底触犯了哪条“天规”3.1 C 层分配内存没有“运行时类型推断”我们要把“多态为什么被拒绝”这个问题继续往下挖一挖。Unity 序列化器的核心实现在引擎的 C 层。当它反序列化一个MonoBehaviour的时候流程大概是解析 YAML 中的字段数据 → 根据类型信息在 C 侧分配对象槽位 → 填充字段。问题是C 侧怎么知道该分配多少内存怎么知道有哪些字段需要填充它只能通过 C# 侧生成的元数据表来查。默认情况下这个元数据表是针对“每个具体类”生成的。比如PlayerSkills这个类元数据表里记录的就是primarySkill的类型是SkillBaseSkillBase有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 ListSkillBase skillList new ListSkillBase(); }就加了这么一行特性FireballSkill的burnDamage、radius就能正常保存和恢复。在 Inspector 里字段右侧会出现一个类型选择下拉框可以选择SkillBase的所有可实例化子类。选完类型之后填参数保存重开数据都在。但[SerializeReference]不是银弹有几个坑必须先打在预防针里不支持泛型类。如果你的FireballSkillT带泛型参数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 ListSkillBase skills new ListSkillBase(); 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]加在ListSkillBase上意味着整个列表的多态状态都会被序列化保存。你不需要再写任何手动 JSON 转换代码剩下的都交给 Unity 处理。5.2 在编辑器中完成配置闭环代码写完之后在场景里创建一个空物体挂上SkillSlot。然后看 Inspectorskills列表右侧会有一个“”号。点“”添加一个元素接着你会看到元素栏里有一个类型选择区域。不同 Unity 版本的入口略有差异2019.3 到 2020.1 之间通常是点击元素类型名弹出一个下拉菜单2020.3 之后同一区域会显示当前类型名鼠标悬停会出现替换或清除的按钮。在弹出的类型列表里选择FireballSkill元素就会变成火球术的字段布局skillName、cooldown、burnDamage、radius。接着再添加一个元素选择HealSkill填好治愈术的数值。配置完毕后把整个 GameObject 保存成 Prefab。如果一切正常你重启 Unity 再打开这个 Prefabskills列表依然会显示两个技能火球术和治愈术的专属字段都还在。这一步就是整个方案的核心价值多态数据真正被持久化了。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 HolderWeapon holder;Inspector 报错或者干脆不显示任何类型选择。原因Unity 序列化系统无法稳定描述泛型构造类型。序列化器从 C 侧看到的是类似Holder1[[System.String, mscorlib]]的CLR类型这种描述在跨平台和版本控制下不稳定官方默认不支持。解决有三个常用路子。一是用一个非泛型的中间类包一层比如定义一个WeaponHolder : HolderWeapon对序列化器来说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 多态要怎么解现象想用一个Dictionarystring, SkillBase保存技能配置发现Dictionary根本不进 Inspector更不用谈多态。原因Unity 原生序列化不支持Dictionary。常见的解决方案是自实现一个序列化字典底层用两个List存放键和值并在ISerializationCallbackReceiver里完成双向同步。如果值需要多态就在值的 List 上加上[SerializeReference][System.Serializable] public class SkillDictionary : ISerializationCallbackReceiver { [SerializeField] private Liststring keys new Liststring(); [SerializeField] private ListSkillBase values new ListSkillBase(); private Dictionarystring, SkillBase runtimeDict new Dictionarystring, 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 里生成ridreference ID和references列表。这个rid是 Unity 在保存时自动生成的局部唯一标识不同分支上的编号可能不一致。合并时如果有人整体替换了references区块另一边的字段引用rid就对不上了。解决工程层面的经验是把容易冲突、经常多人改的数据拆成独立 ScriptableObjectPrefab 里只保存资源引用这样每次修改的 diff 范围会小很多。如果必须多人改同一个 Prefab尽量避免并行修改同一段序列化列表而且合并后一定要在编辑器中打开 Prefab 再做一次保存让 Unity 自动修复rid。另外Unity 自带的 YAML Merge 工具在处理普通字段时表现不错但对[SerializeReference]的引用块兼容性一般不要盲目依赖。说到底“Unity 序列化为何拒绝多态”不是一个无理取闹的问题它是 Unity 为资源确定性和跨平台一致性付出的代价。理解这套机制之后你就不会再问“为什么不能直接存”而是会主动选择[SerializeReference]、ISerializationCallbackReceiver或 ScriptableObject 资源化方案根据数据形态做正确的设计。最后分享一个小技巧我每次写完带序列化字段的类都会先保存一次 Prefab然后用文本编辑器打开 YAML 扫一眼确认关键字段真的出现在文件里。这个习惯救了我很多次。只要记住了“YAML 里没有的东西Unity 重启之后必然没有”你在序列化这条路上就能少走 80% 的弯路。