1 Unity 游戏内存简要分析

一般来说,Unity 内存按分配方式分为:Native Memory(原生内存)和 Managed Memory(托管内存)。Native Memory 是由 Unity 引

擎自身管理的内存,主要用于存储 AssetBundle,Texture,Audio 等资源。Managed Memory 是由托管代码(Mono 或 IL 2 CPP)管理的内存,主要用于存储 C# 对象,数组,字符串等。此外,Native Memory 并不会被系统自动管理,需要我们手动去释放。而 Managed Memory 的内存管理是自动的,会通过 GC 来释放。

因此,我们对 Unity 内存的优化需要分别对 Native Memory 和 Managed Memory 这两个方向做分析和优化。

2 优化 Managed Memory

Managed Memory 可以分为栈 (Stack) 和堆 (Heap)。

栈的内存大小较小,是用于存储值类型和函数。堆的内存更大,用于存储所有的引用类型

栈的内存大小通常在游戏运行时已经进行分配好了,不会在游戏过程中动态变化,通常只需要注意嵌套的函数不能太多,避免 StackOverflow。

堆的内存大小是可变化的,每创建一个新的对象,就会在堆中找到下一个足够存放的空位置,将其存储。但是当我们销毁对象后,内存空间不会马上释放出来,而是标记成未使用,触发 GC 后,垃圾收集器会释放这部分空间。对象实例化和摧毁的过程很慢。如果需要的内存比之前已经配置好的还多,在放不下的情况下,堆会膨胀扩容,并且不会再缩回去,堆内存过大就会影响到我们游戏的性能。此外,当一些占用内存小的对象被释放后,会导致内存变得断断续续,一些大的内存放不下,就会尝试扩容堆,从而导致内存空间的浪费。

Unity 的 GC 是在堆上进行的,每一次 GC,都会遍历堆积上所有的对象,找到没有被引用的对象,然后将其释放。因此,我们的一些错误引用,会导致一些我们希望释放掉的对象没有被释放掉,这种情况就会造成内存泄漏。

2.1 具体优化措施

2.1.1 避免在 Update 和其他频繁调用的函数中分配内存

尽量避免在每帧中创建新对象、数组、委托或使用 string 类的操作(如拼接)。

字符串的拼接,包括 string.Format() 、内插字符串以及直接进行 string1+string2,本质上都是开辟一个新的内存空间给新的字符串,而不是在原有字符串的基础上修改。因此,如果直接在 Update() 函数里对 string 进行操作,会导致频繁内存申请,堆内存一直扩大。

此外,在 Update() 等高频触发的函数中,也应当尽量避免创建一个新的对象,建议把需要的对象、委托等提前初始化或者缓存下来。

在 TileMatch 中,我们遇到的问题就是,在 Update() 函数里查找某一个页面,在查找页面的方法里有用到 enum.ToString()typeof().Name 这两个方法都会产生 GC,然而大多数情况下,频繁获取的页面是同一个页面,因此可以将所有查找过的页面名称缓存下来,避免了 ToString 产生的 GC。

此外,Linq 库虽然很好用,但是 Linq 的实现里有着大量的装箱和拆箱。装箱和拆箱都会带来额外的内存消耗,这些都是需要在 Update() 这种高频调用的函数中避免的。

错误示例

public void Init()  
{  
    RefreshPlayerBehavior();  
    DateModel.Instance.RefreshDate(NewDayEvent);  
}  
  
private void Update()  
{  
    DateModel.Instance.RefreshDate(NewDayEvent);  
}  
  
private void NewDayEvent()  
{  
    RefreshPlayerBehavior();  
  
    GameManager.Objective.ResetDailyObjectiveProgress();  
}

正确示例

private Action newDayEvent;  
  
private Action UpdateDayEvent  
{  
    get  
    {  
        if (newDayEvent == null)  
        {            
	        newDayEvent = NewDayEvent;  
        }        
        return newDayEvent;  
    }
}  
  
public void Init()  
{  
    RefreshPlayerBehavior();  
    DateModel.Instance.RefreshDate(UpdateDayEvent);  
}  
  
private void Update()  
{  
    DateModel.Instance.RefreshDate(UpdateDayEvent);  
}  
  
private void NewDayEvent()  
{  
    RefreshPlayerBehavior();  
  
    GameManager.Objective.ResetDailyObjectiveProgress();  
}

2.1.2 使用对象池

在游戏程序中,创建和销毁对象事很常见的操作,通常会通过 Instantiate 和 Destroy 方法来实现,如果频繁的进行这些操作,GC 的时候会导致负载很重,因为会有大量的已摧毁对象的存在,不仅会造成 CPU 的负载峰值,还可能导致堆积碎片化。因此我们可以使用对象池来处理这类问题。

使用对象池时需要注意,要决定对象池的大小,以及一开始要产生多少数量的对象在池中。因为如果你需要的对象数量多过池中现有的,就必须将对象池变大,扩的太大可能造成浪费,扩的小可能又造成频繁的添加。

2.1.3 Destroy 与 null

Destroy 用于显式地销毁游戏对象,而将引用设置为 null 仅仅是移除了对对象的引用。

  • 当你想从游戏中完全移除一个对象时,应该使用 Destroy。这适用于不再需要的游戏对象,比如被玩家摧毁的敌人或使用完毕的临时效果。
  • 如果你只是想释放一个引用,并且不关心对象是否仍然存在于场景中,可以将引用设置为 null。这在处理临时引用时有用,但请记住,这不会影响对象的生命周期。
  • 在内存管理方面,合理使用 Destroynull 可以帮助避免内存泄漏和其他资源管理问题。

2.1.4 Class 和 Struct

  • Class 是引用类型,实例化涉及动态内存分配,内存管理由垃圾回收器(GC)处理,可能导致 GC 压力增加。一般适用于比较复杂的或者较大的数据结构,或者需要对象的生命周期跨越多个方法调用。
  • Struct 是值类型,直接存储在栈上,结构体的创建和销毁通常比类更快,因为它们通常在栈上分配。结构体不受垃圾回收器管理,所以不会对 GC 造成压力。适用于小型、简单的数据结构,尤其是当这些数据需要频繁创建和销毁时。

如何选择

  • 数据大小和复杂性:对于较大或更复杂的对象,类是更好的选择。对于小型、不可变的数据结构,结构体可能更合适。
  • 生命周期和共享:如果你需要在多个对象间共享或引用同一个实例,使用类。如果你需要的是数据的副本而非引用,那么结构体更合适。
  • 性能考虑:频繁创建和销毁大量小型对象时,结构体可能因为较小的 GC 压力而更高效。

2.1.5 减少装箱拆箱操作

装箱和拆箱是值类型和引用类型之间转换的过程,这两个过程在内存和性能上有一定的开销。

例如 LINQ 和常量表达式以装箱的方式实现,String.Format () 也常常会产生装箱操作等。

2.1.6 闭包和匿名函数

实现方式

  • 匿名函数:这是没有名称的函数,通常用于简短的操作,如 LINQ 查询或事件处理器。
  • 闭包:是指那些捕获了外部作用域中一个或多个变量的匿名函数。闭包“记住”了它被创建时的环境。

内存占用

  • 当编译器遇到闭包或匿名函数时,它会生成一个或多个类来支持这些结构。这些类存储了闭包捕获的变量和匿名函数的代码。
  • 这意味着每当你使用闭包或匿名函数时,实际上都会创建一个新的对象实例。这些对象需要在堆上分配内存,并由垃圾回收器管理。

如何理解

  • 灵活性与成本:使用闭包和匿名函数提供了极大的编码灵活性和表达力,但这是以增加内存使用为代价的。
  • 适度使用:在性能关键的代码路径中,特别是在频繁调用的循环或高频更新方法(如 Unity 的 Update 方法)中,应谨慎使用闭包和匿名函数。
  • 性能分析:如果你的应用遇到性能问题,使用性能分析工具来检查是否有大量闭包和匿名函数的创建,这可能是优化的一个方向。

2.1.7 协程

协程属于闭包和匿名函数的特例。游戏开始启动一个协程直到游戏结束才释放是是错误的做法。因为协程只要没被释放,里面的所有变量,即使是局部变量(包括值类型),也都会在内存里。建议用的时候才生产一个协程,不用的时候就丢掉(StopCoroutine)。

2.1.8 单例

慎用单例,且不要什么都往里放,因为里面的变量会一直占用内存。

2.1.9 Scriptable Objects

Scriptable Objects 是一种特殊的数据容器,它们用于存储大量数据,但不需要附加到游戏对象(GameObjects)上。与附加到游戏对象的传统组件(如 MonoBehaviour)不同,Scriptable Objects 不需要一个游戏对象即可存在,它们提供了一种更轻量级的、不依赖于场景的方式来处理数据和行为。

  • 特性和用途
  1. 数据存储Scriptable Objects 非常适合用于存储不频繁变化的数据,如配置文件、游戏设置、预设等。
  2. 内存效率:由于它们不需要附加到游戏对象上,Scriptable Objects 可以更高效地管理内存,尤其是在处理大量数据时。
  3. 共享和重用数据:可以在不同的游戏对象或系统之间共享和重用 Scriptable Objects,这有助于减少重复和提高数据一致性。
  4. 独立于场景Scriptable Objects 不依赖于特定的场景,因此可以在多个场景和项目之间共享。

假设我们有一个控制敌人的组件,名叫 Enemy,代码如下:

public class Enemy : MonoBehaviour
{
    public float maxSpeed;
    public float attackRadius;
}

这个组件挂载在每个敌人身上,但是其中这两个浮点数(maxSpeed 和 attachRadius)的数值都是不变的。那么当场景中存在很多的敌人时,每次生成敌人的时候,这些数据就会重复一份。

所以即使所有数据都一样,这两个浮点数还是重复的出现在有此脚本的对象上。所以建议改用 Scriptable Objects,这样就只会耗费一组这样数据的内存,代码如下:

public class EnemyConfiguration : ScriptableObject
{
    public float maxSpeed;
    public float attackRadius;
}
public class Enemy : MonoBehaviour
{
    public EnemyConfiguration enemyConfiguration;
}

2.1.10 属性与变量

属性相对于变量有封装性、可扩展性、数据绑定支持、接口实现等优点。但是在调用时和函数一样会在栈上分配内存。一般情况下不需要考虑,除非循环嵌套。

2.1.11 缓存一些哈希值

在我们想要在运行时修改动画或者材质的时候,可以使用下面方法来实现

animator.SetTrigger("Idle");
material.SetColor("Color", Color.white);

这类方法往往也可以通过索引来作为参数,使用字符串只是能显示的更加直观,但是当我们传递字符串时,程序内部会进行一些处理,频繁调用的话可能就会造成性能的消耗。因此我们可以先找到对应的索引,并将其缓存起来,供后续使用,如下:

int idleHash = Animator.StringToHash("Idle");
animator.SetTrigger(idleHash);
int colorId = Shader.PropertyToID("Color");
material.SetColor(colorId, Color.white);

整体而言,意义不是特别大,除非每帧调用。

2.1.12 缓存引用对象

例如我们常常会在游戏运行的时候去查找一些对象,GameObject. Find 与其他所有关联的方法,需要遍历所有内存中的游戏对象以及组件,因此在复杂场景中,效率会很低。GameObject. GetComponent,会查询所有附加到 GameObject 上的组件,组件越多,GetComponent 的成本就越高。若使用的是 GetComponentInChildren,随着查询变复杂,成本会更高。

因此不要多次查询相同的对象或组件,而且查询一次后将其缓存起来,方便后续的使用。

3 优化 Native Memory

Unity 在里面重载了 C++ 的所有分配内存的操作符,例如 alloc,new 等。每个操作符在被使用的时候要求有一个额外的参数就是 Memory Lable,Profilter 中查看 Memory Detailed 里的 Name 很多就是 Memory Label。它指的就是当前的这一块内存内存要分配到哪个类型池里。

Unity 在底层会用 Allocator,使用重载过的分配符分配内存的时候,会根据 Memory Lable 分配到不同的 Allocator 池里面。每个 Allocator 池,单独做自己的跟踪。当我们要在 Runtime 去 Get 一个 Memory Lable 下面池的时候,可以从对应的 Allocator 中取,可以从中知道有什么东西,有多少兆。

前面提到的 Allocator 的生成是使用 NewAsRoot 的分配逻辑,生成一个所谓的 Memory Island (内存块),它下面会有很多的子内存。例如一个 Shader,当我们加载一个 shader 进内存的时候,首先会生成一个 shader 的 Root,也就是 Memory Island。然后 Shader 底下的数据,例如 Subshader,Pass,Properties 等,会作为该 Root 底下的成员,依次的分配。所以我们最后统计 Runtime 的内存时,统计这些 Root 即可。

因为是 C++ 的,所以当我们去 delete 或 free 一个内存的时候,会立刻返回给系统

3.1 具体优化措施

3.1.1 Scene

导致 Native Memory 增长的原因,最常见的就是 Scene。因为是 c++ 引擎,所有的实体最终都会反映在 c++ 上,而不会反映在托管堆上。所以当我们构建一个 GameObject 的时候,实际上在 Unity 的底层会构建一个或多个 object 来存储这一个 GameObject 的信息(Component 信息等)。所以当一个 Scene 里面有过多的 GameObject 存在的时候,Native Memory 就会显著的上升,甚至可能导致内存溢出

注:当我们发现 Native Memory 大量上升时,可以先着重检查我们的 Scene。

3.1.2 各类资源的压缩与配置

3.1.2.1 Audio

音频资源可以开启强制单声道,设置合适的压缩格式,降低源文件的比特率。

DSP Buffer:DSP Buffer,是指一个声音的缓冲,当一个声音要播放的时候,需要向 CPU 去发送指令。如果声音的数据量非常的小,会造成频繁的向 CPU 发指令,造成 IO 压力。在 Unity 的 FMOD 声音引擎里面,一般会有一个 Buffer,当 Buffer 填充满了才会去向 CPU 发送一次播放声音的指令。

3.1.2.2 Texture

没有特殊需求就关闭Read/Write、​Mip Maps

Upload Buffer:在 Unity 的 Quality 里设置如图,和声音的 Buffer 类似,填满后向 GPU push 一次。

对于不透明的纹理,​​关闭alpha配置。

纹理的大小尽量为 2 的幂次方。

Android 设备运行平台要求支持 OpenGL ES 3.0 的使用 ETC 2,RGB 压缩为 RGB Compressed ETC 2 4 bits,RGBA 压缩为 RGBA Compressed ETC 2 8 bits。需要兼容 OpenGL ES 2.0 的使用 ETC,RGB 压缩为 RGB Compressed ETC 4 bits,RGBA 压缩为 RGBA 16 bits。(压缩大小不能接受的情况下,压缩为 2 张 RGB Compressed ETC 4 bits)

IOS 设备运行平台要求支持 OpenGL ES 3.0 的使用 ASTC,RGB 压缩为 RGB CompressedASTC 6 x 6 block,RGBA 压缩为 RGBA Compressed ASTC 4 x 4 block。对于法线贴图的压缩精度较高可以选择 RGB CompressedASTC 5 x 5 block。需要兼容 OpenGLES 2.0 的使用 PVRTC,RGB 压缩为 PVRTC 4 bits,RGBA 压缩为 RGBA 16 bits。(压缩大小不能接受的情况下,压缩为 2 张 RGB Compressed PVRTC 4 bits)

3.1.2.3 Mesh

Read/Write:同 Texture,若开启,Unity 会存储两份 Mesh,导致运行时的内存用量变成两倍。

Compression:Mesh Compression 是使用压缩算法,将 Mesh 数据进行压缩,结果是会减少占用硬盘的空间,但是在 Runtime 的时候会被解压为原始精度的数据,因此内存占用并不会减少。需要注意的是有些版本开了,实际解压之后内存占用大小会更严重。

如果没有使用动画,请关闭 Rig,例如房子,石头这些。

如果没有用到 Blendshapes,也关闭。

如果 Material 没有用到法向量和切线信息,关闭可以减少额外信息。

3.1.3 Code Size

代码也是占内存的,需要加载进内存执行。模板泛型的滥用,会影响到 Code Size 以及打包速度(IL 2 CPP 编译速度,单一一个 cpp 文件编译的话没办法并行的)。例如一个模板函数有四五个不同的泛型参数(float,int,double 等),最后展开一个 cpp 文件可能会很大。因为实际上 c++ 编译的时候我们用的所有的 Class,所有的 Template 最终都会被展开成静态类型。因此当模板函数有很多排列组合时,最后编译会得到所有的排列组合代码,导致文件很大。

3.1.4 Resource

Resource 文件夹里的内容被打进包的时候会做一个红黑树(R-B Tree)用做索引,即检索资源到底在什么位置。所以 Resource 越大,红黑树越大,它不可卸载,并在刚刚加载游戏的时候就会被一直加在内存里,极大的拖慢游戏的启动时间,因为红黑树没有分析和加载完,游戏是不会启动的,并造成持续的内存压力。所以建议不要使用 Resource,使用 AssetBundle。

3.1.5 按需加载和卸载资源

不需要的资源及时卸载,多利用 profiler 查看是否还有没有卸载的图集资源。这一点是检查的重点方向,接下来会详细介绍。

4 其余优化方向(非内存)

4.1 GameObject 的层次结构

某些情况下,场景中的物体可能有很深的嵌套结构,当我们对父节点的 GameObject 进行坐标转换时,就会产生 OnTransformChanged 事件,这消息会传递给该 GameObject 下所有子对象,即使这些对象没有任何渲染组件(也就是我们看不见任何变化),造成一些不必要的转换运算,包括平移,旋转和缩放。同时,尽量减少堆 transform 的移动等操作。

此外,较深的结构也会导致在 GC 时,花费更多的时间在层级结构间遍历。

4.2 避免在 Awake 和 Start 中添加大量的逻辑

这对游戏启动很重要,Unity 会在 Awake 和 Start 方法执行后渲染第一个画面,某些情况可能会导致启动画面或是载入画面需要花更长的时间渲染,因为你必须等每个游戏对象都完成 Awake 和 Start 的执行。

4.3 删除空的 Unity 事件

Monobehaviour 中的 Start,Update 这些方法即使是空的,也会带来些微的性能消耗,因此若为空,就删除它们。

4.4 避免添加组件

在运行时调用 AddComponent 其实很没效率,尤其在一帧中多次启用这类调用。

当我们添加一个组件的时候,Unity 会做下列操作:

  • 先看组件有没有 DisallowMultipleComponent 的设置,如果有,就要去检查是否有同类型的组件已加入
  • 然后检查 RequireComponent 设置是否存在,如果设置了,就代表这个组件需要别的组件同步加入(重复做添加组件的操作)
  • 最后调用所有被加入的 MonoBehaviour 的 Awake 方法

上述这些步骤都发生在堆积上,所以可能会影响性能和增加 GC 的处理时间。

5 TileMatch 中的优化示例

5.1 资源的加载与卸载

在优化之前的 TileMatch 中,为了提高应用的流畅度,很多图集资源是保留在内存中的,这样可以方便用户在重复打开同一个页面时避免了卡顿感。但是这种策略会极大的提高应用的内存占用,在低性能手机上,过高的内存占用会导致应用的卡死或者崩溃。

因此 Tile 中我们将内存策略修改为:当从某一个页面、场景退出时,及时卸载该场景所用到的图集等资源。

5.1.1 资源的卸载

在 Unity 中,卸载未引用的资源可以通过调用 Resources.UnloadUnusedAssets(); 这一方法来实现。但是在实际操作的过程中,我们发现,虽然在 Addressables 中的引用已经被清除了,但是内存中的图集仍有残留。如下图所示:

Pasted image 20231219101059

经过分析,一般出现这种情况时,有以下几种可能性:

  1. 没有触发系统的 GC,脚本还没有被回收
  2. 动态加载的图片没有清空引用
  3. 对象池中还有残留对象,引用了图集中的资源
  4. 部分 Prefab 中的 Image 图片还在

根据上述情况,卸载资源的代码优化为:

GC.Collect();  
Resources.UnloadUnusedAssets();  
GameManager.ObjectPool.ReleaseAllUnused();

动态加载的图片在 Addressables.Release() 之外,还需要将被赋值的图片的引用清除:

image.sprite = null;

如果还有图集残留,则需要在 Unity Profiler 中定位到具体残留的图片,并且在引用的脚本中手动清除引用:

[SerializeField]  
private Image[] imagesNeedToRelease;
 
private void OnDestroy()  
{  
    for (int i = 0; i < imagesNeedToRelease.Length; ++i)  
    {        
	    imagesNeedToRelease[i].sprite = null;  
    }
}

一般情况下,经过上面的操作,图集是可以正常释放的。

5.1.2 资源策略的优化

在上一小结中,我们分析了资源卸载的流程,其中提到在资源卸载时,需要执行一次 System.GC()。但是频繁执行 GC 会给机器的 CPU 带来较重的负荷,同时,在用户反复打开同一界面时,会导致用户反复进行同样资源的加载和卸载。这无疑是低效的。

因此,我们将资源的卸载策略进行了优化:

  1. 当用户切换场景时,游戏场景卸除装修资源,主界面场景卸除 tile 图集资源。
  2. 当用户关闭活动页面时,延迟 5 秒进行回收,避免短时间内加载相同的图集资源。
  3. 当用户连续关闭页面时,将两个页面的资源一起回收,避免触发多次 GC。
  4. 设定某些音效资源不回收,其他音效使用完回收掉。音效资源之前占内存 8 M 并且会一直增长,目前音效资源占内存 2~3 M。
  5. 可以考虑在低性能手机上使用低分辨率贴图,降低内存使用。

5.2 资源分析工具

在上面的资源卸载流程中,我们提到了 Unity Profiler 这一工具。下面来介绍一下如何使用 Unity Profiler。

5.2.1 Unity Profiler

Unity 性能分析器 (Unity Profiler) 是一种可以用来获取应用程序性能信息的工具。可以将性能分析器连接到网络中的设备或连接到已连接到计算机的设备,从而测试应用程序在目标发布平台上的运行情况。还可以在 Editor 中运行性能分析器,从而在开发应用程序时概要了解资源分配情况。

性能分析器可以收集并显示有关应用程序各个方面(例如 CPU、内存、渲染器和音频)的性能数据。该工具可用于识别应用程序中可提高性能的方面,并在这些方面进行迭代。您可以查明代码、资源、场景设置、摄像机渲染和构建设置如何影响应用程序性能等方面的情况。性能分析器在一系列图表中显示结果,因此可以直观地查看应用程序性能出现尖峰的位置。

以下是 Unity Profiler 的一些模块:

  1. CPU 使用率模块:显示主要函数调用的 CPU 时间开销。
  2. GPU 使用率模块:跟踪 GPU 的性能。
  3. 内存模块:分析内存使用情况。
  4. 渲染模块:显示渲染性能。
  5. 音频模块:检查音频性能。
  6. 其他模块:如物理、2 D 物理、全局光照、UI 等。

我们主要用到的是 CPU 使用率模块和内存模块。

5.2.2 使用 Unity Profiler 分析频繁 GC

依次点击 Window > Analysis > Profiler 打开 Profiler 窗口。然后点击 CPU 模块,在下方的 Hierachy 中把项目按 GC Alloc 倒序排序,检查应用在某一页面挂机停留时是否一直有 GC 产生,如果有则在 overview 中定位具体的 GC 发生的位置,检查代码中是否有装箱拆箱、新对象申请等操作。

Pasted image 20231219103917

5.2.3 使用 Unity Profiler 分析资源

依次点击 Window > Analysis > Profiler 打开 Profiler 窗口,然后点击 Memory 模块。

Pasted image 20231219104438

其中:

  1. Managed Heap:这是由 Unity 的垃圾收集器管理的内存部分,通常包含了应用程序中的所有托管对象(例如,由 C# 脚本创建的对象 )。“In use” 指当前被托管对象实际使用的内存量,而 “Reserved” 则指为托管堆保留的总内存量。
  2. Graphics & Graphics Driver:这部分内存用于存储图形相关的资源,如纹理、网格和渲染纹理等。这也包括图形驱动程序使用的内存。
  3. Audio:这代表被音频引擎使用的内存,包括音频剪辑和相关的音频处理缓冲区。
  4. Video:视频引擎所使用的内存。
  5. Other:系统内存资源和其他私有内存资源占用。
  6. Profiler:调用 Profiler 分析所使用的内存资源,实机运行时没有这块内存。

然后将标签页切换至 detailed,然后点击采样,可以查看分析 Asset 中的资源使用情况。

Pasted image 20231219104226

此外,分析资源还可以使用 Memory Profiler。Memory Profiler 需要在 Unity Packages 中自行安装。相对于 Unity Profiler,Memory Profiler 可以获取应用内存快照,方便深入分析内存的块占用并且与之前的快照进行比较。

5.2.4 广告 SDK 的内存占用

目前来说,Tile 中的代码内存占用和图片资源占用已经被反复压缩过了。在实机测试的过程中,内存的突增主要是在播放广告和加载广告时。并且在播放广告后,内存水平没有能够回到播放广告前的内存水平。一般来说,发生这种情况主要是因为内存泄漏,前一个播放完的广告没能正常销毁释放。但是在不同的手机上的测试结果却大相径庭:

高性能手机测试

播放前播放时播放后
Pasted image 20231219110125Pasted image 20231219110237Pasted image 20231219110307

低性能手机测试

播放前播放时播放后
Pasted image 20231219110513Pasted image 20231219110539Pasted image 20231219110635

可以看到,对于同样的包,高性能手机看完广告的内存能正常回落,低性能手机的内存却较之前上升了部分内存。在低性能手机上,内存不是每一次看完广告都会有上升,而是在运行一段时间后,内存会逐渐上升。这个问题尚未解决,仍需要持续跟踪。