👉目录
1 整体思路
2 具体实践
3 总结
内存优化属于性能优化的一部分,内存优化没有功耗、卡顿和帧率等优化可以被玩家们直观的体验到,但是一旦发生Out Of Memory(OOM)问题,玩家将会直接从游戏中闪退。所以内存优化是一个重要的工作。 随着手游3A化、精品化的发展,越来越精美细致的画面表现,越来越丰富的游戏玩法都对内存带来了更大的压力。所以内存优化是一个常态化的工作。 业内的内存分享大多介绍平台侧、引擎侧、工具侧的内存实践。本文将从内存优化的整体思路到具体的实践细节,总结整理Unity手游客户端在内存优化方面所做的实践。
关注腾讯云开发者,一手技术干货提前解锁👇
01
整体思路
内存优化主要分为两个方面:增量内存优化和存量内存优化。
项目初期,优化重点集中在存量优化上。
此阶段主要优化引擎框架、业务代码框架及美术标准等基础内容,为项目打下坚实基础。有句话叫做“优化越早开始收益越大”。当然在项目初期玩法开发最为重要,需要集中力量赶项目进度,而内存优化往往会被忽略。
从后来人的角度往前看,很多内存难题都是因为项目初期赶进度产生的。当项目上线并发展到特定阶段,对一些基础模块进行调整的风险显著增加。有时就不得不在“玩家体验”和“项目质量”之间做出权衡。
所以“优化越早开始收益越大”是有道理的。
在项目发展阶段,工作重心在增量内存优化上。
在这个阶段需要对测试流程,内存分析工具和业务代码逻辑进行优化。到了这个阶段,项目已经在线上稳定运行,业务代码都有固定的框架来开发,所以进行一些大的、底层的、框架性的优化方法需要谨慎再谨慎。
并且到了这个阶段,项目复杂度已经大大提升,需要优化测试流程来覆盖丰富的场景,需要新的内存分析工具来提高内存优化效率。
内存优化从工作流程上可以分为以下3点:
QA测试产生内存数据。
分析测试数据定位内存问题。
开发解决方案降低内存。
QA测试需要关注以下方面:
主流程内存峰值。
连续多次主流程后的内存峰值。
特殊场景内存峰值,比如运营活动,第三方SDK。
数据稳定性。
分析测试数据需要关注以下方面:
工具效率。
数据归档。
开发解决方案需要注意以下方面:
预防大于兜底。
优化核心增长点。
02
具体实践
2.1 测试产生数据
测试需要遵循这几个要求:可重复,可对比,尽量全面。
Unity手游项目目前有以下类型的测试,每种测试类型针对的场景不同。
2.1.1 每日冒烟测试
目的:检查每日新增内存。
方法:对游戏主玩法的主流程进行测试。
为了数据的可对比性和稳定性,每日冒烟测试会尽可能多的固定变量:固定阵容,固定皮肤,固定设备,固定账号,固定服务器。
固定以上条件之后,内存增量来自于:主流程逻辑变化,战场玩法变化,装备/符文/地图等通用模块的美术资源变化。
为了进一步提高测试数据的稳定性和全面性,每日冒烟测试还分为了自动化测试和技能流测试。
自动化测试:
战场的10个英雄皆由脚本按照既定规则运行,会根据对局的发展做出不同行为。自动化测试的优点在于可以覆盖战场新增玩法,但是内存数据会存在一定差异。
技能流测试:
战场的10个英雄的所有行为都有提前预制的技能流控制。技能流的优点在于英雄的行为完全受控所以内存数据会更加稳定,但是每个新版本都需要先更新技能流,以覆盖战场新玩法。
主流程冒烟测试:
2.1.2 功能测试
目的:对新版本的新增功能进行内存测试。
方法主要有:
新皮肤测试。
子系统测试。
运营活动测试。
2.1.3 专项测试
专项测试是一些特殊测试,主要有:
模拟玩家行为进行测试。
特殊设备测试。
业内竞品测试。
2.2 分析数据
“分析数据”的最主要工作其实是开发内存分析工具。有效的工具能够让测试提供有效的数据,有效的数据能够提高分析效率,从而加快内存优化工作。
那么什么是有效的数据呢,什么工具能够提供有效数据呢?
2.2.1 阶梯式数据
有效的数据应该是“总-分-细分”的阶梯式数据:
整体数据 表示当前内存量级,判断OOM风险。
分模块数据 表示大型模块的内存。在大型项目中,各团队负责的模块不同。有了分模块数据可以方便调动各管线力量进行内存优化。
细分数据 提供具体的内存分配堆栈,具体的资源名,能够非常直观的表示哪行代码分配了多少内存。
2.2.2 数据工具链
有了想要的数据之后,就可以去找相应的工具来获取数据。
下面是Unity手游项目的具体实践:项目的内存数据以iOS为主,联合XCode工具链+Unity工具链+第三方工具链实现内存数据采集分析工作。
工具链的整体工作流如图所示:
1、整体数据
对于整体数据,有3个工具可以获取:
PerfDog:公司级自研性能分析工具。
XUP:光子内部自研性能分析工具。
XCode MemGraph:Apple提供的工具。
这3个工具都可以获取项目占用的全部FootPrint内存,可以用来衡量游戏OOM的风险。这3款工具都提供了丰富的使用指引和开发文档,使用起来没有门槛。
业内还是Unity UPR和UWA Tool,但是这两个工具并不支持项目当前使用的引擎,而且都需要额外付费,所以没有被大规模采用。
2、分模块数据
对于分模块数据,这是内存工具遇到的第一个难点。
一般情况下大家会使用Unity提供的Unity Profiler工具。但是Unity Profiler统计的内存是少于游戏总内存的,而且在项目上是远少于游戏总内存的。
Unity Profiler统计不到的内存是:第三方SDK内存,项目 GameCore内存,Unity引擎框架自身运行使用的内存。那么这3块内存该如何统计呢?
对于项目 GameCore内存,在框架设计的时候内存的分配都是统一接口管理的,所以这块内存在分配/释放函数上加统计就可以了。
对于第三方SDK和Unity引擎框架自身的内存,一般的做法是使用hook的方式。
Android平台使用plt Hook方式进行hook。IEG有自研的工具LoliProfiler,使用起来比较方便。
iOS平台使用FishHook的方式进行统计,然后自己统计堆栈。
对于iOS平台,除了Fish Hook的方式,还有一套使用门槛更低的工具链:MemGraph + vmmap + malloc_history
MemGraph + vmmap + malloc_history工具链实践
1)先通过XCode工程截取内存MemGraph快照。
2)然后通过vmmap工具将快照文件按照VM ZONE进行分类。
这里我们关注以下VM Zone:
Malloc_Large/Small/Tiny,这部分 = Heap内存 = 所有通过new() / malloc()/ alloc() / align_alloc()分配的内存。
根据项目游戏特点,Malloc_* = Unity Native + 第三方SDK分配的内存:
VMALLOCATE,这部分 = 所有通过mmap()分配的内存。
根据项目游戏特点,VMALLOCATE = GameCore + IL2CPP Managed的内存:
IOAcclerator,这部分 = GPU占用的所有内存。
到这里,我们通过vmmap --summary命令就得到了一个初步的分模块内存。而且所有模块内存相加等于游戏整体内存,没有遗漏。
并且vmmap工具给出的数据很清晰表示出内存的Dirty Size 和 Swapped Size,而Dirty Size + Swapped Size = FootPrint内存。减少了clean内存的干扰。
3)还可以再进一步,将GameCore内存和IL2CPP Managed内存从VMALLOCATE中区分出来:
通过malloc_history工具可以将addr内存转为堆栈。具体做法是:
先通过vmmap工具将VMALLOCATE中的所有内存地址打印出来。
然后通过malloc_history工具将内存地址转为分配堆栈。
最后再将堆栈按照GameCore和IL2CPP Managed进行归类,就可以统计出GameCore和IL2CPP Managed内存。
经过以上的数据处理,我们将游戏整体划分为了 Heap + GameCore + IL2CPP Managed + IOAccelerator 4个大模块。
3、细分数据
最后就是对Heap + GameCore + IL2CPP Managed + IOAccelerator 这4个模块进行细分。
1)Heap内存
Heap内存 = Unity引擎框架占用内存 + 第三方SDK内存 + Unity Native内存。Heap内存的分配堆栈可以由2种方式获取:
1是通过Heap命令打印出Heap区域的所有内存地址,然后通过malloc_history输出地址对应的堆栈。
但是这个对于Unity手游项目这种大型项目来说效率太低了,或者说根本无法实现。
2是使用Instrument Allocations工具。
Instrument Allocations工具可以通过堆栈来查看Heap的内存,并且已经将堆栈进行了聚合。
这部分堆栈可以完整的Copy出来,然后进一步按照关键字进行业务分类,或者直接按照堆栈进行对比找到2个版本堆栈相同但是内存占用不同的地方。
但是项目使用C#开发,因为协程+IL2CPP会让堆栈变得复杂。
我们在实践中总结出以下经验:
将统计中的count类别取消掉,
将limit限制为1KB甚至1MB。
这样子做有2个用处:1、这样子过滤之后堆栈的缩进是一致的,可以按照缩进处理堆栈的层次关系。2、可以减少堆栈数量,提高操作效率。
通过Instruments Allocation工具,我们可以得到Heap内存的所有堆栈。
2)GameCore内存
GameCore采用C++语言编写,内存申请和释放完全由业务代码控制。在开发GameCore的时候就在内存申请。
3)IL2CPP Managed内存
这块内存之前很难处理。
我们都知道Unity MemoryProfiler工具可以按照对象类别将IL2CPP Managed内存进行分类,并且可以查到内存对象的依赖关系。
但是Unity Memory Profiler的比较难用。难用主要体现在以下方面:
对于大型项目,单单是打开Unity Memory Profiler的GUI就会非常卡顿。
然后Unity Memory Profiler的对比功能非常鸡肋。
最后就是最致命的地方,它对数据的GUI处理方式导致对于大型数据简直无法使用。
举一个实际例子,问:新版本比老版本多了2W+字符串,请回答这些字符串出自哪里?
首先从20W+的字符串中找出这2W字符串在GUI上就已经无法实现了,就更别说这些字符串来自哪里了。
再举一个实际例子,问:新版本比老版本多了2K个int[]数组,请回答它们的分配来源是什么?
但凡数据量上升到“百”这个单位后,靠人工肉眼来排查问题几乎不可能了。所以得另寻工具。
Unity MemoryClawer通过terminal的方式来处理数据,很方便对数据进行二次处理:
对于上面讲到的2w个字符串问题,我们可以通过MemoryClawer将2个版本的所有字符串都打印出来,然后就可以对比出来增长的字符串是什么。
对于上面提到的2K个int[]数组问题,我们扩展了MemoryClawer能力,根据内存对象的依赖关系将内存对象分类。这样分类之后,对于这些数量巨大的对象,我们直接分类,就可以很清晰的看到它来自哪些模块。
MemoryClawer本身还提供了很多能力,可以帮助定位小内存,内存碎片问题。MemoryClawer提供源码,方便接入和扩展。
4)IOAcclerator的内存
我们使用XCode的截帧工具来具体分析这部分内存。
XCode截帧工具GUI友好,使用简单,还有大量的使用指南。使用成本很低。
有了这套工具链就基本完成了总-分-细分的数据分析。
更进一步的,我们通过在资源加载函数上增加日志,结合这部分日志就可以找到具体的资源名。
这样子堆栈和资源名都被获得,而且是阶梯状数据。所以不论是排查增量问题还是分析存量内存,都可以提供准确的数据。
通过这套工具链,降低了数据分析工作的门槛,便于在特定时期投入更多人力进行内存分析工作。提高了内存分析效率,为内存优化提供了明确指引。
2.3 具体优化
大部分优化方法都可以在各类分享中找到实现原理和实现方式。下面列出我们验证过效果比较明显的优化方法,以及具体实践中的经验。
2.3.1 业务优化
资源LOD
如果资源分的够细致,内存问题都不会太大。毕竟内存中占大头的是贴图,shader,音频。低内存设备加载低精度的资源,高内存设备加载高精度的资源这样子做就会比较合理。但是多一份LOD对于美术就多一份工作量,也会撑大包体。所以LOD的划分需要根据项目实际情况来制定。
大数组防止扩容
对于一些很大的数组,初始化的时候就指定一个足够的空间,会比数组自己扩容要省内存。这一招虽然简单但是有效。
避免字符串冗余
资源路径,文案,配置会使得游戏中存在大量的字符串。我们的做法是将字符串转成ID使用。这样可以避免存储重复字符串。
压缩
ASTC使用合适的压缩比,动画使用ACL压缩,表格字符串压缩。尝试将资源压缩使用。
图集
禁用图集的read/write。这个优化要做个工具进行校验。大型项目管线复杂,很容易出现低级错误。用工具来做校验可以避免这类问题。
限制图集的大小。超大的图集往往是为了制作方便,但是也往往导致内存浪费。
03
总结
内存优化的分享就是以上内容。感谢耐心阅读。
这里再总结一下,对于内存优化我们需要以下措施:
可以稳定重复的测试流。
“总-分-细分”的阶梯式数据。
防范重于兜底的优化思路。
下面分享一些业内比较优质的内存优化相关的文章,做一个知识汇总,常看常新。
也感谢各位大佬过往的分享,让我可以站在巨人们的肩膀上工作。
由于能力有限,可能在某些内容的理解上存在谬误,也请大家包涵指正。
拓展阅读:
《Profile and optimize your game's memory》https://developer.apple.com/videos/play/wwdc2022/10106/
《浅谈Unity内存管理》https://www.bilibili.com/video/BV1aJ411t7N6/?vd_source=b4ce29f7f4280b6a23c340a93abff9d4
-End-
原创作者|黄志恒
感谢你读到这里,不如关注一下?👇
📢📢来领开发者专属福利!点击下方图片直达👇
你在资源管理方面有哪些内存优化的经验?欢迎评论留言补充。我们将选取1则优质的评论,送出腾讯云定制文件袋套装1个(见下图)。7月8日中午12点开奖。