Unity 序列化机制 (上):引擎内的应用场景和基本原理
写在前面这篇文章讨论 unity引擎的一个核心功能,也是开发中经常遇见的话题:序列化。
内容比较多,分了两部分。这篇讲一些比较基础的前置和背景知识,以及unity序列化到底在哪些地方用使用。下篇展开说 unity序列化的一些细节和原理,以及unity本身序列化的优缺点,我们需要在什么场景使用什么样的序列进行思考。
下篇传送门: 大可:Unity 序列化机制 (下):技术细节,优缺点分析,序列化方案总结
<hr/>从UnityEngine.Object.Instantiate()说起
Unity里有个很重要的基类,UnityEngine.Object。一般Object这种名字一般不是乱取的,它往往意味着一个系统万物的最底层设计。Unity所有核心的东西:monobehvaiour, gameobject, material 都是继承于UnityEngine.Object.
要了解一个底层的基类对应了哪些底层功能,最直接的方法就是,看它定义了哪些接口(Unity - Scripting API: Object (unity3d.com) )里面有个Instantiate(),这个接口,可以将任何Object实例化到场景,这几乎是贯穿每个游戏里的功能,最常见的就是动态加载的Prefab在场景里实例化,以及游戏过程中动态生成一些比如子弹,小兵等GameObject实例。而这个接口的实现,就是这篇文章讨论的主题:序列化
UnityEngine.Object.Instantiate 解决的需求是:我要拷贝一个&#34;对象”。 这在很多编程语言里都会有这样的问题,对象怎么拷贝,以及那个高频面试题:深拷贝还是浅拷贝(Difference between Shallow and Deep copy of a class - GeeksforGeeks) ?
从一个不严谨的角度,实现拷贝对象有两种流派
[*]建新的对象,对成员变量逐一赋值
[*]先序列化,再对数据反序列化创建新实例
UnityEngine.Object.Instantiate拷贝对象的原理
Unity使用的就是后者。简单的说就是Object.Instantiate<T>()这个函数干的事情它的方式,是先将目标序列化,然后再反序列化。这个实现原理来自于官方文档,虽然不能看C++的源码没法亲自证实,但是可以通过profilor去看这个接口被调用的时候,确实通过序列化合反序列化实现的 。顺带一提,当你实例化一大堆prefab,gameobject的时候,光是这个序列化和反序列化的过程,也是性能开销的一部分。
Profilor 里看到序列化和反序列化的回调在Object.Intstantiate里调用
序列化使用场景
作为最底层的机制之一,序列化从Unity的编辑器到运行时,是无处不在的。有时候甚至都没意识到,这是基于序列化实现的,正所谓润物细无声了。这里列举三处常见的场景。
a) Asset和Scene的加载和保存
所有你存在游戏项目里的资产,无论是Prefab,Material还是Mesh,都可以通过Resource.Load() 或者AssetBundle 方式将磁盘里的Asset加载到游戏的运行时里的Object。整个过程,就是用的序列化。与之相反的,当你引擎里将prefab或场景按cirl+save保存到磁盘里,就是反序列化的过程。
实际上,当你用文本的方式打开这些资产的时候,你会发现他们的数据格式都是长一个样的。因为他们都是用的同一套序列化方式(文章后面会详细剖析Unity这种基于YAML的序列化方式)
b) Inspector Window
我们自己写的MonoBevaiour脚本,C# 类的一些成员,都可以在Inspector面板里,直接监视属性的值,以及实时修改。 熟悉UnityEditor拓展编辑器的同学应该知道,Inspector实际上就是一个编辑器面板。我们自定义写编辑器话,怎么编辑都是要写代码的。而我们创建一个monobehaviour的脚本,似乎“自动生成”了这个编辑器代码。
我最开始以为,unity是通过C#的反射来获取,设置C#的对象的成员变量实现的。但其实Inspector不和C#直接交互,而是通过序列化的机制实现。 如下图,inspector不和class本身打交道,不会通过调用类的API来读取和设置成员的值。而是和序列化后的数据打交道:展示序列化后的数据,以及通过反序列化设置值。 (可以了解一下:Unity - Scripting API: SerializedObject (unity3d.com) )
Inspector Window 显示/修改 UnityEngine.Object的原理
这也就解释了为什么一些getter和setter在inspector里是看不到的,只有实实在在能被序列化的成员变量才能在inpector上显示
c) 引擎C#层重载(Hot reloading)
开发游戏会频繁修改C#层的逻辑代码,可在应用程序开发中: 修改代码意味着关掉运行的程序,重新编译,再重新运行,才能看得见你最新的修改。 所以每次你改代码,重新编译,整个C#运行时都会被干掉重启(除了重新编译,游戏运行 /退出 也会让整个C# reload)
但是,你会发现unity在重新编译后有一些神奇的现象。 比如你游戏正在运行的时候,你改代码,刷新后游戏还能继续运行,运行到一半的变量的值还在(虽然大部分情况都是一堆报错,还是得重新运行)。比如你改了Editor侧的代码,重新运行后,整个UnityEditor的界面还是之前的样子,上次编辑的值还在,inspector里改的值还在,没有丢掉(这个功能其实十分重要,不然你每次一编译,引擎编辑器界面就像被杀进程了一样被重置了)
整个神奇的地方在于,你整个C#层都被扬了,静态变量什么的全都重置了,为什么有些运行时信息还是“幸存”了下来(比如inspector里的刚才调的参数),仿佛都没发生过?
通过把数据序列化到C++层,使得C#在重载恢复之前的状态
因为当要重新编译时,unity会先把C#层里继承UnityEngine.Object 的数据序列化,然后放进C++层避难,然后等c#编译好了后,再通过反序列化回去恢复数据。
UnityEngine.Object 在C#和C++ 双面身份
因为序列化等多个围绕UnityEngine.Object的底层功能是写在C++里的,所以在继续剖析系列化之前,先简单地探究一下UnityEngine.Object在C++和C#的行为。
Unity是个C++引擎,不是C#引擎。底层的基础功能在C++层实现,然后C#层写应用层功能,以及游戏的业务逻辑。对于每个UnityEngine.Object,除了C#层有一个实例之外,C++层也有一个对应的实例。C#侧暴露给业务逻辑,可以在C#层调用接口,C++的实例则用来做一些序列化,ID管理,回调管理等功能。 比如Update, OnCollisionEnter 这种回调,实际的主循环和碰撞检测都在C++,C++通过反射,平台调用等方式再调C#
a) 使用new和CreateInstance的区别
有同学可能会疑惑:虽然Unity对Object的拷贝,都是通过序列化实现的,但是我们可不可以用C# new 一个UnityEngine.Object, 然后手动一个个拷贝赋值?
这就涉及到了一个危险的操作:自己在C#层通过Activator或者构造函数创建UnityEngine.Object。因为上面说了,UnityEngine.Object是在C++和C#两边都有实例的,你自己在C#层创建了一个实例,C++层是不知道的,创建的这个Object在C++层的实例也没有,是没法正常运作的。
unity在一定程度上保护了这种情况,对于C++侧没有实例的Object,你在C#层会被当作null处理 。比如看下面的代码:
MonoBehaviour testBehaviour= new MonoBehaviour();
Debug.Log(testBehavirour); // 结果为null
UnitEngine.Object testObj = new UnitEngine.Object();
Debug.Log(testObj); // 结果为null
但是为什么这样又可以了?:
GameObject testGO= new GameObject(&#34;NewGameObject&#34;);
Debug.Log(testGO.name); // 正常显示
为什么GameObject, Material这些也是继承与UnityEngine.Object , 但还是能通过new创建,能正常使用? 因为Unity在对应的构造函数C#里调用了C++侧的接口,来生成C++侧的实例。 如下图:
Unity的C#源码中用new创建一个Material
<hr/>下篇传送门:
页:
[1]