unity3d减少占用内存

2015年02月02日 13:37 0 点赞 0 评论 更新于 2017-05-03 11:42

在使用Unity时,我们常常希望减少内存占用。那么,该如何解决这个问题呢?下面我们就来深入探讨。

内存管理的核心要点

虽然在Unity中都被称为“Asset”,但复制的和引用的资产存在本质区别,这一细节被Unity的底层技术所掩盖,需要开发者自行理解。

按照传统编程思维,最佳做法是自己维护所有对象,使用一个Queue来保存所有object,在对象不再使用时,手动处理DestroyUnload操作。然而,在C# .NET框架下,这种做法不仅没必要,还会增加开发的复杂度。

稳妥的内存管理方式如下:

创建阶段

  1. 首先建立一个AssetBundle,可以从www、文件或内存中获取。
  2. 使用AssetBundle.load加载所需的资产。
  3. 加载完成后,立即调用AssetBundle.Unload(false),释放AssetBundle文件本身的内存镜像,但不销毁已加载的资产对象。这样做既无需保存AssetBundle的引用,又能及时释放一部分内存。

释放阶段

  1. 如果存在Instantiate克隆的对象,使用Destroy进行销毁。
  2. 在合适的时机调用Resources.UnloadUnusedAssets,释放已经没有引用的资产。
  3. 若需要立即释放内存,可调用GC.Collect(),否则内存可能不会立即被释放,有时还可能因内存占用过多而引发异常。

通过这种方式,可以确保内存得到及时释放,占用量保持在最低水平,且无需对每个加载的对象进行引用管理。

需要注意的是,系统在加载新场景时,所有的内存对象(包括使用AssetBundle.Load加载的对象和Instantiate克隆的对象)都会被自动销毁,但AssetBundle文件自身的内存镜像不会自动释放,必须使用Unload方法进行释放。用.NET的术语来说,这种数据缓存属于非托管类型。

各种加载和初始化方法总结

加载方法

  1. AssetBundle.CreateFrom.....:创建一个AssetBundle内存镜像。需要注意的是,同一个AssetBundle文件在未调用Unload之前不能再次使用。
  2. WWW.AssetBundle:功能与上述方法相同,但需要先创建一个WWW对象,使用yield return等待加载完成后才能使用。
  3. AssetBundle.Load(name):从AssetBundle中读取指定名称的资产并生成资产内存对象。多次加载同名对象时,除第一次外,后续调用都会返回已生成的资产对象,即多次加载同一资产不会生成多个副本(单例模式)。
  4. Resources.Load(path&name):与AssetBundle.Load类似,只是从默认位置加载资产。
  5. Instantiate(object):克隆一个对象的完整结构,包括其所有组件和子物体(详见官方文档)。这是浅拷贝,不会复制所有引用类型。有一种特殊用法,可使用Instantiate完整拷贝一个引用类型的资产,如Texture等,但要拷贝的Texture必须将类型设置为Read/Write able

释放方法

  1. Destroy:主要用于销毁克隆对象,也可用于场景内的静态物体,但不会自动释放该对象的所有引用。虽然也可用于销毁资产,但概念不同,需谨慎使用。若用于销毁从文件加载的资产对象,会销毁相应的资源文件;若销毁的资产是复制的或用脚本动态生成的,则只会销毁内存对象。
  2. AssetBundle.Unload(false):释放AssetBundle文件的内存镜像。
  3. AssetBundle.Unload(true):释放AssetBundle文件的内存镜像,同时销毁所有已加载的资产内存对象。
  4. Resources.UnloadAsset(Object):显式释放已加载的资产对象,只能卸载从磁盘文件加载的资产对象。
  5. Resources.UnloadUnusedAssets:用于释放所有没有引用的资产对象。
  6. GC.Collect():强制垃圾收集器立即释放内存。由于Unity的GC功能并不完善,在不确定内存状态时,可强制调用该方法。

在Unity 3.5.2之前,似乎无法显式释放资产。

实例分析

例子1

常见的错误场景:从某个AssetBundle中加载一个预制体并克隆:

obj = Instantiate(AssetBundle1.Load("MyPrefab"));

当不再需要该预制体时,使用Destroy(obj)销毁克隆对象。但此时,通过Load加载的所有引用和非引用资产对象仍保留在内存中。正确的做法是在Destroy之后调用AssetBundle1.Unload(true),彻底释放内存。

如果AssetBundle1需要反复读取,不方便调用Unload,可以在Destroy之后调用Resources.UnloadUnusedAssets(),销毁所有与该预制体相关的资产。当然,如果该预制体需要频繁创建和销毁,为了提高游戏性能,可以让这些资产保留在内存中。

这也解释了为什么第一次Instantiate一个预制体时会卡顿:第一次Instantiate之前,需要加载系统内置的AssetBundle并创建资产;第一次之后,虽然销毁了克隆对象,但预制体的资产对象仍保留在内存中,因此后续操作会更快。

顺便提一下几种加载方式的区别:

  • 静态引用:创建一个public变量,在Inspector中将预制体拖入,使用时进行instantiate操作。
  • Resource.Load:使用Load方法加载预制体,然后进行instantiate操作。
  • AssetBundle.Load:使用Load方法加载预制体,然后进行instantiate操作。

前两种方式中,引用对象textureinstantiate时加载;而AssetBundle.Load会在加载时将预制体的所有资产加载到内存,instantiate时只是生成克隆对象。因此,前两种方式在第一次instantiate时,若未提前加载相关引用对象,会包含加载引用资产的操作,导致第一次加载出现延迟。

例子2

从磁盘读取一个1.unity3d文件到内存并创建一个AssetBundle1对象:

AssetBundle AssetBundle1 = AssetBundle.CreateFromFile("1.unity3d");

AssetBundle1中读取并创建一个Texture资产,将obj1的主贴图指向该资产:

obj1.renderer.material.mainTexture = AssetBundle1.Load("wall") as Texture;

obj2的主贴图也指向同一个Texture资产:

obj2.renderer.material.mainTexture = obj1.renderer.material.mainTexture;

Texture是引用对象,除非手动编写代码实现复制,否则不会自动复制,只会创建和添加引用。

  • 若调用AssetBundle1.Unload(true)obj1obj2的主贴图都会变黑,因为指向的Texture资产已被销毁。
  • 若调用AssetBundle1.Unload(false)obj1obj2的主贴图不变,只是AssetBundle1的内存镜像被释放。
  • 调用Destroy(obj1)obj1被释放,但不会释放刚才加载的Texture
  • 此时调用Resources.UnloadUnusedAssets(),不会释放任何内存,因为Texture资产仍被obj2引用。
  • 调用Destroy(obj2)obj2被释放,但同样不会释放Texture
  • 再次调用Resources.UnloadUnusedAssets(),此时Texture资产被释放,因为没有任何引用。
  • 最后调用GC.Collect(),强制立即释放内存。

由此引申出论坛中常见的问题:如何加载大量图片并轮流显示,同时避免内存溢出。

  • 方法一
    List<string> fileList;
    int n = 0;
    

IEnumerator OnClick() { WWW image = new WWW(fileList[n++]); yield return image; obj.mainTexture = image.texture; n = (n >= fileList.Length - 1) ? 0 : n; Resources.UnloadUnusedAssets(); }

这种方法可以确保内存中始终只有一个巨型`Texture`资产,无需代码追踪上一个加载的`Texture`资产,但加载速度较慢。

- **方法二**:

List fileList; int n = 0;

IEnumerator OnClick() { WWW image = new WWW(fileList[n++]); yield return image; Texture tex = obj.mainTexture; obj.mainTexture = image.texture; n = (n >= fileList.Length - 1) ? 0 : n; Resources.UnloadAsset(tex); }

这种方法卸载速度较快。

## 总结与建议
有开发者评论认为,Unity的内存管理较为复杂,特别是涉及到`Texture`资产时。实际上,使用`AssetBundle`加载的资产也可以通过`Resources.UnloadUnusedAssets`卸载,但必须先调用`AssetBundle.Unload`,这些资产才会被识别为无用资产。

比较稳妥的做法是:
### 创建阶段
1. 建立一个`AssetBundle`,可从`www`、文件或内存中获取。
2. 使用`AssetBundle.load`加载所需的资产。
3. 用完后立即调用`AssetBundle.Unload(false)`,关闭`AssetBundle`,但不摧毁创建的对象和引用。

### 销毁阶段
1. 对`Instantiate`的对象进行`Destroy`操作。
2. 在合适的地方调用`Resources.UnloadUnusedAssets`,释放无引用的资产。
3. 若需要立即释放内存,调用`GC.Collect()`。

通过这种方式,可以确保内存得到及时释放。并且,只要对`AssetBundle`调用过`Unload`,在`LoadLevel`时,创建的对象和引用都会被自动释放。

作者信息

feifeila

feifeila

共发布了 570 篇文章