《Unity3D高级编程之进阶主程》第四章,UI(七) - UI优化(三)

前面介绍过优化UI的几种方法,包括,UI动静分离,拆分过大的UI,UI预加载,UI图集Alpha分离,UI字体拆分,Scroll View 滚屏优化,以及UGUI图在改变颜色或Alpha后导致对Mesh重构的优化。这篇我们来讲解,UI展示与关闭的优化,对象池的运用,UI贴图设置的优化,内存泄露排查与预防。

===

⑧ UI展示与关闭的优化。

UI的展示与关闭动作最常见,需要查看界面时打开,结束了关闭。但打开和关闭会消耗一定的CPU,打开时需要实例化和初始化,关闭需要销毁GameObject。这些是CPU消耗在实际项目中的消耗量巨大。

对于关闭和打开的CPU消耗的优化这里有几个策略可寻,

	1.前面提过利用碎片时间的预加载,会让展示速度更加快。

	2.在关闭时隐藏节点,打开时再显示所有节点。

	3.移出屏幕。移出屏幕并不会让CPU消耗全部消失,但会减少GPU在这个界面上的消耗。当需要显示时再移入屏幕,有时候移入后进行初始化回到原来的状态也是必要的。

	4.打开关闭时,设置UI界面为其他的层级Layout,使得其排除在相机渲染之外,当需要展示时再设置回UI层级。

上述中 2、3、4方法相同点是,都是用内存换CPU,关闭界面时不减少内存,只减少了CPU的消耗。不同点是,方法2 在关闭期间CPU消耗比方法3的更少,在打开时CPU消耗比方法3 的却更多。因为在显示所有节点的同时,UI网格需要重构,而移出屏幕则不需要重构网格。

方法3 和方法4都使用了相同的原理。只是方法3 用坐标去做摄像机的渲染排除,而方法4 则用层级Layout去做摄像机的排除操作。方法3和4 在CPU消耗上会更少,不过也要注意它们在关闭的同时也需要关闭Update更新程序,以减少不必要的消耗。

⑨ 对象池的运用。

什么是对象池,以及为什么要用对象池?

对象池,即对象的池子。对象池里寄存着一些废弃的对象,当计算机程序需要该种对象时,可以向对象池申请,让我们对废弃的对象再利用。

如果对废物再利用就能省去了很多实例化时的CPU消耗。实例化消耗包括了,模型文件读取,贴图文件读取,GameObject实例化,程序逻辑初始化,内存销毁消耗等。

对象池的规则是,当需要对象时向对象池申请对象,对象池从池子中拿出以前废弃的对象重新‘清洗’下(重置下)给出去,如果对象池也没有可用对象,则新建一个放入给出去,当对象用完后,把这些废弃的对象放入对象池以便再利用。

对象池的方法,本质是用内存换CPU的策略。我们在UI界面中,时常会需要不断跳出不同的物体。这时实例化和销毁UI物体是逻辑中消耗最大的,物体被不断新建出来,又不断被销毁。CPU大部分浪费在了实例化和销毁上,渲染只占了很小一部分比重。这时运用对象池就能解决大部分浪费的问题,将要销毁的实例对象,放入对象池并移出屏幕或隐藏,当需要他们时再放出来重新初始化。

对象池是个用内存换CPU的方法,它用内存付出代价来换取CPU的效率。不过使用的不恰当的话也会引起不少内存问题的,因此对象池最好是要用在重复利用率高的对象上。这里总结了几条对象池运用的经验:

  1. 当程序中有重复实例化并不断销毁的对象时需要使用对象池进行优化。重复实例化和销毁操作会消耗大量CPU,在此类对象上使用对象池的优化效果极佳,相反如果在很少或较少做重复和销毁操作的对象上使用对象池,则会浪费内存,得不偿失。

  2. 每个需要使用对象池的对象都需要继承对象池的基类对象,这样在初始化时可以针对不同对象做重载,区别对待不同类型的对象。让不同对象的初始化方法根据各自的情况分别处理。

  3. 销毁操作时使用对象池接口进行销毁。在销毁物体时要使用对象池提供的销毁接口,让对象池来决定是真销毁,还是只是隐藏对象。

  4. 场景结束时要及时销毁整个对象池,避免无意义的内存驻留。当场景结束后,在对象池内的物体,已经不再适合新的场景了,或者说面临的环境情况与旧场景不同时所以需要及时清理对象池,把内存空出来留给新场景使用。

⑩ UI贴图设置的优化。

为什么要关心UI贴图设置?

首先我们得知道,Unity3D会重置全部贴图格式。可以理解为,无论你是JPG,PNG,PSD等,只要放在Unity3D中,Unity3D会读取图片内容,然后重新生成一个自己格式的图,在引擎中使用的是自己生成的图和格式。因此在Unity3D中使用图片其实不必关心用什么格式的图,只要你做好内容就可以了,比如JPG是没有alhpa通道的,通常做透贴都是PNG,这些图形或颜色内容上的东西是我们需要关心的,其他的交给Unity3D就可以。

Unity3D中图片的设置也有很多讲究,因为关系到重新生成的图片的格式,最终将决定加载入引擎的是什么样格式的图片,所以我们不得不要研究下贴图的设置问题。

这里以NGUI和UGUI为例分别讨论。NGUI的UI贴图使用传统的贴图方式,常使用 Editor GUI and Legacy GUI 方式,这种方式隐藏了一些设置参数,为了需要全面掌握所有对图片的功能才能做好优化工作,我们把Editor GUI and Legacy GUI 方式展开为 Advance 类型。

Advance 里面需要注意的有:

1.Alpha是否需要。如果需要透明通道,则要把透明通道点开,否则最好关闭。

2.是否需要进行2次方大小的大小纠正。Non Power of 2,对UI贴图来说基本上都是2次方大小的图集,使用对象大多是头像之类的Icon。

3.读写权限去除。常会默认勾选,导致内存量大增。此选项会使贴图在内存中存储两份,内存会有比不勾选大1倍。

4.Mipmap去除。Mipmap是对3D远近视觉的优化,Mipmap会在摄像头离物体远时因为不需要高清的图片而选择使用Mipmap生成的贴图小的模糊图像,从而减轻GPU压力。但是UI里没有远近之分,所以并不需要Mipmap这个选项,而且Mipmap会导致内存和磁盘空间加大,选择小尺寸图像会使得UI看起来模糊。

5.	压缩方式选择。

	压缩方式的选择,主要是为了降低内存消耗,降低CPU与GPU之间的带宽消耗,以及减少包体的大小,在清晰度足够的情况下,我们可以针对性的选择一些压缩方式来优化内存和包体。

	最高的色彩度是无压缩,其次是RGBA16色彩少了点且有透明通道,再次是RGB24没有alpha通道的全彩色,再RGB16色彩少了一半也没了透明通道,最后是算法级别的压缩,RGBA ECT2 8bits和RGBA PVRTC 4bits是带透明通道的压缩算法,RGB ECT2 4bits和RGB PVRTC 4bits,是不带透明通道的压缩算法。

	这样逐级下来,压缩的越来越厉害,同时画质就越来越差。前面有介绍过关于UI贴图Alpha分离的方法,这方法就是压缩的极致和平衡,既做到好画质又最大极限的压缩了图片。

UI的选项的优化,我们可以通过写脚本的方式,把放入UI的贴图自动设置我们规定好的图片选项,辅助我们更改UI贴图设置,省去不少二次检查时间。例如以下这段,就是利用Unity3D的 Editor API 来自动设置UGUI的精灵图片。

void Apply_ui_sprite()
{
	if(!UIAssetPost.IsInPath(assetImporter.assetPath, UI_Sprite_path))
	{
		return;
	}

	TextureImporter tex_importer = assetImporter as TextureImporter;

	if(tex_importer == null) return;

	tex_importer.textureType = TextureImporterType.Sprite;
	FileInfo file_info = new FileInfo(assetImporter.assetPath);
	string dir_name = file_info.Directory.Name;
	tex_importer.spritePackingTag = dir_name;
	tex_importer.alphaIsTransparency = true;
	tex_importer.mipmapEnabled = false;
	tex_importer.wrapMode = TextureWrapMode.Clamp;
	tex_importer.isReadable = false;

	SetCompress(tex_importer);
}

要完全省去检查时间是不可能的,在实际项目中我们也不得不从头检查一遍所有贴图的设置情况,来确认是否是我们所期望的设置,不过工作量比以前少了很多,可靠性也增强了许多。

⑪ 内存泄露。

内存泄露是个敏感的词汇,在各大项目中,都会对内存泄露进行检验,一旦涉及到内存泄露所有内存,大家都会格外重视。其实在整个项目各个地方都有可能,我把内存泄露放在UI章节里是因为UI逻辑占去了游戏逻辑中比较大的一部分,所以内存泄露在UI逻辑中也是重灾区。

什么是内存泄露?

内存泄漏,简单来说就是由程序向系统申请内存,使用完毕后并没有将内存还给系统导致内存驻留或者浪费的过程。系统本身的内存是有限的,如果内存泄漏一直被调用,就会耗尽系统内存,最终导致奔溃。就像你一直向银行借钱不还一样,银行虽然一直容忍你的不道德行为但银行也是有底线的,最终会切断你的资金来源,一下子收回全部资金,到那时候你就崩溃了。计算机系统也是一样,他不会无限制的让程序申请到内存,当申请内存影响到系统运行时就会停止。

为什么会内存泄露?

游戏项目内存泄漏简单分两种,一种是程序上的内存泄漏,另一种是资源上的内存泄漏。虽然资源上的内存泄漏也跟程序有关,但跟程序上的自身内存块的内存泄漏相比,它主要是因为资源在使用后或不再使用时没有卸载的原因导致的。

程序上的内存泄漏主要是因为Mono的垃圾回收机制并没有识别“垃圾”的原因造成的。为什么会没有识别呢,根源还是在编程时的疏忽,在编程时一些不好的习惯,错误的想法,不清晰的逻辑,导致申请的内存或指向内存的引用,没有有效的释放,导致垃圾回收机制没能识别出释放此块内存的理由。 而资源上的内存泄漏,主要是因为人为的申请资源使用完毕后并没有释放,导致资源内存长期驻留在内存里。

程序上的内存泄漏,需要借助些工具,也可以从框架的角度建立有效的指针计数器来排查,可以说是属于预防型为主排查为辅。而资源上的内存泄漏,就完全是属于人为的过错或疏忽,关键是容易排查。资源内存泄漏,主要排查的内容就是,资源在不需要使用时,却仍然驻留在内存里的情况。

什么是垃圾回收机制?

Unity3D是使用基于Mono的C#作为脚本语言,它是基于Garbage Collection(简称GC)机制的内存托管语言。那么既然是内存托管,为什么还会存在内存泄漏呢?GC本身并不是万能的,GC能做的是通过一定的算法找到“垃圾”,并且自动将“垃圾”占用的内存回收,并且每次运行垃圾回收需会消耗一定量的CPU。

找“垃圾”的算法有两种,一种是用引用计数的方式,另一种是跟踪收集的方式。

引用计数,简单的说,就是当被分配的内存块地址赋值给引用时,增加计数1,相反当引用清除内存块地址时,减少计数1。当引用计数变为0时,表明没有人再需要此内存块了,所以可以把内存块归还给系统,此时这个内存块就是垃圾回收机制要找的“垃圾”。

另一个是跟踪收集,简单的说就是遍历一遍引用内存块地址的根变量,以及与之相关联的变量,对内存资源没有引用的内存块进行标记,标记为“垃圾”,在回收时还给系统。

为什么有了这么智能的垃圾回收机制,还会有内存泄漏呢?

首先引用计数的方式它很难解决对象之间相互循环引用的问题,导致引用计数时无法被释放。现代计算机语言中已经很少使用这种方式去做了,但在逻辑组件上或业务框架上有很多,因为这样做简单方便,比如C++智能指针就是这种方式。

比如 A类中有B类的实例变量,而B类中有A类的实例变量,现在有A,B两个实例,这时A的引用计数为2,B的引用计数也为2,当B变量被置NULL后,B的引用计数只减少了1,因为在A中还有一个,因此,只有当A的实例变量也被销毁时,B实例的引用计数才真正变为0。也就是说B类变量是否销毁的命运同时取决于A和B。

其次跟踪收集并不是万能的,很多时候会有环状的引用链存在,以及包括在编码时错误操作的泄漏,这些编码的泄漏问题在实际编码过程中是非常隐蔽且难以查找的,不少的泄露问题需要人工去检查引用变量是否释放引用,工作量比较巨大且繁琐,特别是程序侧的内存泄漏尤其难找。

比如常常会有 A类中有B,B类中有C,C类中有D,D类中有A。这种环装的链路,导致跟踪收集比较困难,当C类实体设置为NULL后,B中依然有C,B设置为NULL后,A中依然有B,进而导致B中依然有C。这种就像‘命运共同体’似的环状引用链,导致跟踪收集的垃圾回收机制在被调用时的效果并不明显。

因此垃圾回收并不是万能的,即使有垃圾回收也一样会存在内存泄露问题。如果想避免内存泄露,至少在建立框架或架构时就应该对此有足够的考虑,对基础组件应该更加严谨,在这基础之上再对编程规范进行严格的把控,即使是这样在排查时也要保持足够的耐心和细心。

资源侧的内存泄漏对游戏项目来说量级上比较大,大到几百MB甚至几个G,不过万幸的是相对程序侧来说资源侧的内存泄漏查找相对比较容易。下面介绍一些关于Unity3D内存运作,泄漏排查,预防泄漏的经验,来帮助大家在实际项目中针对内存泄漏理解,排查,和预防。

Unity3D内存是如何运作的?

Unity3D中C#起初使用Mono做为虚拟机(VM,和Java一样都是虚拟机托管)运行在各大平台上,也就是说C#代码只要一份就够了(准确的来说应该是IL即中间语言是同一份的),各大平台的Mono需要各自制作一份来应对各系统的执行接口,简单说也就是说Unity3D通过Mono来跨平台解析并运行C#代码,在Android系统上App的lib目录下存在的libmono.so文件,就是Mono在Android系统上的实现。

C#代码通过Mono这个虚拟机解析和执行,当需要的内存自然由Mono来进行分配管理。只是Mono的堆内存大小在运行时是只会增加而不会减少的。可以将Mono内存堆理解为一个内存池,每次C#向Mono内存的申请堆内存,都会在池内进行分配,释放的时候也是归还给池里去,而不是释放归还给操作系统。假如某次分配,发现池里的内存不够了,则会对池进行扩建,即向操作系统申请更多的内存扩大池以满足该次以及后面更多的内存分配。需要注意的是,每次对池的扩建都是一次较大的内存分配,每次扩建都会将池扩大6-10MB左右。

分配在Mono堆内存上的都是程序上需要的内存块,例如静态实例以及这些实例中的变量和数组、类定义数据、虚函数表,函数和临时变量更多得则使用栈来存取。Unity3D的资源则不同,当它被读取进来时是通过Unity3D的C++层,分配在Native堆内存上的那部分内存,与Mono堆内存是分开来管理的。

Mono通过垃圾回收机制(Garbage Collect,简称GC)对内存进行回收。前面我们说了当Mono需要分配内存时,会先查看空闲内存是否足够,如果足够的话则直接在空闲内存中分配,否则Mono会扩容,在扩容之前Mono会进行一次垃圾回收(GC)以释放更多的空闲内存,如果GC之后仍然没有足够的空闲内存,这时Mono才会向操作系统申请内存扩充堆内存。

除了空闲内存不足时Mono会自动调用GC外,我们也可以在代码中主动调用GC.Collect()来手动进行GC。不过问题是GC本身是个比较消耗CPU计算量的过程,不仅如此,由于GC会暂停那些需要Mono内存分配的线程(C#代码创建的线程和主线程),因此无论是否在主线程中调用GC都会导致游戏一定程度的卡顿,需要谨慎调用。

由于各种原因Unity3D后来不再完全依靠Mono了,而另寻了一个解决方案那就是IL2CPP,Unity3D将C#翻译成IL中间语言后再翻译成C++以解决所有问题。那么翻译成C++语言内存就不托管了吗?不是的。内存依然托管,只是这次由C++编写VM来接管内存,不过这个VM只是内存托管而已,并不解析和执行任何代码,它只是个管理器。

IL2CPP与Mono的区别在什么地方呢?区别在于Mono只将C#翻译为IL中间语言,并把中间语言交给VM去解析和执行,VM的工作既要解析又要执行,这样的话Mono要针对不同平台执行IL程序就需要为每个平台定制一个单独的VM。IL2CPP则是把C#代码翻译为IL中间语言后又再继续翻译为C++代码,对于不同平台来说每次翻译的C++代码必须针对当前平台的API做出些变化,也就是说IL2CPP在不同平台下需要对不同平台的接口进行改造。与Mono针对不同平台拥有不同的VM相比,IL2CPP只是在翻译时改造了不同平台的接口代码,显而易见IL2CPP对程序员来说维护的工作量减少了很多。不仅仅只是程序员维护的工作量少了,在IL2CPP翻译完成后的编译时,使用的是平台本身都各自拥有的C++编译器,用各自平台的C++编译器进行编译后就可以直接执行编译内容无需再通过VM,因此IL2CPP相对Mono的效率会更高一些。

资源内存泄漏

资源内存泄漏就是Native内存泄漏,与程序内存泄漏不一样,资源内存泄漏都是因为加载后没有释放造成的,也有在逻辑中拷贝了一份资源但没有在使用完释放的情况。基本上都是疏忽大意造成的,除非完全不知道需要卸载。

Unity3D的 MemoryProfiler 是个查内存泄漏的利器,他是由官方开发的专门用于Unity3D 5.x以上版本的内存快照工具。

MemoryProfiler

它可以快照内存的信息,并可以以文件形式保存和加载,这样我们可以在不同的节点进行内存快照,再经过两者的对比找出内存泄漏的资源,定位泄漏的资源文件,再根据此文件从程序逻辑中寻找泄漏点。

比较遗憾的是,MemoryProfiler并没有提供两次(或多次)内存快照的比较功能。所以更多的是需要人工去核实。

MemoryProfiler

从图中可以看出整体上的内存占用规模,包括,音效,字体,Assetbundle,动画,模型,粒子,贴图,Shader等。也可以点击整个模块细致的检查,模块中的各个点位资源的信息。比如我选中的Texture模块中的一个贴图,就展示出此贴图的信息包括:名字、图案、材质球、以及关联了哪些脚本等。

我们也可以借助Unity3D自带的Memory Profiler,这是个比较老的工具。它会记录CPU使用情况,精准定位CPU耗时节点,也可以记录Mono堆内存和资源内存的使用情况,并且详细记录下了内存中资源的详细情况。

MemoryProfiler

当我们检查到当前场景,不需要用到的资源时,这个资源就是泄漏的点。我们可以顺藤摸瓜根据Profiler提供的信息,在代码中寻找线索。寻找的过程还是很枯燥的,这是肯定的,但当我们寻找出一个资源泄漏点时,可以举一反三的找出更多的资源泄漏点。不过在Editor下编辑场景时Editor本身会加载些资源来达到可视化的目的,这导致在Editor下的使用Memory Profiler时不太准确因为前面你已经查看过这个资源,这个资源已经被加载到内存里了,所以最好在使用 Memory Profiler 前重启Unity3D查看和不编辑任何资源立刻调试。

这里介绍两种寻找资源内存泄漏的技巧:
	1) 通过资源名来识别。

		即在美术资源(如贴图、材质)命名的时候,就将其所属的游戏状态放在文件名中,如某贴图叫做bg.png,在房间中使用,则修改为Room_bg.png。

		这样在Profile工具里一坨内存资源里面,混入了一个Room大头的资源,可以很容易地识别出来,也方便利用程序来识别。

		这个方法但也不是万能的,因为在项目制作过程当中,一张图需要被用到各个场景中去,很可能也不只一两个,有时甚至四五个场景中都会用,只用前缀来代替使用场景的指定,很多时候也会造成另一种误区。

		甚至由于项目的复杂度扩展到一定程度,包括人员更替,在检查资源泄漏时,用前缀来判断使用场景点不太靠谱,因为你根本就不知道这张图在哪使用了。所以说技巧只能辅助你,并不是说一定能有效。

	2) 我们可以通过Unity提供的接口Resources.FindObjectsOfTypeAll()进行资源的Dump.

		可以根据需求Dump贴图、材质、模型或其他资源类型,只需要将Type作为参数传入即可。

		Dump成功之后我们将这些信息结果保存成一份文本文件,这样可以用对比工具对多次Dump之后的结果进行比较,找到新增的资源,那么这些资源就是潜在的泄漏对象,需要重点追查。

在平时项目中,我们找到这些泄漏的资源的方法,最直观的方法,就是在每次游戏状态切换的时候,做一次内存采样,并且将内存中的资源一一点开查看,判断它是否是当前游戏状态真正需要的。这种方法最大的问题,就是耗时耗力,资源数量太多眼睛容易看花看漏。

现在市面上比较有名的Unity3D项目优化工具UWA的GOT,它会逐帧记录资源内存和Mono堆内存的使用情况,并且可以在快照之间进行相互比较,得出新增或减少的资源名称。有了内存快照之间的对比就可以大大加快了我们查找内存泄漏的问题。

另外在Github上有一个在Editor下可以对内存快照进行比较的工具。

内存快照进行比较的工具

它是将Unity Memory Profiler改造后,加了快照比较,搜索,内存分配跟踪的功能,在原来Unity Memory Profiler的快照功能上提升了不少实用性。我们可以用这个工具来方便得快照内存以及比较内存的使用情况,借此来查找内存泄漏情况,确实是一个内存泄漏查找利器。

排查还是后置的方法,在编写程序和架构,特别是基础组件(即内存管理器,资源管理器)时,我们应该强化生命周期的理念,无论是程序内存还是资源内存,都应该有它存在的生命周期,在生命周期结束后就应该及时被释放。具体我们将在“资源加载与释放”章节中详细讲解。

参考文献:

	深入浅出再谈Unity内存泄漏 作者:Arthuryu
· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第四章,UI(七) - UI优化(三)

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)