unity3d减少占用内存
在使用Unity时,我们常常希望减少内存占用。那么,该如何解决这个问题呢?下面我们就来深入探讨。
内存管理的核心要点
虽然在Unity中都被称为“Asset”,但复制的和引用的资产存在本质区别,这一细节被Unity的底层技术所掩盖,需要开发者自行理解。
按照传统编程思维,最佳做法是自己维护所有对象,使用一个Queue
来保存所有object
,在对象不再使用时,手动处理Destroy
和Unload
操作。然而,在C# .NET框架下,这种做法不仅没必要,还会增加开发的复杂度。
稳妥的内存管理方式如下:
创建阶段
- 首先建立一个
AssetBundle
,可以从www
、文件或内存中获取。 - 使用
AssetBundle.load
加载所需的资产。 - 加载完成后,立即调用
AssetBundle.Unload(false)
,释放AssetBundle
文件本身的内存镜像,但不销毁已加载的资产对象。这样做既无需保存AssetBundle
的引用,又能及时释放一部分内存。
释放阶段
- 如果存在
Instantiate
克隆的对象,使用Destroy
进行销毁。 - 在合适的时机调用
Resources.UnloadUnusedAssets
,释放已经没有引用的资产。 - 若需要立即释放内存,可调用
GC.Collect()
,否则内存可能不会立即被释放,有时还可能因内存占用过多而引发异常。
通过这种方式,可以确保内存得到及时释放,占用量保持在最低水平,且无需对每个加载的对象进行引用管理。
需要注意的是,系统在加载新场景时,所有的内存对象(包括使用AssetBundle.Load
加载的对象和Instantiate
克隆的对象)都会被自动销毁,但AssetBundle
文件自身的内存镜像不会自动释放,必须使用Unload
方法进行释放。用.NET的术语来说,这种数据缓存属于非托管类型。
各种加载和初始化方法总结
加载方法
AssetBundle.CreateFrom.....
:创建一个AssetBundle
内存镜像。需要注意的是,同一个AssetBundle
文件在未调用Unload
之前不能再次使用。WWW.AssetBundle
:功能与上述方法相同,但需要先创建一个WWW
对象,使用yield return
等待加载完成后才能使用。AssetBundle.Load(name)
:从AssetBundle
中读取指定名称的资产并生成资产内存对象。多次加载同名对象时,除第一次外,后续调用都会返回已生成的资产对象,即多次加载同一资产不会生成多个副本(单例模式)。Resources.Load(path&name)
:与AssetBundle.Load
类似,只是从默认位置加载资产。Instantiate(object)
:克隆一个对象的完整结构,包括其所有组件和子物体(详见官方文档)。这是浅拷贝,不会复制所有引用类型。有一种特殊用法,可使用Instantiate
完整拷贝一个引用类型的资产,如Texture
等,但要拷贝的Texture
必须将类型设置为Read/Write able
。
释放方法
Destroy
:主要用于销毁克隆对象,也可用于场景内的静态物体,但不会自动释放该对象的所有引用。虽然也可用于销毁资产,但概念不同,需谨慎使用。若用于销毁从文件加载的资产对象,会销毁相应的资源文件;若销毁的资产是复制的或用脚本动态生成的,则只会销毁内存对象。AssetBundle.Unload(false)
:释放AssetBundle
文件的内存镜像。AssetBundle.Unload(true)
:释放AssetBundle
文件的内存镜像,同时销毁所有已加载的资产内存对象。Resources.UnloadAsset(Object)
:显式释放已加载的资产对象,只能卸载从磁盘文件加载的资产对象。Resources.UnloadUnusedAssets
:用于释放所有没有引用的资产对象。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
操作。
前两种方式中,引用对象texture
在instantiate
时加载;而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)
,obj1
和obj2
的主贴图都会变黑,因为指向的Texture
资产已被销毁。 - 若调用
AssetBundle1.Unload(false)
,obj1
和obj2
的主贴图不变,只是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
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`时,创建的对象和引用都会被自动释放。