找回密码
 立即注册
查看: 274|回复: 0

Unity 序列化机制 (下):技术细节,优缺点分析,序列化 ...

[复制链接]
发表于 2022-9-20 07:32 | 显示全部楼层 |阅读模式
接上篇的铺垫(大可:Unity 序列化机制 (上):引擎内的应用场景和基本原理) ,继续展开讲讲
<hr/>序列化的数据格式:Unity YAML

Unity并不是自己造轮子,发明了一种序列化的数据格式,而是利用了现有的叫YAML的数据交换格式。同时,Unity的YAML 并没有实现全部的YAML语法,很多如注释,空行这种特性是不支持的,这个估计也是因为优化原因,同时简化序列化的过程。 至于YAML是什么具体语法这里不展开,网上大把教程。这篇文章的目的只是能够看懂Unity的格式。实际上,yaml本身就是一种强调可读性的数据格式,即使你不知道语法,但你其实是读得懂的。
当你了解了他的格式后,你就学会了一个新技能:手动改prefab代码。
a) 保姆级教程: 剖析Prefab的YMAL存储格式

我们以序列化引用地最多的prefab为例,看下unity是怎么对一个”带层级+带多个component+带内部引用+外部引用“的GameObject的进行存储。
以下面一个HelloWorld级别为例: Gun下面嵌套一个body的子物体,然后有个简单的脚本引用了内部的这个子物体,以及外部的一个perfab。(当然,也可以自己随便创建一个简单的GameObject)



一个”带层级+带多个component+带内部引用+外部引用“的GameObject例子

平铺的结构
然后我们用文本编辑器打开.prefab 看看它长什么样。 从数据的结构来看,它不像JSON那样,采用层级式的存储。而是所有gameobject, transform, monobehaviour 全部平铺地序列化在最外层。 如下图,虽然实例化后的物体有父级子级,但是在序列化的时候,都是放在最外层的。



所有Prefab里的Object在结构上都是同一层的,即使像Transform这样是有父子层级关系的

个人猜测这可能也是unity不采用类似json来序列化的原因,因为这样的平铺式有一个很大的优点:版本管理的时候容易diff和merge。
在多人协作的开发里,无论是提交的代码还是资源,能够diff和merge是十分重要的点。正是因为这种方式,很多时候别人改动了prefab,但和你改的不是同一个地方,很多时候是也是能merge的。一个反面的例子就是项目里的配的那些excel表,查看提交日志不装一些插件的话,看不清每次改了哪处地方,而且不太好merge,经常需要加锁。
序列化的“Header”
很多数据协议,比如网络报文,都有“文件头+实际内容” 这种常见的方式。UnityYAML也有类似的模式。注意到段数据的开头都是 “--- !u!{CLASS ID} &{FILE ID}” 的格式:



Unity序列化的Object一定是以这种结构开头的

ClassID 是来标识下面这段数据是什么类型,从而采用对于的方式进行序列化和反序列化。常见的入GameObject是1,Transform是4,MonoBehaviour是114。详细的ID对于的类型可以看下面这张表Unity - Manual: YAML Class ID Reference (unity3d.com)
从ClassID这个标记位可以看出,Unity的序列化,应该并不是对所有类型的UnityEngine.Object进行一个通用的实现,而是针对不同类型不一样的实现或处理方法。这个其实好理解,monobehaviour脚本就是序列化C#的对象字段就够了,但比如ParticleSystem的实际数据确实在C++层的,而Texture2D这种媒体文件的序列化肯定就更不一样了。
File ID 可以看做这个Object在这整个资源文件(这个例子是prefab)里的唯一ID。它的用处主要是用于引用的寻址,这个下面马上说到。
引用的处理
对象的序列化都绕不开一个问题:对于引用的对象,我们怎么序列化?
像Unity里的JsonUtility是直接用inline的方式,复制一遍。这样在runtime是共同引用的一份对象,一序列化就变成各自一份副本了。但这个方式,在Unity资产的序列化是绝对不行的,因为很多引用的是贴图,mesh这类对象,这个一复制文件容量就爆炸了。
首先,Unity对于UnityEngine.Object的引用,是存引用的。其他自己的C#类都是采用inline方式。 UnityEngine.Object引用的本体,也需要是存储下来的资源(比如prefab, texutre).

Unity的引用定位有两种方式:

  • 内部引用 {fileID: XXXXXXX} , fileID就是上面说的,每个开头都会标识的ID。
  • 外部引用 {fildID: xxxxxx, guid: xxxxxxxxxxx, type: x}  GUID是用来定位这个文件在哪里,这个是存在meta文件里的。确定了这个之后,再用fileID定位文件里哪个



内部引用和外部引用的记录格式

特别注意下,我们的Monobehaviour,都会有一个m_Script, 来定位对于.cs文件。某种程度上,对于编辑来说,cs代码也是一种资源。
b) 其他序列化细节补充

媒体资源的导入
上面剖析了prefab,material这种完全是unity的资源是怎么反序列化的。那png,fbx这种媒体资源是怎么序列到Texture2D,Mesh的? 他们又没有在png里写入什么相关信息?
对于这种媒体文件,修饰的信息都是存到meta文件里面的一个ImportSetting。这个可以自行用文本编辑器随便打开一个meta文件来验证。
Instantiate()时对于引用的特殊处理
前面那说了UnityEngine.Object.Instantiate()是将其先序列化,再反序列化。然而这个反序列化对于引用的处理,和直接从asset反序列化有些不一样

  • 外部引用:比如texture,material, 还是一样,保持一致,一模一样复制过来
  • 内部引用:比如GameObject内部的一个子物体或一个脚本,则引用到对应的复制后的子物体或脚本(这个很符合直觉)
对过期字段(Stale Object/Refrence )的处理
不知道怎么翻译Stale Object, 总之就是比如你monobehaviour原先定义了一个字段,然后序列化了,然后你又修改代码把这个字段给删掉了。这样原来已经存在磁盘里的那个字段就变成了Stale Object。这在开发途中其实经常遇到,
对于修改定义之后造成的stale reference,unity不强制重新序列化,一方面是为了性能,因为要把整个项目已经用这个脚本的asset全部翻出来重新序列化一遍,未免太耗时了。同时也是为了万一是手贱不小心删错了,这个把定义改回来,原来引用的数据还在。
不过官方文档提醒:这样会有一个弊端,assetBundle算引用的时候,这个stale reference也会被算进去。
<hr/>Unity序列化的避坑指南

a)数据冗余

从信息的编码效率来看,YAML是信息冗余率极高的一种方式,随便抓一个prafab用zip之类的压缩一下,都能压到原来的10%甚至更少。
但是prefab的设计用意,本身就不是大量数据的。但unity系统自身也有存海量字段数据的例子,比如粒子系统,一个例子系统即使是空的,啥也不干都是三千多行的序列化数据。另一个例子是动画文件,里面的animationCurveData之类的。

海量的配置数据,要么可以自己用protobuf或者json自己做序列化。要么可以用ScriptableObject,它的底层实现也是monobehaviour,是专门设计了来做数据容器的,所以可以选择二进制的方式(参见:Unity - Scripting API: PreferBinarySerialization (unity3d.com))。然而,unity也不支持一个asset多种方式,所起嵌套的就麻了。而且虽然压缩了,但是几乎是人为不可读的。
b) 对class引用的序列化

对于UnityEngine.Object 继承下来的东西,是以引用的方式序列化,也就是只存一个guidID + fileID来找对应的那个class的实例资源。 而对于自定义的一些C#类,则是以inline的方式直接序列化,也就是当做struct了。比如在runtime的时候,你两个monobehvaiour引用同一个对象实例,序列化之后再反序列化,是各自引用的了两个实例。


如果不希望这种数据的副本出现,有两个方法

  • 使用ScriptableObject,他就是继承与UnityEngine.Object,它的定位就是data container
  • 2019版本后支持SeriaiilzeReference  (参见: Unity - Scripting API: SerializeReference (unity3d.com))
c) 不支持多态

这个和Unity的jsonutility一样,不支持多态。也就是说,即使定义为父类的字段,序列化的时候就当做他是父类了,即使实际上存的是一个子类的实例,这就是造成子类的一些字段信息全丢了。
但这似乎对不上: MonoBehaviour也是C#的类呀?为什么Monobehaviour这些又好像是支持多态的?

先说一下,为什么unity不支持C#类型的多态。虽然没看过其源码,但官方在文档里给出了下面一段解释:
The reason for these limitations is that one of the core foundations of the serialization system is that the layout of the datastream for an object is known ahead of time; it depends on the types of the fields of the class, rather than what happens to be stored inside the fields.
意思是由于unity序列化系统的底层限制,序列化之前,必须要先知道数据对应的类型,从而确定数据的结构和布局去做序列化和反序列化。也就是先知道数据类型->再确定数据布局。多态要求的顺序是完全相反的,他得通过数据的布局,再反过来确定数据的类型是父类还是基类。
再说为什么继承MonoBehvaiour的东西是支持的,因为仔细看MonoBehaviour序列化的地方,他有一个m_Script的字段。通过这个scriptID 我们可以找到对于的cs代码文件,然后再找对应的C#类型。(我猜这可能也就是为什么MonoBehvaiour要求文件名和类名要一致了,因为它只定位到文件,然后假设类名和文件名一致,去找对于的类)



MonoBehaviour 都有一个m_Script字段来定位代码文件

解决的方案:

  • 多态的问题依然可以用ScriptObject解决,因为ScriptObject的底层实现就是一个不需要挂在Gameobject上MonoBehvaiour。
  • 还有就是用odin, 或者其他json库,这个不但可以解决多态的问题。还能解决unity不能序列化像Dictionary这种复杂数据结构的问题。
d) 对null的处理

Unity对inline class序列化是不支持字段为null的(实际上它的JsonUtility的序列化也不支持Null)。这个表现和C#的类不一样,正常情况下你不去赋值,是个null,但标记位序列化的字段,序列化时会默认给你创一个,
这意味着,很多递归定义的class,比如二叉树的node,不能null的话就无限递归下去了? 比如下面这个例子:
Class LinkedListNode{
   public  LinkedListNode next = null
}
当然,unity的序列化不会无限递归下去。 比较老的版本的处理是,是设置的最大深度为7的(如果你是链表的话,可能只有7个...)。但是也你本意为null,结果递归了七层,可能就是生成了指数个class,一不小心直接爆炸。不过试了下比较新的unity版本,这种递归的字段,好像倒是直接不序列化了

<hr/>思考:我们需要什么样的序列化?

上面说的是unity序列化游戏资产的方式,但是我们实际开发这,有很多序列化自己数据的地方,直接用unity的那一套未必是合适。
序列化是个软件开发中很通用的一个需求,序列化的数据格式也都层出不穷:JSON, XML,YAML, Protobuf.... 这些方案能长期共存的原因,不存在万能解药,是很多方面是无法兼得的,下面就列举一下这些方面。
a)可读性,数据压缩率,序列化速度

这三个基本是衡量一个序列化方案的核心熟悉,也基本是个不可能三角:你不可能三个兼得。
你要有很好的可读性,就必然不能对数据采取极端的压缩策略。相反,为了开发人员能直接读起来顺畅,可能还得加一些空行,格式字段,甚至允许注释这种额外信息。这是无法兼得的。
数据的压缩率,和序列化速度,也是优化中常见的两个悖论:空间和时间。很多优化的本质,要么是空间换时间,要么是时间换空间。有可能是两个都能一起优化,但是两个都同时做到某种极端就很难。

这就要根据情形做出选择:在包体大小敏感,CDN成本, 网络流量压力 等情况可能选择数据压缩率优先;游戏启动时加载海量的配置表时,可能反序列化速度读优先;而经常需要调试查看,或者作为字段需要diff,那么可读性就很重要。。
b) 可以Diff与Merge

这个是往往被低估,但是实际开发中很重要的一点。 尤其是在序列化“资产”属性的东西,也就是很多人会前前后后同时编辑的东西的时候,尤为重要。
这也是Unity基于YAML的方式序列化prefab很好的一个优点:它舍弃层级化结构,采用平铺的序列化方式,方便版本控制,方便merge实际上它平铺的序列化方式,估计就是为了方便merge。
c) 修改定义的兼容性(Backwards compatibility)

在软件开发迭代的过程经常会存在这样一个问题: 我要修改这个类的定义了,比如把字段名name1改成name2,但是这么做,之前已经存着的老的序列化就不兼容了,文件里面还是写的老名字name1,用新的定义去反序列化就完了。更要命的是,如果不是在开发的中后期修改,可能项目里有成吨的老格式的数据了。
这个解决的层面一般不是在序列化数据格式上,是在序列化反序列化的实现层面上。你反序列化老数据的时候,自动识别新的格式名字可能难了点,但是至少别引发crush,或者填个默认值之类的。
d) 对语言特性和数据结构的支持

其实就是一个使用的便利性了。向多态,泛型,NULL,字典,嵌套结构这种语言特性,不一定所有的序列化格式或者实现方案都支持的。
如果实在不支持,其实最直接的法子是在序列化的场景放弃使用这些特性… 不过放弃字典,二维数组这种数据结构到好办,有时候多态和泛型这比较麻烦。
e) 跨语言兼容性

一般的项目,都不止一种语言环境。比如客户端常见的用Lua, C#,C++都会有,客户端和服务端的语言可能也不一样。他们直接交互的数据,也得保证双方都能读。不过这个其实不是什么大问题,已经流行起来的序列化格式,比如json,protobuf,基本在各大环境都有对应的实现。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-24 10:35 , Processed in 0.094967 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表