Unity3D高级编程主程手记 学习笔记四:用户界面

发布时间 2023-07-03 12:31:33作者: CatSevenMillion

   用户界面(UI)是游戏项目中重要的组成部分。面对一个从零开始的项目,首先要选择选用哪个UI系统作为主框架。主流公司里最常用的UI系统有:NGUI,UGUI,除此之外还有部分公司使用FairyGUI,DoozyUI。

UGUI的运行原理

  UGUI是在3D网格下构建起来的UI系统,它的每一个可显示元素都是通过3D模型网格的形式构建起来的。使用UGUI构建一个图元可以看成是构建了一个3D模型,用一个网格绑定了一个材质球,材质球里存放要显示的图片。到这里你也许会发现一个问题,要是有成千上万个图,就会有成千上万个材质球。如果CPU对每个材质球和网格进行渲染的话,那么CPU的负担会过大。

  UGUI系统对这种情况进行了优化,它将一部分相同类型的图片集合到一张图,然后拥有相同图片,相同着色器的材质球指向同一个材质球,并且把分散的模型网络合并。但UGUI并不是将所有的网络和材质球都合并成一个,不然模型层级就会有问题,它只是把相同层级的元素,以及相同层级上拥有相同材质球参数的进行合并处理。当移动了网格中任意元素后,或者销毁任意元素,原有的网格就不符合要求,UGUI会重新构建一个新的网络。UGUI系统构建完成,主要的性能差异就在这里。我们要想办法合并更多的元素,减少重构网络次数。

快速构建一个简单易用的UI框架

1.管理类

  画面通常是由多个界面组成的,对UI进行管理的基本功能是生成、展示、销毁、查找。如果我们对每个UI分别进行管理,会比较麻烦,而且维护工作量也会很多,所以我们需要一个单例来管理所有的UI,我们可以把他命名为UIManager。

  UIManager的作用有哪些呢?它需要创建UI,查找如今存在的某一个UI,销毁某一个特定的UI,并且可以完成UI的统一接口和调配工作。不仅如此,一些常用的变量也存储在这里,例如屏幕适配大小,只有单个个体的实例,例如Camera。

public class ScreenManager : CSingleton<ScreenManager>
{
    protected Transform _transform = null;
    private Dictionary<string, UIScreenBase> _DicScreens = new Dictionary<string, UIScreenBase>();
    //关闭所有界面
    public void CloseAll()
    {
        
    }
    //UI是否打开
    public void IsShow(string screenID)
    {
        
    }
    //关闭界面
    public void CloseScreen(UIScreenBase screen)
    {
        
    }
    //创建所有界面
    public T CreateMenu<T>() where T : UIScreenBase
    {
        
    }
    //找出某个页面
    public T FindMenu<T>() where T : UIScreenBase
    {
        
    }
}

2.基类

  对于游戏内的界面,通常都有一些共性,比如它们都要进行初始化,都要展示接口,都可以关闭。这些共性为他们产生了统一的接口,例如Init,Open,Close。继承基类可以极大减少代码耦合性,并方便管理。我们可以把基类名字成为UIScreenBase,使每个UI都继承它。

public abstract class UIScreenBase : MonoBehaviour
{
    protected bool mInitialized = false;
    protected UIState mState = new UIState.None;
    public UIState State{get{return mState}};
    public delegate void OnScreenHandlerEventHandler(UIScreenBase screen);
    public event OnScreenHandlerEventHandler onCloseScreen;
    //Iniialize
    protected virtual void Init()
    {
        mInitialized = true;
    }
    public virtual void Open()
    {
        
    }
    public void Close()
    {
        
    }
}

3.输入时间响应机制

  在UI中输入事件的响应机制比较重要,好的输入事件响应机制能提高效率,让程序编写逻辑更加舒服。

  建立Unity的UGUI输入事件响应机制通常由两种方法:一种是继承型。继承型指事件先相应到基类,再由基类反馈到父类,由父类做处理。这样基类既可以得到对输入事件的响应,又可以自行修改需要的逻辑;

  另一种是绑定型是指在对输入事件响应之前,为UI元素编写一个事件响应的组件。编写一个绑定型事件类UIEvent,当某一个UI元素需要输入事件回调时,对这个物体绑定一个UIEvent,并且对UIEvent里需要的相关响应事件进行赋值或注册操作函数。当输入事件响应时,由UIEvent来区分输入的是什么类型的事件,再分别调用响应的具体函数。

  一般,绑定型更适合现有的UI组件的输入事件封装处理。例如,在UI初始化中,为需要响应的输入事件绑定一个事件处理类,然后对句柄进行赋值。

4.自定义组件

   在实际的游戏开发中,自定义组件也是必不可少的,自定义组件方便程序的优化以及对UI的管理。以下是一些作者常用的组件

  (1)UI动画组件

    一个·UI动画组件首先要依赖Animator组件(使用[RequireComponent(typeof(Animator))])。其次它需要有Play接口来播放指定的动画。此外还需要在Play函数里面添加一个AutoPlay参数来实现非程序调用的功能。这样美术人员就可以很方便的使用我们的组件,实现对动画的播放管理了。

  (2)播放音效组件

    此组件功能很简单,就是判断当输入事件触发了Click事件时发送绑定的声音文件即可。但是很多项目使用的音频并不是原生的Untiy音效系统,所以得自己为这些系统定制组件。

  (3)UI元素跟随3D物体移动

    项目中很多时候都需要使用UI元素来跟随3D物体,比如游戏中的血条、场景中的标志等,因此这个组件是必备的。实现也很简单,可通过不断计算3D物体在屏幕中的位置来确定UI位置,当前后位置不同时再进行更新以避免不必要的移动。

  (4)滑动页组件

    滑动的菜单栏类似于游戏中的背包界面,如果有几百个元素同时生成或同时移动,效率就会非常低。因为UI系统中当元素发生移动时会重新构建网络,每一次的滑动都会引起不小的CPU消耗。因此用一个自定义的无限滑动页面组件来代替原有的,让CPU花最少得代价来运行这个滑动页面是非常有必要的。

    这个组件的关键点是:我们需要减少UI的数量,最好减少到与屏幕上显示的数量差不多。就比如,当UI元素滚动时,一部分元素就会被挡住,不再需要它们,这是可以对这些元素再利用。既当上面有一行元素被遮挡时可以被再利用时,就把它们移到下面去。

  除此作者介绍的之外,还有很多自定义组件,使用自定义组件的目的就是为了让项目的运行效率更加的高效,也可以让程序员在编写UI界面时更加的快捷。

UI优化

  UI可以有很多优化的方法,包括:动静分离、拆分UI、预加载、Alpha分离、字体拆分、滑屏优化、网络重构优化、UI展示与关闭优化、对象池运用、贴图设置的优化、内存泄漏、针对高低端机型优化、图集拼接的优化、UI业务逻辑中GC的优化等。

  1.UI动静分离

    什么是动静分离?动是指那些会随着游戏运行时,移动、旋转、缩放、更换贴图的UI元素。静则是那些一直不改变的UI元素。

    为什么要分离?因为不管是UGUI还是NGUI,当移动UI时会在Canvas中进行重构(后续再专门学习),都是通过网格构建UI画面的,在构建后都执行了合并网格的操作(不合并的话渲染队列会阻塞,增加Drawcall),合并网格是有益的,但是无论哪一个UI元素的移动都会导致网格重新合并。这就大大降低了效率,把好事变成坏事。

·  · 怎么分离?UGUI中有Canvas作为画布,NGUI中有UIPanel作为画布。分离的要素就是把静止不动的UI元素停留在同一个画板上,把运动的元素绘制在另一个画板上。这样当UI运动时只会合并运动的Canvas。静止的Canvas不会重新合并不会有额外的消耗。CPU在重绘和合并时的消耗就大大降低了。

  2.拆分过重的UI

    为什么要拆分过重的UI?首先项目制作是一个长期的过程,在这个过程中UI系统肯定也会不断地扩大,很多时候项目总会莫名奇妙的“本来好好的,突然就变卡了”。随着项目的推进,经手UI的人越来越多,添加的功能也越来越多,有时候一个Prefab中装在这2-3个界面。它们在展示一个界面时隐藏了其他界面,这样的操作会导致UI里的东西过多,在实例化和初始化时,消耗的CPU也会很大,因此我们要想办法拆分这些过重的UI。

    如何拆分?把隐藏的UI拆分出来,使其成为独立运行的界面,只在需要展示时才调用实例化。如果在拆分后界面内容依然太多则需要进行二次拆分。既把二次显示的内容进一步拆分。二次显示内容指:一个界面打开时会显示一些内容(例如动画),之后或者点击后才能看到另外一些内容,或者当点击按钮时才出现某些图标和动效,那么这些内容就可以视为二次显示。可以考虑为其拆分出来设置为一个预制体,当需要时才进行加载。

  3.UI预加载

    为什么要UI预加载?当UI实例化时,需要将Prefab实例化到场景中,期间还会有很多的网格合并、组件的初始化、渲染的初始化、图片的加载、界面的逻辑的初始化等程序调用,会消耗很多的CPU。这导致我们在打开某个界面时,会频繁出现卡顿现象。这其实就是CPU在那个点消耗过重的表现。

    上面说的拆分UI是一种方法只适用于一些大型的界面,对于一些小、难以拆分的界面,则可以使用预加载的方式,在游戏开始前或者某个场景前预先加载一些UI,让实例化和初始化的消耗在游戏前均摊到等待的时间线上。

    如何进行UI预加载?最朴素的想法就是在游戏开始前加载UI资源但是不进行实例化,但是就算是这样打开界面时CPU的消耗还是很严重的,所以我们需要把实例化和初始化也放在游戏开始前。只是在实例化和初始化后对UI进行隐藏,当需要的时候再进行显示,而不是重新实例化,在关闭时也同样只是隐藏而不是销毁。这样以来所有的消耗都只是打开和隐藏时所消耗的少量CPU了。

    如今很多项目都需要热更新,使用的是AB包来加载资源。也有些项目使用自带的Resource来加载资源。对于使用Resource时,可以使用自带的PreLoad功能,它位于Untiy编辑器设置中,可以把需要预加载的资源都放在这里面。但是所有的预加载都会引出新问题:CPU集中消耗会带来卡顿问题。原因是预加载并没有削减CPU的运行总量。如果把所有的预加载都集中的放在某一个位置,比如游戏开始前或者是进度条的某个位置,同样会产生强烈的卡顿感,因此要尽量的分散CPU的消耗。

  4.UI图集Alpha分离

    为什么要Alpha分离?压缩UI图集可以减少App包的大小,是减少内存使用量的有效方法。需要I/O的消耗少了,内存的申请少了,可以减少CPU的消耗。但对图集压缩后会降低图片效果,出现模糊、锯齿、线条等问题,这是因为使用压缩模式ECT或PVRTC时将透明通道一并压缩进去了,导致渲染的扭曲,因此需要将透明通道Alpha分离出来单独压缩。这样既可以缩小内存,又不会使图像太失真。

    怎么实现Alpha分离?由于UGUI是内部集成的,alpha的分离在Unity3D中已经完成了,这里就不介绍。因此主要讲一下NGUI中的方案。

    在NGUI中,Alpha分离的原理是将原本以ETC方式压缩的一张图改为压缩一张没有Alpha的图和一张有Alpha的图。RGB888的PNG图没有Alpha,所有的Alpha通道都在Alpha8的PNG图里。也可以使用程序分离的方式,把原图中的颜色提取出来 放入一张新的图片中,而Alpha部分提取出来放入另一张图中。然后,修改NGUI的原始着色器,将原来只绑定一张主图的着色器改为需要绑定一张主图和一张Alpha图的着色器。这里需要修改4个着色器:

Unity - Transparent Colored.shader
Unity - Transparent Colored 1.shader
Unity - Transparent Colored 2.shader
Unity - Transparent Colored 3.shader

    修改的内容就是加入 _AlphaTex("Alpha(A)",2D)="black"{} 变量,用来绑定Alpha的图。接着,在frag()函数中,将Alpha与主图Alpha操作的内容替换成Alpha图中的Alpha值。用Alpha图中的Alpha值替代原来主图Alpha部分,而主图仍然承担主要色彩内容。源码如下: 

//原图用一张图承担颜色和透明通道的情况
fixed4 col = tex2D(_MainTex, i.texcoord) * i.color;
return col
//用两张图的情况
fixed4 col = tex2D(_MainTex, i.texcoord);
fixed4 result = texcol;
result.a = tex2D(_AlphaTex,i.texcoord).r*i.color.a;

    做好上述操作后,选中一个创建好的图集prefeb,会发现Inspector窗口下的预览窗口以及Sprite选择窗口看到的Sprite都没有Alpha通道,因为在Editor下展示模式仍然使用的是原图,即使使用的是两个通道,因此需要修改这些编辑器上的NGUI工具。解决方案是在编辑器模式下动态生成一个rgb32的texture将其替换。需要修改NGUI编辑类下几个文件:

UIAltas.cs
UIAtlasInspector.cs
SpriteSelector.cs
NGUITools.cs
UISpriteInspector.cs

    修改以上类后,绘图时,启用的是用RGB888和Alpha合成的临时图。

    总结:1、将原图生成两张图,一张只带颜色,一张只带Alpha通道。2、将着色器的Alpha来源修改为新的Alpha图。3、对于着色器修改导致的编辑器显示问题,需要在编辑器部分生成临时图来提替换原来的图

  5.UI字体拆分

    为什么要UI字体拆分?项目中字体通常会占很大的空间,如果多个不同的 字体一同展示在屏幕上会消耗较大的内存。我们需要更高的性能效率,拆分字体可以让字体的加载速度加快,使得场景加载速度加快。

    如何拆分UI字体?解决方案是把字体中常用字拆分出来,另外生成一个字体文件,让字体文件边小,消耗内存减少。实际上还可以针对不同的语言进行拆分,如果将所有国家的语言都放进去,字体文件就会很大,加载时也就会更加消耗CPU和内存。我们可以将不同种语言拆分成独立的字体包,每个语种版本只加载的自己的字体

  6.ScrollView优化

    ScrollView常用在类似背包的界面中,通常背包界面中有大量的元素存在,它们在窗口中不停地滚动会导致合批和渲染裁剪,在生成和滑动UI元素时,会消耗大量的CPU来重构网格,进而导致游戏卡顿。

    要优化这种情况,则必须对滑屏菜单组件进行改造,将原来策略中所有元素都将实例化改为只需要实例化显示的实例数量。再拖拽时判断是否有UI元素被移出画面,再将这些被移出并看不到的元素进行重复利用,将它们填补到需要显示的位置上去,再对该单位元素的属性重新设置,让重复利用的元素更新为在该位置需要显示的元素的样子

  7.网格重构优化

    首先解释一下,为什么UGUI中图元素在改变颜色或Alpha后会导致网格重构。UGUI系统的合并机制是,只有将拥有相同材质球的网格合并在一起,才能达到最佳效果。一个材质球对应一个图集,只有相同图集内的图片才需要合并在一起

    在UGUI系统中,当元素需要改变颜色时,是通过改变顶点的颜色来实现的,既改变当前元素的顶点颜色,然后将它们合并到整块网格中。因为不能直接从原来合并好的网格上找到当前的顶点位置,所以需要一次性合并重构网格。

    而在UI动画中,每一帧都会改变UGUI的颜色和Alpha。自然的,UGUI的每一帧里面都会对网格进行重构,这样做消耗了大量的CPU运算,通常这会使得UI在运行动画时效率低下,即使是使用了动静分离也无济于事!

    如何优化?我们可以自己建一个材质球,提前告诉UGUI:我们使用自己的特殊材质球进行渲染。当颜色动画对颜色和Alpha更改时,我们直接基于自定义的材质球改变颜色和Alpha。这样UGUI就不需要重构网络了,因为把渲染工作交给了新的材质球,而新的材质球的颜色和Alpha都是由材质球本身的属性控制的,并不是通过UGUI设置顶点颜色来实现的,从而实现优化的效果。

    具体步骤:

    首先把UGUI的着色器下载下来。

    然后,建立自己的材质球。

    再次,把这个材质球放入Image中的Material中,与Image进行绑定。

    接着,编写一个类ImageColor继承MonoBehavior,新建一个public Color mColor的变量,这个类只干一件事,在Update里面一直判断是否需要改变颜色,如果颜色改变,就把颜色的值赋给Material。

    最后,将动画文件中颜色部分从更改Image的颜色变量改为更改ImageColor的颜色变量。

    值得注意的是,因为启用了自定义的材质球,每个材质球都会单独增加一次drawcall,这会导致drawcall的增加。并且,当Alpha不是1时会有半透明材质球重叠渲染混乱的问题,而该问题归根究底是渲染时无法写入深度数据的问题,无法彻底解决。

  8.UI的展示与关闭优化

    当使用SetActive时,虽然内存没有变化,但是网格会进行重构和组件激活会有大量的CPU消耗。所以UI的展示和关闭我们可以使用移除屏幕外来进行代替

  9.对象池的运用

    对象池可以将废弃的对象再利用,实质是内存重复利用的性能优化方案。这可以省去很多的内存碎片问题以及GC问题,还能节省实例化CPU的消耗。其中实例化消耗包括模型文件读取,贴图文件读取,GameObject实例化,程序逻辑初始化,内存分配等。

    网上的对象池代码有很多,下面说说运用对象池的经验。

    1)当程序中有重复实例化并不断销毁的对象需要使用对象池进行优化。重复实例化和销毁操作会消耗大量CPU,在此对象上使用对象池优化效果更佳。

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

    3)销毁操作时使用对象池接口进行回收,切记不能重复回收,也不要错误地放弃回收。在销毁物体时要使用对象池提供的接口,以便让对象池集中存储对象。

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

 

  10.内存泄漏

    什么是内存泄漏?内存泄漏,简单来说就是程序向系统申请内存之后,使用完毕后并没有将内存还给系统而导致内存驻留或者浪费的过程。系统本身的内存是有限的,如果内存泄漏一直被调用,就会耗尽内存,最终导致系统崩溃。计算机也不会无限制的让程序申请内存,当申请内存影响到系统运行时就会停止。

    为什么会有内存泄漏?游戏中的内存泄漏有两种,一种是程序上的内存泄漏,另一种是资源上的内存泄漏。资源上的内存泄漏主要是由于使用资源后没有及时卸载导致的程序上的内存泄漏主要是由于Mono的垃圾回收机制并没有识别“垃圾”导致的。为什么会没有识别的?根源还是编程时的疏忽,不好的习惯,错误的想法以及不清晰的逻辑,引用没有及时释放都会导致这个问题。

    程序上的内存泄漏主要还是以预防为主,因为排查工作十分的困难,不仅需要借助一些工具,还需要从架构的角度建立有效的指针计数器。资源上的内存泄漏就容易排查得多,主要是排查资源在不需要使用时是否依然会停驻在内存中。

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

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

    引用计数,简单来说,就是当被分配的内存块地址赋值给引用时,增加引用计数1,相反引用清除内存块地址时,减少引用计数1。引用计数变为0就表明不需要此内存块了,所以此时这个内存块就是“垃圾”。

    跟踪搜索,则是遍历引用内存块地址的根变量,以及与之相关联的变量,对内存资源没有引用的内存块进行标记,在回收时还给系统。

    为什么使用这两个机制后,Unity依然会有内存泄漏的问题呢?

    首先,引用计数无法解决对象之间的循环引用的问题,跟踪搜索也会遇到环状引用链。对于A类中有B,B类中有C,C类中有D,D类中有A的这种环状链路。C实体设置为Null后,B中依然有C,B设置为Null后,A中依然有B。。。这会导致跟踪搜索收集的垃圾在调用时效果不明显。

    Unity的内存是如何运行的?Unity在C#中起初是使用Mono作为虚拟机运行在各大平台上的,也就是说,C#代码只需要一份就够了,各大平台的Mono需要自己实现对应各系统的执行接口。

    C#的代码是通过Mono解析并执行的,所以内存部分也是由Mono来管理的。只是Mono的堆内存只会增加不会减少,可以将Mono的堆内存理解为一个内存池,每次C#向Mono内存申请堆内存都会在池内进行分配,释放内存后内存也会返回到堆内。如果内存不够的话堆内存就会进行扩充,每次会扩充6-10MB。

    分配在Mono堆内存上的内存块是程序需要使用的内存块,例如静态实例以及这些实例中的变量等。函数和临时变量是放在栈上来存取的。Unity3D中的资源不同,Unity3D的资源是通过Untiy的C++层读取的,即分配在Native堆内存上的那部分内存,这是与Mono的堆内存分开进行管理的。

    Mono通过GC机制来对内存进行回收,如果内存不够,Mono会进行扩容,在扩容前,Mono会进行一次垃圾回收,释放得到更多的空闲内存,如果操作之后仍然没有足够的空闲内存,这是Mono才向操作系统申请扩充堆内存。

    但是后来,Untiy不再完全依靠Mono了。而是使用IL2CPP算法,Unity3D将C#翻译成IL中间语言后再翻译成C++以解决问题。此时内存托管依然存在,不过是使用C++作为托管语言。两者区别是什么呢?Mono只将C#翻译成了IL中间语言,并把IL交给虚拟机去解析和执行,针对不同的平台需要使用不同的虚拟机。而IL2CPP是翻译成了C++,针对不同的平台使用的虚拟机是一样的,不一样的只是针对不同平台所使用的函数接口不同。这样使得平台可以使用各自C++编译器进行编译后就可以执行编译内容,而无需再通过VM处理,因此IL2CPP效率要更高一些。

    资源上的内存泄漏如何排查?对于资源内存泄漏排查比较容易,我们可以使用MemoryProfiler来进行排查。我们可以通过更改资源文件名,在Profiler中查看是否有资源留存,或者是使用Unity提供的Resources.FindObjectOfTypeAll()接口,从而检查内存泄漏情况,可以更具需求存储贴图、材质、模型或其他资源类型,只需要将Type作为参数即可。

  11.针对高低端机型优化

    为什么要区分高低端机型?显而易见的,玩家群体更多才是我们想要的,所以我们要区分高低端机型。

    如何处理高低端机型的画质问题?

    1)UI贴图质量区别对待 可以针对不同机型,采用两套UI贴图。NGUI和UGUI中都有接口可以实现。

    2)模型和特效资源区别对待 可以针对不同配置,将资源进行等级分配,特效是游戏项目中最常见,也是使用最频繁的。针对低端机可以使用低质量特效甚至关闭特效。

    3)阴影的使用情况区别对待 可以使用QualitySetting.shadowResolution来设置渲染质量,QualitySetting.shadows来设置有无和模式,QualitySetting.shadowProjection设置投影质量,QualitySetting.shadowDistance设置阴影显示距离,QualitySetting.shadowCascades设置接收灯光特效

     还可以关闭传统的阴影渲染选项,使用简单的底部黑色阴影面片来代替。或是使用静态的阴影烘焙将阴影固定。

    4)整体贴图渲染质量区别对待 

    UI贴图质量的区别对待?NGUI和UGUI的切换方式有所不同,NGUI是基于图集Atlas,而UGUI是基于Sprite2D,两个共同之处是都可以通过制作高清和标准两个Prefab来替换。

    生成高低清Prefab的步骤是:先把所有UI用到的图集、材质球都复制到一份固定的文件夹下,再复制一份Prefab存放在文件夹下面,并让Prefab里与图集有关变量都指向标清材质球或者标清图集,也可以是标清图。这样就有两个Prefab了。(可以通过编写工具脚本来完成)

    只需要再使用时进行Prefab的替换就行。

  12.UI图集拼接优化

    为什么要优化UI图集拼接的优化?没有优化过的UI图集项目,会浪费很多空间,包括硬盘空间和内存空间,同时也会降低CPU的工作效率。

    如何优化?

    1)充分利用图集空间 当大小图拼接时,尽量不要让图集空出太多碎片。通常是使用大图穿插小图,或者把大图单独使用图片进行渲染不加入图集。

    2)图集大小控制 如果不对图集大小加以控制就会形成2048*2048的图甚至是4096*4096的,这会导致游戏加载UI时卡顿,内存消耗也会加大。所以项目执行时需要规定图集的大小,并且在UI加载时只加载需要的图集,而不会加载不需要的图集,这样加载速度会更快。

    3)图集拼接归类 由上可知,规范图集分类也是重要的事情,我们可以将图集分为常用图集(各个场景都会使用的图集),功能性图集(比如大厅界面图集,背包界面图集,任务界面图集等),链接类图集(链接两种界面的图集,比如只在大厅和背包使用的)

    我们优化图集的目的就是,减小图集大小,减少图集数量,减少一次加载所需图集数量。

  13.GC的优化     

    首先来看看内存分配和申请系统缓存是如何影响耗时的:

    1)Unity内部有两个内存管理池:栈内存和堆内存。栈内存主要用来存储比较小和短暂的数据,堆内存主要用来存储比较大的和存储时间比较长的数据。

    2)Unity中的变量只会在堆或者栈内存中进行内存分配。

    3)只要变量处于激活状态,其占用的内存就会被标记为使用的状态,该部分内存则处于被分配的状态。

    4)一旦变量不再处于激活状态,其所占用的内存则不再需要,这部分内存就可以返回到内存池中。处于栈上的内存回收极其快速,处于堆上的垃圾并不是及时回收的,此时其对应的内存依然会标记为使用状态。

    5)GC主要是指堆内存的回收操作,系统会定时进行GC。

    6)Untiy中堆内存只会增加不会减少,不够用时会向系统申请更多。

    然而Unity的GC方式往往会产生大量的内存碎片,堆内存碎片化会导致游戏占用内存越来越大,另一个是GC会更容易被触发。同时,在游戏项目中,经常不断地调用GC接口,每次调用都会重新检查所有内存变量是否被激活,并且标记需要回收的内存块以便在后面回收,这样就在逻辑中产生了很多不必要的检查和不集中的销毁,导致内存的命中率下降,最终会浪费宝贵的CPU资源。

    三个主要引发GC的操作:

    1)在堆内存上进行内存分配,如果内存不够,就会触发GC操作来利用闲置内存

    2)自动的触发GC操作,不同的平台运行的频率不一样

    3)被强制执行的GC操作

    所以有三种方向可以用来降低GC的影响:

    1)减少GC的运行次数

    2)减少单次GC的运行时间

    3)将GC的运行时间延迟,避免在关键时候触发,比如可以在加载场景时调用GC。

    以下为常见的解决方案:

    1)缓存变量,达到重复使用的目的,避免重复的创建与销毁。

void OnTriggerEnter(Collider other){
    MeshRenderer meshRenderer = gameObject.GetComponent<MeshRenderer>();
    ExampleFunction(meshRenderer);    
}

如上述,可以优化成为:

private MeshRenderer mMeshRenderer;

void Start(){
    mMeshRenderer = gameObject.GetComponent<MeshRenderer>();
}

void OnTriggerEnter(Collider other){
    ExampleFunction(mMeshRenderer);
}

     2)减少逻辑调用。在需要反复调用的函数中执行堆内存分配时极其消耗性能的。

void Update(){
    ExampleFunction(transform.position.x);  
}

对于每帧都调用,可以使用计时器优化,可以优化为:

private float timeSinceLastCalled = 0;
private float delay =1f;
void Update(){
    timeSinceLastCalled +=Time.deltaTime;
    if(timeSinceLastCalled>delay){
        ExampleFunction();
        timeSinceLastCalled = 0f;
    }  
}

针对这种情况还可以修改为当物体坐标移动时才进行更改:

private float previousTransformPositionX;
void Update(){
    if(transform.position.x != previousTransformPositionX){
        ExampleFunction(transform.position.x);
        previousTransformPositionX = transform.position.x;
    }  
}

    3)清除链表。在进行链表分配时清除链表,而不是不停地创建新链表。

void Update(){
    List myList = new List();
    ExampleFunction(myList);    
}

修改为:

private List myList = new List();
void Update(){
    myList.Clear();
    ExampleFunction(myList);
}

    4)对象池,使用方法与之前说的一致。

    5)字符串使用注意。字符串是一个引用类型的变量,是存储在堆内存中的,并且C#中字符串是不可改变的,每当对字符串进行操作时,C#会重新创建一个新的字符串,并将原有的字符串废弃,这会造成多余的垃圾。所以优化的方向有:

      1.减少创建不必要的字符串

      2.减少不必要的字符串操作(将变动的字符串单独提出来操作,使得内存垃圾减少)。

      3.特殊情况下(需要频繁操作字符串情况下)使用StringBuffer来替换String

      4.移除游戏中的Debug.Log()等日志函数代码,因为Log中会创建大量的字符串并写数据进文件,会严重消耗CPU和内存。

      5.协程,yield在协程中不会分配堆内存,但是如果yield带返回值了就会产生不必要的垃圾

yield return 0;
// 改为
yield return null;

还有一种情况时,每次返回时都使用同一变量,避免产生多余垃圾。

while(isComplete){
    yield return new WaitForSeconds(1f);    
}
// 改为
WaitForSeconds delay = new WaitForSeconds(1f);
while(isComplete){
    yield return delay;    
}            

      6.Foreach循环,在早些版本中使用Foreach迭代时每次循环都会产生一个Object对象,导致性能损耗非常大。如今已经被修复。

                7.函数引用。函数的引用,都会被分配在堆上。特别是System.Action匿名函数在通常项目中会使用非常频繁。所以最好尽可能的减少使用。

      8.LING和常量表达式。这两个东西是使用装箱实现的,使用时最好进行性能测试,效果不好时最好替换掉。

      9.主动的GC操作。在场景切换时等不会影响玩家操作时,最好都调用System.GC.Collect()进行主动回收内存。