Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存(下)

Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存(下)

《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发,例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题,并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题,希望能提供给Unity开发者更多高效的研发方法和实战经验。

今天向大家介绍文章第二部分:资源内存、Mono堆内存等常见游戏内存控制,共13小节,包含了纹理资源、网格资源、动画资源、音频资源、材质资源等多个资源内存以及Mono堆内存等常见的游戏内存控制讲解。

本章包含8-13小节,1-7小节请点击《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存(上)》

(全文长约11400字,预计阅读时间约20分钟)

文章第一部分《Unity移动端游戏性能优化简谱之 前言》可戳此回顾,完整内容可前往UWA学堂查看。

8. Render Texture

8.1 渲染分辨率
资源列表中的一些RT资源能反映项目当前的渲染分辨率。对于GPU和渲染模块压力较大的项目,在中低端机型上降低其渲染分辨率是非常直观有效的分级策略。但相关调整比起说影响内存,还是更多地影响GPU片元计算压力和带宽,后文GPU章节会进行更加详细的讨论。

如果一些其他的RT资源分辨率过高也应引起注意,尤其是2048*2048以上的资源。应当排查是否有必要用到如此精细的RT,在低端机上考虑采用更低分辨率的效果。

8.2 抗锯齿
资源列表中展示了RT资源的AA倍数。开启多倍AA会使RT占用内存成倍上升,并对GPU造成压力。建议排查是否有必要开启AA,尤其在中低端机上,可以考虑关闭此效果。

特别的是,在华为部分机型上2倍的AA会失效。即已经造成了性能消耗但没有实际起到抗锯齿效果。

8.3 后处理
一些常见的后处理相关的RT(如Bloom、Blur)是从1/2渲染分辨率开始采样,可以考虑改从1/4开始采样、并减少下采样次数,从而节省内存并降低后处理对渲染的压力。

站在性能优化的角度,在中低端机型上甚至最好分级地关闭各类后处理。围绕一些常见后处理效果的讨论会在下文GPU部分中进一步展开。

8.4 URP下的RT
使用URP时,内存中会多出_CameraColorTexture和_CameraDepthAttachment两份RT资源作为渲染目标,而开启URP相机的CopyDepth和CopyColor设置时会额外产生_CameraDepthTexture和_CameraOpaqueTexture作为中间RT。当资源列表中出现这两种RT时,需要排查确实是否用到CopyDepth和CopyColor,否则应予以关闭以避免不必要的浪费。


9. Shader资源

9.1 ShaderLab
Unity 2019.4.20是Shader内存统计方法的一个转折点。在此之前,Shader的内存主要统计在ShaderLab中,而之后则主要统计在Shader资源自身身上。

对于Unity 2019.4.20之前的版本的项目,查看ShaderLab的内存使用Memory Profiler截帧查看。而无论是Shader资源本体还是ShaderLab内存占用过高,都要着手于控制Shader的数量、变体数量和Shader复杂度。

9.2 变体数和复杂度
对于单个Shader资源而言,其内存占用高本质在于其编译后的行数高。因此也可以视为,怎么控制变体的数量和每个变体的平均行数(也就是Shader的复杂度),这两个乘数的问题。

其中,变体数过多是造成一个Shader资源内存占用过大、占用包体过大的主要原因,也是相对比较好和排查和修改的部分。在项目迭代中可能会出现已经被弃用或者没有被实际使用到的关键字,导致变体成倍上升;又或者Shader写的比较复杂,其中一些关键字组合永远不会被用到,从而导致很多变体是多余的。UWA的本地资源检测中提供了Shader检测功能,可以看到变体数量,定位变体数过多的Shader资源。

针对上述情况,Unity提供了回调函数,在项目打AssetBundle包或者Build时线剔除用不到的关键字或关键字组合相关的变体。剔除Shader变体的方法可以参考《Stripping scriptable shader variants》

而Shader的复杂度同时还涉及到GPU性能相关的优化。具体在后文GPU部分有更加详细的讨论。

9.3 冗余
Shader冗余尤其需要予以关注,Shader的冗余不光导致内存上升,还可能造成重复解析,即运行时不必要的Shader.Parse和Shader.CreateGPUProgram API调用耗时。

一般的做法是,首先使用ShaderVariantCollection这一Unity自带的资源类型收集项目渲染过程中会使用到的变体。将其和Shader依赖打成一个包。在游戏运行过程中统一加载、缓存和Warmup,将Shader.CreateGPUProgram API调用耗时集中在游戏初期或场景切换时完成,避免战斗过程中出现首次加载和参与渲染的Shader变体因而产生耗时卡顿的问题。

收集变体还有一个好处,即可以反推哪些关键字组合或变体是渲染中用不到的,再进行上文的通过回调剔除变体的做法,可以使得Shader整体的内存得到良好的控制。

一般而言,在复杂度和变体数量都控制得比较好、排除了冗余并且剔除了多数用不到的变体的前提下,根据经验,一些对画质需求非常高、PBR Shader也使用得非常重度的移动端项目也完全可以把Shader内存控制在50-60MB,而这种值进行缓存是完全可以接受的。

特别地,以上描述主要对开发者自己写的Shader适用。而内置Shader一般建议放进Project Settings-Graphics中的Always Include Shader列表中以防止冗余;相反地,自己写的Shader则不建议放入,以防止冗余。

9.4 Standard Shader
在资源列表中发现Standard、ParticleSystem/Standard Unlit。这两种Shader变体数量多,其加载耗时会非常高,内存占用也偏大,不建议直接在项目中使用。出现的原因一般是导入的FBX模型中或者Unity自身生成的一些3D对象使用了自带的Default Material,从而依赖了Standard Shader,建议予以排查精简。也可以结合UWA在线AssetBundle检测工具排查是哪个AssetBundle包中哪些资源引用了Standard Shader和ParticleSystem/Standard Unlit。如果确实要使用Standard Shader或ParticleSystem/Standard Unlit,应考虑自己重写一个Shader并只包含自己需要用到的变体。

9.5 使用本地资源检测排查Shader问题
在本地资源检测中包含了“项目中:全局关键字过多的Shader”、“项目中:可能生成变体数过多的Shader”、“Build后:生成变体数过多的Shader”、“使用了Standard Shader的材质”等上文已经提及的检测规则,方便开发者精确定位存在潜在性能问题的Shader资源。


10. 字体资源

若单个字体资源内存占用超过10MB,可以认为该字体资源内存偏大。可以考虑使用FontPruner 字体精简工具或其他字体精简工具,对字体进行瘦身,减小内存占用。

我们也需要关注项目中字体数量过多的情况,因为每个Font会对应一个Font Texture字体纹理,所以字体资源数量多了,Font Texture的数量也多了,从而占用较多内存。


11. 粒子系统

将UWA的粒子系统曲线结合粒子系统资源列表来看。会发现很多项目的内存中粒子的数量会远远高于实际Playing的粒子数量,在曲线中表现为蓝色曲线要比紫色曲线高出很多;在资源列表中表现出有大量资源在内存中的数量峰值远高于Playing数量峰值。这说明粒子系统内存很可能存在大量的缓存浪费。

此时一方面需要检查是否存在迭代过程中有被弃用但未删除的粒子系统对象、或制作过程中测试过的组件但未解除依赖的粒子系统组件。尽管是Deactive状态,但仍会随着Prefab进入包体、加载进场景,从而进入内存;另一方面则可以考虑优化对粒子的缓存策略,减少不必要的粒子缓存,尽量做到播多少粒子就缓存多少粒子。


12. Mono堆内存

UWA GOT Online Mono模式报告提供了堆内存具体分配和堆内存泄露分析两个主要功能,供开发者分析项目中堆内存存在的问题。

12.1 原理简述
对于游戏项目来说,除了上述常见的资源导致的内存占用外,还有一部分大头来自于代码相关的占用。

Mono堆内存是Unity C#脚本运行时的核心内存区域,主要用于托管对象(如string、List、自定义类实例、委托等)的动态分配与管理。除了驻留量高同样会和其它内存问题一样影响闪退风险外,堆内存的分配情况不合理还会直接影响GC(垃圾回收)耗时、帧稳定性,也需予以关注。

机制上,+Unity采用分代式垃圾回收机制管理Mono堆,核心逻辑可分为三步:
对象分配:C#中new创建的引用类型对象、值类型装箱、字符串拼接等操作,均会从Mono堆中申请内存空间。堆内存以连续内存块形式管理,优先使用空闲区域分配新对象。

堆扩容:当堆内空闲空间不足时,Unity不会立即触发GC,而是向系统申请扩容堆内存(默认扩容幅度为当前堆大小的50%~100%),直至达到设备内存上限。

垃圾回收:当堆内存占用达到阈值、或手动调用GC.Collect()时,GC会遍历所有托管对象,标记不再被引用的对象,在连续约6次GC都不被引用时,释放其内存,回收的空间会重新加入堆的空闲列表,供后续分配使用。

这就涉及到堆内存分析中三个相当常见的问题:

  1. 堆内存的构成。我们不妨延续2.1章节的讨论。如图,结合Memory Profiler截图来看,Mono堆内存占用(即Reserved Mono)实际上主要分为3个部分,即依次为预留值(210.9-201.5的部分)、实际对象占用(Objects)、碎片(Empty Fragmented Heap Space)。开发者可控的部分基本只有对象实际占用部分,从上文的扩容机制来看、这部分基数越高则预留的部分也会越高。因此优化对象实际占用对内存的降低效果往往大于表面数值。

  2. 堆内存碎片。结合以上堆的扩容机制,游戏过程中常存在高频的堆内存分配,则在大块对象被释放后立刻分配小对象,即很容易出现碎片。在堆内存分配释放较为频繁的项目中,UWA常观察到对象实际占用的部分和碎片会逐渐趋于相等,因此只能通过尽量避免大小对象频繁交错分配释放的方式,减缓碎片逐渐上升的趋势;且与预留值同样地、降低对象实际占用仍是主要的优化对象

  3. “Mono 堆内存只增不降”的错误说法。时不时开发者向UWA如此提问:测试报告中Reserved Mono曲线为什么是有增有降的?按网上普遍的说法,Mono堆内存不该是只增不降的吗?事实上,GC回收的空闲内存会保留在堆内供复用,若堆内空闲空间长期过剩,Unity会在特定时机(如低内存告警、场景切换)主动将多余空闲内存归还系统,实现堆内存收缩。也即,造成堆内存主要占用的内存页是会返还给系统的,仅有地址空间是只增不降的。

事实胜于雄辩。如图为2022.3.60版本下使用IL2CPP打包的测试,分配了1GB大小的String类型堆内存对象并在一段时间后释放,并适当调用GC。可以观察到无论是Reserved Mono还是整个进程的PSS Total都得到了正常释放。 对于堆内存分配和驻留不合理导致的性能问题,UWA GOT Online Mono模式报告都有相应的子功能定位和分析具体问题。

12.2 持续/峰值分配堆栈
在堆内存具体分配页面中,可以排查高堆内存分配函数的具体堆栈。我们主要关注两种形式的堆内存分配。

一种是单次过高的堆内存分配。这种峰值一般出现在游戏初期的读表操作导致的大量分配,需要开发者结合具体堆栈信息排查是否合理。而游戏运行过程如果还出现堆内存分配峰值则需要着重关注。

另一种则是持续偏高的堆内存分配。如果项目中存在每帧或者每隔几帧就分配较多堆内存的现象需要引起注意。持续的高堆内存分配会导致GC频率增高,从而在游戏中形成频繁的卡顿,可以结合堆栈排查是什么子节点在持续分配堆内存。

12.2 泄露分析
在泄露分析页面中排查项目中各个函数的堆内存驻留情况。选中图表中前后两处采样帧进行比较,就可以从堆栈中查看堆内存驻留情况的变化,查看驻留上升主要是什么堆栈分配造成的。

一方面可以避免堆内存持续上升造成泄露的风险,另一方面针对驻留高的函数进行优化,予以及时释放,可以降低单次GC的耗时。我们一般推荐测试GOT Online Mono模式的测试时长尽量长一些,比如1个小时,否则泄露问题往往难以被暴露。


13. 其他内存

我们在处理完相对可控的Unity分配的Reserved Total部分内存后,仍然不能掉以轻心。常见情况下,我们在内存部分一开始介绍的PSS Total值是要比Reserved Total值高出200-300MB,可以认为是在接受范围内。但是,也会有项目出现这两个数值差值高出500-600MB甚至更高的情况。

13.1 Lua 内存
UWA GOT Online Lua模式提供了针对Lua脚本语言的性能测试。

其中出现的函数名称格式为:函数名称@文件名:行号。

可以通过报告提供的Lua文件名/行号/函数名来定位CPU耗时的瓶颈函数和CPU耗时峰值的具体原因。Lua函数的命名格式为X@Y:Z,其中X是其函数名,在无法获取时,X会变为默认的unknown;Y是该函数定义的文件位置;Z则是该函数被定义的行号。需要注意的是,当Lua脚本以字节码运行时,该值将始终为0,因此建议在测试时尽可能使用Lua源码来运行。

针对Lua分配的内存,报告在总体堆内存、Lua内存峰值分页中的折线图每30帧采一次值。根据折线图走势,帮助开发者对项目运行过程中的堆内存分配情况有大致的了解。其中,堆内存的下降意味着发生了一次GC。相对高频的GC使得Lua内存峰值变低,但也会有相应的额外耗时开销。关于Lua GC机制和策略对内存的具体影响,下文还将具体讨论。从下图的示例曲线中就可以看出,该项目的Lua内存在整体测试流程中存在非常显著的上升趋势,不过所幸总体基数很小,如果这里是从70MB持续上升至120MB,就需要重点关注Lua模块中可能的泄露风险了。

还可以注意到在总体堆内存页面中还有对Table、Function、Userdata的统计,提供一些辅助信息。整体的内存变化趋势其实开发者已经可以从刚才的曲线中窥知一二了,那这些具体的数据结构的数量统计意义在于,一方面可以推测是那种数据结构的上升趋势相同,那么它可能就是罪魁祸首,为之后的分析提供参考和抓手;另一方面则是对数量尤其高的类型产生警惕,比如大部分项目Table数量主体一般在10w-20w,但有的项目则达到了80w-90w的驻留,这就存在比较大的排查和优化空间了。

其中,查看内存具体分配功能和Mono模式报告大同小异。

Lua模式报告中还有一个重要功能,即Mono对象引用统计。该功能的意义就在于辅助开发者排查Lua端GC机制导致的Mono堆内存泄露风险,具体的解释将在下文讨论。

在柱状体选择对应帧后,列表中会显示该帧的Mono对象类型列表。其中:
对象类型:表示Unity Object对象的具体类型;
对象个数:表示这种类型的对象个数;
Destroyed对象个数:表示已经被Destroyed,但Lua层还有相关引用的这种类型的对象个数。需要关注Destroyed对象个数,如果数目较大,C#堆内存存在泄露风险。

13.2 Lua GC
对于现代编程语言的自动化内存管理机制而言,为了实现在内存不再使用的时候能够自动释放它们的目的,有两种主流做法:引用计数和扫描标记。Lua使用的是基于后者的GC机制,对于动态语言来说,引用计数带来的额外开销太大,尤其是即使一段程序完全不分配内存,也需要承担这额外开销。Lua的垃圾收集器本身并不负责内存分配释放,内存的底层管理是通过在创建Lua虚拟机时从外部注入的分配器完成的。虚拟机工作时所有产生的对象都被串在一个链表上组成一个集合,而被虚拟机根集间接引用的对象都会被保留,剩下的对象引用无法被根集引用,则会在恰当的时机回收。虚拟机的根集包括了注册表,以及原生类型的metatable。而全局表、主线程、标准库的代码等等,都被注册表所引用。简单来说,就是扫描并标记了无引用的对象,并在适当的时候回收。

在Lua 5.0以前,Lua使用的是一个非常简单的标记扫描算法。它从根集开始遍历对象,把能遍历到的对象标记为活对象;然后再遍历通过分配器分配出来的对象全集链表,把没有标记为活对象的其它对象都删除。

从Lua 5.1开始实现了一个步进式垃圾收集器。这个新的垃圾收集器会在虚拟机的正常指令逻辑间交错分布运行,尽量把每步的执行时间减到合理的范围。Lua 5.1采用了一种三色标记的算法。每个对象都有三个状态:无法被访问到的对象是白色;可访问到,但没有递归扫描完全的对象是灰色;完全扫描过的对象是黑色。基于此,我们可以假定在任何时间点,下列条件始终成立:

  1. 所有被根集引用的对象要么是黑色,要么是灰色的。
  2. 黑色的对象不可能指向白色的。
  3. 白色对象集就是会被回收的部分;黑色对象集就是需要保留的部分;灰色对象集还需要被进一步遍历。

那么,在垃圾收集器工作过程中,通过充分遍历灰色对象,就可以把它们转变为黑色对象,直至所有灰色对象消失,收集过程也就完成了。

接下来,Lua GC的时机究竟是什么时候呢?事实上,GC的步进并不和真实的时间相关,它只和虚拟机分配新的内存有关。也就是说,只要虚拟机不分配更多内存,GC是不会自动运行的。GC依靠新增内存量来决定该做多少工作,它也依靠标记或清理的内存量来决定工作做了多少。每次做多了,会导致程序更多额外的停顿,做少了,会导致内存回收速度赶不上新增速度。步进式GC比全量GC复杂之处在于,它不能再只用一个量来控制GC的工作时间。对于全量GC,我们能调节的是GC的发生时机,对于Lua 5.0,就是2倍上次GC后的内存使用量;在5.1以后的版本中,这倍数可以由参数LUA_GCSETPAUSE调节。另外增加了LUA_GCSETSTEPMUL来控制GC推进的速度,默认是2,也就是新增内存速度的两倍。Lua用扫描内存的字节数和清理内存的字节数作为衡量工作进度的标准,有在使用的内存需要标记,没在使用的内存需要清理,GC一个循环大约就需要处理所有已分配的内存数量的总量的工作。这里2是个经验数字,通常可以保证内存清理流程可以跑的比新增要快。

举例而言,默认不做任何修改的状况下,上次GC回收后是100MB,那么Lua就会等到内存一直达到200MB时才会进行下一次GC。这种默认的频率有其风险性,而实际上是可以通过LUA_GCSETPAUSE调节的——当开发者结合数据判断当前GC频率过低而一定程度上导致了Lua内存峰值偏高时,完全可以考虑调低这个参数来提高GC的频率。

13.3 Lua的Mono对象引用
在使用Lua进行游戏开发时,肯定会涉及到去访问一些Unity(或者说C#的)的对象。此时,我们在Lua中写了Object.Instantiate来实例化一个对象出来,实际上在Lua层会基于各个类自动生成Wrapper代码,把所有的接口都额外包一层,来把这些Unity的接口注册到Lua中。

在Lua中调这些函数时,会传入Lua的状态机到这些Wrapper代码中,就可以通过这个状态机取得Lua这头的数据,比如能根据栈中参数的数量和类型去判断实际上是要调函数的哪个重载。

继续以Object.Instantiate为例,调用完该函数进行实例化以后,返回的对象在Unity这一层实际上就已经进了Scene场景,产生了引用。但不难想象到一种情况是,当我们自己的Lua逻辑代码New了一个对象,在C#端却没有任何地方会引用到这个对象,那么当当前函数直接Return后,这个对象就会在下次GC被回收掉,但你实际上很有可能还是要在Lua端访问它的。即这个对象的生命周期应该是Lua端在管,而C#端得先建立引用,否则C#端的GC将予以回收。

所以,一定要通知C#端去把它引用起来,即会做一个Push操作来把这个对象放在一个容器中。但这样为了不GC掉可能要用到的对象而进行的操作,反过来又有了另一种风险:C#端可能不知道什么时候该回收这部分内存了,从而导致Mono堆内存越来越高的泄露问题。这时就要靠Lua的GC来回收存储的Userdata,从而也回收掉C#端对应的引用。

GOT Online的Lua模式中,就可以通过Mono引用对象统计页面中进行分析。由于采集时主动定时调用了GC,所以在图表中仍然统计到的对象,就是Lua端没有解引用、短期内回收不掉的。数量不宜过高。

重点在于同时统计的Destroy对象数量。它统计了上述对象中其实在场景里已经被销毁的对象。但这些对象还在容器中、还占着堆内存,尤其数量越来越高时就可能存在Mono堆内存的泄露了,需要结合GOT Online Mono报告的情况进行综合分析。解决的方法就需要在Lua中找到相关对象的引用链,在Lua中及时释放。

所以,UWA的Lua专项报告提供的Mono对象引用柱状图中,黑色部分表示未被Destroyed的对象数目,由于受到Lua端GC的影响,导致会有一些Destroyed对象。这时候就要注意它是否是趋于稳定的,如果持续上涨就需要引起重视。

13.4 Lua配置表优化
Lua除了用于业务逻辑外,还有一种常用的用法,就是数据存储,也即配置文件。一般来说配置文件的初始来源是策划同学以Excel表格形式维护的,经由程序员提供的导出工具,把Excel的表格数据导出成为游戏能直接读取使用的Lua源码。

这些源码文件以Lua table的形式存储与Excel等价的数据,通常可以简单把这个配置表看成是一组2维数组,转换成配置就是一个key(Excel第一列)对应一组子数据(Excel中一行),那么整个配置数据就是一个大表包含着若干小表。

如果配置文件中的数据过大,或着是有冗余的无用数据,那么势必会导致输出的Lua文件过大,这将严重影响加载速度和增大内存占用量。冗余的原因在于:

  1. 大量的数据是重复的,或着是代表没有意义的空值(比如0,[]等);
  2. 大量的中文字符串是需要游戏后期做本地化处理的;
  3. 很多复合型数据(子表,数组)内容是一样的。

基于此的优化方案为:

  1. 对于Excel中的一列,出现次数最多的值认定为默认值,然后把它从Lua表中剔除掉,然后利用metatable机制实现全局默认值存储;
  2. 对于中文字符串,替换为一个唯一的ID标识,写回到Lua表中,读取的时候加上相应的查找替换;
  3. 对于一些复杂的子表或着数组,做唯一化替换处理,替换后写回到原始数据中。

上面的操作其实做的都是唯一化处理,所以有个要求就是整个Lua表的数据必须是只读的,如果不满足以上条件,一切优化都是错误的。这样做势必会降低一些可读性,一些被多处引用的子表已经被替换成了一个变量,这些变量是以Local的形式存储在作用域的。由于Lua本身的一些限制,一个作用域内能够存放的最大Local变量的个数是200个(lparser.c #define MAXVARS 200),所以超过个数限制的多余部分表会被放入一个临时数组中,初始化的时候需要额外的查表,稍微多一些开销,但可以接受。

13.5 Native Heap优化
如2.1章节讨论的,当通过adb dumpsys meminfo发现Native Heap持续走高、且已排除第三方插件与Lua模块的明显问题后,Perfetto是定位原生堆内存分配与泄漏的核心工具,尤其适用于IL2CPP打包、引擎底层或自定义C++插件导致的Native内存异常。该工具基于Android系统底层Heapprofd,可采样malloc/free调用栈,以火焰图直观呈现内存分配来源,弥补Unity Memory Profiler无法追踪Untracked部分中Native Heap内存的短板。

前置条件
设备系统:Android 10及以上;非UserDebug设备需将游戏APK设置为Debuggable或Profileable。
工具准备:下载Perfetto工具包,配置Python环境;确保adb可正常连接设备并识别游戏进程。

采集步骤
启动游戏并进入内存异常场景,通过adb shell ps获取游戏包名/进程 ID。
执行Perfetto堆采样命令:python perfetto/tools/heap_profile -n游戏包名,启动Native内存追踪;首次运行会自动下载依赖工具,需保证网络通畅。
复现内存增长场景(如反复切换场景、加载资源、运行核心玩法),过程中可通过adb shell killall -USR1 heapprofd手动触发多轮内存快照,便于后续对比。
追踪结束后停止命令,导出raw-trace文件,在Perfetto官方在线分析平台(ui.perfetto.dev)打开文件。

结果解读与问题定位
核心视图:优先查看Unreleased Malloc Size(未释放内存大小),按内存占用排序调用栈,定位持续持有内存的分配路径;Total Malloc Size可用于排查频繁分配释放导致的内存抖动与碎片。
火焰图分析:色块宽度代表内存占用量,自上而下为调用链路。重点关注IL2CPP虚拟机、Unity引擎底层(如Rendering、Audio模块)、自定义C++插件对应的色块,若某一路径未释放内存持续增长,基本可判定为泄漏或不合理驻留。
符号化解析:若调用栈显示为乱码地址,需注意是否以IL2CPP Debuggable模式打包;或配置llvm-symbolizer并指定符号文件,将地址映射为具体函数名,精准定位代码行。

常见问题和优化方向
引擎触发的泄漏:若定位到和Unity原生模块有关的占用(如有时会观察到AssetBundle序列化信息),需检查相关缓存机制。
外部C++代码占用:Lua、HybridCLR等方案的部分版本,或有些项目自己写了一些C++代码,都可能会导致一定的Native Heap占用,可在堆栈中观察到相应的分配函数、对象构造节点,从而予以定位优化。
渲染线程下图形API相关占用:最常见的有Shader运行时生成的GPUProgram信息;还有的项目因引擎设置或渲染功能导致的一些Buffer占用等。

13.6 .so mmap优化
.so mmap是Android系统对共享库文件的内存映射,属于“Untracked Memory”的重要组成部分,在游戏项目中,主要包含libunity、libil2cpp、插件、第三方SDK的.so文件。在中低端机型上,代码量过高或插件过多导致的.so mmap占用过高同样会挤压游戏运行内存,提升OOM闪退风险。

连接真机,执行adb shell dumpsys meminfo游戏包名,查看.so mmap对应的PSS占用,若超过设备总内存的10%或超过200MB,需予以关注。

只观察到.so文件的总占用显然不足以具体定位问题,还是需要进一步拆分具体是哪些库文件占用过高。

首先对硬盘中的文件进行静态分析,可解压APK安装包,提取lib目录下所有.so文件,统计文件数量与体积。

紧接着结合运行时的数据进行映射关系核对,通过adb shell cat/proc/游戏进程ID/maps,查看内存映射表,对比lib目录下的.so文件,确认运行时的实际占用,并检查是否存在未使用的.so被强制加载、重复映射或加载后未卸载等情况。

优化上,可优先减少浪费:按发行目标机型保留主流架构(如国内市场仅保留arm64-v8a);删除已弃用SDK的.so文件;剔除调试、测试用的工具类.so。

若libil2cpp.so体积过大,需检查编译打包时是否进行了合理的代码裁剪设置,避免最终包含大量未使用的引擎模块、第三方库代码。如,可尝试在Player Settings中开启Strip Engine Code,选择High级别的代码剥离;移除项目中未使用的Unity模块(如AR、VR、物理模块)。

若项目用到Lua等类似插件,也会出现liblua这类库文件占用过高的现象,同样也应关注代码量、代码裁剪是否合理。

针对以上代码导致的内存占用,还可检查是否存在以下不合理的情况,也都会导致.so文件内存占用过高:

  1. 泛型Template用的是否较多;
  2. 自动化生产的代码是否较多;
  3. Lua生产的Wrapper代码是否较多。

13.7 Wwise音频内存优化
Wwise作为移动端主流音频插件之一,采用事件驱动+SoundBank模块化架构,其内存管理独立于Unity Native内存与Mono堆内存,往往观察到其内存分配时主要体现在系统层Native Heap和Unknown部分内存的上升,一般可使用Wwise自带的编辑器工具分析、设置和优化内存。Wwise音频内存由元数据内存、媒体内存、引擎运行时内存、I/O与流送内存四部分构成。

  1. 元数据内存。加载Bank时分配,包含Event、Switch/State、RTPC、混音图、触发规则等结构,占用小但决定音频逻辑链路,卸载Bank可完整释放。

  2. 媒体内存。音频波形数据(WEM)的驻留区,是音频内存大头。和引擎自带音频累死地,需使用合理的加载方式。

  3. 引擎运行时内存。播放实例、路径计算、3D空间化、混音器缓冲区等动态开销,随播放启停自动分配与回收,受初始化设置中最大播放数、最大路径数约束。

  4. I/O与流送内存。流送音乐的预读缓冲区与Bank异步IO缓存,大小可按平台配置,不使用时可收缩归还系统。

在UWA观察到的使用Wwise的项目中,常见的性能问题和一般优化方案如下:

  1. Bank粒度失控。大Bank全量加载,整个Bank中可能只有个别音频同时被用到,而大多数音频对象用不到却随该大Bank长期驻留;过于碎片化的小Bank则可能导致元数据与IO开销叠加。需尽量按场景/关卡/功能拆分Bank,并配套加载前预载、切换后卸载的生命周期控制;高频通用音效(UI、点击、脚步)则打入SharedBank,全局一次加载。

  2. 加载/卸载不匹配:只加载不卸载、重复加载、跨场景残留引用,引发媒体内存泄漏;需结合Wwise Profiler监控相关资源缓存加载策略是否合理。

  3. 对象设置不合理:长音乐错误地用全额加载、高频短音效反而用流送,造成内存暴涨或解码卡顿。长音频应采用流式以降低内存;反复播放的短音频则采用全额加载以避免反复触发耗时。

  4. 初始化参数不合理。默认内存池、播放数、路径数、IO缓冲区过大,中低端机内存浪费。应同样地针对不同内存分档设计不同的策略和设置。


本文内容就介绍到这里啦,更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。