揭秘 Unity 的黑盒世界
🎾

揭秘 Unity 的黑盒世界

Tags
演讲
Published
Published May 11, 2022
Author
💡
我的博客:萤火之森 ,所有的公开笔记放在:公开笔记
  • Unity 是个 C++ 引擎
    • 大部分真正运行的逻辑或内存管理都是在 native 层管理的。
    • 当我们要创建一个底层对象的时候
      • 我们会首先运用 UNITY_NEW 宏创建一个对象的实例,然后通过 Transfer Data 为实例填写一些数据,比如纹理开不开 read and write、Mipmap,再重新返回到我们的运行时的内存当中。最后会在主线程做 Awake(跟我们常写的不一样)。
      • 下图的问号,在文末尾会揭秘。
        • notion image
    • 当我们要创建一个托管对象的时候
      • 大部分时候不需要反序列化一些数据,通过 IL2CPP 或 Mono 的 new 方法直接生成 Instance 对象。生成依据实际上是在运行时的元信息。
        • il2cpp_gen_new_object 方法里会传递参数,C# 提供的元信息,例如这个类内存中占用多大、是否有虚方法、是否有基类,是否有引用等信息,来创建出一个真正的内存实例,然后实例交给 GC 管理。
        • 下图的问号,在文末尾会揭秘。
          • notion image
  • 序列化
    • 序列化 ≠ 持久化
    • 序列化是把我们在内存中的内存结构重新叙述成一个可以进行序列输出的过程。但这个过程最终并不一定写在磁盘上。
    • Unity 序列化
      • 举例
        • Inspector 看对象能看到不同参数,也能设置。
        • Prefab 实例化时,这个过程也是 Prefab 反序列化的过程。
        • Copy/Paste Component 复制的时候,序列化成一个可复制的可移动的序列文件,重新在另一个地方反序列化。
        • Undo: 修改一个东西的时候,Unity 记录的是两次序列化出来的可序列化的差异。类似 Git Merge 做了个 Diff,Diff 的前提是有能够比较,能够做 Diff 的数据,这个数据就是序列化后的数据。
        • Dependency Collection: 通过序列化结构来收集和剔除,Unload\Unuse asset 也会用到序列化反序列化。
        • Asset garbage collection
      • Built-in Serialization
        • 原生的序列化
        • 托管的序列化
        • Blobfication(特殊的序列化)

原生的序列化

  • 首先序列化的是各个数据成员。
  • Unity 中,序列化的过程是通过一套叫“Transfer” 的系统来完成的。
    • 简要的来说是一个反向控制系统。
      • 正向控制系统是类似于我们写一个函数,传递数据进去,返回的是处理过的数据。我有一个行为,你给我数据,我来做动作。
      • 反向控制是你给我一个行为,我帮你把它做了。
      • 正向控制的时候,我们需要序列化有 A、B、C 三个成员的类,我们需要分别对 A、B、C 做一次行为。如果我的行为是不一致的,例如:读、写、比较,我们就要写出三套不同的行为,分别来对应它,也就是 套控制方法。
      • 反向控制的好处是,针对同样的一个东西,我只传一个固定的行为模式给你,而我不管行为模式在数据中做了什么。
    • 如下图,首先做 Init,Init 保证我们在不同版本 Transfer 之间仍然保证一定程度的可兼容性。
      • notion image
    • Init 之后,开始 Transfer data,首先会判断这个 Transfer data 是不是一个 Base type data 简单类型: int、double 等。
      • 如果是,会调用 Transfer_Base 来 Transfer,这个 Transfer 可能代表了不同的实际行为。
        • 比如说写 asset bundle,这个 Transfer 就代表把数写到文件对应的位置上。如果 Transfer 是向外读取,那么就是把这个文件中对应位置的这个数读回 Data。
        • Transfer_Base 干了什么,是由每一个传进来的行为来具体控制的,对于数据来讲,我是不知道的,我只告诉你,你要去操作一下 Transfer_Base ,具体干什么自己来定。
      • 如果不是,会判断是不是 PPtr 这个特殊类型,Unity 中这是一个特殊的经过封装的指针。
        • 如果是指针,那么我要指向另一个类。
          • 如果我 PPtr 创建过了,要指向的类是存在的,那么我就会继续调用我要操作的类的 Transfer 来进行递归的序列化和反序列化
          • 如果 PPtr 是空的,PPtr 还没有创建,我们也会进行递归调用,就是我去创建一个新的类,并且优先完成它的 Transfer。
    • 通过一系列这样的操作,我们就能把一个我们之前打到 asset bundle 中的数据,通过一系列反向序列化,通过 Transfer,将这些数据依次填充回我当前在内存中创建的一个实例中,以供我后面来使用。
  • 另一种最常见的 Transfer 就是下图,在 Unity 创建的一个 Prefab,经过 Transfer 之后创建出 YAML Transfer,其实就是根据之前的流程图,不断地对对象进行递归调用,然后形成的 YAML。
    • notion image
    • 开始调用的时候,我会先序列化出一个 GameObject,然后向下依次序列化 Object 里面需要序列化的内容,例如第一个是基础类型的时候,YAML Transfer 要做的就是将名字和值写到对应的地方。直到找到比如 Component 这样的指针 PPtr,fileID 指向底下的某块内容 Component。
    • 大部分东西都是指向一个内部结构,但其中 Script 比较特殊。这个 Script 指向一个外部结构,asset bundle 要加载的时候,就会是一个外部指针。如果指向 Texture,资源不存在,指针指向的时候就会报错。
    • 什么时候是打开 asset bundle 最后的时机?
      • 当你要加载的某一个 Component 需要到这个资源,而这个资源在另外一个 asset bundle 的时候,你需要保证这个 asset bundle 数据源在这个资源被加载的时候是打开的。
  • 我们现在有多少种 Transfer (TransferFunctions )呢?
    • StreamedBinaryRead 序列化
    • StreamedBinaryWrite 反序列化
      • 这两个一般常见的是在打包的时候会生成一些二进制文件,如:asset bundle、Resources。
    • SafeBinaryRead
    • YAMLRead
    • YAMLWrite
      • 也常见,如上图的 YAML 文件
    • RemapPPtrtransfer
    • GenerateTypeTreeTransfer
      • DisableTypeTree 这个选项如果打开,会省内存、CPU 时间、包体大小,带来的问题是 asset bundle 不再能跨版本使用。
      • 因为这个 Transfer 是比较特殊的,前面的 Transfer 关心的是值,这个 Transfer 关心的是名字和类型的匹配情况。如果新版本有个选项不存在了,我就需要 Type tree 的匹配来保证能够继续正确地把这个东西反序列化出来。
      • 正常加载一个 asset bundle 的时候,会经历两次反序列化
        • 第一次会先把 type tree 信息反序列化放到内存里
        • 第二次再根据 type tree 反序列化的信息,来反序列化数值的部分,保证反序列化是正确的。
    • BlobWrit
  • PPtr 是 Unity 里特殊的一种指针
    • 指向的不是内存地址,可以简单的认为是一个 ID 的映射,因为我们知道 Unity 在运行的时候会通过 InstanceID 索引一个真正的指针。但是 InstanceID 可能会产生一些变化,例如在编辑器中,因此 PPtr 会配合 RemapPPtrtransfer 来进行映射的改变。
    • 内存优化时,会常在 Profiler 里面看到 Remapper 在占用内存,对象越多,Remapper 占用越大。

托管的序列化

  • 托管内存支持有限几种序列化和反序列化的方式,
  • Supports reading and writing binary, Yaml and JSON formats.
  • Can handle most common data types, including:
    • lists
    • arrays
    • value types
    • reference types (via [SerializeReference])
  • 没有 Transfer function
  • 不具有版本兼容性。
    • 大家打 asset bundle,修改了 script,例如删除、增加了成员变量,再用删除或增加之后版本去加载 asset bundle 的时候,就会获得一个报错信息,版本不匹配,需要重新打一个 asset bundle。
      • notion image
    • 为什么不能直接用?
      • 是因为托管内存和非托管的内存,数据结构很多是不一样的,类型是需要映射的,例如 List<T> 需要映射成 vector,然后才能正确地用刚才的技术去序列化它,所以我们对每一个托管的类做了一系列的工作,如上图。
        • 首先看这个类有没有被解析过
          • 如果解析过,那么会首先构建一个 Command queue 解析队列,会依次遍历当前托管类里面所有的 field。对于每一个 field 类型,会有一套对应的规则,规定怎么去进行序列化反序列化。
        • 遍历完所有 field 之后,我们就对这个类产生了一整套的规则序列,然后执行这个规则序列。
        • 执行的过程中,还是会调用刚才说的 Transfer function,从而完成托管的序列化过程。

Blobfication(特殊的序列化)

  • What is a blob?
    • a chunk of memory
      • 大量数据时,例如 Animcation Clip,Animator 有大量紧凑的数据。如果还按照刚才那样一个一个解析的动作行为规则做,会非常影响性能。因为内存是非常紧凑的,不存在跨内存块的指针引用。所以完全可以把它读进来,然后指针指向它就完事儿了。
    • relocatable with memcpy
    • contains no virtual classes
  • 优点
    • tightly pack data
      • 不仅是数据紧凑,Blobfication 本身也有一定的去冗余的过程。
    • reduce duplicate data
    • makes assets completely relocatable
    • read from disk directly instead of serializing
    • simplify streaming
    • lays out memory nicely
  • 限制
    • Code must be written data oriented
    • Can't use virtual functions
    • Can't use STL
    • No container type support
  • 有限制,因此我们引入一个概念——OffsetPtr
    • 前面说我不能使用外部指针,那么我就用内部指针,内部指针是允许的。
    • 首先我们不可能是真正的指针,因为真正指针存储的内容是当前的物理地址。但是序列化反序列化到另一台机器的时候,物理地址一定是会变的,物理地址就无效了,因此我们不能直接使用数字,不能直接使用地址。
    • 但是我们可以使用相对位移
      • 首先我们可以先创建出一块内存,然后依次向内赋值。
      • 当我赋值到第一个 Ptr 的时候,右边代码是 NULL,我们就填 0,当我们给到第二个指针的时候,我们就量出灰色部分的整个长度,然后给出一个 offset,向后偏移 128 位。然后在一大段的后面存储这个指针的值 5.5。
      • 因此这里我们指针存储的不再是真正的物理地址,而是一个基于位置的向后偏移量。这样保证它是可以移动的内存块,无论移动到哪里,我们都可以正确地解析出这个指针。
      • notion image
         

Native 与 Managed Object 关系

notion image
notion image
  • 大部分 native 对象和托管对象,在构建完成之后都会进行一个串联的过程。
  • 当我们创建一个 native 对象的时候,往往会意味着我们也创建了一个托管对象。反之亦然。
  • 最明显的例子是,当我们加载一个 Texture 的时候(如:Resources.Load<Texture>),指针指向了什么?
    • notion image
    • 我们在 native 内存里面加载了一张贴图,但实际上,在非托管内存也创建了一个 UnityEngine.Texture2D 类的实例,也是一个 wrapper,一个封装层。
    • 生命周期是不太一样的,当我们点了 GC 之后,这个 wrapper 是不会真正被放掉的,因为 wrapper 的创建不是通过 Load 创建的,而是通过 Unity 底层 native 函数那一层来创建的
    • 因此 Collect 之后,_texture 依然存在。只有在 Resources.UnloadUnusedAssets、资源的 destory 时,native 这一层对象没有的时候,才会真正释放掉。
    • 如果想观察,可以通过 memory profiler 抓帧,对比托管和非托管对象的地址之间的关联关系,是可以找到两个对象是严格地绑定在一起的。