前言
由于Unity在手机以及其他平台很容易出现占用内存太大的问题,因此对于Unity本身的内存管理和优化是很有必要了解的,这篇博客主要内容来源于Unity3D占用内存太大的解决办法,外加我自己通过搜索资料总结的见解。(因为刚入门能力有限,所以大部分资料来自于网上各博客,如果侵权可以联系我删除)
Unity资源类型
Unity资源类型如下:
- Unity内置的常用asset,比如fbx、jpg、script、prefab等等
- textasset,比如txt、binary等,对应了它的TextAsset类,可以直接读入文本或者二进制byte
- scriptable object,他是序列化的Object实例,例如把一个Object实例保存成scriptable object,读入进来后就直接在内存中变成那个实例(主要用来保存游戏数据)
- asset bundle,他是一个资源压缩包,里面包含了一堆资源。
通常我们自定义的文件类型可以通过textasset 或scriptable object 来存储,区别在于前者是一个字节或文本流,后者要对应于程序中一个定义了的类型,textasset 还通常用于资源的加密。
Unity项目中的三个特殊文件夹
- Resources(只读不可写)
Resources文件夹下的资源会被全部打包到apk或者ipa里面,相当与一个unity默认的AssetBundle,按理说是可以将游戏中所有的资源全部放在Resources进行加载.
但是Resources下的文件数量会影响游戏的启动速度,越多越慢。其次游戏为了降低drawcall,需要对相同材质的图像打包图集,但是如果放在Resources里面是没法打包图集的。 Resources下的资源打包时会进行压缩。
- StreamingAssets(只读不可写)
StreamingAssets文件夹下的资源会被全部打包到apk或者ipa里面。
StreamingAssets文件夹下的资源不会影响游戏启动速度
StreamingAssets文件夹下的资源不会被压缩,所以占用内存较大
- persistentDataPath (可读写)
persistentDataPath文件夹在unity项目中是不存在的,他是程序的沙盒文件夹,只有你游戏安装完成之后,才会存在这个目录,但是它可读写。
综上三种文件夹,我们自己在使用时,Resources里面放的资源只是在本地开发阶段使用,打包的时候会把Resources中的文件都拷贝出去打成AssetBundle包,然后再将打包好的AssetBundle放到StreamingAssets文件夹下打包apk。
而要实现资源的热更新,你只有把资源下载到PersistentDataPath目录下才可实现资源更新(增加或者替换),其他目录不可能实现。因为只有PersistentDataPath可读写,其他两个只读不可写!
结合示意图来看下Unity资源热更新的流程:
首先得有一个资源服务器(FTP为例),因为StreamingAssets目录是只读的,我们想要实现热更新,StreamingAssets目录里面的东西一旦第一个版本打出APK的包之后,这里的东西将永远不会变(只读)。由于PersistentDataPath目录是可读可写的,所以游戏下载资源都会下载到这里面。这样就实现了资源的热更新。
ps:绿色线的代表流动,可以不断改变的资源。红色线代表读取persistent目录没有的情况下,读StreamingAssets目录,所以,是永远不变的资源。(除非你去重新下载一个apk的包,那就不是热更了)
细节参考:详谈 Unity3D AssetBundle 资源加载,结合实际项目开发实例
Unity3D资源加载
首先区分一下静态加载和动态加载。静态加载即你将资源直接拖入编辑器面板(Hierarchy面板)。动态加载即你在代码中从文件夹、服务器动态加载资源。
Unity3D里有三种动态加载机制:
(1)Resources.Load
顾名思义即从Resources(Unity规定只能是该名字)文件夹中加载资源,从上面可以可以看出Resources.Load无法加载热更新后的资源,因此在项目发布后很少用到该加载方式。而且,如果资源保存在Resources文件夹里,Unity不管你在场景中有没有引用到都会将其打入安装包中。
Resources.Load就是从一个缺省打进程序包里的AssetBundle里加载资源,而一般AssetBundle文件需要你自己创建,运行时 动态加载,可以指定路径和来源的。
其实场景里所有静态的对象也有这么一个加载过程,只是Unity3D后台替你自动完成了。
(2)AssetBundle
即将资源打成asset bundle放在服务器或者本地磁盘,然后使用WWW模块get下来,然后从这个bundle中load某个object。
其加载分为两个步骤:
(1)AssetBundle运行时加载
- 来自文件夹里的就用CreateFromFile(注意这种方法只能用于standalone程序)这是最快的加载方法。
- 来自Memory,用CreateFromMemory(byte[]),这个byte[]可以来自文件读取的缓冲,www的下载或者其他可能的方式。
其实WWW或者文件夹的assetBundle就是内部数据读取完后自动创建了一个assetBundle而已,Create完以后,等于把硬盘或者网络的一个文件读到内存一个区域,这时候只是个AssetBundle内存镜像数据块,还没有Assets的概念。
(2)Assets加载
- 同步加载: 用AssetBundle.Load(同Resources.Load) 这才会从AssetBundle的内存镜像里读取并创建一个Asset对象,创建Asset对象同时也会分配相应内存用于存放(反序列化)
- 异步加载:用AssetBundle.LoadAsync,也可以一次读取多个用AssetBundle.LoadAll
AssetBundle的释放有两种方式:
- AssetBundle.Unload(flase)是释放AssetBundle文件的内存镜像,不包含Load创建的Asset内存对象。
- AssetBundle.Unload(true)是释放那个AssetBundle文件内存镜像和并销毁所有用Load创建的Asset内存对象。
(3)AssetDatabase.loadasset
这种方式只在editor范围内有效,游戏运行时没有这个函数,它通常是在开发中调试用的。其存在是有意义的: Resources的方式需要把所有资源全部打入安装包,这对游戏的分包发布(微端)和版本升级(patch)是不利的,所以unity推荐的方式是不用它,都用bundle的方式替代,把资源打成几个小的bundle,用哪个就load哪个,这样还能分包发布和patch,但是在开发过程中,不可每没更新一个资源就打一次bundle,所以editor环境下可以使用AssetDatabase来模拟,这通常需要我们封装一个dynamic resource的loader模块,在不同的环境下做不同实现。
使用AssetBundle加载资源的过程
一个Prefab从assetBundle里Load出来 里面可能包括:Gameobject/transform/mesh/texture/material/shader/script和各种其他Assets。
当你 Instaniate一个Prefab,是一个对Assets进行Clone(复制)+引用结合的过程,GameObject transform 是Clone是新生成的。其他mesh/texture/material/shader 等,这其中些是纯引用的关系的,包括:Texture和TerrainData,还有引用和复制同时存在的,包括:Mesh/material/PhysicMaterial。引用的Asset对象不会被复制,只是一个简单的指针指向已经Load的Asset对象。这种含糊的引用加克隆的混合, 大概是搞糊涂大多数人的主要原因。
专门要提一下的是一个特殊的东西:Script Asset,看起来很奇怪,Unity里每个Script都是一个封闭的Class定义而已,并没有写调用代码,光Class的定义脚本是不会工作的。其 实Unity引擎就是那个调用代码,Clone一个script asset等于new一个class实例,实例才会完成工作。把他挂到Unity主线程的调用链里去,Class实例里的OnUpdate OnStart等才会被执行。多个物体挂同一个脚本,其实就是在多个物体上挂了那个脚本类的多个实例而已,这样就好理解了。在new class这个过程中,数据区是复制的,代码区是共享的,算是一种特殊的复制+引用关系。
你可以再Instaniate一个同样的Prefab,还是这套mesh/texture/material/shader…,这时候会有新的GameObject等,但是不会创建新的引用对象比如Texture。 所以你Load出来的Assets其实就是个数据源,用于生成新对象或者被引用,生成的过程可能是复制(clone)也可能是引用(指针)
当你Destroy一个实例时,只是释放那些Clone对象,并不会释放引用对象和Clone的数据源对象,Destroy并不知道是否还有别的object在引用那些对象。
等到没有任何 游戏场景物体在用这些Assets以后,这些assets就成了没有引用的游离数据块了,是UnusedAssets了,这时候就可以通过 Resources.UnloadUnusedAssets来释放,Destroy不能完成这个任 务,AssetBundle.Unload(false)也不行,AssetBundle.Unload(true)可以但不安全,除非你很清楚没有任何 对象在用这些Assets了。
示意图如下:
Unity3D占用内存太大时怎么优化?
虽然都叫Asset,但复制的和引用的是不一样的,这点被Unity的暗黑技术细节掩盖了,需要自己去理解。
关于内存管理 按照传统的编程思维,最好的方法是:自己维护所有对象,用一个Queue来保存所有object,不用时该Destory的,该Unload的自己处理。 但这样在C# .net框架底下有点没必要,而且很麻烦。
稳妥起见你可以这样管理:
(1)创建时
先建立一个AssetBundle,无论是从www还是文件还是memory用AssetBundle.load加载需要的asset加载完后立即AssetBundle.Unload(false),释放AssetBundle文件本身的内存镜像,但不销毁加载的Asset对象。(这样你不用保存AssetBundle的引用并且可以立即释放一部分内存)
(2)释放时
-
如果有Instantiate的对象,用Destroy进行销毁在合适的地方调用Resources.UnloadUnusedAssets,释放已经没有引用的Asset。
- 如果需要立即释放内存加上GC.Collect(),否则内存未必会立即被释放,有时候可能导致内存占用过多而引发异常。 这样可以保证内存始终被及时释放,占用量最少。也不需要对每个加载的对象进行引用。 当然这并不是唯一的方法,只要遵循加载和释放的原理,任何做法都是可以的。
- 系统在加载新场景时,所有的内存对象都会被自动销毁,包括你用AssetBundle.Load加载的对象和Instaniate克隆的。但是不包括AssetBundle文件自身的内存镜像,那个必须要用Unload来释放,用.net的术语,这种数据缓存是非托管的。
总结
各种资源加载和初始化的方法
- AssetBundle.CreateFrom…..:创建一个AssetBundle内存镜像,注意同一个assetBundle文件在没有Unload之前不能再次被使用
- WWW.AssetBundle:同上,当然要先new一个再 yield return 然后才能使用
- AssetBundle.Load(name): 从AssetBundle读取一个指定名称的Asset并生成Asset内存对象,如果多次Load同名对象,除第一次外都只会返回已经生成的Asset 对象,也就是说多次Load一个Asset并不会生成多个副本(singleton)
- Resources.Load(path&name):同上,只是从默认的位置加载,且不用你自己从AssetBundle创建内存镜像。
- Instantiate(object):Clone 一个object的完整结构,包括其所有Component和子物体(详见官方文档),浅Copy,并不复制所有引用类型。有个特别用法,虽然很少这样 用,其实可以用Instantiate来完整的拷贝一个引用类型的Asset,比如Texture等,要拷贝的Texture必须类型设置为 Read/Write able。
各种资源释放的方法
- Destroy:主要用于销毁克隆对象,也可以用于场景内的静态物体,不会自动释放该对象的所有引用。虽然也可以用于Asset,但是概念不一样要小心,如果用于销毁从文件加载的Asset对象会销毁相应的资源文件!但是如果销毁的Asset是Copy的或者用脚本动态生成的,只会销毁内存对象。
- Resources.UnloadAsset(Object):显式的释放已加载的Asset对象,只能卸载磁盘文件加载的Asset对象。
- Resources.UnloadUnusedAssets:用于释放所有没有引用的Asset对象。
- AssetBundle.Unload(false):释放AssetBundle文件内存镜像。
- AssetBundle.Unload(true):释放AssetBundle文件内存镜像同时销毁所有已经Load的Assets内存对象。(此时从释放的Asset初始化的gameObject也会被销毁)
- GC.Collect()强制垃圾收集器立即释放内存 Unity的GC功能不算好,没把握的时候就强制调用一下。
几种AssetBundle创建方式的差异
- CreateFromFile:这种方式不会把整个硬盘AssetBundle文件都加载到 内存来,而是类似建立一个文件操作句柄和缓冲区,需要时才实时Load,所以这种加载方式是最节省资源的,基本上AssetBundle本身不占什么内 存,只需要Asset对象的内存。可惜只能在PC/Mac Standalone程序中使用。一般这种加载是同步的,因为有IO开销(在需使用时加载)
- CreateFromMemory和www.assetBundle:这两种方式AssetBundle文件会整个镜像于内存中(缓存),理论上文件多大就需要多大的内存,之后Load时还要占用额外内存去生成Asset对象。一般使用:WWW.LoadFromCacheOrDownload,是异步加载。
三种初始化方式的区别
(1)静态引用,建一个public变量,在Inspector里把prefab拉上去,用的时候instantiate。
(2)Resource.Load,Load以后instantiate。
(3)AssetBundle.Load,Load以后instantiate。
三种方式有细 节差异,前两种方式,引用对象texture是在instantiate时加载,而assetBundle.Load会把perfab的全部assets 都加载,instantiate时只是生成Clone。所以前两种方式,除非你提前加载相关引用对象,否则第一次instantiate时会包含加载引用 assets的操作,导致第一次加载的延时。
什么时候才使用Resources.UnloadUnusedAssets
比如:
Object obj = Resources.Load("MyPrefab");
GameObject instance = Instantiate(obj) as GameObject;
.........
Destroy(instance);
创建随后销毁了一个Prefab实例,这时候 MyPrefab已经没有被实际的物体引用了,但如果这时:
Resources.UnloadUnusedAssets();
内存并没有被释放,原因:MyPrefab还被这个变量obj所引用,这时候:
obj = null;
Resources.UnloadUnusedAssets();
这样才能真正释放Assets对象
所以:UnusedAssets不但要没有被实际物体引用,也要没有被生命周期内的变量所引用,才可以理解为 Unused(引用计数为0)。
因此:如果你用个全局变量保存你Load的Assets,又没有显式的设为null,那在这个变量失效前你无论如何UnloadUnusedAssets也释放不了那些Assets的。如果你这些Assets又不是从磁盘加载的,那除了 AssetBundle.Unload(true)或者Resources.UnloadAsset(Object)或者加载新场景以外没有其他方式可以卸载之。