UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 简谱
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都不被引用时，释放其内存，回收的空间会重新加入堆的空闲列表，供后续分配使用。 这就涉及到堆内存分析中三个相当常见的问题： 堆内存的构成。我们不妨延续2.1章节的讨论。如图，结合Memory Profiler截图来看，Mono堆内存占用（即Reserved Mono）实际上主要分为3个部分，即依次为预留值（210.9-201.5的部分）、实际对象占用（Objects）、碎片（Empty Fragmented Heap Space）。开发者可控的部分基本只有对象实际占用部分，从上文的扩容机制来看、这部分基数越高则预留的部分也会越高。因此优化对象实际占用对内存的降低效果往往大于表面数值。 堆内存碎片。结合以上堆的扩容机制，游戏过程中常存在高频的堆内存分配，则在大块对象被释放后立刻分配小对象，即很容易出现碎片。在堆内存分配释放较为频繁的项目中，UWA常观察到对象实际占用的部分和碎片会逐渐趋于相等，因此只能通过尽量避免大小对象频繁交错分配释放的方式，减缓碎片逐渐上升的趋势；且与预留值同样地、降低对象实际占用仍是主要的优化对象 “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采用了一种三色标记的算法。每个对象都有三个状态：无法被访问到的对象是白色；可访问到，但没有递归扫描完全的对象是灰色；完全扫描过的对象是黑色。基于此，我们可以假定在任何时间点，下列条件始终成立： 所有被根集引用的对象要么是黑色，要么是灰色的。 黑色的对象不可能指向白色的。 白色对象集就是会被回收的部分；黑色对象集就是需要保留的部分；灰色对象集还需要被进一步遍历。 那么，在垃圾收集器工作过程中，通过充分遍历灰色对象，就可以把它们转变为黑色对象，直至所有灰色对象消失，收集过程也就完成了。 接下来，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文件过大，这将严重影响加载速度和增大内存占用量。冗余的原因在于： 大量的数据是重复的，或着是代表没有意义的空值（比如0，[]等）； 大量的中文字符串是需要游戏后期做本地化处理的； 很多复合型数据（子表，数组）内容是一样的。 基于此的优化方案为： 对于Excel中的一列，出现次数最多的值认定为默认值，然后把它从Lua表中剔除掉，然后利用metatable机制实现全局默认值存储； 对于中文字符串，替换为一个唯一的ID标识，写回到Lua表中，读取的时候加上相应的查找替换； 对于一些复杂的子表或着数组，做唯一化替换处理，替换后写回到原始数据中。 上面的操作其实做的都是唯一化处理，所以有个要求就是整个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文件内存占用过高： 泛型Template用的是否较多； 自动化生产的代码是否较多； Lua生产的Wrapper代码是否较多。 13.7 Wwise音频内存优化 Wwise作为移动端主流音频插件之一，采用事件驱动+SoundBank模块化架构，其内存管理独立于Unity Native内存与Mono堆内存，往往观察到其内存分配时主要体现在系统层Native Heap和Unknown部分内存的上升，一般可使用Wwise自带的编辑器工具分析、设置和优化内存。Wwise音频内存由元数据内存、媒体内存、引擎运行时内存、I/O与流送内存四部分构成。 元数据内存。加载Bank时分配，包含Event、Switch/State、RTPC、混音图、触发规则等结构，占用小但决定音频逻辑链路，卸载Bank可完整释放。 媒体内存。音频波形数据（WEM）的驻留区，是音频内存大头。和引擎自带音频累死地，需使用合理的加载方式。 引擎运行时内存。播放实例、路径计算、3D空间化、混音器缓冲区等动态开销，随播放启停自动分配与回收，受初始化设置中最大播放数、最大路径数约束。 I/O与流送内存。流送音乐的预读缓冲区与Bank异步IO缓存，大小可按平台配置，不使用时可收缩归还系统。 在UWA观察到的使用Wwise的项目中，常见的性能问题和一般优化方案如下： Bank粒度失控。大Bank全量加载，整个Bank中可能只有个别音频同时被用到，而大多数音频对象用不到却随该大Bank长期驻留；过于碎片化的小Bank则可能导致元数据与IO开销叠加。需尽量按场景/关卡/功能拆分Bank，并配套加载前预载、切换后卸载的生命周期控制；高频通用音效（UI、点击、脚步）则打入SharedBank，全局一次加载。 加载/卸载不匹配：只加载不卸载、重复加载、跨场景残留引用，引发媒体内存泄漏；需结合Wwise Profiler监控相关资源缓存加载策略是否合理。 对象设置不合理：长音乐错误地用全额加载、高频短音效反而用流送，造成内存暴涨或解码卡顿。长音频应采用流式以降低内存；反复播放的短音频则采用全额加载以避免反复触发耗时。 初始化参数不合理。默认内存池、播放数、路径数、IO缓冲区过大，中低端机内存浪费。应同样地针对不同内存分档设计不同的策略和设置。 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。
Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（上）
《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。 今天向大家介绍文章第二部分：资源内存、Mono堆内存等常见游戏内存控制，共13小节，包含了纹理资源、网格资源、动画资源、音频资源、材质资源等多个资源内存以及Mono堆内存等常见的游戏内存控制讲解。 本章包含1-7小节，8-13小节请点击《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存（下）》。 （全文长约11400字，预计阅读时间约20分钟） 文章第一部分《Unity移动端游戏性能优化简谱之 前言》可戳此回顾，完整内容可前往UWA学堂查看。 1. 总览 1.1 运行时内存占用 首先，在讨论内存相关的各项参数和制定标准之前，我们需要先理清在各种性能工具的统计数据中常出现的各种内存参数的实际含义。 在包括UWA在内的多种性能工具中，我们最常见到和关心的PSS（Proportional Set Size）Total内存，其含义为一个进程在RAM中实际使用的空间地址大小，即实际使用的物理内存（比例分配共享库占用的内存，按照进程数等比例划分），也即我们游戏进程的实际运行时内存占用。 不过，有的时候也会出现使用RSS Total参数的工具。它和PSS同样代表实际使用的物理内存，不过包含了共享库占用的全部内存，有时会偏大，参考性相对下降；而共享库内存是不会导致杀进程的。在下文它出现的时候，会额外标记。 当然，在实践过程中这两个值还会受到安卓系统版本、具体硬件机型等因素的影响。 无论如何，游戏运行过程中闪退，或暂时切出（如玩家游戏过程中切到社交软件回复信息）就被杀进程，对玩家体验显然是一种严重的破坏。而在UWA经验中，上述两个现象绝大多数情况下都是过高的运行时内存导致的。 当系统内存触发高占用告警时，系统会根据进程优先级自动终止部分进程以释放内存。这也是许多开发者在闪退日志中看到的Out of Memory（OOM）类型闪退的主要原因。 在系统的进程优先级体系中，包括游戏进程在内的玩家应用通常处于较低的一档；而在同一优先级层级中，内存占用越高的进程，被系统终止的概率也越大。因此，游戏进程往往成为被系统杀死的高风险对象。 由于我们几乎无法在引擎层面对系统进程优先级进行重新调度或调整，因此在实际项目中，控制PSS Total的整体占用，仍然是最常见且最有效的优化手段。 有了这样一个总值，当我们发现总值过高时，就需要予以拆解才能定位到问题。接下来，我们从两个视角来拆解PSS Total： 首先是很多开发者都有在用、相对熟悉的Memory Profiler。该工具对内存的划分和命名也一直在随版本调整，因此本文就比较常用的Memory Profiler 0.7.1和1.1.0版本进行分析，在笔者看来，两者各有千秋，实际使用时甚至经常两个一起用。以下是同一个Snapshot结果在两个不同版本上的解析结果： 0.7.1 老版的Summary界面把内存以三个维度进行拆分，比较清晰。其中还能看到In use和Reserved部分分别占了多少，体现出了预留内存。 但老版的细节分析页整合地较差，甚至Take Example时都能清晰呈现的Manager、SerializedFile等类型都没有区分出来。且还需要自己点击分类、隐藏不需要的字段、按大小排序，才能看到上图这样相对有价值的画面。 1.1.0 新版的Summary将原本最上层相对价值较低的维度删除，反之对五种资源内存做了拆分。但主要问题在于移除了预留值的呈现，全部（甚至包括堆内存的预留值）都被计入了Native。 新版的细节整合页明显要好的多，分类更全面清晰，且已经做好了排序、还有简单的可视化。但上面提到的预留值，包括IL2CPP虚拟机的内存都被算进了Unknown里。在UWA的一些排查经验中，还发现会有个别特殊情况会导致这里的Unknown特别高，比如项目的AssetBundle使用LZMA格式进行打包时。尽管知道是不怎么需要针对性优化的部分，但看到这几百MB的占用总还是比较糟心的；更别说很多开发者不理解这部分占用时产生的困惑了。所以，有时候两个版本都看一下作对比就能发现问题。 接下来我们还是取0.7.1的Summary进行详细介绍。可以看到，在图中的第一部分，Unity把统计到的运行时内存总值（此处Unity抓取了RSS内存）分成了两大部分：Tracked Memory和Untracked Memory。 Tracked Memory为Unity引擎分配的、引擎自身可捕捉定位的内存，其实也就等同于我们一直以来讨论的Reserved Total内存，也往往是游戏进程内存占用的大头；而Untracked Memory则恰恰相反，可能是系统层分配的、引擎无法捕捉定位的内存，甚至包括一些共享库内存。前者为Memory Profiler主要支持分析的部分。 而Tracked Memory还有In use和Reserved两种数据， 一般来说，引擎在分配内存时并不是向操作系统 “即拿即用”，而是首先获取一定量的连续内存，然后供自己内部使用，待空余内存不够时，引擎才会向系统再次申请一定量的连续内存进行使用。这表示除了实际使用的内存外，还会有一部分预留，两者加起来才是最终占用的内存，这种情况后面还会出现，不再赘述。一般来说，In use的部分越高，预留的部分也会越高，因此还是需要优先优化实际In use的部分。 在图中的第二部分，Unity把Tracked Memory进一步拆解。其中，需要我们关注的是以下四个部分：Managed Heap、Graphics&Graphics Driver、Audio和Other Native Memory。而其他的如虚拟机、Profiler、DLLs内存在最终IL2CPP的Release包中可能就不存在或占用很低了，一般不需要予以关注。 其中需要解释的是Graphics&Graphics Driver主要包含纹理、网格、RenderTexture等需要提交给GPU渲染的相关资源；而Other Native Memory则包含除了上述渲染资源和音频外其他的资源内存，如字体、粒子系统等，还包含了Scene Object、序列化文件等其它类型的资源。 上述所有内存，都可以在Tree Map\Object and Allocations中进行更详细地分类和分析。但Memory Profiler虽然信息较为完整，但局限性也在于只能截取单帧、最多能做到两帧比较。而实际分析内存问题的时候，内存对象的生命周期、内存统计量的变化情况都是极为重要的信息，而UWA GOT Online Resource模式提供的自动采样功能和衍生出的一系列资源列表的交互设计就要更有优势。 不过，我们在越来越多的项目中发现，Untracked Memory的部分占用出乎意料的高，甚至达到总内存50%左右的水平，这就需要我们通过第二个视角来分析。 虽然用Android Studio也能完成类似分析，但我们还是以adb指令获取的最直接的数据为例。连接真机后，输入“adb shell dumpsys meminfo 项目包名/进程号”指令，就能采样一次内存拆分如图例： 显然地，图中就主要展示了当前采样点下，前文提及的PSS Total及其组成部分。一般而言，需要开发者关注的有三个部分：Native Heap、Gfx dev/EGL mtrack/GL mtrack和Unknown。 其中，前文讨论的Unity Tracked Memory部分仅渲染资源能被系统层识别，所以其内存占用统计分散在了Gfx dev/EGL mtrack/GL mtrack和Unknown这两部分中。而前文讨论的Unity Untracked Memory部分则往往体现在一些外部代码、第三方库、插件等占用的内存上，他们则会分散在Native Heap和Unknown这两部分中。 因此，当我们发现项目中内存高压点存在Gfx dev/EGL mtrack/GL mtrack+Unknown远大于Tracked Memory，或Native Heap过高时，就有必要作进一步的排查了。 若研发团队对这些外部代码和插件的管理较为明确，则可以用对比测试的方式、开关一些插件后打包来看内存的变化；或者直接用一些插件自带的性能分析工具，如Wwise；若Lua使用比较重度，则使用UWA的GOT Online Lua模式进行针对性分析。 不过，在越来越多项目中会发现Native Heap这一部分的内存较高且有升高趋势，这就需要尝试用Perfetto工具协查。 综上，我们就详细解剖了一个移动端Unity项目的运行时内存，以此基础出发再去治疗病灶，就能做到对症下药。而通过以上排查流程在项目中发现、定位和解决内存问题的案例，会在后文相应小节中更加详细地呈现。 1.2 内存参数标准 在我们了解了内存相关的各项参数的含义之后，知道了避免游戏闪退的重点在于控制PSS内存峰值。而PSS内存的大头又在于Reserved Total中的资源内存和Mono堆内存。对于使用Lua的项目来说，还应关注Lua内存。 根据UWA的经验，只有当PSS内存峰值控制在硬件总内存的0.5-0.6倍以下的时候，闪退风险才较低。举例而言，对于2GB的设备而言，PSS内存应控制在1GB以下为最佳，3GB的设备则应控制在1.5GB以下。 特别的，UWA还认为Mono堆内存需要予以关注，因为在很多项目中，Mono堆内存除了存在本身驻留偏高或存在泄露风险的问题外，其大小还会影响GC耗时。 下表为UWA提供的细化到每一种资源内存的推荐标准，制定较为严格。不过，仍需要开发者根据自身项目的实际情况予以调整。比如某个2D项目节省了几乎所有网格资源的使用，那么其他资源的标准就可以放宽很多。 关于更多的细化标准，大家可以直接在UWA线上产品中进行对应查看。 基于项目实情制定内存标准后，一般需进一步与美术、策划协商，给出合理的美术规范参数，并撰写成文档。 定好规范后，定时检查项目里的所有美术资源是否符合规范，及时修改和更新。检查美术是否合规的过程，可以请程序同学帮忙在引擎中编写自动化工具，提高效率；也可以利用下文提到的UWA本地资源检测功能筛查存在设置问题的资源。 如果资源若不能批量处理成高中低配版本，就需要美术为各个画质等级制作不同的资源。 1.3 本地资源检测服务-项目资源检测 各项资源内存的引擎设置项繁琐且并不都能在运行时被采集，下文即将提到的内容虽然是众多项目中常见且重要的问题，但实际项目中的情况更加复杂。通过本地资源检测服务的项目资源检测界面，往往能发现更多资源设置项的问题。它们不光影响相关资源的内存占用，还会根据情况对CPU耗时和GPU造成不同程度的压力。 为此UWA根据经验设计了检测规则和阈值，以此为依据采集和统计了存在这些问题的资源，并给出了对应的优化建议，帮助开发者针对资源进行更加深入的排查和优化。 2. 常见的共通性问题 这一部分提到的问题没有特定性，不仅仅出现在一种资源内存中。所以，为了避免赘述，此处统一予以讨论。 2.1 疑似冗余现象 在UWA GOT Online Resource模式报告的具体资源列表（下文简称资源列表）中，我们常能看到某一项资源的数量峰值大于1且被标红。数量峰值同样是资源使用中非常重要的一项指标。所谓 “数量峰值”，是指同一资源在同一帧中出现的最大数量。理论上，数量峰值这一参数不应大于1，当数量峰值大于1时，列表中会将其标红，我们称之为疑似冗余资源。 一般情况下，出现这种问题有几种可能：最常见的冗余是由AssetBundle资源加载导致的，即在制作AssetBundle文件时，部分共享资源（比如Texture、Mesh等）被同时打入到多份不同的AssetBundle文件中但没有进行依赖打包，从而当加载这些AssetBundle时，内存中出现了多份同样的资源，即资源冗余，建议对其进行严格的检测和完善。 针对排查出的疑似冗余现象，可以使用UWA在线AssetBundle检测工具排查是否确实存在AssetBundle冗余的问题，尽量减少AssetBundle的冗余。建议根据冗余资源的内存大小来决定对冗余问题的优化优先级。 值得一提的是，所谓 “疑似冗余资源”，是指在检测过程中，我们尝试搜索项目运行时的冗余资源并将其反馈给用户。但是，我们并无法保证该项检测的100%正确性。这是因为，我们判断的标准是当两个资源的名称、内存占用等属性均一致时，我们认为这两个资源可能为同一资源，即其中一个为 “冗余” 资源。但项目中确实也存在资源不同但各项属性都相同的情况。因此，我们将通过以上规则提取出的资源归为 “疑似冗余资源”。所以，是否确实为冗余资源，还需要结合项目实情和在线AssetBundle检测报告才能下结论。 GOT Online为此问题还做了进一步的功能更新。现在，Resource报告和Overview报告都新增了关联AssetBundle报告的功能，以便开发者在Resource数据中发现资源冗余时，更便利地排查冗余是否与AssetBundle打包有关。 在利用包体版本号关联成功后，具体资源使用情况表中的资源会和AssetBundle报告中的资源进行匹配。对于匹配成功的参数，系统将以醒目的蓝色链接形式进行标识，方便开发者快速查看和追踪相关资源的详细信息。 通过资源名称旁的AssetBundle打包冗余Tag，开发者可以快速判断Resource资源冗余和AssetBundle打包冗余的情况。若发现异常，可以点击资源名称上的链接查看资源详情，查阅资源的详细信息，包括其名称、涉及的AssetBundle文件数量以及相应的AssetBundle文件名等，从而更全面地了解资源的状态和使用情况。 还有一种常见的冗余是加载和缓存策略导致的。举例而言，当一个资源被加载后，被某个容器引用着，而其相应的AssetBundle包通过Unload(False)卸载了，但这个资源并没有被卸载。当该AssetBundle包重新加载时，显然该资源就会存在多份。 当然，还有一些其他的冗余问题，比如首场景引用的资源又通过AssetBundle加载了一份；Always Include Shader引入后又通过AssetBundle加载的Shader等等，需要具体问题具体分析。 2.2 未命名资源 在资源列表中，有时发现存在资源名称为N/A的资源。一般来说名为N/A的资源都是在代码中new出来但是没有予以命名的。建议通过.name方法对这些资源进行命名，方便资源统计和管理，尤其是其中冗余比较严重的或者个别内存占用非常大的N/A资源应予以关注和严格排查。 2.3 常驻资源数量多/占用大 在资源列表中，有时结合资源的生命周期曲线发现，一批本身数量较为可观、同时单个内存占用较大的资源在某一时刻被加载进内存后，驻留在内存中，直到测试流程结束都没有被卸载，可能造成越到游戏后期资源内存占用越大、峰值越高。建议排查这些资源是否有常驻在内存中的必要。如果不再需要被使用，则应检查为什么场景切换时没有卸载；对于持续时间久的单场景中持续驻留的资源，则可以考虑手动卸载。 对于资源是否常驻的考量涉及内存压力和CPU耗时压力之间的取舍。简单来说，如果当前项目内存压力较大，而场景切换时的CPU耗时压力较小，则可以考虑改变缓存策略，在场景切换时及时卸载下一个场景用不到的资源，在需要时再重新加载。 特别地，针对纹理和网格这两种在多数项目中非常重要、内存占比也很高的渲染资源，UWA提供了一种更科学的方式来判断是否存在这类浪费。 在GOT Online GPU模式中，“渲染资源分析”这一功能通过GPU底层接口，实时采样获取了纹理和网格这两种资源在测试流程中参与渲染的情况。 首先，UWA提出了“渲染利用率”的概念。即测试采样过程中，捕捉到某一纹理或网格资源参与渲染的采样点数量占其在内存中采样点数量的比例。举例来说，一次测试总共进行了10000次固定间隔有规律的采样，其中6000次采样点中，A纹理被发现在内存中；而仅3000次采样点中，A纹理参与了GPU渲染。则A纹理的渲染利用率被判为50%。 事实上，在实际项目中，尤其现在很多移动端游戏也有3D大地图的设计，一些渲染物体被放置在场景的某个角落，玩家游玩过程中不总是能看到它、自然也就很难参与渲染。所以在测试中，一些渲染资源的渲染利用率即便只有百分之几甚至百分之零点几都是很正常的现象。但若发现整个测试流程、或单个测试场景里存在大量渲染利用率根本为0的资源，则存在浪费的可能性非常大。 这可能是因为：一些跨场景的资源被缓存住，在后续场景实际已经用不到没有及时卸载；打包加载了一些开发中已经被弃用的资源；打包和加载的机制过于激进，粒度太大，没有做好按场景和类型分包；场景设计中存在完全不可见或可见概率极低的不合理的渲染物体等等等等。 总之，导致浪费的原因多种多样，举例来说，UWA就发现很多项目会把一开始登录界面、Loading界面的高精度过场图一直缓存到后面的战斗场景。这就说明有些看似比较低级的错误，往往只有利用好的工具和排查流程才能予以根除。 只要在渲染资源分析页面中勾选“渲染利用率为0的资源数量”这一项，就能在下面的资源列表中筛查出本次测试捕捉到的疑似存在浪费的资源。基于此，开发者再回到工程中进行排查即可。（纹理和网格这一项概念的定义和检测没有本质差别。） 3. 纹理资源 3.1 纹理格式 纹理格式设置不合理通常是造成纹理资源占据较大内存的主要原因之一。即便是对于很多已经建立过美术资源标准并统一修改过纹理格式的项目而言，仍然很容易统计到存在大量的RGBA32、ARGB32、RGBA Half、RGB24等格式的纹理资源。这些格式的纹理不但内存占用较大，还会导致游戏包体较大、加载这些资源的耗时较高、纹理带宽较高等等问题。 出现这类问题的原因主要有以下几种：存在一些“漏网之鱼”，比如美术命名不规范导致没有被回调函数修改，或者是代码中创建的资源没有设置其纹理格式；硬件或纹理资源本身不支持目标格式纹理，导致被解析为未压缩格式的纹理。 对于前一种情况，在资源列表中发现有问题的资源后，需要回到项目中自行排查修改；对于后一种情况，UWA推荐的硬件支持的纹理格式主要有ASTC和ETC2。 其中ETC2格式需要对应的纹理分辨率为4的倍数；而即便是对于ASTC格式的纹理，在对应的纹理开启了Mipmap时更是严格要求其分辨率为2的次幂。否则，该纹理还是会被解析成未压缩格式。 3.2 Mipmap Mipmap设置本身并不降低内存，甚至还会使内存上升。但这里提到它的主要原因在于这个设置将作为后续大量讨论的前提，因此需要先对它有基本的认识。 当一张纹理开启Mipmap时，它的内存占用会上升为原始数据的1.33（4/3）倍。这是因为，引擎将它自动生成了多个Mipmap通道进入内存，实际上是它各个通道所占用的内存之和。举例而言，一个具有原大小为1MB的纹理（10241024分辨率、ASTC44格式），在开启Mipmap后获得了11个通道，其内存占用为1+1/4+1/16+...的11项等比数列之和，即约4/3。等比数列的各项就对应了Mipmap0层、Mipmap1层、Mipmap2层......各个Mipmap通道的内存占用。 其价值在于，移动端GPU在渲染采样纹理时，对于开启了Mipmap设置的纹理，总是会根据渲染物体到相机的距离，在不同的像素采样该纹理合适的Mipmap层级。 反过来距离可能更加好理解：如果在移动设备上，一个渲染物体的贴图高达20482048甚至更高，但实际画面中这个物体离相机非常远、绘制的像素区域很小，而GPU在绘制一个像素时，会同时保存NN范围的像素在OnChip高速缓存中，若在绘制下一个像素时还落在NN范围内，就不用向更远端的内存重新获取，从而降低Cache Miss概率。但此时20482048这一不合适的超高分辨率，使得实际绘制的两个像素在这个纹理中间隔得很远，GPU不得不总是要向更远的内存去取新的N*N，这样显然开销就增大了。于是就有了Mipmap这一机制供GPU自己选择合适的分辨率层级进行采样，这样一来，GPU带宽就能够得到大幅下降。 对于3D对象，比如场景中的地形、物件、人物，乃至3D空间中的粒子系统、Spine对象的贴图，只要是和相机存在不固定距离的，其纹理的Mipmap功能是建议开启的；即便是与相机距离恒定的UI纹理，即便一般不需要开启Mipmap，但也要求美术总是设计合理的分辨率。 回到内存的视角，开启Mipmap尽管会使一部分纹理的内存上升共几十MB，但考虑其到对带宽的优化和基于Mipmap能做的一系列后续优化手段，对于多数项目来说都是利大于弊的。 3.3 分辨率 纹理资源的分辨率（即资源列表中的长度和宽度参数）同样也是造成内存占用过大的主要原因。一般来说，分辨率越高，其内存占用则越大。其中最为需要关注的是占据较大分辨率（一般为 ≥ 1024）的纹理。对于移动平台来说，过于精细的表现通过玩家的肉眼很难分辨出差异，而过大的分辨率往往意味着不必要的浪费。 但是，降低纹理分辨率这种优化方式究竟是减少了浪费还是其实是牺牲了表现？我们的程序同学如何去说服美术同学调整纹理的分辨率呢？这里我们就要提到UWA在GOT Online GPU模式的渲染资源分析功能中提到的第二个概念：Mipmap0层采样率过低的资源数量。 与前文提到的渲染利用率概念类似地，工具在测试过程中进行频繁地采样，在渲染该纹理的采样点中，统计各Desired Mip层级所占的比例。举例来说，一次测试总共进行了10000次固定间隔有规律的采样，其中3000次采样点中，A纹理被发现参与了GPU渲染；而仅300次采样点的Desired Mip为0，也就是这些采样点中GPU尝试选择第0层Mipmap进行采样（因为对于未开启Mipmap的纹理同样能采集该值，所以称之为“尝试选择”）；另外2700次采样点的Desired Mip为1。则A纹理的Mipmap0层采样率判为10%。 事实上，在实际项目中，在3D空间下，玩家游玩过程中相机和一个渲染物体的距离可能时而远时而近。但若发现整个测试流程、或单个测试场景里，某一个纹理的Mipmap0层采样率小于5%，则说明GPU极少有必要用到分辨率最高层级的纹理，在多数时候这部分纹理造成了内存占用但根本不参与渲染，可能是一种浪费。更极端来说，一个分辨率过高的纹理可能实际上0层、1层、2层......加起来的利用率也不足5%，浪费更加严重。 只要在渲染资源分析页面中勾选“Mipmap0层采样率过低的资源数量”这一项，就能在下面的资源列表中筛查出本次测试捕捉到的疑似存在浪费的资源。基于此，开发者再回到工程中进行排查即可。举例而言，如下图红框圈出的Tex_B3_Songshu这一资源，从名称上来看大概率是一个松鼠模型的贴图资源，也确实开启了Mipmap。但作为一张10241024的高分辨率纹理，其实际渲染过程中Desired Mips0和1层的占比都为0，直到2层才有85.71%。这也就意味着该纹理直接改为256256也不会对表现有任何影响，同时还能节省绝大部分的内存占用。 当然，在不同档位的机型上使用不同分辨率大小的纹理资源本身是非常实用且易操作的分级策略。 这一点即便对于图集纹理也同样适用，特别地，Unity针对SpriteAltas提供了Variant功能，可以快捷的复制一份原图集并根据Scale参数降低该变体图集的分辨率，以供较低的分级使用。 3.4 Global Mipmap Limit & Texture Streaming 除了通过上述方式筛查存在浪费的纹理并一一修改外，对于纹理大量开启Mipmap并且纹理内存压力较大的项目来说，还有两种自动控制纹理总内存的方式。即Project Settings-Quality设置下的Texture Quality/Global Mipmap Limit和Texture Streaming。 Texture Quality/Global Mipmap Limit 在一直以来的很多Unity版本中提供的Texture Quality设置，实际上是一种颇为“暴力”的手段。开启后，引擎会在相应分档上强制移除所有开启Mipmap的纹理的相应层级。比如若设置为Half Res，则所有Mipmap 0层的纹理都不会进入内存。这种一刀切的做法一半在内存压力极大的低端机上出现、降低内存的效果非常好，但很多项目并不会选择这个做法，因为可能对表现的影响较大。 不过，在较新的Unity版本中（图中为2022.3.60版本），该功能被重置为Global Mipmap Limit，其按Mipmap层级强制移除的逻辑和之前是一样的。不过除了这种全局影响所有Mipmap纹理的设置外，多出了按照“Mipmap Limit Groups”分组设置移除层级的方式。在Quality中设置不同组别移除不同数量的层级后，在纹理的高级设置里勾选应用Mipmap Limits并选择所在分组，即可对不同纹理应用不同的Mipmap层级移除策略。用过UE的开发者可能会比较眼熟，不过Unity这边是单独维护了一套纹理组来专门处理Mipmap的使用。 Texture Streaming 在项目资源量很大且需要针对不同分档应用不同分级策略时，上述功能很可能仍不足以满足开发者的需求，维护成本太高、又显得不够灵活。 Texture Streaming则是基于一种流式加载场景控制资源的思路，由引擎自行判断哪些Mipmap层级该进内存，从而节省一部分纹理占用。 但Texture Streaming的使用过程中是有些坑存在的： 注意点一：在Project Settings-Quality中开启Texture Streaming选项。然而实验发现有时Editor中开启该选项、真机上却会失效，导致所有纹理的Streaming Mipmap设置全部失效。应该在代码中调用API全局地开启这个选项，才能确保Texture Streaming可用。 注意点二：正确的参数才能使该功能起到好的效果。比较重要的参数是Memory Budget参数和Max Level Reduction参数：Memory Budget表示纹理资源的预算，默认值是512MB，但对于比较常规的移动端分档策略来看，中低端机上一般200MB左右就够了。它的数值代表的是所有纹理资源的预算——即，它既包括了非流式的纹理、又包括了我们想要采用流式的纹理——但这个”预算”并不代表纹理资源可占用的上限，只是Unity判断对于一个开启Streaming Mipmap的纹理到底采用它的哪些Mipmap通道的参考值。而Max Level Reduction则是代表Unity至少要保留的Mipmap通道（如设置为2时，尽管内存已经超过了预算，引擎仍最多只能移除0、1层的Mipmap纹理）；同时Max Level Reduction也代表了纹理流式系统在初始加载一张Texture时加载的Mipmap等级（如设置为2时，纹理刚加载时是不包括0和1层的，只有当预算富余时才会加载）。 所以整个判断的流程是，先将所有非流式纹理正常加载；将流式纹理按照Max Level Reduction剔除高分辨率后的部分加载进内存中。然后将此时所有纹理内存之和和Memory Budget比较，如果已经饱和甚至超过，则不再变化；如果距离Memory Budget的值还有盈余空间，则会酌情加入流式纹理中更高分辨率的Mipmap通道，使纹理的表现更好。 也就是说，如果预算值设置的较高，或者项目中纹理占用的内存本来就比较低、远低于预算值，则Texture Streaming实际上几乎完全不起效、所有开启Streaming Mipmap的纹理仍将保留它们的全部Mipmap通道。 注意点三：设置开启Streaming Mipmap的纹理。官方文档只说了该设置会对开启了Streaming Mipmap设置的纹理生效。从实际操作来看，Texture Streaming的生效对象其实更苛刻，需要同时满足： 开启了Streaming Mipmap且开启了Generate Mipmap的纹理（显然开启Generate Mipmap才会生成Mipmap通道供Streaming Mipmap剔除）； 被即时加载的纹理（如通过AssetBundle加载或Resources.Load()加载的纹理）； 传向GPU部分内存（这里指的是纹理资源开启Read/Write选项时，复制到CPU端的那一部分内存是不受Texture Streaming影响的）。 注意点四：勾选Texture Streaming设置后，运行时会出现一个名为TextureStreamingManager.Update的函数持续造成开销。这是因为引擎要持续计算哪些层级的纹理能够被加载进内存，若项目使用的纹理数量多，则该耗时在低端机上颇为可观。因此要确定当前的Texture Streaming设置是否正确、是否正常生效；节省的内存是否值得这额外的耗时开销。 3.5 Read/Write Enabled 上文提到过，纹理资源的内存占用是计算在GFX内存中的，也就是传向GPU端的部分。而开启Read/Write Enabled选项的纹理资源还会保留一份内存在CPU端，从而造成该资源内存占用翻倍。 UWA GOT Online Resource模式报告资源列表或是本地资源检测报告中都直接展示了哪些纹理开启了Read/Write Enabled选项。实际上，不需要在运行时进行修改的资源是不需要开启Read/Write Enabled选项的，开发者应排查并关闭不必要的设置从而降低内存开销。 3.6 图集制作 图集制作不够科学也是项目中常会发生的问题。资源列表中有时会出现数量峰值较高的图集纹理，但不一定是冗余。一种情况是，大量小图被打包到同一图集中，导致该图集纹理资源设置的最大分辨率（比如2048*2048）一张装不下这么多小图，该资源就会生成更多的纹理分页来打包这些小图。因此，只要游戏过程中依赖某一张纹理分页中的某一张小图，就会将该资源、也即该资源下所有的分页都全部加载进内存中，从而造成不必要的浪费。所以一般建议控制到2-3张分页以内较为合理。 即便不出现上述这个较为极端的现象，很多项目中也会出现“牵一发而动全身”的现象。即明明只用图集中的一张或几张小图，却将内存占用颇大的整个纹理都加载进了内存。 为此，在制作打包图集时，严格按照小图的使用场景、分类进行打包是非常重要的策略。选用合适的分辨率从而避免纹理没有被填充满而导致浪费，也是开发者需要注意的点。 3.7 使用TextMeshPro的情况 TextMeshPro能为UI组件提供更好的表现和便利的功能，使得其受到不少开发者的青睐。但使用TMP而产生的TMP字体图集纹理（名称中带有SDF Atlas，格式为Alpha 8的纹理）也有一些坑值得注意。 有时，结合字体资源列表注意到内存中还存在TMP图集纹理对应的.ttf字体文件。说明该TMP字体图集为动态字体。可以考虑在项目开发结束、确保游戏要用到的字符都已添加到动态字体的Altas纹理中后，将动态TMP重新设置为静态TMP，并且解除对.ttf文件的依赖。这样一来，对应的字体资源将不会出现在内存中。不过，如果这种字体还被用作用户输入，则不建议采用此方法。 Atlas字体纹理的分辨率较大。此时建议在引擎中排查字符有没有填满图集纹理，纹理的制作生成是否合理。对于动态TMP，如果没有填满，如只占据了纹理的3/4不到，则可以考虑开启Multi Atlas Textures选项，并设置纹理大小，举例而言就可以使1张40964096的纹理变为3张20482048的纹理，节省32MB-3*8MB=8MB的空间。 资源列表中有TMP相关的资源（LiberationSans SDF Atlas、EmojiOne），它们都是TMP的默认设置，可以在Project Settings-TextMesh Pro Settings中解除对这些默认资源的依赖，就不会出现在内存中了。 由于Multi Atlas Textures是动态TMP的选项，所以第1、2点无法同时使用，可以根据项目实情酌情选用。 4. 网格资源 4.1 顶点和面片数 顶点和三角形面片数过多的网格资源不仅会造成较高的内存占用，同时也不利于裁剪，容易增加渲染面数，在渲染时对GPU和CPU造成压力。针对这些网格，一方面可以简化网格，减少顶点数和面数，制作低模版本，供中低端机型分级使用；设计制作好合理的Level of Details系统；而对于单个顶点数过高的静态网格，比如一些复杂的地形和建筑，可以考虑拆分成若干个重复的小网格重新拼接，能够节省内存。如果能通过SRP Batcher合批，或规划好作运行时静态合批，又或对大量重复物体用GPU Instancing制作，则渲染耗时也还是能得到良好控制。 但是，降低模型面数这种优化方式究竟是减少了浪费还是其实是牺牲了表现？我们的程序同学如何去说服美术同学调整模型的复杂度呢？又或者怎样的LOD设计才是比较合理的呢？对于这个有点眼熟的问题，我们需要引出GOT Online GPU模式的渲染资源分析功能中提到的第三个概念：渲染顶点密度过高的网格。 同理，工具在测试过程中进行频繁地采样，在渲染该网格的采样点中，将该网格每次参与绘制的顶点数量和绘制的像素数量作比值，当这个比值最小的时候都超过每10000像素画1000个顶点这个水平时，认为其渲染密度过高。形象地说，10000像素为100*100的范围，实际为大拇指指甲盖大小的、屏幕中很有限的一个范围内，却至少绘制了超过1000个网格顶点，可以直观地感受到这显然是肉眼难以分辨的精细程度，而在实际的GPU渲染过程中，部分三角形也很可能被GPU进行微小面裁剪剔除，对表现毫无贡献。 这种规范来自于移动端GPU硬件厂商提出的最佳渲染实践概念，认为：一个合理均匀的网格，其顶点数量和三角形数量的比例应在1.5：1最为合理；每个三角形至少由10-20个像素进行绘制最为合理。UWA将这两个因素综合，提出了具有可行性和现实意义的标准。 只要在渲染资源分析页面中勾选“渲染顶点密度过高的资源数量”这一项，就能在下面的资源列表中筛查出本次测试捕捉到的疑似存在浪费的资源。基于此，开发者再回到工程中进行排查即可；也同样适用于排查Level of Distance的各层级模型是否仍精度过高。 4.2 顶点属性 如果没有统一美术资源标准且在导入时没有进行处理，则项目中的网格很有可能包含大量“多余”的顶点数据。这里的“多余”数据是指网格数据中包含了渲染时Shader中所不需要的数据。举例而言，如果网格数据中含有Position、UV、Normal、Color、Tangent等顶点数据，但其渲染所用的Shader中仅需要Position、UV和Normal，则网格数据中的Color和Tangent则为“多余”数据，从而造成不必要的内存浪费。其中，一个小网格资源带有顶点属性，会使所在的Combined Mesh也带有顶点属性，需要予以注意。 针对这个问题，一个比较简单的方法是，尝试开启“Optimize Mesh Data”选项。该选项位于Player Setting的Other Settings中。勾选后，引擎会在发布时遍历所有的网格数据，将其“多余”数据进行去除，从而降低其数据量大小。但是，需要注意的是，对于在Runtime情况下有修改Material需求的网格，建议研发团队对其进行额外的注意。如果Runtime时需要为某一个GameObject修改更为复杂、需要访问更多顶点属性的Material，则建议先将这些Material挂载在相应的Prefab上再进行发布，以免引擎去除Runtime中会进行使用的网格数据。 4.3 Read/Write Enabled 在资源列表中，常常统计到大量顶点属性不显示为-1（或“-”）的网格资源。只有网格资源开启Read/Write时，UWA报告才能采集到顶点属性信息。此时，顶点属性不显示为-1，且会使得网格占用内存上升。一般而言，不需要在CPU端进行修改的网格是不需要开启Read/Write的。可以在编辑器中通过API修改这些网格的Read/Write属性，或者对于FBX中的网格可以直接在Inspector窗口中修改。 5. 动画资源 动画资源的内存占用与运行时性能开销，核心取决于动画类型选择、压缩策略配置及曲线精度控制，不合理的设置易导致内存冗余或CPU/GPU额外消耗，需结合项目场景与机型分级针对性优化。其中： 动画类型选型。现在Unity的主流方案为Generic动画类型（基于Mecanim系统，耗时体现在Animators.Update函数），其多线程计算支持与资源利用率优于Legacy类型（耗时体现在Animation.Update函数），但这种优势尤其体现在骨骼动画上，一些简单的动画（如UI动画），其性能表现可能反而不如Legacy；其中，人形角色可选用Humanoid类型，支持动画重定向但需遵循人形骨骼规范，避免无意义使用高开销类型。 动画压缩策略。将Anim. Compression设置为Optimal，Unity会自动选择曲线优化算法（如关键帧合并、数值量化），在保证视觉效果的前提下最小化内存占用；对精度要求较低的动画（如背景特效、次要角色动作），可选用KeyframeReduction模式，手动调整Reduction Tolerance参数（建议0.01-0.1区间），减少冗余关键帧。一般不建议使用默认的Off模式，否则动画数据无任何压缩，内存占用可能翻倍。 精简动画内容。剔除无效曲线（如未使用的Scale曲线、冗余的骨骼动画通道），删除空白关键帧或合并相似帧；缩短非必要动画时长，移动端单段动画内存占用建议控制在200KB以内，复杂动画可拆分为多个短片段按需加载。 精度与采样率控制。通过API压缩动画精度，例如使用AnimationUtility.SetAnimationClipSettings调整动画帧率（移动端建议15-30fps，一般不建议用60帧动画）；对循环动画（如呼吸、待机动作），可减少循环段的关键帧密度，利用插值计算保证流畅度。参考Unity官方优化方案，可通过代码遍历动画曲线，合并差值小于阈值的关键帧，进一步降低数据量。 还可以结合项目实际情况关注： 动画资源复用。将通用动画（如行走、攻击、待机）提取为公共资源，通过Animator Controller的状态机复用，避免重复创建相似动画片段；利用动画重定向功能，使同一套动画适配不同模型，减少资源冗余。 内存与性能权衡。压缩策略（如Optimal）可能导致轻微精度损失，需通过真机测试验证核心角色动画、关键技能特效的表现是否符合预期；对中低端机型，可关闭次要动画的Root Motion、IK等功能，降低CPU计算压力。 资源检测与筛选。通过UWA本地资源检测工具筛查精度过高、冗余关键帧过多的动画资源，重点关注单帧数据量过大、动画时长与实际需求不匹配的资源；对内存占用超500KB的非核心动画，应考虑要求美术重新优化或拆分。 分档策略。复杂动画（如多骨骼联动、物理驱动动画）建议分档处理，高端机使用完整精度版本，中低端机替换为简化版动画（减少骨骼数量、降低帧率）。 6. 音频资源 音频资源的内存占用与运行时性能开销，核心取决于加载方式、压缩格式、声道类型及采样率配置，不合理的设置易导致内存冗余、加载卡顿或播放时的CPU额外消耗，需结合音频类型（BGM/音效/语音）与机型分级针对性优化。其中： 声道类型优化 优先开启Force To Mono选项，将双声道音频自动混合为单声道。该设置不会丢失核心音效信息，却能使音频内存占用直接减半，是移动端音频优化的 “性价比之王”。仅对核心立体声需求的音频（如3D空间音效、环绕声BGM）保留双声道，其余音频（如UI音效、角色语音）强制单声道。 选择合理加载方式 短音效（时长＜3秒，短促且会反复播放，如点击、碰撞音效）：选用Compressed In Memory加载模式，音频以压缩格式驻留内存，播放时快速解压，平衡内存与耗时； 长音频（时长≥30秒，时间较长，如背景音乐；或10＜时长＜30，时间适中但不怎么会重新播放，如剧情语音）：选用Streaming流式加载模式，仅加载音频头部信息与缓存片段，内存占用可稳定控制在200KB以内，避免一次性加载导致的内存峰值； 一般不建议使用默认的Decompress On Load模式，该模式会在加载时完全解压音频数据，导致内存占用暴涨（如10MB的压缩音频解压后实际占用可能达50MB以上）。 压缩格式与质量调整。 对于Compressed In Memory模式的音频，优先选择高压缩比格式：Android平台推荐Vorbis，iOS平台推荐MP3，两者压缩率均高于Unity默认格式，可降低30%-50%的内存占用； 调整Quality参数进一步优化：非核心音效可将质量设为50%-70%，语音类音频设为60%-80%，BGM设为80%-90%，具体需结合实际项目中声音效果，在音质可接受范围内最大化压缩收益； 尽量避免使用PCM等无压缩格式，其内存占用是压缩格式的5-10倍，仅适用于对音质要求极高的短时长核心音频（如关键剧情语音片段）。 采样率与位深精简。 移动端音频采样率建议按类型分级设置，不过也同样需要结合实际的声音表现进行调整。 还可以结合项目实际情况关注： 音频资源复用与裁剪 提取通用音效（如按钮点击、道具拾取）作为公共资源，避免重复创建相似音频文件； 裁剪音频首尾的空白片段，缩短无效时长，减少内存占用与加载耗时。 分级策略适配 高端机型：保留双声道、高采样率与高压缩质量，优先保证音质表现； 中低端机型：强制单声道、降低采样率、关闭非核心音频（如环境音效），优先保证内存与帧率稳定。 加载缓存策略 集中加载大量音频资源的场景需要结合使用需要调整加载缓存策略。例如，语音类音频建议采用分段加载策略，避免一次性加载完整剧情语音包，减少内存压力。 7. 材质资源 材质资源本身内存占用较小，我们一般更加关注如何优化其数量，因为它的数量过多会影响之后会提到的Resource.UnloadUnusedAssets API的耗时。 材质资源数量过多，往往主要是因为Instance类型的冗余Material资源过多。一般来说，该种情况的出现是因为通过代码访问并修改了meshrender.material的参数，因此Unity引擎会实例一份新的Material来达到效果，进而造成内存上的冗余。对此，建议通过MaterialPropertyBlock的方式来进行优化，具体相关操作和例子见如下文章《使用MaterialPropertyBlock来替换Material属性操作》。不过这种方法在URP下不适用，会打断SRP Batcher。除此之外，则需要关注和优化非Instance的材质资源的疑似冗余现象。 除了数量上的问题外，材质资源往往还涉及到一些纹理采样和Shader使用相关的问题，导致一些额外的内存和GPU性能浪费，如： 对于使用纯色纹理采样的材质，可以将纹理采样替换为一个颜色参数，从而节省一张纹理采样的开销；而对于空纹理采样的材质，Unity会采样内置提供的纹理，但是计算得到的颜色是一个常数，仍然属于浪费。 上述问题也都可以通过UWA本地资源检测筛查静态资源筛查，比起人工排查大量材质资源高效地多。 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。
《Unity移动端游戏性能优化简谱》是从Unity移动端游戏优化的一些基础讨论出发，例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题，并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题，希望能提供给Unity开发者更多高效的研发方法和实战经验。 今天向大家介绍第一部分，共五小节。完整内容请前往UWA学堂查看。 玩家眼中的游戏性能 开发者关注的游戏性能 如何确定问题 如何解决问题 如何持续监控性能 前言 1. 玩家眼中的游戏性能 任何一个玩家在游玩游戏过程中都会碰到类似的疑问：为什么我玩一段时间就会闪退？为什么我的游戏这么卡？为什么我的硬件这么烫？为什么移动设备电量下得这么快？ 对于玩家而言，无关手里这款游戏本身的美术是否精良、玩法是否有趣，当上述这些疑问出现时，其游戏体验会自然而然地变差，并不可避免地导致黏性下降。这是每个游戏开发者都不想看到的结果。 而实情是，对于如今的移动端游戏市场而言，国内市面上各类硬件机型性能跨度很大；如果项目要支持海外市场，则往往项目所要承担的性能压力更大。那么，如何让持有不同硬件设备的玩家，尤其是通过中低端设备接触到我们游戏项目的玩家也能顺利流畅地体验游戏内容，就是我们今天讨论的主要话题。 2. 开发者关注的游戏性能 在游戏项目的开发过程中，一个非常常见的现象是：研发团队在新项目的中前期往往把全部精力投入到实现功能中去，而忽略了性能优化的工作；到了游戏中后期甚至临近上线的时候进行测试，才发现项目充斥着大量严重的性能问题、影响到了游戏的正常体验，但却不知道从何改起、时间非常窘迫；再到上线后，受到大量玩家对性能的负面反馈，留存数据也受到影响。 足以见得，性能问题是一款游戏项目重要的生命线，需要开发者树立良好的优化意识和优化知识体系，才能让项目少走弯路。 和玩家视角不同的是，像闪退、卡顿、发热、耗电这种感官层面的性能问题，UWA认为在开发者的视角中应该将其主要分类为内存、CPU、GPU三个大类的问题。本文的主要内容旨在围绕三个大类的常见典型问题进行例举和讨论，从而形成一份Unity移动端游戏性能优化方法论的简谱。 那么，既然已经发现了若干性能问题，如何排查确定问题和解决问题就成了最关键的两个步骤。 3. 如何确定问题 发现了性能问题后的第一大步骤就是确定问题。 举例而言，我们的游戏项目运行时间一长就会闪退，是因为我们的缓存策略做的不好或者存在冗余现象，导致内存不断上升造成的闪退？还是因为我们的美术资源内存控制的不好，几个较大的资源就轻易把整体内存给撑大了？ 又或者我们发现一个游戏场景卡顿严重、帧率很低，是因为这里的同屏渲染面片数和DrawCall数都太高导致渲染模块压力大？还是因为此处的UI过于复杂且变化频繁造成的更新耗时？ 还或者我们感到手里的手机烫的惊人，是不是因为当前场景GPU压力过大，我们在当前机型上做的分级策略并不合适？ 以上只是众多千奇百怪层出不穷的性能问题中的寥寥几点。后文还将详细讨论一些比较常见的问题。总之，确定问题的工作本身也是一项挑战。 3.1 计算机语言/图形学/游戏引擎知识 知识和经验无疑是一个开发者在优化项目性能时最宝贵的武器，不断地学习和更新也是每一个程序员武装起自己的过程。完善而饱满的知识自然不仅仅有利于确定问题，同样有助于解决问题。放在这里说是因为它是我们有能力着手优化工作的重要前提。 除了查看各类工具源码和文档、跟踪工具更新内容、翻阅大牛撰写的书籍、浏览各类技术论坛和博客等等以外，也可以在UWA的技术社区中找到很多优质内容。 UWA 学堂：持续更新各类优质课程，内容从某些开发工具的入门和使用到某个引擎模块的机制原理详解；从最新的移动端游戏技术实践到精品项目案例讲解等等。还邀请了众多具有多年从业经验的业界大佬和著名游戏公司合作伙伴撰稿，涵盖面广、质量颇高。更有每年侑虎科技UWA主办的UWA DAY游戏开发者大会的优秀内容。 UWA问答：帮助开发者找到更好的答案。是游戏开发者们日常沟通交流技术问题的良好平台。在这里你对游戏制作方方面面的任何问题，都能够得到讨论或解答。通过最直接的技术交流来少走弯路、获得经过实践检验的开发经验，让确定和解决问题的过程变得简单许多。 UWA开源库：帮助开发者发现更好的解决方案。不断更新和整合了各种来源的开源项目，翻译完毕、分类清晰、图文并茂，为开发者提供了更加方便地搜索发现和获取开源资源的渠道。 UWA Blog：包含UWA精选的各类技术文章、提醒学堂优质内容上新、整合问答优质问题、介绍UWA产品和其更新等各类UWA社区动向。 3.2 多种多样的辅助工具 工欲善其事必先利其器。使用工具获得直接精确的性能数据，能更加直观地反映出性能瓶颈，从而达到事半功倍的效果。 随着业界的发展和成熟，各大引擎、IDE和硬件厂家都推出并更新着自己的性能分析辅助工具，也有不少开发者或团队喜欢根据自身项目的情况开发自己的性能监控插件。 以引擎而言，Unity Profiler是很多开发者都会用且在用的工具，可以记录并查看CPU/渲染/UI/内存等各模块的实时消耗参数；此外，Unity还衍生出Frame Debugger、Memory Profiler等越来越成熟有用的编辑器性能分析工具。 以IDE而言，Windows、MAC OS/iOS、Andriod平台都分别有适用的工具，如Visual Studio的性能探查器、XCode的Instruments、Android Studio的Profiler等。 以硬件而言，SnapDragon Profiler、XCode Metal Frame Capture、Mali Graphics Debugger等种类就更多了，不一一赘述。 总而言之，根据开发者的使用熟练度、不同硬件平台的兼容性、工具功能的覆盖面等等因素，开发者在挑选合适的性能优化辅助工具时很容易挑花眼 3.3 UWA工具 那么有没有一款简单易用、兼容性强、功能全面的性能优化辅助工具呢？ 当然有啦—— UWA GOT Online工具可以通过UWA提供的SDK快速集成到测试项目中，在真机上完成测试流程后，在极短的时间内完成数据的上传和解析，从而自动生成一系列可视化的图表。与此同时，还基于UWA丰富的优化经验和庞大的数据库进行评分，根据各个性能模块提供针对性的分析建议和性能参数的变化趋势。 不仅如此，UWA还提供了真人真机测试服务、在线AssetBundle检测、本地资源检测、Pipeline、GPM 2.0、Gears、深度优化驻场等工具或服务，从不同维度满足开发者的优化需求。 4. 如何解决问题 性能的第二大步骤就是解决问题。在有了知识经验和上述工具的加持下，我们的项目越是复杂、越是来到后期，就越是会面临一条长长的优化问题清单。在有限时间里合理地规划并缩短这条清单，就是我们面临的下一项挑战。 4.1 优化工作优先级 在进行具体的优化工作前，合理的规划往往能节省大量总体时间。 规划工作的第一点就是要抓住问题的主要矛盾。在一条长长的优化清单中，把每一项条目并行地、完全地优化完毕往往是不现实的。此时就需要重新整理这份清单，按照优先级重新进行排序，为优化性价比更高的问题投入更多。 对于优先级的判断，UWA认为主要参考两点：一是重要性，如果一个问题对当前场景的性能压力影响远大于其他因素、显然是主要矛盾，如果不予修正很难维持正常的游戏体验，那么就应该投入更多的人力时间集中优先解决；二是易操作性，如果一个问题造成的性能压力相对来说并不大，但只是耗费极少功夫的举手之劳，比如只是在引擎设置中关闭一个开关，就应该优先把它改掉。 4.2 性能和效果的权衡 第二点则是一种取舍，针对资源和渲染等部分的优化往往会影响到游戏的表现效果。为此，程序员往往需要和策划、美工同事加强沟通，在两者之间找到一个平衡点。这种平衡是一种动态平衡，需要结合实际情况操作。比如，一款吃鸡游戏画面变化频繁、且需要流畅的联机战斗操作，那么对表现的要求可以适当低一些；而一款养成类游戏把精美的角色作为卖点，其模型和动画就需要非常精致，而牺牲一部分性能。哪怕在同一款游戏中也可以在过场动画和角色展示界面中注重表现，而在战斗场景中关注性能，需要具体问题具体分析。 总的来说，游戏项目的优化最终目标是能够在玩家硬件上顺利运行，而为此放弃很大一部分表现效果，也是大多数开发团队的实际选择。但，UWA在大量的项目优化实践中发现，往往很多对表现并无贡献、属于完全浪费地性能问题占据了相当大的比例，优先解决这类问题的优先级显然要更高。 4.3 分级策略和制定标准 第三点，在每一类机型上都达成上述的平衡就是我们的分级策略。我们大可以在高端机乃至旗舰机上开启后处理、使用高分辨率高模；但在中低端机型上则使用另外一套标准。 制定分级策略同样需要我们根据项目类型调整。更细节准确的分级还应参考项目发行目标市场的硬件机型和同类游戏的分档经验，具体可以参考文章《基于移动设备算力的UWA性能标准系统上线》。 另外，开发团队还最好制定一套详尽合理的标准来管理约束整个开发流程。最常见的就是制定美术资源标准，从而极大程度避免内存问题的失控。Unity还提供了回调函数在资源导入时就进行归一化设置，具体也可以在UWA学堂中找到相关课程《自动化规范Unity资源的实践》。 4.4 UWA建议 规划完毕后就来到了具体的优化工作。这个工作冗长且需要耐心，而UWA很乐意在这个过程中继续提供帮助： 你当然可以在UWA社区中寻找方法，或者是在UWA工具的自动化报告中找到解决方案。你还可以联系UWA工作人员，从而提供真人真机测试报告1对1讲解服务或者是更进一步地深度优化服务。UWA的引擎技术团队将对游戏项目进行更加详细的诊断和建议。 5. 如何持续监控性能 在迈出了性能优化的前两步后，很多开发团队都想要或者尝试走出第三步，也就是持续监控性能的这一步。 为此，一些团队安排专门的引擎优化人员、开发自己的DevOps工具。当在团队内部逐步形成较为成熟的相关体系后，将节省项目后续开发维护的成本，并可使经验延续到其他新项目中，大大提升开发效率。 而另外一些团队则有在游戏上线后继续监控性能的必要。 5.1 UWA Pipeline 针对这些需求，UWA推出了UWA Pipeline。它是一款面向游戏开发团队的本地协作平台，旨在为游戏开发团队搭建专属的DevOps研发交付流水线。 UWA Pipeline主要包括： 专属的DevOps研发交付流水线 私有云真机远程调试 可视化配制自动测试 Pipeline内快速创建UWA服务 支持远程构建 五大功能，提供了专为游戏设计的DevOps研发交付流水线，且集成了各项UWA工具和服务，构建简单、操作便利。更多详细信息欢迎点击下文了解。 《速成，掌握UWA Pipeline 功能和使用方法》 《UWA Pipeline的使用与自动化流水线的搭建详解》 5.2 UWA GPM 2.0 UWA持续吸取各个开发团队对于性能监控方面的需求，发现游戏上线后玩家端的性能情况在很多时候具有无可替代的参考价值，协助开发者发现许多在研发迭代、Development包阶段难以发现的诸多性能问题，从而指导开发团队后续的性能优化方向。 GPM 2.0是一款基于UWA Pipeline、面向游戏开发团队的游戏运行性能监测平台，旨在帮助游戏开发团队监控和分析游戏项目上线后玩家真实的性能运行情况，及时发现并解决游戏性能问题，进而提升玩家游戏体验。 主要提供： 游戏宏观性能指标的分钟级实时监控 根据游戏版本、游戏场景、硬件设备分级的数据聚合分析与对比 可自由配置的性能异常场景次数统计 具体到玩家的深度性能数据分析 游戏运行的硬件信息统计 本文内容就介绍到这里啦，更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。