Unity引擎资源管理代码分析 ( 2 )

前言

上一篇《Unity引擎资源管理代码分析 ( 1 ) 》讲解了Unity引擎资源管理代码的类型设计架构和Resources.Load接口的实现。感兴趣的同学推荐先点击链接阅读上一篇文章。本文将继续讲解对象实例化、销毁和资源释放接口的代码实现。

1. Object.Instantiate

上一小节我们讲解了Unity引擎的Resources.Load函数是如何实现资源加载的,但众所周知,该函数返回的GameObject是不能直接使用在游戏中的,想让它出现在场景树中必须再调用Object.Instantiate函数对这份资源进行实例化。但奇怪的是,Instantiate函数返回的对象类型和传入的资源类型是完全相同的,而常见的引擎设计一般是传入一个Mesh之类的资源对象,返回一个Actor或Entity之类的引用这份资源的实体对象。从这个角度看,Instantiate函数不像是个“纯资源”到“对象实例”实例化函数,而更像是个进行对象复制的Clone函数。那么在Unity引擎内部,Resources.Load返回的Object和Instantiate后的对象有什么区别呢?

在解释资源Object和实例Object的区别之前我们先来关注一个有趣的UnityEditor接口:

AssetDatabase.Contains public static bool Contains(Object obj); public static bool Contains(int instanceID); Description Is object an asset? Returns true when an object is an asset (corresponds to a file in the Assets folder), and false if it is not (for example object in the scene, or an object created at runtime).

这个API的说明指出它可以用来判断一个Object是一个Asset,还是说一个运行时对象(在场景中或运行时创建的对象)。经测试当我们将Resources.Load的返回值直接作为参数传入到该函数中进行调用,函数返回值为true。而当我们讲Instantiate的返回值作为参数传入时,返回值是false。也就是说这个Asset就是资源,而所谓的Runtime对象就是实例。那么接下来我们分析下在Unity引擎中这个函数是如何实现的。

这个接口对应的C++函数为AssetDatabase_CUSTOM_Contains,它所做的工作是根据传入Object的InstanceID在一个PersistentManager类包含的map中查找对应的SerializedObjectIdentifier类对象。这个类包含两个int类型的成员变量:serializedFileIndex和localIdentifierInFile,分别记录包含该对象的序列化文件ID和该对象在文件中的局部索引ID。如果能在这个map中找到Object对应的文件标识符,函数则返回true,否则返回false。显而易见,所有从文件中加载的Object肯定是能查到记录的。

那Instantiate函数本身又是如何实现的呢?其实它内部的实现函数Object_CUSTOM_Internal_CloneSingle就是在执行Clone操作,且这个Clone操作只会为新生成的Object产生对应的InstanceID,但并不会在PersistentManager类的map中加入新对象InstanceID到SerializedObjectIdentifier的映射条目,自然也就不是Persistent的Asset对象了。

在Clone对象树的时候Unity引擎不同于传统的递归+深拷贝克隆方式,而是先将需要复制的对象树中的所有对象都创建出一个新的副本,但先不复制其内容。这样的好处是可以集中创建新对象,避免长时间锁定Object的全局表,提高多线程访问效率。

创建完所有的新对象后,Unity会通过一个继承于TransferBase基类的序列化读写器来进行对象数据的复制操作。这个序列化读写器类主要负责实现数据流的IO操作,它有多个子类,例如:StreamedBinaryRead、StreamedBinaryWrite、YAMLRead、YAMLWrite、RemapPPtrTransfer等等,它们的接口统一,但实现不同,可以用来进行两进制文件/内存数据读写、YAML脚本读写、对象指针的重映射(浅拷贝)等不同的工作。

而如何通过Transfer类复制对象数据的过程我们可以用如下的伪代码说明:

void CloneObjects(Object srcObject, Object destObject)
{
    //创建数据写入器
    StreamedBinaryWrite writer;
    writer.InitCache();

    //将源对象的数据写入缓存
    srcObject.Transfer(writer);

    //创建数据读取器,从先前写入器的缓存读取数据。
    StreamedBinaryRead reader;
    reader.Init(writer.GetWriteCache());

    //从缓存中读取数据到目标对象
    destObject.Transfer(reader);
}

class MeshFilter : public Component
{
public:
    void Transfer(TransferBase transfer)
    {
        //写入/读取Mesh成员变量的数据
        transfer.Transfer(m_Mesh);
    }

protected:
    PPtr<Mesh>    m_Mesh;
};

从上面的代码中可以看出,虽然数据的IO操作由Transfer对象实现,但哪些成员需要序列化、如何序列化,仍由具体的Object子类所负责。这样在实现例如MeshFilter类的Transfer代码时,即可只复制对相同Mesh对象的引用ID,让两个MeshFilter组件引用同一个Mesh对象,而无需完全复制一份相同Mesh资源数据,从而节省了内存开销。

  1. Object.Destroy、Object.DestroyImmediate

上文讲到无论是从文件中加载的资源还是实例化出来的对象其基类都是Object,那么对应的对象删除接口理应就是Object.Destroy和Object.DestroyImmediate这两个函数了。而这两个函数有什么区别呢?它们又真的能释放掉资源吗?

在Unity的API说明文档里是这么解释这两个函数的:

Object.Destroy

public static void Destroy(Object obj, float t = 0.0F);

Description

Removes a gameobject, component or asset.

The object obj will be destroyed now or if a time is specified t seconds from now. If obj is a Component it will remove the component from the GameObject and destroy it. If obj is a GameObject it will destroy the GameObject, all its components and all transform children of the GameObject. Actual object destruction is always delayed until after the current Update loop, but will always be done before rendering.

Object.DestroyImmediate

public static void DestroyImmediate(Object obj, bool allowDestroyingAssets = false);

Parameters

obj

Object to be destroyed.

allowDestroyingAssets

Set to true to allow assets to be destoyed.

Description Destroys the object obj immediately. You are strongly recommended to use Destroy instead. This function should only be used when writing editor code since the delayed destruction will never be invoked in edit mode. In game code you should use Object.Destroy instead. Destroy is always delayed (but executed within the same frame). Use this function with care since it can destroy assets permanently! Also note that you should never iterate through arrays and destroy the elements you are iterating over. This will cause serious problems (as a general programming practice, not just in Unity).

从函数说明文档来看,它们的主要区别在于Destroy是在当帧的Update操作执行完毕后再延迟删除对象,而DestroyImmediate是在调用时立即删除对象。且这两个函数都可以自动判断传入的Object对象类型,如果是GameObject还会自动删除其下挂接的子节点和组件。

在DestroyImmediate的函数说明中还特别强调了只在编辑器的代码中调用它,游戏中应使用Destroy。因为如果在编辑器中使用Destroy的话延迟销毁对象的调用是不会进行的。注意这里指的是在实现编辑器扩展功能的代码中调用它,而不是指在编辑器中执行的游戏运行时代码。至于第二个allowDestroyingAssets的参数我们稍后再谈。

接下来让我们看看这两个函数在Unity引擎代码中的实现:

Object.Destroy的调用的引擎内部函数叫DestroyObjectFromScripting,这个函数一开头就先进行了两个判断和报警返回:

if (!IsWorldPlaying())
{
    ErrorString("Destroy may not be called from edit mode! Use DestroyImmediate instead.Also think twice if you really want to destroy something in edit mode. Since this will destroy objects permanently.");
    return;
}

if (object->IsPersistent ())
{
    ErrorStringObject ("Destroying assets is not permitted to avoid data loss. If you really want to remove an asset use DestroyImmediate (theObject, true);", object);
    return;
}

第一个判断的IsWorldPlaying函数在游戏运行时会返回true,否则返回false。也就是说在编辑器代码中调用Object.Destroy是会直接返回的。警告中也指明应调用Object.DestroyImmediate替代。

第二个判断是IsPersistent,其内部逻辑正是我们前文中提到的,用来判断该对象在PersistentManager中是否存在对应的序列化文件。也就是说如果我们在调用Object.Destroy时传入的对象是使用Resources.Load加载的返回值,而不是Object.Instantiate出来的实例,这个函数是不会作任何处理的。也就是说用Object.Destroy函数是无法卸载掉Resources.Load加载的对象的。

在进行完判断后,Object.Destroy函数将延迟销毁对象的回调函数DelayedDestroyCallbackz注册到了一个叫DelayedCallManager的类中,该类负责在每帧的Update后统一执行这些回调。而DelayedDestroyCallback函数的实现则是简单调用了DestroyObjectHighLevel这个函数。

那DestroyObjectHighLevel函数的HighLevel体现在哪里呢?它其实是一个递归的对象销毁函数,也就说当我们把根级GameObject传进去的时候,它会自动把其下挂接的所有子节点和组件都删除掉。除此之外它还会做一些安全处理,例如是否重复销毁,对象是否还在被物理引擎使用中等等。

接下来让我们看看Object.DestroyImmediate函数的实现。它内部其实调用的是下面这个函数:

void DestroyObjectFromScriptingImmediate(Object* object, bool allowDestroyingAssets)
{
    if(object && object->IsPersistent() && !allowDestroyingAssets)
    {
        ErrorStringObject ("Destroying assets is not permitted to avoid data loss.If you really want to remove an asset use DestroyImmediate (theObject, true);", object);
        return;
    }

    DestroyObjectHighLevel(object);
}

这个函数中首先进行了一个判断,如果传入的对象是Persistent的资源对象,且未指定allowDestroyingAssets参数为true则直接报错返回。再接下来它和Object.Destroy函数调用了同样的DestroyObjectHighLevel函数,只不过这次没通过DelayedCallManager是立即调用的。

那么我们是不是只要将allowDestroyingAssets参数设为true就可以在游戏运行时用它来卸载Resources.Load加载的对象呢?答案是否。原因有二:

  1. 这个函数是在调用返回前就把Object删除掉了,而未等待当帧的Update结束。在游戏运行时状态有很多处理操作是异步执行的,这样很可能造成逻辑的漏洞,不安全。
  2. 当Object的IsPersistent标志为true时,这个函数不但会把内存中的Object删除掉,还会把PersistentManager中保存的文件关联信息也删除掉。在编辑器中运行时甚至还会把文件中的资源数据也一并删除掉。这样的后果是我们再也无法重复加载该资源。

所以最终的结论很遗憾,在游戏运行时的代码中,我们只能使用Object.Destroy来销毁通过Object.Instantiate函数实例化的对象。

2.Resources.UnloadAsset

那我们再来看看Resources.UnloadAsset这个函数的实现。它的引擎内部函数如下:

void UnloadAssetFromScripting(Object* object)
{
    if(object == NULL)
        return;

    if(!object->IsPersistent())
    {
        ErrorStringObject ("UnloadAsset can only be used on assets;", object);
        return;
    }

    bool isUnloadableType = IsUnloadableType(object);
    if(!isUnloadableType)
    {
        ErrorStringObject ("UnloadAsset may only be used on individual assets and can not be used on GameObject's / Components or AssetBundles", object);
        return;
    }

    UnloadObject(object);
}

首先这个函数只接受Persistent的Object,否则直接返回。之后它调用了IsUnloadableType这个函数用来判断Object的类型是否可卸载,如果不符合要求也是直接返回。而IsUnloadableType函数的实现如下:

static bool IsUnloadableType(Object* object)
{
    if (object->IsDerivedFrom (ClassID(GameObject) ) )
        return false;

    if (object->IsDerivedFrom (ClassID(AssetBundle) ) )
        return false;

    if (object->IsDerivedFrom (ClassID(MonoBehaviour) ) && ((MonoBehaviour*)object)->IsScriptableObject() )
        return true;

    if (object->IsDerivedFrom (ClassID(Component) ) )
        return false;

    return true;
}

坑爹呢这是!连GameObject和Component类型的对象都不给卸载么!换句话说我们只能用它来卸载诸如Texture、Mesh、Material、Shader等继承自NamedObject基类的纯资源对象。再仔细跟下最终调用的UnloadObject卸载函数,这个函数的确也没有任何的递归卸载处理代码,它只是一个在DestroyObjectHighLevel函数的递归调用代码中用来删除单个对象的函数。

3. Resources.UnloadUnusedAssets

文章读到这里,想必各位读者跟我一样也是非常的失望。如此高大上的一个Unity引擎竟然连一个好用的手动卸载单个资源的接口都没有。人类最后的希望就落到了Resources.UnloadUnusedAssets这个接口身上。

Resources.UnloadUnusedAssets接口的引擎内部C++函数是Resources_CUSTOM_UnloadUnusedAssets,这个函数本质上是一个异步处理函数,调用它后其实只是创建了一个叫UnloadUnusedAssetsOperation的异步操作处理对象,并将其加入到了PreloadManager的队列中,然后就直接返回了。

这里需要先解释下这个PreloadManager的工作原理。这个类有一个std::vector<PreloadManagerOperation*>类型的成员变量,其中存储的PreloadManagerOperation是所有异步操作的基类,它有两个重要的虚函数,一个是Perform、另一个是IntegrateWithMainThread。PreloadManager初始化后会在主线程外新启动一个线程运行一个循环的Run函数,这个函数会不断地从队列中取出尚未执行完成的PreloadManagerOperation对象,并调用它的Perform虚函数执行异步工作。当Perform执行完毕返回后,如果PreloadManagerOperation对象被标记为需要通知主线程,则这个PreloadManagerOperation对象的指针会被记录在一个可跨线程访问的m_IntegrationOperation的成员变量中,游戏的主线程则会在主循环中调用这个m_IntegrationOperation的IntegrateWithMainThread虚函数。

UnloadUnusedAssetsOperation类就是继承自PreloadManagerOperation异步操作基类的对象之一。但它的Perform函数实现是空的,只有IntergateWithMainThread函数内调用了下GarbageCollectSharedAssets函数。顺带一提,还有负责场景异步加载的PreloadLevelOperation、负责AssetBundle异步创建的AssetBundleCreateRequest等类也是继承自PerloadManagerOperation。

GarbageCollectSharedAssets是Unity引擎底层真正实现无用对象回收的函数,它的实现逻辑是:

  1. 遍历对象InstaceID到指针的全局表,收集仍未销毁的Object对象到资源回收表中。
  2. 在资源回收表中查找所有仍挂接在场景中的根节点对象,并递归遍历其下引用的所有Object对象,将其标记为被引用对象。
  3. 遍历资源回收表,卸载表中所有不存在任何引用的对象。 如上所述,这是一个典型的被动型垃圾回收机制,而且实现方法非常暴力,其中涉及到多次对全局对象表的遍历操作。在一般的游戏场景中,Object对象可能动辄几千或者上万,一次UnloadUnusedAsset函数的调用可能会耗时几百毫秒,造成非常严重的卡顿。但同时它也是唯一能自动递归卸载GameObject节点树下所有Persistent资源的接口,真是让人又爱又恨。

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏IT可乐

Java IO详解(七)------随机访问文件流

 File 类的介绍:https://cloud.tencent.com/developer/article/1012532 Java IO 流的分类介绍:ht...

2368
来自专栏郭耀华‘s Blog

浅析java构造函数前的访问限定符问题

  曾经一直有个问题困扰着我,我一直以为构造函数前面不能加任何东西,但偶然间看到了一本书上写的代码中,构造函数前加了public限定符,心里很是疑惑,构造函数前...

2135
来自专栏偏前端工程师的驿站

意译:《JVM Internals》

译者语                                  为加深对JVM的了解和日后查阅时更方便,于是对原文进行翻译。内容是建立在我对JVM的认...

1967
来自专栏逆向技术

C语言_第二讲_规范以及常用数据类型

一丶编码规范基本数据类型 编码规范 任何程序员,都应该有良好的的编码习惯,便于以后的代码可读性和维护 常见了编码规范有 匈牙利命名法 驼峰式大小写 匈牙利命名法...

1900
来自专栏魂祭心

原 What Every Dev need

2688
来自专栏前端架构

那伤不起的provider们啊~ AngularJS 之 Factory vs Service vs Provider

用AngularJS做项目,但凡用过什么service啊,factory啊,provider啊,开始的时候晕没晕?!晕没晕?!感觉干的事儿都差不多啊,到底用哪个...

841
来自专栏偏前端工程师的驿站

前端魔法堂——异常不仅仅是try/catch

1113
来自专栏偏前端工程师的驿站

前端魔法堂——异常不仅仅是try/catch

前言  编程时我们往往拿到的是业务流程正确的业务说明文档或规范,但实际开发中却布满荆棘和例外情况,而这些例外中包含业务用例的例外,也包含技术上的例外。对于业务用...

2447
来自专栏Java技术

Java 面试题问与答:编译时与运行时

在开发和设计的时候,我们需要考虑编译时,运行时以及构建时这三个概念。理解这几个概念可以更好地帮助你去了解一些基本的原理。下面是初学者晋级中级水平需要知道的一些问...

723
来自专栏冰霜之地

深入浅出 FlatBuffers 之 Encode

FlatBuffers 的使用和 Protocol buffers 基本类似。只不过功能比 Protocol buffers 多了一个解析 JSON 的功能。

752

扫码关注云+社区