[资源管理器]之前资源加载的纠错

前言

这篇文章是为了之前在做资源依赖的时候关于Asset->Assetbundle依赖加载之中错误的一个纠正

正文

在之前的文章我说了一种依赖加载的方案,就是记录用Asset资源对于它所依赖的assetbundle的依赖关系,然后通过这种的依赖关系来进行资源加载,这种方案的好处就是,当我Asset不在依赖Assetbundle的时候就可以卸载这个Assetbundle的,而不需要因为我Asset所在的assetbundle没有卸载就不去卸载依赖的Assetbundle。但是今天在和大佬在聊天的时候,他和我说了一种情况,我们选确定一个依赖关系AB1依赖AB2,然后我们先加载AB2在加载AB1然后卸载AB2再加载AB2然后去实例化AB1中的资源。是不是看起来很绕,其实很简单,就是去确定一点,在Unity中的AB包之间的依赖关系,会不会因为多次加载他们的唯一标识会修改,导致AB1中所记录到的AB2的标识没有更新,导致找不到AB2。通过测试,我发现如果第一次走这样的情况,其实AB1的资源是不会丢失的。loadAB1->instanceAB1(丢失资源)->loadAB2(资源被自动赋值上去,正常现实)。但是第二次加载了就会出现问题loadAB1->instanceAB1(丢失资源)->loadAB2(自动赋值)->DestroyAB1->unloadAB2->loadAB2->instaceAB1(丢失资源)。在这种情况下资源就不会赋值上去了。

结束

因为我在之前的测试不研究,也因为对AB的加载过程没有那么熟悉,导致得到了一个错误的结论。现在特出一篇文章作为纠错。之后我会很严谨的测试不同的情况。

[Unity技巧]查Package包内的问题的小技巧

前言

这几天在查一个unity中inputsystem这个输入控件在有的手机端上出现ui的输入不响应,为了查找这个问题,我开始研究该怎么在package包内的文件输出一些关键日志,毕竟在手机端不太方便断点调试。在打印中我发现了两条路线,一个是用反射,这个是我最开始的方案,机缘巧合让我发现了可以把package包使用本地的文件打包进移动端。现在我就基于这两个方案讲讲我遇到的坑

正文

反射

因为我是为了获取到封装好的类中的一些数据,所以我所讲的只是我用到的一些功能,其他关于特性,动态赋值这些功能我应该不会讲。
当我们拿到一个对象,我们一开始想通过getType来获取它的类型,通过获取到的类型我们就可以通过它的实例来获取到它的一些属性值,比如我们想获取到变量的值,我们就可以通过type.GetFields(变量名).GetValue(实例)就能获取到变量的在当前这个实例中的数据。其他的什么属性呀,方法呀,都能这样获取到。
如果我们获取一个数组就有点麻烦了,首先我们得先判断这个对象是不是一个数组类型,如果是数组类型,我们就可以把这个对象转成迭代器(IEnumerable)这个迭代器是基础迭代器,它内部是object这个类型的,然后继续获取属性,获取对象,这样一点点的拿到我们想知道的数据。
在获取属性的时候记得要传入这个属性的类型,比如instance(实例化的),Public(公共),NonPublic(非公共)。

本地Package包


一个没有修改过的package包可以看到在他的说明左下角有个Develop这个选项,在我看来这个选项就是代表你可以把这个package包自定义开发。可以做成自己的自定义(可能会有些包不支持)。当我们选中了这个选项之后

这个包的位置就换到了Custom本地package了,它就被下载下来了,放到的路径在我们的Package路径下同名的文件夹下,然后我们重新加载我们的工程,就可以发现脚本的路径已经换掉了,还没有本地化之前的包是在Library/packageCache这里的。

然后我们去看Packages路径下的Packages-lock.json文件夹就会发现上面这样的修改。

结束语

我们在日常工作中经常会遇到需要去调试源码的情况,但是在手机端这种调试很麻烦,所以我们只能通过打印log去验证我们一些想法。调试麻烦不是说不能调试,我之前就在xcode上调试过unity工程,太麻烦了。

【unity】协程和迭代器

前言

在很早之前忘了是哪次面试还是什么时候,有人问我协程是什么,那时候我根本就没有这个概念,所以在网上找了一些资料,发现很多人都是讲协程和什么线程这种概念的区别,某一次我看到了某篇文章,上面详细讲解了一次协程的原理,和自己实现一个协程。那时候我也想自己去了解一下,但是因为各种各样的缘由,我在前段时间看书看到了迭代器这一章的时候,根据迭代器的定义,发现协程其实就是一个迭代器的一种实现。现在我就简单的基于协程的原理来通过迭代器实现下这个功能。

正文

迭代器

迭代器是一种设计模式,在所有讲设计模式的书上都有介绍的。它是对于遍历的一种封装,它依赖于一个叫迭代器的接口,我们通过实现这个迭代器的接口可以对各种集合的一个遍历,我们可以不用关系这个集合是数组,列表还是散列表的。

c#中的实现

  1. IEnumerable和IEnumerator
  2. IEnumerable<T>和IEnumerator<T>

上面的两个的区别是一个是基于泛型的接口还有一个是非泛型的接口

枚举源

借助c#语言的一个强大的功能,能够生成创建枚举源的方法。这些方法称为“迭代器方法”。迭代器方法用于定义请求如何在序列中生成对象。使用yield return 上下文关键字定义迭代器方法。可以在迭代器方法中使用多个离散yield return语句。迭代器有一个重要的限制:在同一个方法中不能同时使用return语句和yield return语句。

IEnumerable和IEnumerator

当我们自己实现一个迭代器的时候,我们需要创建两个类,分别继承IEnumerable和IEnumerator这两个接口,他们最重要的是IEnumerator,IEnumerable这个接口需要实现的方法是返回一个IEnumerator的对象,然后我们通过对IEnumerator进行遍历。在实现IEnumerator接口的时候我们需要实现一个是MoveNext这个方法,它是用来判断集合中是否还有下一个元素,Reset这个方法用来重置遍历,下一次从头遍历。Current这个方法是用来获取当前遍历的元素。Dispose这个方法是用来对迭代器进行回收。枚举器用来是读取集合中的数据,不能用来修改基础集合。在最开始的时候枚举数定位在集合中第一个元素的最前面,所以在reset的时候把这个枚举数定义为-1,当我们调用MoveNext的时候会判断当前的枚举数(++curindex)是否在集合的最后一位,如果已经在最后一位了,那就会返回一个false,然后current的值不变,如果不在最后一位,那就把current的值修改到下一个元素。因为是通过枚举数来确定是否在集合的最后一位,所以当我们对集合做出了修改,就会导致枚举器的行为是不确定的。在这里我发现了一个事情,就是默认的IEnumerator的current的返回值是一个object的类型,这个会不会就是人们常说的用foreach来遍历集合会有很高的消耗呢?毕竟如果是一个int这样的值类型会有一次拆装箱的操作。如果说被优化了,那优化点就是值类型的泛型他们单独实现。通过我查看了下他们的源码发现真的是这样的,他们对list的IEnumerator自己改成了一个Enumerator的一个结构(struct)内部实现的Current是一个T的泛型,所以这里就不会出现我看到的拆装箱的操作。

yield

  1. yield return <expression>
  2. yield break

使用yield return 语句可一次返回一个元素。
迭代器方法运行到yield return语句时,会返回一个expression,并保留当前在代码中的位置。下次调用迭代器函数的时候,将从该位置重新开始执行。
使用yield break来终止迭代器。
返回yield或IIEnumerable的迭代器的IEnumerator类型为object。所以当我们yield return返回一个int这样的值类型的时候会进行一次装箱操作。
yield break
是用来跳出迭代器的。

协程

    IEnumerator test2 = test();
                while (test2.MoveNext())
                {
                    Console.WriteLine(test2.Current);
                }
     IEnumerator test()
            {
                Console.WriteLine(DateTime.Now);
                yield return new test1();

                Console.WriteLine(DateTime.Now);
            }

            public class test1
            {
                public test1()
                {
                    System.Threading.Thread.Sleep(2000);
                }
            }

看到上面的代码有没一种很熟悉的感觉,这个是我实现的一个延时执行下一行代码的协程,在实现的时候,我一直在想,其实我们可以把expression当成是一个同步阻塞方法,当这个方法执行结束了之后,我们的迭代器再执行下面的方法。

结束语

万变不离其宗,掌握了原理其实很多东西都是很简单的。当前我就吃了研究不深的亏,很多东西都是用就完事了。现在回想起来,知识这个东西还是不能拖的,趁着热情还在,仔细研究透。

【插件】Wwise在unity中的使用

Wwise 内存管理

SoundBank

初始化Bank

我们现在打包出来的路径下能看到一个叫Init.bnk这个Bank,它包含了工程所有通用信息。是在生成SoundBank时自动生成的,每个工程只有一个。在启动游戏的时候加载的第一个Bank必须得是它。如果不是它会影响其他的Bank无法加载。

正常SoundBank

包含Event和播放他们所需要的对象和音频数据。如果当前的event所播放的音频数据是流播放数据,那它会记录到相应的.wem文件。

在Unity中使用SoundBank

使用标识类型

  1. 字符串

    • 提高代码的可读性
    • 把字符串转换成声音引擎使用的ID需要少量的CPU。
    • 声音引擎中不存储字符串。
  2. ID

    • 代码可读性不好
    • 声音引擎在内部使用ID,因此无需占用任何额外的CPU
    • 无需占用额为内存

LoadBank

我们使用AkSoundEngine.LoadBank来使用字符串加载SoundBank的时候,它会返回给我们一个SoundBank ID这个ID是存在SoundBank里的,所以这个ID和我们在wwise内看到的一样。

UnloadBank

使用UnloadBank来卸载SoundBank的时候,我们需要传入的是它的ID,根据上面的加载,我们需要好好保管这个ID,以便我们能正常的卸载ID。

通过内存加载

LoadBankMemoryView

这个方案其实就是通过使用Unity自带的本地资源二进制加载,然后通过AkSoundEngine.LoadBankMemoryView把这部分的数据序列化成声音引擎能使用的数据。它加载的资源是位于内存中的实际位置,不会拷贝资源,所以在使用的时候必须保证内存一直有效,直到卸载掉SoundBank。

LoadBankMemoryCopy()

也是通过序列化数据加载soundbank,这个和上面那个方法的不同之处,是会把数据拷贝到声音引擎的分配内存中,所以当数据被加载完成之后,就可以从unity中卸载掉。

PrepareBank

AkBankContent_All

这个是一个PrepareBank的一个枚举选项,使用这个选项的时候,在加载SoundBank的时候是把它所有的内容都加载进去,在加载媒体文件的时候,运用于类似于PrepareEvent的机制,先查询媒体文件在不在内存中,如果不在就加载媒体文件,如果存在了就不在加载。提高内存的利用率。

AkBankContent_StructureOnly

使用这个选项的加载方式,只会加载时间和结构元数据,会忽略SoundBank中的媒体文件。当SoundBank中不包含媒体文件的时候,它和上面的那种模式可以产生相同的结果。

清空SoundBank

调用ClearBanks()函数可以重置声音引擎中的内容。调用这个函数之后:

  • 所有声音停止播放。
  • 所有SoundBank均会被卸载,包括Init.Bank
  • 所有的State管理均会清空。

注意点

  1. 每个SoundBank只可加载一次。如果试图显式加载一个SoundBank两次的时候,它在第一次加载之后的返回值将会是AK_BankAlreadyLoaded,告诉你已经被加载过了。当你把媒体存在两个不同的SoundBank里,你就可以显示加载它两次,内存中就会出现两份资源,这个时候你就可以使用PrepareEvent的特性,让同一份媒体资源只在内存中存在一份。
  2. 在使用SoundBankID加载的时候,需要在设置中取消勾选“Use SoundBank Names”这个选项,然后生成出来的SoundBank就是ID.bnk格式的。如果使用Wwise自带的Unity插件,就需要注意,它里面初始化的Init.bnk。在使用ID格式是找不到的,所以需要把Init.bnk改成初始化bank的ID名字。
  3. 当把音效资源当成stream加载的时候,会在SoundBank中记录下来音效资源的流信息,在使用event的时候就会去使用Stream Manager来加载流音效资源播放。

流播放

有限的带宽往往会使得存储设备的访问速度成为游戏的瓶颈。因此,为了保持良好的数据传输请求顺序,游戏通常会集中访问 I/O。排序的依据是相对优先级、相对于传输大小的开销要求、吞吐量和延迟。音频一般需要大量的 I/O 资源:音乐和较长的声音在播放时通常从磁盘传输。这块后面单独写一个文章用来讲解,展示先放下不讲了。

使用wwise的event

AKGameObj

在播放wwise的时候,我们首先需要确定游戏对象,因为不管是听者还是发声器,我们都需要知道他们在世界坐标中的位置,通过调用AkSoundEngine.SetObjectPosition我们可以设置场景中物体的位置和朝向。这个物体记录到声音引擎中,当我们需要给一个听者或者一个发声器设置场景坐标的时候,就可以使用这个记录的信息。通过AkSoundEngine.RegisterGameObj这个方法,我们可以把场景中的物体在声音引擎中记录一个id,这样的话,我们就可以通过id找到这个物体。AkSoundEngine.UnregisterGameObj通过这个方法,我们就把这个物体从声音引擎里移除掉了。在wwise里去记录gameobject对应id是通过AkSoundEngine.GetAkGameObjectID这个方法来计算的。这样我们把unity场景中的一个物体的位置和旋转信息就在声音引擎通过id记录下来的。

AkAudioListener

对于声音我们需要一个收听音乐的节点,这个脚本就是为了给我们定义一个听者的信息。通过AkSoundEngine.AddDefaultListener来设置一个默认的听者,它将指派到所有的发声器。AkSoundEngine.AddListener可以给发声器设置一个听者,用来覆盖掉默认听者。RemoveListener和RemoveDefaultListener是用来取消听者设置。

AkEvent

播放wwise的事件调用的接口是AkSoundEngine.PostEvent它会返回一个id,这个id可以通过调用stopPlayingID来停止event的播放,但是在看unity内的代码的时候发现只在Windows做了拓展。还有一个停止Event的方法调用AkSoundEngine.ExecuteActionOnEvent传入枚举为stop

AkAmbient

当我们为了节省内存的时候,可以把使用相同Event实例只生成一个声音实例,以便节省内存,调用AkSoundEngine.SetMultiplePositions相当只用了一个声音实例却可以设置不同的位置。

AkEnvironment

当我们想给声音加一个Auxiliary Bus来达到一个混响的效果,我们可以调用AkSoundEngine.SetGameObjectAuxSendValues来添加一个混响的效果。

AkSwitch

当我们使用wwise内的音乐切换功能,相当于在同一组声音内根据某一种规则进行播放的切换。可以使用AkSoundEngine.SetSwitch传入组id和你要切换的id加上播放声音的对象。

Spatial Audio

这个是Wwise一个提供和空间音频相关联的服务的一个模块。普通的wwise提供了一些正常的根据位置,方向,角度的声音渐变效果,和一些声音左右声道的切换的效果,但是我们如果需要对声音进行一些反射,衍射和透射的效果的话,还是需要用到Spatial Audio这个模块。这些的处理很依赖游戏引擎,并且很消耗性能。

Room和Portal

通过Room和Portal进行一次简单的几何抽象,对发声体的声音传播进行准确的建模。对于房间驱动的声音传播,其主要特性为衍射,耦合以及混响的空间化。Room没有尺寸大小,他们通过Portal相互连通,并形成由房间和开口组成的网络。其他房间发出的声音可以通过开口传播至听者所在房间。

衍射

对于相邻Room内的每个发声体,Spatial Audio都会计算相连Portal最近边缘与Shadow Boundary的衍射角。通过衍射角映射衍射系数,通过获取这个系数,我们可以修改发声体的值,obstruction值或者Diffraction值。
还需要考虑到Room的漫反射能量。这部分的衍射也会同时计算出来,在Spatial Audio假定漫反射能量沿着Portal垂直方向从Room向外渗透。因此计算相对于Portal法向量的衍射角。

透射

当听者和发生源不在同一Room且两者无法直接通过Portal看到彼此时,Spatial Audio会向游戏对象应用透射损失系数,并据此对声音穿透墙壁的情形进行建模。通过设置Occlusion曲线或Transmission Loss内置参数。
Room之间的透射损失依据Room设置参数进行计算,并取听者所在Room和发声体所在Room当中的最大值。
若发声体被一个或多个几何表面阻挡,则应用几何构造的透射损失系数。若发声体和听者之间存在不止一个表面,则采用最大透射损失系数。
若声音即被几何构造所阻挡,又跟听者不在同一个room,则应用所有透射损失中的最大值。

在unity中使用

AkRoom

调用AkSoundEngine.SetRoom来设置room(gameobject的id),然后通过音频引擎获取到这个id的gameobject对象,从而得到坐标,设置room的朝向和辅助音频总线等信息,还有就是几何信息(比如collider这样的信息)。值得注意的一点,因为wwise的游戏对象和room的id都是通过AkSoundEngine.GetAkGameObjectID计算出来的,所以当这个对象被设置成游戏对象的时候,不要重复用在roomid。

AkRoomPortal

调用AkSoundEngine.SetRoomPortal来设置Portal的信息

  1. protal的id,通过这个id找到gameobject在wwise内注册的信息。
  2. 设置protal的collider的位置信息,中心点,正前方
  3. 设置protal的大小信息
  4. 设置protal是否激活状态
  5. 设置protal连接的两个room的id

AkRoomAwareObject

当wwise调用了SpatialAudio的时候,它会针对每个发声体和听者调用AkSoundEngine.SetGameObjectInRoom把他们设置到相应的room内。

使用AkEvent

我们可以将RoomID当成发声体的ID来设置PostEvent的gameobject对象。

Geometry

通过Geometry可以把三角形网格发送给Spatial Audio实现reflect模拟早期反射,和模拟声音从发声体到听者位置中产生的边缘衍射。

终结

上面我就讲解了下wwise的音频资源在unity内api的使用方案,其实很多声音的效果我们都是可以在wwise内做好的,比如声音的衰减方式,比如弹壳掉落地上的声音随机在左右声道播放,等等效果。它把很多需要我们在unity中自己实现的一些声音表现,使用wwise让音频制作人员给做好了,我们在游戏内就只需要播放一下声音的事件,和声音资源的管理方案。

【Unity】IL2CPP初探

前言

在上一篇文章发出来之后,一大佬和我说,他详细的看了好几遍,完全不知道我在说什么。给我提了一个建议,就是用问题带领读者去思考,我所讲的知识是什么。这次我就按这个思路来写一篇文章。试试效果怎样。

正文

一堆数字的困惑

不知道大家有没有看过这样的一种错误堆栈信息,全都是一些数字,看不出来它所代表的类和函数,还有函数中的哪行代码的问题。然后脑袋里有一个大大的问号,这个究竟是啥呀,该怎么解析呢?这个东西就是elf文件的符号,它需要靠一张符号表来解析成我们人类可以认识的内容。选项现在我们来看看它的字典是什么?

符号表

大家应该知道,真正在计算机上跑起来的东西是一组组二进制文件。我们所编写的什么c#这样的语言其实是属于高级语言,它需要通过一系列的编译变成机器所能执行的机器码。当不同的文件相互糅合的过程,就是将它们之间对地址的引用,或者可以说是对函数和变量的地址的引用进行合并。那怎么来进行这一步的合并呢?人们就想到了这样的一种方法,把每一个文件都解析出一张表,用来记录这个文件中所用到的所有符号以及它们所对应的符号值。当在链接的时候,文件就会去互相寻找自己需要的符号来完成链接。这张表就是符号表。

符号表长啥样子呢?

在汇编阶段,就是生成目标文件的时候,就会产生这张符号表,每一个生成的elf文件都有符号表。符号表中的符号和源码中的变量名和函数名是一一对应的(这种对应关系不是说它们名字是一样的,而是说它们的一种映射关系,在c++编译的时候偶他们生成的就不是完全一样的)。这里是一段测试代码:

//main.c

void test2();
int main(){ 
    test2();
    return 0;
}

//test.c
static int a;
int b ;
static void test1(){
    return;
}

void test2(){
    return;
}

生成目标文件之后,用read -s test.o main.o命令即可查看其中的符号表,如下:

Symbol table '.symtab' contains 11 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     8: 0000000000000000    21 FUNC    GLOBAL DEFAULT    1 main
     9: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND test2 //注意这里,Ndx是UND的,且value为0

// test.o的符号表

Symbol table '.symtab' contains 12 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS test.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     5: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 a
     6: 0000000000000000     7 FUNC    LOCAL  DEFAULT    1 test1
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
    10: 0000000000000000     4 OBJECT  WEAK   DEFAULT    3 b
    11: 0000000000000007     7 FUNC    GLOBAL DEFAULT    1 test2

其中,value叫符号值,对于变量和函数而言,符号值就是他们的地址,size是一个符号值所占字节数,type是符号的类型,像变量的类型就是OBJECT,函数的类型就是FUNC。Bind列是符号的作用域,LOCAL表示是局部符号,GLOBAL表示是全局符号,WEAK表示是弱符号等等。Vis列表示符号的可见性,一般用的比较少。Ndx列
表示符号所属的段的index(.text段,,data段等等)。Name列就是刚才说的与变量函数名一一对应的符号名。
注意一下,在没有链接之前,main.o并不知道test2函数的定义和地址,所以在main.o的符号表里将test2标记为UND(undefine的意思),地址值也缺省为0000000000000000,等到链接的时候寻找到定义了test2函数的目标文件的符号表,就提取出它的地址值。
现在用gcc test.0 main.o将它们链接到一起。导出a.out的符号表

//a.out的符号表

Symbol table '.dynsym' contains 6 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTab
     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
     3: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
     5: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.2.5 (2)

Symbol table '.symtab' contains 66 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
    ......
    31: 00000000000005f0     0 FUNC    LOCAL  DEFAULT   13 frame_dummy
    32: 0000000000200df0     0 OBJECT  LOCAL  DEFAULT   18 __frame_dummy_init_array_
    33: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS test.c
    34: 0000000000201018     4 OBJECT  LOCAL  DEFAULT   23 a
    35: 00000000000005fa     7 FUNC    LOCAL  DEFAULT   13 test1
    36: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.c
    37: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c
    38: 0000000000000834     0 OBJECT  LOCAL  DEFAULT   17 __FRAME_END__
    49: 0000000000201010     0 NOTYPE  GLOBAL DEFAULT   22 _edata
    50: 0000000000000694     0 FUNC    GLOBAL DEFAULT   14 _fini
    51: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@@GLIBC_
    52: 0000000000201000     0 NOTYPE  GLOBAL DEFAULT   22 __data_start
    53: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
    54: 0000000000201008     0 OBJECT  GLOBAL HIDDEN    22 __dso_handle
    55: 00000000000006a0     4 OBJECT  GLOBAL DEFAULT   15 _IO_stdin_used
    56: 0000000000000620   101 FUNC    GLOBAL DEFAULT   13 __libc_csu_init
    57: 0000000000201020     0 NOTYPE  GLOBAL DEFAULT   23 _end
    58: 00000000000004f0    43 FUNC    GLOBAL DEFAULT   13 _start
    59: 0000000000201010     0 NOTYPE  GLOBAL DEFAULT   23 __bss_start
    60: 0000000000000608    21 FUNC    GLOBAL DEFAULT   13 main
    61: 0000000000000601     7 FUNC    GLOBAL DEFAULT   13 test2 //重点关注
    62: 0000000000201010     0 OBJECT  GLOBAL HIDDEN    22 __TMC_END__

这里看到有两个符号表,一个是.dynsym,一个是.symtab。这里简单介绍一下,.dynsym是动态符号表,是动态链接的时候用到的,而.symtab是静态符号表,金泰链接时用到的。

那在Unity生成的符号表是啥格式的呢?

通过上面我们知道了,我们要把二进制的那串信息解析成我们能看到的,需要用到符号表来对应起来,那我们就开始去Unity里看看这个小可爱藏在哪里的。

Android 的符号表

官方库的符号表
除了上面去找直接到unity的安装路径下找符号表,我们还能在打包之后在Temp\StagingArea\libs[architecture]路径下的。
我们还可以通过打包选项选择Create symbols.zip之后,打完包的符号表会生成一个.zip文件放在apk同级路径下。

IOS的符号表

当我们在xcode中使用Archive来编译的时候,会生成一个.xcarchive文件,这个文件可以在window页签里点击organizer这个选项看到。然后再.xcarchive文件右键显示包内内容,我们就能看到一个.dsym文件。这个就是我们需要的ios的符号表。

人工找太麻烦了

有些时候我们在使用第三方的bug记录工具的时候,需要上传这个符号表,通过堆栈的解析,我们就可以在第三方的工具上看到错误的类和函数信息。但是如果每次我们都通过手动找到文件,然后去上传。且不说我们上传的版本是不是和我们发布的版本一样,每次都要靠人工去操作,这是对人力的一个极大的浪费。这个时候,我们就可以通过Jenkins这样的流水线的工具来自动搞定它。对于androi的符号表我们还好操作,毕竟它出包了以后,会放在一个固定的路径下。但是ios你就能发现,通过直接生成的.xcarchive文件是放在一个默认的文件夹下,它的名字是根据时间来变化的。年月日时分秒都上了,这如果我们需要匹配到文件名那得循环遍历了,虽然能够解决,但是处理方法及其的不优雅。那我们是不是可以直接把.xcarchive文件生成到我们指定的路径下呢?稍微一查,嘿嘿,我找到了,可以用过官方的编译命令行来处理这件事xcodebuild -exportArchive 这个命令指定.xarchive的生成路径,那后面我们直接拼接固定路径就能找到.dsym文件了。

让我们去定位问题吧

我也不会解析符号,都是通过第三方的工具来解析,网上一堆,就不说了。右手问题,左手是字典,这翻译很快了吧。

怎么会找不到方法

通过翻译之后的堆栈,我们定位到问题是找不到一个静态方法,通过对比代码,我们发现这块用了反射,而且在mono环境下却不会出现。难道是因为打包的姿势不对?那我们去看看IL2CPP究竟是个什么样的可爱玩意。

IL

来我们来进行一波说文解字,头两个单词是IL,那我们去查下IL是个啥东西。通过微软官方的解释,IL是微软出品的一种基于.NET平台的中间语言。那我们来讲讲为啥需要这个中间语言。因为机器能认识的语言是汇编语言,这个语言太晦涩难懂了,入个门都要好长时间,对于当代这么快节奏的社会来说,平常人根本不能忍受,所以这种苦难还是那些牛人去体验吧。所以微软就出了一个稍微能让人看懂的语言IL语言,它比汇编要好认一点,最起码有一些高级特性,泛型呀,类呀方法呀,继承,字符串等等都能认识。那我这种垃圾码农就很容易写出我想要的代码。然后通过解释器,把IL转换成汇编,让CPU去跑吧,反正我才不管你汇编语言是啥玩意,也才不管你机器的CPU架构是啥玩意。某一天,我觉得IL语言也写的很不爽,不自由了。这个时候微软掏出了高级语言(c#,vb,F#),它帮我们把很多需要关注的点(内存管理等等)给管理起来的,我们就可以只去写逻辑(这个时候大部分人就可以进化成逻辑狗),去关注算法怎么实现,不去管理内存究竟是怎么管理的,不需要知道内存怎么回收的,反正高级语言都给你安排的明明白白了。

IL2CPP

那我们知道了IL是什么,那我们来看看IL2CPP是干啥吃的,它是把我们的IL语言编译成机器能跑的机器码。可能有人会问了,上面讲了IL就是会被转换成汇编呀,那现在这步和上面那个步骤有啥区别吗?其实也没事区别上面讲的是一张实时的编译,就是在运行的时候在把IL编译成机器码,而IL2CPP就直接在离线的时候把IL编译成机器码,一个是离线操作,一个是实时操作的。那我们知道了离线操作,和实时操作,那我们来看IL2CPP是怎么把我们的IL编程机器码了。

它通过了一个叫il2cpp.exe这个离线编译器把IL编译成c++的语言,然后再去生成对应平台的机器码。c++那被优化的可太好了,各个平台的c++编译器都优化到极致了,所以相当于站在巨人的肩膀上,歇一口气,我们很多事都可以少做了,直接用现成的。

那丢方法的原因在哪里呢?

通过上面我们了解到,我们的代码是通过AOT来编译的,那在AOT编译的时候,对于那种没有直接引用的方法或者类(比如反射等),它不知道要不要生成机器码,毕竟离线的也就是做一次扫描代码的活,又不是真的跑了一遍代码。所以当它觉得你没有用,它就会通过代码托管剥离来删除它觉得你没有用到的代码。你说它为啥还自作主张去删减代码呀,这不是吃饱了撑的。其实它这样做也是为了能加快我们打包的效率,毕竟代码少了转换成c++的时间也就变少了,构建机器码也就变快了。那我们能做什么操作来明确告知它我们这个代码是要用的,你不能删除。

我告诉你这块代码我要用,上帝来了也不能动

那我明确使用这个方法,扫描的时候,就可以知道这个代码块被引用了,不能被删除了,还有一个通过一个叫Link.xml的文件配置不能被删除的代码块。

结束语

也不知道这样的写的风格大家认不认可。快给一点建议呀!

【Unity】关于unity中IL2CPP遇到的一些情况

前言

在很早之间因为ios和android需要支持arm64位的时候,我就已经在打包的时候接触到了IL2CPP,但是那个时候也就只是为了能打出一个arm64位的包,所以对于它的一些原理都没有去了解过,碰到的一些问题也没有去详细解答,用了最蠢的方式来规避问题,刚好最近也用到了IL2CPP所以在这里记录下,最近对它的一些理解

IL2CPP的前世

首先我们需要知道Unity的跨平台的一些知识点,为啥Unity出的包能在ios和android这两个平台上跑,而我们不需要做啥特殊的操作,只需要在选择出包平台的时候,选对应的打包平台。这是因为在unity会把我们所编写的c#翻译成CIL(公共中间语言),然后再汇编成字节码。

CIL

CIL是一种属于通用语言框架和.NET架构的低阶的人类可读的编程语言,它是有微软公司所提供的。目标是把.NET框架的语言(c#,VB等等)被编译成CIL然后在汇编成字节码。CIL类似一个面向对象的汇编语言,并且它是完全属于堆栈的。它运行在虚拟机上。是一种独立于具体CPU和平台的指令集。然后由JIT编译器转换为特定的计算机环境特定的机械码。这个是在执行CIL之前完成的。它的转换的根据需求转换的,并不是编译整个CIL代码。

上图展示了CIL的创建和转换为机械码的过程。

  • 源代码在CLR的编译时由特定的语言编译器转换为CIL,并且元数据也在这个时候生成。元数据包含代码中类型的定义和签名,运行时信息等内容。
  • CIL是一个用于安全,部署,版本控制等编译代码库,它有两种类型,进程程序集(EXE)和库程序集(DLL)。
  • JIT编译器将CIL转换为特定与JIT编译器运行的计算机环境的机械代码。按需转换。
  • JIT编译器转换的机器代码由计算机处理器执行。

这块内容其实我也没有详细去研究(虽然我买了这本书,但是包装壳还没有拆,等我看完再水一篇)。但是看完上面的这些基础的信息,我们应该对于C#的跨平台有个简单的了解,它通过CIL这个中间语言来打包成程序集,然后再通过JIT来转换到各个平台所能跑的机械码。所以跨平台的重要点就是CIL和解释CIL的编译器。

关键点

但是在ios平台上机器码被禁止映射到内存中,就是不能把JIT实时编译出来的机械码放在一个地方,所以就不能实时在平台上跑CIL的程序集。
所以在这个时候,我们就需要提前做好机器码的编译(AOT)。所以在这个时候,我们打包成功以后,我们在包里的代码就已经变成了机器码了。

Unity中的IL2CPP干了啥

AOT编译器

IL2CPP AOT编译器名为il2cpp.exe。它是由c#编写的受托管的可执行程序,它接受我们在Unity中通过Mono编译器生成的CIL,并把它生成指定平台下的c++代码。
它的工具链如下:

运行时库

它还支持虚拟机的运行时库。几乎都是用c++代码实现的这个库。运行时库的名字叫ilbil2cpp,它作为链接到播放器可执行文件的静态库提供。这个是IL2CPP技术的主要优势,简单且可移植的运行时库。它还有一个关键部分就是垃圾收集器(GC)。使用的是libgc一种BoehmDemers-Weiser垃圾收集器。但是libil2cpp的设计允许我们使用其他垃圾收集器(GC)。

为啥转成CPP呢

  1. 运行效率快:官方实验数据,换成IL2CPP以后,程序运行效率有了1.5-2.0倍的提升。

  2. Mono VM在各个平台移植,维护非常耗时,有时候不可能完成。

  3. 可以利用现成的在各个平台的c++编译器对代码进行编译期优化,这样可以进一步减少最终游戏的尺寸并提高游戏运行速度。

  4. 由于去除了IL加载和动态解析的工作,使得IL2CPP VM可以做的很小,并且使游戏载入时间缩短。

脚本限制

System.Reflection.Emit

AOT平台无法实现System.Reflection.Emit命名空间中的任何方法。System.Reflection的其余部分是可接收的。只要编译器可以推断反射使用的代码需要在运行时存在。

序列化

AOT平台可能会由于使用了反射而遇到序列化和反序列化问题。如果仅通过反射讲某个类型或方法作为序列化或反序列化的一部分使用,则AOT编译器无法检测到需要为该类型或方法生成代码。

通用虚拟方法

如果使用泛型方法,编译器必须做一些额外的工作,才能将编写的代码扩展到设备上执行的代码。如果使用虚拟方法,将运行时而不是编译时确定行为,存在虚拟方法时,编译器可在不完成明显的地方轻松要求从源代码生成运行时代码。

从原生代码调用托管代码

需要编组到c函数指针以便可以从原生代码调用的托管方法会在AOT平台上有一些限制:

  • 托管方法必须是静态方法。
  • 托管方法必须具有[MonoPInvokeCallback]属性

    线程

有些平台不支持使用线程,因此任何使用System.Threading命名空间的托管代码都将在运行时失败。此外..NET类库的某些部分存在对线程的隐私依赖。

异常过滤

IL2CPP不支持c#异常过滤器。应该将依赖于异常过滤器的代码修改为正确的catch块。

TypedReference

IL2CPP不支持System.TypedReference类型和__makerefc#关键字

MarshalAs 和 FieldOffset 属性

IL2CPP 不支持在运行时反射 MarhsalAs 和 FieldOffset 属性。它在编译时支持这些属性。应正确使用它们以进行正确的平台调用编组。

动态关键字

IL2CPP 不支持 C# dynamic 关键字。此关键字需要 JIT 编译,而 IL2CPP 无法实现。

代码托管剥离

托管代码剥离将从构建中删除未使用的代码,从而可以显著减小最终构建大小。使用 IL2CPP 脚本后端时,托管代码剥离还可以减少构建时间,因为需要转换为 C++ 并进行编译的代码减少。托管代码剥离将从托管程序集(包括从项目中的 C# 脚本构建的程序集、包含在包和插件中的程序集以及 .NET 框架中的程序集)中删除代码。

托管代码剥离的工作方式是对项目中的代码进行静态分析,检测出在执行过程中永远无法访问的类、类成员甚至函数的某些部分。可以通过 Player Settings 窗口中的 Managed Stripping Level 设置(在 Optimization 部分)来控制 Unity 删除无法访问的代码的激进程度。

重要信息:当代码(或插件中的代码)使用反射来动态查找类或成员时,代码剥离工具不能总是检测出项目是否正在使用这些类或成员,因此可能会删除它们。要声明某个项目正在使用这样的代码,请使用 link.xml 文件或 Preserve 属性。

Unity代码的托管剥离级别设置

使用项目的 Player Settings 中的 Managed Stripping Level 选项来控制 Unity 删除未使用代码的激进程度。

C#所引用的代码剥离

UnityLinker

Unity 构建过程使用一个名为 UnityLinker 的工具来剥离托管代码。UnityLinker 是 Mono IL Linker 的一个定制版本,专为 Unity 设计。UnityLinker 基于我们的项目分叉,此分叉密切跟踪上游 IL Linker 项目。(请注意,该分叉中未维护 UnityLinker 的 Unity 引擎特有自定义部分。)

UnityLinker 的工作方式

UnityLinker 将分析项目中的所有程序集。首先标记顶级、根类型、方法、属性、字段等,例如,向场景中的游戏对象添加的 MonoBehaviour 派生类便是根类型。然后,UnityLinker 分析已标记为要进行识别的根,并标记这些根所依赖的托管代码。完成此静态分析后,所有剩余的未标记代码都无法通过应用程序代码中的任何执行路径来访问,并将从程序集中删除。

请注意,这一过程不会对代码进行混淆处理。

反射和代码剥离

UnityLinker 不能总是检测出项目中的代码通过反射时来引用其他代码的实例,因此可能会误删除实际在使用的代码。将 Managed Stripping Level 设置从 Low 提升为 High 时,代码剥离导致游戏中发生意外行为变化的风险也会增加。这种行为变化小到细微的逻辑变化,大到调用缺失方法造成的崩溃。

UnityLinker 能够检测和处理一些反射模式。如需查看该工具可以处理的最新模式的示例。但是,如果使用的不仅仅是简单的反射,必须给 UnityLinker 一些提示,说明哪些类不应该被处理。可以通过 link.xml 文件和 Preserve 属性的形式提供这些提示:

Preserve 属性 — 直接在源代码中标记要保留的元素。
link.xml 文件 — 声明应如何保留程序集中的元素。

UnityLinker 在分析程序集中未使用的代码时,会将使用属性或 link.xml 文件保留的每个元素视为根元素。

Preserve 属性

在源代码中使用 [Preserve] 属性可防止 UnityLinker 剥离该代码如下属性:

  • Assembly:保留程序集内的所有类型(就好像您为每个类型输入了 [Preserve] 属性一样)。要为程序集分配 Preserve 属性,请将该属性声明放在程序集包含的任何 C# 文件中,但需在所有命名空间声明之外

  • Type:保留类型及其默认构造函数。

  • Method:保留方法、其声明类型、返回类型及其所有参数的类型。

  • Property:保留属性、其声明类型、值类型、getter 方法以及 setter 方法。

  • Field:保留字段、其声明类型和字段类型。

  • Event:保留事件、其声明类型、返回类型、add 方法以及 remove 方法。

  • Delegate:保留委派类型及其所有方法。

请注意,相比使用 Preserve 属性,在 link.xml 文件中标记代码实体可以提供更强的控制。

托管堆栈跟踪

使用发布版本配置时,IL2CPP 可能会生成缺少一个或多个托管方法的调用堆栈。这是因为 C++ 编译器已经内联了缺少的方法。方法内联通常对运行时的性能有好处,但可能会使调用堆栈更难理解。IL2CPP 始终在调用堆栈上提供至少一个托管方法。此方法便是发生异常的方法。调用堆栈上还包括其他未内联的方法。IL2CPP调用堆栈不包含源代码的行号信息,我们需要通过一个叫符号表的文件来对应起来

符号表

其实吧这个东西我也不太清楚是为了什么,网上查到的资料说,这个东西就用来保存函数地址映射信息的中转文件。当程序崩溃的时候,会生成一份崩溃日志,这个日志里所记载的东西就是app出错的函数内存地址,而这些函数地址是可以在符号表中找到具体的文件名,函数名和行号信息。每个app在导出的时候都会生成对应的符号表。

总结

现在我就说说我因为IL2CPP所遇到的一些问题

  1. 在之前使用pb做配置二进制序列化的时候,因为在反序列化时候,使用了反射的功能,导致在IL2CPP中解析出错,那时候,我没有仔细去研究为啥会出现问题,因为这是第一次升级IL2CPP的版本,所以我很强制的和同事一起写了脚本,把反射的所有方法都给翻译出来,强行当c#脚本调用。

  2. 在bugly上看崩溃日志的时候,发现在IL2CPP版本中显示的堆栈信息是字符码,不像之前使用MONO一样显示的函数名这样的信息。那时候知道有符号码这样的东西,但是经常上传的符号码和奔溃的版本不匹配,导致解析还是有问题。

  3. 在含有第三方插件的工程导出的时候,因为少做了一个Link.xml的代码裁剪过滤,导致因为第三方库使用了反射功能使得在IL2CPP的包上出现错误。

【系统】Unity3D中的AssetBundle资源管理器(三)

前言

上一次讲到了用asset做资源管理,但是在asset的管理的时候,我们是同步把所需要的asset全部都加载出来的,前段时间看了下别人的管理机制,发现可以用unity的资源加载依赖来做这件事,就是不用我们自己加载asset,而是改用记录asset的依赖就好了。

正文

基于unity自身的依赖加载

在以前我们了解过,unity在加载一个资源的时候,如果它所依赖的assetbundle已经被打开了,那当我们加载它的时候,unity会自动把它所依赖资源一起加载出来,让asset能正常使用。在这个情况下,那当我们在根据asset来记录依赖的时候,我们只需要把它依赖的asset所在的assetbundle给加载出来,然后去对asset做一个引用计数的记录,这样的话,我们就可以省下去做加载依赖的asset这步的操作。在上一次我们所测试下来的结果,让我在这个版本做的方案就是,对assetbundle的卸载使用true这个参数,让它完全卸载掉。所以asset对于我来说就是一个做依赖关系绑定的用途。

改用异步的方式来做资源加载

在体验游戏的时候,我们可能最讨厌的就是界面卡顿,当点一个按钮需要打开一个界面的时候,我们发现,界面需要很久才出现,在等待的时候,我们会产生点击失败的感觉,这样很影响用户体验了。这种卡顿在我之前的方案里,很大一部分是因为我使用的是同步加载接口,它会阻塞住线程,等待资源全部加载回来才结束。这样的结果不是我们想要的表现方式。所以我改成用异步的方案来加载资源。但是在加载的时候,因为是异步,所以也会出现加载资源回来的顺序不一定是按照我们加载之前的顺序回来的。而且要抵消全部加载回来才显示的问题,我们可以做一个UI表现的动效,来抹平加载时间内的UI没有资源展示。

加载方案

当我们加载一个UI界面的时候,我们可以用对这个UI界面所需要的资源进行一个大的分类,如果是那种滑动列表内的资源,我们可以做一个优先级比较低的加载顺序,然后滑动列表加载进来的时候,做一个进入动画。那界面上顺序比较高的是哪个呢?我认为应该是玩家第一眼就能见到的界面,当我们在加载回来之后就可以直接显示了,那些一开始让玩家见不到面的资源,就可以在播动效的时候,再加载回来,其实不会慢很多,但是我们这样做就可以及时响应玩家的操作。我们需要给资源做一个引用列表,哪几个需要显示的prefab在引用它,还要记录一个状态,如果在加载中,所有需要引用的资源直接添加到引用列表里,如果它已经加载成功了,那就直接返回回调。

骚操作(我一般叫奇巧淫技)

在unity自带的资源依赖管理里,我们在加载出来一个prefab的时候如果它的资源没有加载成功,但是我们现实出来,它是会变紫红色的,但是,如果我们在接下来的时间内能加载出它依赖的asset所在的assetbundle的话,unity就会自动把资源赋值上去,正常现实的。所以在加载如果玩家看不到的资源,我们就可以一开始不用理会,只要第一眼能看到的资源以及加载成功了,我们就可以直接显示prefab。这里我就不知道如果出现这样的情况在unity底层会不会有cpu的消耗,一直在找丢失的资源。

卸载方案

如果我们使用了异步来做资源管理的话,会出现一个问题,就是我们如果这个资源还在加载的时候,我们就已经要把它卸载了,那我们就要在资源加载结束的时候做一个判断,这个资源是不是还用。所以我们需要给这个资源的回调做一个管理,如果不需要的话,我们就把回调事件-1,当资源加载回来之后,发现自己没有任何一个资源需要引用了,那它就把自己的状态变成卸载状态,等待下一次的回收。

资源的引用计数的问题

在之前我们所做的所有方案都是在一个理想的状态下做的,如果我们生成的一个prefab的资源,它被clone了,直接走unity的实例化,然后再把它给赋值到一个其他的地方,这个时候我们是不知道它真实的生命周期,这样的情况下,我如果默认它的生命死亡时间是跟某一界面,或者某一模块相关的话,那我们卸载了资源,它在别的地方的实体就会出现紫红色,然后出现问题。所以这种情况下,我现在想到的办法就是框架限制,资源的生命周期就限制死了,不能其他的地方使用我这个里面的资源,你要用自己去加载去。这样的话就能暂时解决了。其实这种方案也是有问题的,靠自觉,不知道什么时候就会出现问题的。只能等下一次解决了。

【系统】Unity3D中的AssetBundle资源管理器(二)

前言

在之前的那片文章上写了我们可以根据Asset的依赖,来做ab包的加载。这样可以在Asset不在用的时候及时释放内存中的资源,减少内存的峰值。也可以减少一次性回收资源太多,导致cpu峰值。

正文

设计思路

准备阶段

首先我们需要在打资源的时候做一次资源相互依赖的配置。然后把这份配置放置在StreamingAssets下,然后可以动态热更下来。在使用的时候,我们先根据ab包里总的Minifest来把每个ab包中有那些资源给读取出来,然后再根据资源依赖关系配置,我们就可以把这两个文件关联起来,asset->depedencyAssets->assetbundle。

加载阶段

根据这样的关系我们在加载的时候,当需要加载一个asset的时候,我们可以去读取它所关联的depedencyAssets资源,然后判断这个资源有在被使用,如果被使用了,我们就给他的引用计数+1;如果没有我们就根据这个asset去找它在哪个assetbundle中,然后把这个assetbundle给加载出来,再看下它所引用的asset有没有加载出来,加载出来就引用计数加1。在这里我们可以看到,只在第一次加载的时候会去给它所依赖的Asset做一次引用计数+1,因为依赖的Asset只需要去关心它是不是被依赖了,而不需要关心依赖它的那个资源被加载几次;因为只要它还在被引用,那就自然而然依赖的资源就不能被回收释放的。

卸载阶段

当我们需要卸载一块资源的时候,我们首先要做的就是,把这个资源的引用计数给-1,然后当他已经完全没有被引用了,那我们就去寻找它所依赖的资源,然后也都减1;然后我们查找这个资源所在的assetbundle包,去遍历它里面的Asset是不是都不被引用了,如果都不被引用了,我们就给这个资源做个标记要被回收的;不立刻被回收而是去做标记的好处就是,当我们的下一次还要用ab包里的资源的时候,我们可以不用再去加载ab包了,直接使用从这个ab包里拿资源,然后把这个ab包在状态给修改回去。我们将找一个空余的时间去把所有不用的ab包给回收掉。

关注点


这张图我们看到没有我们要加载的资源

这张图是我们第一次加载资源的时候它所被引用到的对象
这个时候当我们调用Resources.UnloadAsset卸载资源,它就会变成第一张图,这个资源就从内存里消失了,然后我们不去卸载ab包,第二次我们直接调用资源加载,我们看到的情况和之前是一样的。
但是,如果我们这个时候把ab包给卸载了,然后重新加载ab再加载asset之后,我们将看到这样的资源依赖图

这个时候我们再去调用Resources.UnloadAsset卸载资源你会看到资源没有被依赖,但是没有从内存中消失。

这个时候我们要卸载这个图片必须得调用assetbundle.Unload(true)来卸载,或者调用Resources.UnloadUnusedAssets()这来卸载,后一种卸载方式是会循环遍历所有的资源然后去卸载,所有会有很大的消耗;前一种的卸载方式是把资源依赖给强行解放了,所以当你实际上资源还在使用的话,就会变紫色,丢资源。
在这种Asset没有被回收的情况下,我们assetbundle没有回收,然后我们再从assetbundle中把资源加载出来,实际上使用的资源还是这个,它会被重新被引用上。但是如果我们调用了assetbundle.Unload(false)来把ab给回收了,但是没有去调用Resources.UnloadUnusedAssets()来回收asset,那你会发现这个丢失引用的资源就成了冗余资源,我再次从新加载ab,加载asset的时候,就是重新生成一份资源了

就成这样的情况了。

总结

源码地址
我们在做asset的资源管理的时候,一般来说直接调用true来做一次强行的资源回收,但是如果需要很在意表现,不想表现穿帮,对于一些资源就算冗余也无所谓的情况下,那最后调用一次Resources.UnloadUnusedAssets()来做一次资源的回收兜底行为。下一次我是做场景加载呢,还是做一个资源打包编辑器呢?看时间吧!鸽的前兆。

【系统】unity3D中的assetbundle资源管理器(一)

导言

assetbundle 的解释

说起 U3D 的 assetbundle 大家应该都不会很陌生吧,我的理解是 —— assetbundle 属于一种 unity 自己可认的压缩文件格式,把你想放在一个包里的资源给打包到一起,这样就形成了一个 assetbundle。

但是使用这个压缩格式还有一个注意点,就是可能你要打包的资源,它引用到了另外一处资源,即它对一个外部资源有依赖。所以你在打 assetbundle 的时候,unity 会把这个依赖关系记载在 manifest 的 Dependecies 上,虽然是对 asset 的依赖,但是 unity 帮你管理的是大范围的依赖关系,所以记录下来的是 assetbundle 对 assetbundle 的依赖。

为什么要做 assetbundle 的资源管理器

当我们在使用 assetbundle 做资源热更新的时候,我们需要了解,在使用asset资源的时候,assetbundle是怎么被使用的。比如我们需要加载一个prefab的时候,我们需要先解包这个prefab所在的assetbundle,然后我们还需要解包这个prefab所依赖的asset所在的assetbundle。这个asset到asset的依赖是unity帮我们管理的,会记录在asset中,我们在用文本打开资源的文件的时候,是可以看到在unity中这个asset的所有信息,包括它依赖的asset,(这部分就不详细展开了)。但我们把assetbundle在内存中解压之后,内存就会存放这个assetbundle的镜像,内存就会变大,如果我们只解压不去卸载这部分内存的话,那游戏内存就会持续升高,超出系统所给的内存大小,系统就会把引用杀死,造成闪退。所以我们需要在加载的时候解压assetbundle,在不用的时候把这个assetbundle给回收了。

实现方案

准备阶段

我们将在这个阶段先解压assetbundle的整个manifest,然后给所有的assetbundle给记一个int的唯一标识,这步是为了在后面使用assetbundle的时候可以使用int来做标识,减少string的使用,节省gc。再把所有assetbundle所包含的asset的资源和这个assetbundle做好一个依赖关系。

使用阶段

当我们要加载某一个asset的时候,我们先判断这个asset有被加载,加载过的,我们就asset做一个引用计数+1的操作,如果没有的话,我们就去加载这个asset所在的assetbundle,然后我们在加载assetbundle的时候,看这个assetbundle还使用了哪些assetbundle,我们都做一次加载,做一次引用计数加1的操作,然后我们在实例化出来这个asset。

使用完成阶段

但我们卸载asset的时候,会对这个asset来做一个引用计数-1的操作,如果引用计数小于等于0了,我们就可以卸载这个asset,进而对这个assetbundle来做一次-1的操作,但assetbundle的计数也小于等于0的时候,我们就可以卸载这个assetbundle了,对它所依赖的assetbundle的引用计数-1。这一整个assetbundle的使用周期就结束了

注意点

在做assetbundle的引用计数的时候,我遇到了一个问题,我是使用GetDirectDependencies来获取这个assetbundle所依赖的单一次的assetbundle,所以如果所依赖的assetbundle再依赖我这个assetbundle的时候,我这时候就会出现循环依赖(a->b->a这种情况),然后卸载就会出现问题,因为我在引用计数的时候对a其实做了两次引用计数了,卸载我其实就引用计数-1,然后因为还有一次引用计数所以b不会被卸载,所以对a的引用也不会减少,这样实际上我们其实不需要引用了,但是事实上循环引用导致我们卸载不掉。
在这里我提供两个解决方案:

  1. 我们做一次调用栈的计数,如果这次调用循环中,如果这次调用的资源是记录在栈里的,那我们就不做引用计数+1的操作了。
  2. 我们换一个接口,GetAllDependencies 这个接口返回的就是这个assetbundle所依赖的所有资源,包括它依赖的依赖。这样我们就可以一次性对所有的资源做引用计数加1了。

优化点

  1. 上面所说的assetbundle的循环依赖,其实我们可以做关于asset的依赖管理,asset的依赖是不太会出现asset之间的循环依赖。这种优化还能解决我们关于assetbundle的提早释放。比如assetbundle(a)中两个资源(a1,a2)对两个assetbundle(b,c)里的资源(b1,c1)做依赖。(a1->b1,a2->c1)这种情况的依赖在我现在的方案中,因为是记录的assetbundle的依赖关系,所以就算a1被卸载了,不使用了,但是a还有一个引用,所以a不会被卸载,b和c也就不会被卸载。其实b不再被使用了,但是还是没有办法被卸载了。如果我们做的是asset之间的依赖引用,那么我们a1被卸载了,b就不再被引用了,就可以被卸载出来

  2. 因为我们使用的assetbundle做的资源依赖管理,所以我们调用的卸载接口是直接assetbundle.Unload(true)。这个接口的问题就是,如果我们的资源还在使用,却调用到了这个接口,那么资源就会丢失,就会出现紫色的。所以最后还是要回到使用asset的依赖管理上。

demo

第一版本的demo

【unity】优化-cpu

CPU耗时

类中存在空的updata,lateupdate和FixedUpdate这样的方法

出现问题的主要原因

  1. 这些方法是native层对托管层的调用,c++与c#之间的通信本身就存在一定的开销
  2. 当这些方法的调用,Unity会进行一系列的安全监测(比如保证GameObject没有被销毁)导致cpu时间的消耗

这个类中的方法存在Camera.main的调用

这个方法获取属性的方法实际上是一个Get的方法,每次调用的时候都会去寻找场景中第一个tag为“MainCamera”的相机,并且返回。这个寻找是遍历所有的tag,并且不会被缓存的。

该类的方法中存在ComputeBuffer.GetData的调用

这个是从GPU的buffer中读取对应的计算结果并输入到相应的数据中,由于整个过程是一个同步的操作,所以调用的时候会堵塞调用线程,直到GPU返回数据为止。

该类中存在对纹理SetPixels的调用

SetPixels用于对纹理特定的mipmap层的像素进行修改,它会将一组数据的像素值赋值到贴图的指定mipmap层,调用Apply()后将像素传到显卡。

该类的方法中存在GameObject.SendMessage调用

这个方法会遍历Gameobject上所有的组件,以及组件中的所有函数,会导致很高的cpu消耗。

该类的方法中存在GetComponentsInChindren调用/GetCompoentsInParent调用

这两个的使用都会涉及到较大范围内的搜索遍历。Unity在GameObject的GetComponentsInChildren的方法是支持传入一个List的,这个是可以减少堆内存的分配。

调用了FindObjectsOfType调用

这个会对于场景中的GameObject和Component进行遍历,并将与目标type类型相同的组件以数组的方式返回。这个方法还会产生对堆内存的分配。

存在Reflection相关函数的调用

在调用反射相关的方法时,需要获取类型与函数等信息,并且进行参数校验,错误处理,安全性检查等。还会造成堆内存的分配。

存在对Renderer进行Material/Materials的获取

如果对renderer类型调用.material和.materials,那么unity就会生成新的材质球实例。所以我们可以使用MaterialPropertyBlock来替代Material属性的操作。使用相同的Shader但是Material实例不同的GameObject是无法进行合批操作,所以dc增加了,也就增加了cpu的消耗。
每次调用.material都会生成一个新的Material实例,并且GameObject销毁后,Material的实例是没有办法进行自动销毁的。所以需要手动调用“UnloadUnusedAssets"来进行卸载,就会添加性能开销;如果管理不好的话,内存也会很高。

Ugui中UpdateBatches占用耗时较高

在ugui的UI元素刷新是基于canvas下的,所以当我们ui上的数据做SetActive的时候是会把Canvas下的所有元素都触发了,可以看到Rendering.UpdateBatches的调用会比较多

Ugui中的Rebatch优化

Rebatch发生在c++层面,指的是Canvas分析UI节点生成最优批次的过程,节点数过多会导致算法(贪心)的耗时比较长。对应SetVerticesDirty,当一个canvas中包含的mesh发生改变就会触发(setactivity,transform,颜色,文本内容等),canvas独立处理,互相不影响的,消耗在meshes按照深度和重叠情况排序,共享材质的检测。
优化点:

  1. Canvas动静分离,合理规划,不能太多(会提高DC的消耗)
  2. 减少节点层次和数量,合批计算量小,速度快。
  3. 使用相同材质贴图的ui尽量保持深度相同。

Ugui中的Rebuild

rebuild发生在c#层面,是ugui库中的layout组件调整RectTransform尺寸,Graphic组件更新material,以及mask执行Cull的过程,耗时和发生变化的节点数量基本呈现线性相关。

只有LayoutGroup的直接子节点,并且是 Graphic类型的(比如 Image 和 Text)会触发SetLayoutDirty。

Graphic改变的原因包括,基本的大小、旋转以及文字的变化、图片的修改等等,对应SetMaterialDirty。
优化点:

  1. 少用layout

堆内存优化

  • 无法及时的释放内存。
  • 过多的分配次数会到时内存中的碎片过多,从而无法开辟所需要的连续内存。
  • 内存的GC会照成卡顿

存在.tag的调用

获取tag的时候实际上是调用了get_tag()函数,从native层返回一个字符串,字符串的返回会造成堆内存的分配,unity也没有通过缓存的方法来做优化。

该类中存在对纹理的GetPixels()/GetPixels32调用

一般是为了获取指定mipmap层的全部像素信息,而图片上的像素往往是很庞大的。
会在堆内存上分配内存,用来存储纹理数据的像素信息,而且引擎不会对其进行缓存。

使用了Linq相关的函数调用

linq在执行过程中会产生一些临时变量,而且会用到委托。如果使用委托作为条件的判断方式,时间开销就会很高,并且会造成一定的堆内存分配。

存在对Renderer进行sharedMaterials的获取

对.sharedMaterials的调用,依旧会分配堆内存,每次调用都会分配一个用来存放Material的索引的数组。

存在Input.touches调用

.touches的实现是每次都会new一个数组touches,从而造成一定的堆内存分配。

存在对TextAsset调用

在获取bytes属性的时候,Unity会从Native层获取字节数组(byte[]),从而分配一定的堆内存

C#和lua的穿插引用

c#层会维护一个cache来引用那些被lua访问过的C#层对象,这是为了防止当lua中再次访问该c#对象时,这个对象已经被c#的gc给回收掉了。但是如果lua始终保持着对某个c#层对象的引用,那讲会导致无法被释放。造成堆内存泄漏。