Unity序列化为何拒绝多态?SerializeReference解决子类数据丢失

发布时间:2026/9/15 4:18:59
Unity序列化为何拒绝多态?SerializeReference解决子类数据丢失
聊一个很多人初学Unity时都会撞上的困惑明明C#里继承、多态玩得挺溜可一旦把子类对象塞进一个基类字段Inspector面板里就是看不到子类的字段运行起来数据也莫名其妙丢了一大半。有人会怀疑自己写法不对有人会以为是Inspector显示问题折腾一番后才发现这根本是Unity序列化机制在“拒绝”多态。今天就把这事彻底说明白顺带给出几种绕过去的方案。先说清楚一点这里说的“序列化”不是指把对象转成JSON或者二进制给服务器用而是Unity引擎内置的那套序列化系统——负责把MonoBehaviour、ScriptableObject上的字段存进场景文件、Prefab、Asset以及在运行时加载回来。这套系统每天在背后默默工作但它的设计目标和C#面向对象那套思路存在根本冲突。理解了这个冲突你就不只是知道“不能用”而是能判断“什么时候该换方案”。1. 先搞明白Unity的序列化到底在做什么1.1 序列化不是保存整个C#对象而是保存字段布局很多人误以为Unity序列化和C#的BinaryFormatter差不多会把整个对象图连带类型信息一起打包。实际上完全不是Unity内置序列化干的事非常朴素把一个类里标记为可序列化的字段挨个按名字、按类型存下来读取时创建一个目标类型的新对象再把字段值填回去。整个过程里它只认“有哪些字段”和“字段是什么类型”不认类的继承关系也不认运行时对象的实际类型。你可以把它想象成在填一张固定表格表格上有多少栏位就填多少栏位。你拿着一个“子类对象”它也只管把基类声明的那几个字段抄下来子类新增的字段在表格上根本没有对应栏位。这就解释了一个非常常见的现象[System.Serializable] public class BaseSkill { public string skillName; } [System.Serializable] public class DamageSkill : BaseSkill { public int damage; } public class Player : MonoBehaviour { [SerializeField] private BaseSkill skill; }你把DamageSkill实例赋给skillInspector里能看到skillName但绝对看不到damage。就算你在代码里硬赋一个带数值的DamageSkill运行后切场景再回来damage依然是默认值。Unity序列化系统在处理这个字段时只按照BaseSkill的类型信息去存取子类那部分数据被静默丢弃了。1.2 YAML场景里的真相没有类型名只有字段值Unity的场景文件、Prefab文件默认是YAML格式你可以用文本编辑器直接打开看。举个例子上面那个Player组件被序列化后在场景文件里大概长这样--- !u!114 11400000 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 13200000} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 1234567890abcdef, type: 3} m_Name: m_EditorClassIdentifier: skill: skillName: 火球术注意看skill节点的子节点只有skillName没有damage也完全没有记录这个字段实际上是DamageSkill类型——没有任何type: DamageSkill之类的标记。YAML里保存的纯粹是“字段路径 值”。这一点就是整个问题的核心Unity的序列化格式里没有C#类型的元数据。它不知道也不打算知道你存的是哪个子类它只知道skill这个字段的声明类型是BaseSkill。反序列化时它老老实实new一个BaseSkill对象把skillName填进去完事。子类数据和类型信息都在这一进一出里蒸发。2. 为什么默认序列化必然“拒绝”多态2.1 反序列化时没有C#类型元数据要支持多态反序列化器必须做这样一件事读取到一段数据后先判断“这个数据原本是什么类型”然后根据类型信息去创建对应实例。可Unity的序列化数据里压根没有这个类型字段它拿到的只有字段名和字段值。好比你收到一个快递箱子上没写里面是什么你就没法决定该把它放进冰箱还是放进书柜。C#里我们习惯的多态是运行时通过虚方法表、is、as、GetType()来识别真实类型这些机制依赖的是CLR内存中的类型句柄。而Unity内置序列化是纯数据格式它只关心“字段名 → 值”的映射从设计上就没打算保存类型句柄对应的元数据。所以这个“拒绝”不是某个版本偷懒而是整个序列化格式的基因决定的。举一个更极端的例子如果字段声明类型是接口或者抽象类连“创建什么类型的新对象”这个动作都做不了。Unity序列化器看到一个抽象字段不知道该实例化哪个具体类干脆把整个字段忽略掉编辑器里显示为None场景文件里直接不保存。很多人在抽象类上标注[System.Serializable]后发现字段根本不工作原因就在这里——[System.Serializable]只是告诉Unity“这个类可以内联展开”但不能解决“反序列化时应创建哪个具体类型”的问题。2.2 基类引用字段在Inspector和运行时都拿不到子类数据受影响的不仅是运行时持久化还有编辑器工作流。Unity的Inspector面板本质上也是基于序列化数据的它读取对象时也是按字段声明类型去解析。你在Inspector里给一个BaseSkill类型的字段创建一个DamageSkill实际上Inspector的默认绘制器只会提供BaseSkill字段的编辑控件damage完全没有出现。这导致策划或设计师在编辑器里根本没法配置子类数据项目工作流直接断掉。有些团队遇到这个问题后会把所有子类字段全塞进基类里靠枚举区分类型这是最常见的规避思路后面我会细讲。但这样做只是绕开了序列化限制编码层面仍然别扭尤其是在状态模式、策略模式这类重度依赖多态的设计里硬把所有数据拍平会非常难受。2.3 “按声明类型创建实例”的代价如果你只是想在运行时动态创建一个子类对象并挂到字段上不切场景也不重启编辑器那默认序列化倒不会立刻给你找麻烦。可一旦涉及持久化——保存、加载、Prefab实例化、场景切换——Unity就会拿声明类型来“重建”字段内容。重建时它先创建基类实例然后按基类字段表填充值所有子类字节都被忽略。这个过程很像把一份Word文档另存为纯文本标题、加粗、表格还在但图片、公式、批注全没了而且保存完还没法恢复。更麻烦的是这个过程通常是静默的没有警告没有报错只有在运行某个功能时才发现数据对不上。调试难度比直接抛异常高得多因为问题可能发生在很久以前的某次保存操作里。还有一个隐藏问题普通的[SerializeField]字段在处理[System.Serializable]类时用的是“内联展开”方式即把子字段平铺进父级节点的YAML里。这意味着同一个类的字段如果被多个对象引用每次都会复制一份完整的字段集Unity不会为它维护对象唯一性——它根本不知道对象图的存在。多态之所以难支持也和这个“展平式”存储模型脱不了干系展平之后类型边界都没了更谈不上多态。3. 官方解法SerializeReferenceUnity官方当然知道这个限制很痛所以在2019.3版本引入了[SerializeReference]特性专门用来解决“序列化多态引用”的问题。用了它之后Unity终于会在序列化数据里额外记录被引用对象的类型信息并在反序列化时创建对应类型。这个特性出现后很多以前只能靠自定义编辑器或JSON硬撑的项目终于松了一口气。3.1 一个例子说明差别还是刚才那个例子改动极小[System.Serializable] public class BaseSkill { public string skillName; } [System.Serializable] public class DamageSkill : BaseSkill { public int damage; } [System.Serializable] public class BuffSkill : BaseSkill { public float buffDuration; } public class Player : MonoBehaviour { [SerializeReference] private BaseSkill skill; }区别只有一处把[SerializeField]换成[SerializeReference]。就这么一行Unity对待这个字段的方式完全变了。如果你用的是Unity 2020.2及以上版本在Inspector上点开skill字段会看到一个类型选择下拉框里面列出所有继承自BaseSkill的具体类。选中DamageSkill后damage字段才会真正出现在面板上你可以正常配置数值保存进Prefab运行时也能拿到完整数据。运行时赋值也变得更合理player.skill new DamageSkill { skillName 火球术, damage 50 };这样赋完值再保存场景然后重开编辑器damage依然在。3.2 SerializeReference在Inspector和YAML里的表现[SerializeReference]序列化后YAML结构和普通字段相比多了类型标识。不同Unity版本的具体格式不完全一样但大体会变成类似这样skill: rid: 1234567890或者更常见的嵌套结构skill: damage: 50 skillName: 火球术无论哪种文件里都会出现类型标识可能是类名、类型索引等Unity借助这个标识在加载时找到对应的具体类型并实例化。可以说这是Unity内置序列化里唯一一个真正记录“对象类型”的通道。[SerializeReference]也支持接口和抽象类这是它比[SerializeField]强很多的地方。你甚至可以把字段类型定义成接口public interface ISkill { void Execute(); } [System.Serializable] public class DamageSkill : ISkill { public int damage; public void Execute() { } } public class Player : MonoBehaviour { [SerializeReference] private ISkill skill; }Inspector下拉框会自动列出所有实现了该接口的可序列化类。这个能力在插件架构、数据驱动配置、组合式技能系统里非常有用。3.3 SerializeReference的使用前提与坑[SerializeReference]不是万能药用起来有不少讲究列几个最常见的问题第一类必须可被Unity发现。字段声明类型所在程序集、派生类所在程序集都必须能被Unity正常加载。如果派生类在编辑器状态下动态生成或者在未编译的脚本中序列化器就找不到字段会变成None或者报“类型不存在”。第二反序列化时会调用构造函数。普通[SerializeField]的反序列化不会调用构造函数Unity是绕过构造直接填充字段内存的。但[SerializeReference]不同它需要真正实例化一个对象因此构造函数一定会在反序列化时被调用。如果你的类构造函数里有重逻辑比如依赖注入、注册事件、访问外部单例反序列化时就会踩雷。构造函数应该保持轻量只做字段初始化。第三对象生命周期不受容器管理。用一个MonoBehaviour持有[SerializeReference]字段时这个引用对象有点像“寄生”在容器里的独立小对象但它没有自己的生命周期函数也没有Awake/OnDestroy这些回调。如果你需要初始化和清理逻辑得由容器手动驱动遵循“组合优于继承”的思路去设计。第四数据安全依赖类型名稳定。[SerializeReference]保存类型引用时会记录类型名称及其所在程序集信息。一旦你重命名类、改命名空间、移动脚本文件到另一个程序集序列化数据里的旧类型就对不上了轻则字段变空重则报SerializationException或ArgumentException。重构时要格外小心最好写个数据迁移工具或者在重构之前把资产导出备份。第五别和Unity的某些旧系统混用。比如Animator、NavMeshAgent等组件内部不依赖[SerializeReference]它们的字段类型大多是固定且内置的。用[SerializeReference]主要是在你自己的MonoBehaviour、ScriptableObject、自定义可序列化类里这一点目前已经比较稳定也有大量第三方插件基于它实现节点图、对话树、技能编辑器。4. 不用SerializeReference时多态数据该怎么组织不是所有项目都能立刻升级到支持[SerializeReference]的Unity版本也不是所有团队都愿意承担它的重构风险。我见过不少老项目至今仍在使用“枚举 合并字段”的老一套运行得也挺好。下面这几种方案都有各自的适用场景你可以按需选择。4.1 方案一枚举区分类型所有子类字段并排躺着这是最传统、最稳妥的办法。核心思路是不再依赖继承来表达类型差异而是用一个枚举标记当前数据属于哪个“子类型”然后把所有可能用到的字段都放进同一个类里[System.Serializable] public class SkillData { public SkillType type; public string skillName; public int damage; public float buffDuration; public float radius; public GameObject effectPrefab; }缺点很清楚字段会随着需求增加而膨胀不同技能类型各自关心的字段混在一个类里很容易出现“有的字段对某个类型毫无意义”的情况。读写时还要写一堆switch (type)来分配数据代码重复度不低。优点是所有Unity版本都支持序列化稳定字段在Inspector里永远可见适合快速开发和原型验证。如果你打算走这条路我建议给每个枚举分支写一个静态工厂或者构造函数重载把“按类型填字段”的逻辑收拢到一处避免业务代码里到处switch。比如public static SkillData CreateDamageSkill(string name, int damage) { return new SkillData { type SkillType.Damage, skillName name, damage damage }; }这样至少能把数据构造逻辑集中管理后续要改字段不会牵扯一堆调用点。4.2 方案二用第三方JSON库自己序列化成字符串如果项目网络层已经用了Newtonsoft.Json或者LitJson可以考虑把多态对象序列化成JSON字符串塞进一个string字段里。因为第三方JSON库通常支持多态序列化配置会额外写入类型信息读取时能正确还原子类实例。[System.Serializable] public class PlayerSaveData { public string skillJson; }配合Newtonsoft.Json序列化时可以指定类型名string json JsonConvert.SerializeObject(skill, new JsonSerializerSettings { TypeNameHandling TypeNameHandling.Auto });反序列化BaseSkill skill JsonConvert.DeserializeObjectBaseSkill(json, new JsonSerializerSettings { TypeNameHandling TypeNameHandling.Auto });这个方案优点是把多态问题从Unity序列化中彻底隔离出去缺点同样明显Unity序列化只保存一个字符串可读性差字符串里嵌套的类型信息对Unity的资产合并、重构工具不透明TypeNameHandling一旦处理不当还可能带来反序列化安全风险比如恶意构造类型所以只用在自己完全可控的存档数据里别用于解析外部输入。有一点要特别提醒Unity自带的JsonUtility不支持多态它需要为FromJson指定具体类型而且只序列化公共字段或带[SerializeField]的字段。不要指望JsonUtility能帮你绕过多态问题它的底层思路和Unity内置序列化高度一致——只认字段布局不认类型。真想用JSON方案直接用第三方库更省心。4.3 方案三ScriptableObject当配置容器如果你需要做的是项目配置数据而不是运行时状态那么ScriptableObject往往比任何序列化技巧都更优雅。思路是把每种技能做成一个独立的ScriptableObject资产子类之间是真正的继承关系MonoBehaviour里用一个具体类型的字段去引用它[CreateAssetMenu(fileName DamageSkill, menuName Skills/DamageSkill)] public class DamageSkill : BaseSkill { public int damage; } public class Player : MonoBehaviour { public BaseSkill skill; }注意这里的BaseSkill必须继承自ScriptableObject并且字段用普通[SerializeField]就能正常工作。因为ScriptableObject本身是Unity引擎对象资产文件在编辑器里是可见的资产类型信息保存在资产的m_Script引用里。Unity能识别某个中拥有指定脚本的资产是什么具体类型所以在Inspector里把DamageSkill资产拖到skill槽位上运行时取出来就是DamageSkill实例多态可以正常工作。ScriptableObject方案的优势非常大策划可以单独创建和配置每种技能的资产资产之间互相独立版本合并时冲突少运行时的数据是共享引用不会像内嵌数据一样每个实例都复制一份而且可以用AssetDatabase在编辑器脚本里批量创建、批量修改。缺点是资产文件管理起来比内嵌字段重如果配置项非常多可能需要引入一套资产组织、命名、检索引擎。很多商业项目的技能配置、道具配置、任务配置都是用ScriptableObject堆出来的配合[CreateAssetMenu]和自定义Inspector能做出非常顺手的编辑器工作流。这一招强烈推荐。5. 实际项目中踩过的坑和排查笔记5.1 Inspector里看不到子类字段如果你确认代码里已经用了[SerializeReference]但Inspector就是不显示子类字段先检查项目Unity版本是不是低于2019.3。低于这个版本只能用普通[SerializeField]那当然看不到。还有一个常见原因是字段类型写在抽象类上但抽象类没有标记[System.Serializable]或者具体子类漏写了[System.Serializable]。另外如果字段是private且没有[SerializeField]或[SerializeReference]Unity默认不会序列化私有字段Inspector自然也不会有任何显示。排查时先在场景中选中持有该字段的对象用Debug模式查看Inspector右上角菜单切到Debug能看到序列化字段名是否出现在对象数据里。再不行就打开YAML搜索字段名看看文件里有没有内容。这种方法能很快确定是序列化没生效还是Inspector绘制器的问题。5.2 反序列化报错引用丢失或类型不存在用了[SerializeReference]后比较常见的是下面这类报错SerializationException: Type DamageSkill in assembly Assembly-CSharp was not found.或者ArgumentException: The type DamageSkill does not exist in the serialized data.这通常发生在类型被重命名、命名空间被修改、脚本从Assembly-CSharp挪到了另一个程序集、或者代码在编译期间出过错导致Unity无法加载类型。遇到这类错误先回忆最近有没有做过改名或搬目录没有的话试试Assets - Reimport All刷新序列化缓存有时Unity的序列化缓存里存了旧类型重新导入能触发更新。更保险的做法是打开资产文件或场景文件全局搜索出错的类型名手动修正或删掉对应节点。注意删节点意味着丢失配置数据最好先备份。5.3 SerializeReference对象的构造函数被反复调用前面提过[SerializeReference]反序列化时会调用构造函数。如果你遇到莫名其妙的多次初始化、重复注册事件、或者构造时访问外部资源导致异常多半就是构造函数写了太多副作用。特别是一些团队习惯把对象池注册、事件订阅写在构造函数里这在小规模测试时没问题一旦场景复杂起来反序列化次数一多问题就爆炸了。把副作用移到显式的Init()或者OnEnable式的生命周期方法里是根治办法。如果实在改不了可以在构造函数里先判断环境是否就绪但这是治标不治本。5.4 重构代码时改了类名场景里数据全丢了这是最让人头大的一个坑。[SerializeReference]保存了类型名你改类名相当于把存档里的钥匙改了数据自然对不上。Unity官方没有提供自动的重命名映射机制所以重构之前一定要先评估影响面搜一下场景、Prefab、ScriptableObject里有没有这类序列化数据的实例最好用FindObjectsOfType或者AssetDatabase.FindAssets批量查询然后写一个编辑器迁移工具把旧的序列化数据转成新类型。我自己的做法是在项目里建立一个独立的“数据兼容层”所有用[SerializeReference]的类都在一个专门的命名空间和程序集里并且严格控制这个层级的变更频率。重构优先改为新增类而非修改旧类必要时用[FormerlySerializedAs]处理普通字段的重命名。5.5 性能序列化引用类型数组比普通数组慢[SerializeReference]可以用于数组和List但序列化开销比普通值类型数组大不少因为每个元素都要额外记录类型信息。如果数组长度动辄上百且并不需要多态还是用普通数组更合适。另一方面反序列化时要为每个元素创建对象CPU消耗明显高于普通数组按连续内存填充的方式。跑性能测试时你会发现填一个一百个对象的[SerializeReference]列表加载耗时可能是普通列表的几倍到几十倍具体取决于类型的复杂度和Unity版本。所以我的建议是只在真正需要运行时多态的节点上使用[SerializeReference]批量配置数据优先考虑ScriptableObject大批量运行时数据尽量用普通字段或裸类型数组。最后聊一点个人体会。很多从纯C#后端或客户端转过来的开发者第一反应都是“Unity序列化做得真烂”觉得连多态都不支持简直说不过去。但如果你从Unity的设计立场看这其实是“确定性优先于灵活性”的选择。游戏编辑器需要处理大量资产、多人协作、版本合并序列化格式越简单稳定各种编辑器工具链就越不容易出错。[SerializeReference]的出现也是在不破坏原格式稳定性的前提下补上类型信息的一种增量方案。你在选型时想清楚这里的数据变更频率高不高会不会跨版本迁移是配置数据还是运行时状态把这些想明白再决定用默认序列化、[SerializeReference]、JSON还是ScriptableObject基本上就不会踩大坑。