引文
事实上,优化的方法千千万。客观上本人实力有限,主观上肯定是说不完的。所以重点不是具体优化的Practice,而是要理解其目的和一些想法。
我们平常总说内存优化,我们是为了优化什么呢?主要有几个目的。
- 从空间角度来说,我们希望降低对用户内存空间的占用,希望使得内存空间的利用率高,这体现在如RSS,PSS的指标上
- 从时间角度来说,因为GC等操作会消耗时间,从而造成游戏体验上的问题,所以我们希望整体趋于稳定,不要频繁的申请和归还。这体现在内存占用曲线的变化率上。不要有频繁的抖动,波峰和波谷之前的差距不要太大。
我们所能够优化的范围有哪些呢?之前的章节有提到,Unity的内存域的划分。
- 有限度的优化Native域
- 优化托管域
- 优化脚本语言的内存。
我们能优化多少呢?性能优化这里其实是个沙漏,业务侧是底,很大,量大,但是可优化空间也大。往上到引擎层再到语言层,就越来越小了。当然越往上的优化越堆全局生效。
内存管理这里有几个常见的问题,这里也大概罗列一下:
- 内存泄漏:指程序中已动态分配的堆内存由于某种原因未被释放或无法释放,导致系统内存的浪费,最终可能导致程序运行速度减慢甚至系统崩溃。
- 僵尸内存:区分于内存泄漏,僵尸内存是指实际上这块内存就在内存池里,但是没人能用到,并不是泄露。典型的例子就是内存碎片化
- 内存抖动:频繁的申请和归还,造成内存占用的曲线频繁抖动
优化的方向私以为主要有这么几种:
- 主观优化:代码写得不优雅,逻辑性的优化,比如内存池优化频繁new,这个主要是程序上的。
- 客观优化:比如美术资源,有些地方可以去掉细节,但是品质不会太降低。比如本来每一帧做一个大的会带来GC的操作,现在每3帧做一次,可能效果差不多。总的来说,是一种权衡和让步。
- 技巧优化:比如内存共用,或者针对特定的策略去做特定的处理。基本是基于某一个原理去做某一个trick,也是最有意思的一部分。
具体Practice
回到Unity这边的具体Practice,我们具体实践中,大多数情况下需要优化的是两个内容。一个是在一个区域内的使用问题,一个是减少跨区域。按这个原则,我大概分了一下几个板块。Native域优化,托管域优化,脚本域优化。Native&托管域跨域优化,托管域&脚本域跨域优化。
Native域优化
正如我们上一章节所讲,其实我们对于Native内存能优化的不多。这边主要参考高川老师的演讲。
Scene
因为所有的实体也就是Gameobject上面的transform等,都会关联到C++层,也就是Native区域。所以场景中单位过多,会导致Native内存增多。值得注意的是层级过多,也会导致解析索引过程中的gc,所以不要太多层级
Audio
- DSP buffer:声音要播放的时候会给CPU发送一条指令,为了防止多条短音频多次发送带来消耗, 所以设置了一个缓冲区。这个缓冲区的大小太小和太大都会带来问题。
- 双声道:很多音频不需要双声道,那就不妨不要开双声道。
- 编码格式的优化:不同的平台有不同的声音格式的支持,IOS对MP3有硬件支持,Android暂时没有硬件支持。建议IOS适合使用ADPCM和MP3格式,Android适合使用Vorbis格式。
- 压缩音频,当然也要保证音频品质
- Load Type:决定声音在内存中的存在形态
- Decompress On Load:当clip被加载的时候解压,适用小音频(<200kb)
- Compressed In Memory:以压缩格式存在内存当中,适用中音频(>=200kb)
- Streaming:从磁盘读取声音数据:适用大型音频文件,比如BGM
- 有些时候不必要的音频可以直接卸载而不是调整音量为0
Code
代码的大小很多时候是会被我们忽略的,一来人不喜欢自己身上找问题,二来我们知道比如美术资源等,这玩意真的太小了。但是也有一些例外,比如模板泛型。我们知道C#不是最终的执行语言。 如果下面的模板反省我们有int,double,float三种,那么编译出来就会有27个
public class A<T,T1,T2>{...}
// 编译后
public class A1<int,int,int>{...}
public class A2<double,int,int>{...}
public class A3<float,int,int>{...}
Asset Bundle
- TypeTree:Unity迭代过程中不同版本的数据类型结构会发生改变,所以有了TypeTree。通过对TypeTree做反序列化,得到当时的版本类型数据。现在版本中没有就不处理,现在有新的就用默认。以此保证序列化不出错。保证版本一致的时候就可以关闭TypeTree。有三个好处,一是包体变小,二是内存减少,三是runtime变快(不用typetree序列化了)
- 压缩模式:推荐LZ4。能分块解压,压缩和读取速度都更快,就是包体会更大。
- 单ab的大小:不能太大,不能太小。主要是因为有ab头,这里可以说很深,所以简单略过。
Resources
构建的时候会生成一个红黑树,加载进内存,又占空间又影响启动速度。反正就两个字,别用。
Texture
- Upload Buffer:和声音一样,贴图也有个缓冲池。这个池的大小也要合适。
- Read/Write:没必要的话就关闭,在显存和内存里各一份。手机内存显存通用,会有两份。
- Mip Maps:例如UI这种,相机Z值无变化的就关掉。
- 压缩&删减:在不影响品质的情况下,进行压缩和删减。比如法线贴图,alpha(不透明就关掉)
- POT:纹理大小尽量为2^n
- 合并:用适当的方式合并成大图
Mesh
- Read/Write:和Texture一样,没必要的话就关闭
- Compression:这个虽然是压缩算法,但是只是减少占用硬盘的空间,Runtime会被解压,所以并不能减少内存。听说某些版本开了以后解压会更大
- 删减:按需要去关闭一些比如Rig,BlendShapes,法线切线等信息
托管域优化
托管域这里主要有两种类型的优化,一种是基于Unity自身特性的独有的优化,一种是共同的程序性的优化。
Destroy,Not null
显式的调用Destroy,而不是单纯的置空。
Class&Struct
比如Playable的底层就全用的struct来减少gc,当然我们也要视情况而定。
减少拆箱装箱操作
例如LINQ和常量表达式以装箱的方式实现,String.Format()也常常会产生装箱操作等。
闭包和匿名函数
所有的匿名函数和闭包在c#编IL代码时都会被new成一个Class(匿名class),所以在里面所有函数,变量以及new的东西,都是要占内存的。
协程
协程如果没被释放,里面的所有变量都会在内存中,所以用完了就去释放。
单例
慎用单例,少放东西,因为和协程一样会一直占用内存。
配置表
一方面,因为Unity的GC机制,为了避免碎片化,大的这些内存占用可以先加载。另一方面,如果数据量很大,就要分开来加载,不要一下子全量加载丢到内存。
对象池
老生常谈了,Pool in Pool,多级对象池。
适当缓存对象
一些比如GameObject,动画哈希之类的,可以释放缓存起来
一些Unity原生类的使用问题
以动画系统为例,controller一开始会把里面的clip全量加载进内存,而避免这个问题的Override也会产生和基础controller状态数正相关的GC。这些原生类需要注意。
比如SetActive的GC问题,可以直接移动到Canvas外面,而不是改变scale和隐现,这些操作都会导致消耗,而移动的话,一来gc小,二来重新显示的时候能省一部分cpu操作。
变量or属性(学的,没啥经验不知道有没有用)
通常我们为了封装安全性,开发时会选择使用属性(getter/setter),而属性本质上是函数的调用,前面提到调用函数时,会在堆栈上分配内存,因此调用属性也是如此。当调用多次时,花费在堆栈中的时间就会增加。当然了,一般来说问题不大,但是如果在使用频繁的循环体中使用属性,可能就需要针对性的优化。
我们可以通过宏命令进行处理,例如在开发时使用属性,发布版本时使用变量,如下:
#if DELELOPMENT_BUILD
int m_health;
public int health { get => m_health; }
#else
public int health;
#endif
脚本域优化
这里和托管域大同小异,区别在于,前者针对Unity特性,后者针对脚本语言和诸如虚拟机等加载脚本的机制的特性,比如Lua的DeepCopy,字符串拼接。而程序性的优化是共通的。这里不再详细列举,这部分其实主要是业务开发人员的基本功问题。总的来说,有几个比较通用的处理思路:
- 对象池处理频繁申请释放
- 选择合适的时机去归还,有些东西不要一直放在内存里面
- 有些垃圾的内存占用可以优化掉,不去申请
- 每个语言的各自"feature"
Native&托管域跨域优化
比如托管域&脚本与的跨域优化,这里其实很多时候是被一些开发人员所忽略的,因为会觉得这些都是Unity的东西。
最典型的就是对Gameobject Transform的一些比较,比如Gameobject的tag和name,频繁访问这些属性是会产生跨桥的开销的,有一些可以通过如CompareTag,System.Object.ReferenceEquals来避免,但是有一些不行,但是都可以通过记录的方式来规避频繁跨桥,当时偶尔用一个的消耗是可以忽略不计的,我们不能抛开剂量谈毒性。
托管域&脚本域跨域优化
这里大家开发业务的时候应该会比较熟悉,主要的问题在于跨域的虚拟机处理这边会有一些时空损耗。主要的优化内容有两个,一个是降低跨域频率,一个是改变跨域方式。
- 降低跨域频率,典型的例子就是脚本层频繁要去拿托管域数据的例子,这里可以具体情况具体分析,经常变更的数据比如速度,位置等,我们可以每次Update都固定获取一次,存在脚本层。其他脚本层从这里读,而不是去跨域读取。不经常变更的数据,可以更新以后去通知记录值变更。
- 改变跨域方式,比如我们可以利用内存对齐,通过传递指针的方式来共用内存。从而避免直接传递数据,最典型的例子就是网络层pb的bytes.