这部分主要讨论AssetBundle在具体使用中会遇到的一些常见问题和对应的解决办法。
1. 管理已经加载的Asset
在内存比较吃紧的环境中,需要非常小心地控制已经加载的Object的数目和大小。当Object在场景中被移除的时候,Unity并不会自动删除Object。Asset的清理工作会在特定的时间被触发,也可以手工进行调用。
AssetBundle自身也需要严格进行管理。加载本地存储器上备份的AssetBundle(在Unity缓存或者通过AssetBundle.LoadFromFile加载的),通常内存的消耗量是最小的,大概只有10kb~40kb。但是,如果AssetBundle的数目过多,还是会引发问题的。
在很多项目中,用户可以重新体验某些内容(如重玩某个关卡),那么AssetBundle相应加载和卸载的时机就会比较重要。如果AssetBundle卸载时机不对,可能会导致内存中出现重复的Object。AssetBundle的不合理卸载也会导致出现某些不可预知的行为,如纹理丢失等。
需要掌握的知识点:
AssetBundle.Unload的参数为true和false的区别?
AssetBundle.Unload被调用的时候,会卸载掉AssetBundle的头部信息。传入参数的意义是是否需要同时卸载掉根据AssetBundle实例化的所有Object。如果传入的参数是true,那么从这个AssetBundle中实例化的所有Object都会立马被移除,即使这些Object正在场景中被使用。
例如:材质M从AssetBundle AB中加载,M正在当前场景中被引用。
如果调用了AB.Unload(true),材质M会立马从场景中被移除并且被卸载掉。如果调用的是AB.Unload(false),AB的头部信息会被移除,但是M仍然可以在场景中被使用。调用AssetBundle.Unload(false)只会破坏M和AB之间的对应关系。如果AB在后面再次被加载进来,AB中的M对应的Object仍然会被重新实例化并且加载进内存,所以可能会产生冗余的资源。
造成资源冗余肯定是不好的,所以应该尽量采用AssetBundle.Unload(true)方法,并且采用一定的机制防止内存中不会存在冗余的Object。通常采用的方法有:
- 在应用的生命周期中,指定一些关键点来卸载掉已经加载的所有的AssetBundle,例如切换关卡的时候。
- 对Object进行引用计数进行管理,只有当某个AssetBundle中的所有Object都没有被引用到的时候卸载掉AssetBundle。这样就可以保证内存中不会出现重复的Object。
如果应用必须要使用到AssetBundle.Unload(false),那么对于每个独立的Object,必须使用如下两种方式进行卸载:
- 对于不需要的Object,去掉其所有的引用,包括代码引用和场景Object之间的引用。在这一步完成之后,调用Resources.UnloadUnusedAssets()方法。
- 采用非累加式方法加载场景。切换场景的时候会销毁掉当前场景中的所有Object,而且会自动调用Resources.UnloadUnusedAssets.
如果项目规划合理的话,在某些时间点,玩家可以容忍比较长的等待时间,如切换游戏关卡或者改变游戏模式,这个时候应该用来卸载多余的Object并且加载新的Object。
这种思路最简单的实践方式就是将游戏按照场景进行划分AssetBundle,将某个场景包含的所有内容打进一个AssetBundle。当应用切换场景的时候,显示Loading画面,并且同时执行卸载旧场景和加载新场景的工作。
但是这种划分方式过于简单粗暴,某些工程需要更为复杂和精细的AssetBundle管理方式。对于这些项目而言,并没有通用的AssetBundle设计模式,最好是根据具体的项目确定不同的策略和方案。划分Object到AssetBundle中,最好是依据这些Object需要加载和卸载的时间来确定。
例如,RPG游戏。地图和场景是肯定要按照场景进行划分打包成对应的AssetBundle,但是存在一些公共的Object会在不同的场景中被同时使用。对于这部分的内容可以分配到一个独立的AssetBundle中,这个AssetBundle在一开始就加载进内存,整个游戏过程中都不会卸载掉。
当Unity必须要重新载入某个已经卸载掉的AssetBundle中的Object的时候,这种情况下,重新加载会失败,在Unity编辑器中的层次列表会出现Missing Object的情况。
这种情况主要发生在Unity已经丢失了图形内容的引用,尝试重新获取管理权限造成的。例如在移动平台或者PC平台上进行锁屏操作时,这种情况下,Unity必须重新载入纹理和Shader到GPU中。如果源头AssetBundle已经不可访问,应用就会使用已经找不到的Shader进行渲染操作。
2. AssetBundle 分发
AssetBundle的分发主要有两种基本方式:
- 放在安装包中一起下载
- 首次安装成功之后进行更新
至于选择哪种方式则是根据平台的限制条件决定的。移动设备上的项目通常会采用首次安装,然后获取资源的方式。而主机和PC平台则更偏好于将所有的资源一起放入安装包的做法。
2.1 一起放入安装包
将所有的AssetBundle和安装包一起发布是最简单的做法,因为不需要额外管理AssetBundle下载的代码。将AssetBundle放入安装包中主要有两个理由:
- 减少打包次数,允许更简单的迭代开发。如果AssetBundle不需要独立于应用自身进行更新,将这些AssetBundle放入到StreamingAssets文件夹即可。
- 包含可以更新部分的最初版本。这样做的主要原因是为了减少玩家第一次安装游戏之后下载资源的时间和减少以后补丁更新的次数和时间。这种情况下,StreamingAssets目录并不是最好的选择。然后,如果对于自己写一个定制化的下载和缓存系统并不实际的话,那么StreamingAssets也是可以完成的。
StreamingAssets
如果想要在安装的时候一次性安装所有的内容,那么最佳的方式就是将打包的时候将所有的内容放入到/Assets/StreamingAssets/文件夹内。StreamingAssets目录中的所有内容都会被打包到最后的安装包中,不仅仅是AssetBundle。
StreamingAssets目录在本地的存储位置在运行阶段可以通过Application.streamingAssetsPath属性获取。这些AssetBundle可以通过AssetBundle.LoadFromFile获取。
Android开发者:在Android平台上,Application.streamingAssetsPath会指向一个压缩后的.jar文件,即使AssetBundle已经被压缩过。这种情况下,WWW.LoadFromCacheOrDownload必须用来加载每个AssetBundle。当然,也可以通过自己的代码完成对.jar文件的解压工作,将AssetBundle放到磁盘上。
注意:在某些平台上,StreamingAssets并不能写入。如果某个项目的AssetBundle在安装之后需要重新更新,最好是使用WWW.LoadFromCacheOrDownload或者自己实现下载功能。
2.2 安装完成之后下载更新
对于移动设备而言,分发AssetBundle的最好方式是在首次安装之后通过下载完成更新。
分发AssetBundle一种简单方式就是将AssetBundle放到某个Web服务器上,然后通过WWW.LoadFromCacheOrDownload或者UnityWebRequest获取。Unity会自动将下载后的AssetBundle缓存到本地存储器。如果下载的AssetBundle是通过LZMA格式压缩,那么AssetBundle会以解压后的形式存放,便于后面快速加载。如果AssetBundle是以LZ4格式压缩,那么AssetBundle仍然以压缩格式存储。
如果缓存区域已满,那么Unity会按照LRU(Least Recently Used)算法移除AssetBundle。
请注意WWW.LoadFromCacheOrDownload方式存在缺陷。WWW对象在下载AssetBundle的时候会缓存和AssetBundle大小一样的内存区域。这样会导致内存泄漏,有三种方式可以避免出现这种情况:
- 让AssetBundle尽可能小。AssetBundle的大小能够决定其被下载的时候所占据的内存消耗。当应用处在下载界面的时候,可以分配更多的内存去下载AssetBundle。
- 如果使用的是Unity 5.3或者更新的版本,考虑使用UnityWebRequest中的DownloadHandlerAssetBundle方法,在下载过程中不会有额外的内存分配。
- 自己实现一个下载器。
通常建议一开始的时候尽量使用UnityWebRequest(Unity 5.3以及之后的版本)或者WWW.LoadFromCacheOrDownload(Unity 5.2以及之前的版本)。只有当这些内置的API的内存消耗和缓存不能够满足某些平台的要求的时候,再去考虑自己实现定制化的下载器。
不适合使用UnityWebRequest或者WWW.LoadFromCacheOrDownload的情况包括:
- 需要对AssetBundle缓存系统有很细粒度的控制权
- 当项目需要实现定制化的压缩策略
- 当项目使用到平台相关的API来满足特定的需求,例如在非激活状态下缓存数据。如使用iOS的后台任务API在后台下载数据。
- AssetBundle需要通过SSL进行分发的平台,而Unity对SSL没有很好的支持的平台(如PC平台)
2.3 内置的缓存系统
Unity有一个内置的AssetBundle缓存系统,来完成通过WWW.LoadFromCacheOrDownload或者UnityWebRequest下载的AssetBundle的缓存操作。
这两个API都有一个接受AssetBundle版本号的重载函数。这个版本号并不是存储在AssetBundle中,并不是由AssetBundle系统生成的。
缓存系统会跟踪传入到WWW.LoadFromCacheOrDownload或者UnityWebRequest的最后一个版本号。但API被调用的时候传入了一个版本号,缓存系统便会开始检查是否已经有了一个缓存的AssetBundle。如果有,会将传入的版本号和之前缓存的版本号进行对比,如果两个版本号一致,Unity便会导入缓存的AssetBundle。如果版本号不一致,或者没有缓存的AssetBundle,那么Unity就会下载一个新的AssetBundle,并且将新的AssetBundle和传入的版本号关联起来。
AssetBundle在缓存系统中是根据名字作为唯一标识符,并不是根据完整的下载路径URL。这就意味着同一个名称的AssetBundle可以放到不同的地方进行存储。例如,一个AssetBundle可以放到CDN(Content Delivery Network)的不同服务器上。只要文件名称一致,缓存系统就可以进行识别。
不同的应用可以自行决定AssetBundle的版本号如何制定。大部分的应用可以采用Unity 5的AssetBundleManifest的相关API。这个API为每个AssetBundle产生一个MD5哈希值。当AssetBundle改变的时候,对应的哈希值也会改变,也就意味着需要重新下载AssetBundle。
注意:基于Unity内置缓存系统实现的怪异方式,Unity之后当缓存区满的时候才会删除掉旧的AssetBundle。Unity可能会在之后的版本中改变这个实现方式。
Unity内置的缓存系统也可以通过调用API进行某些参数的设定。
Caching.expirationDelay:当AssetBundle被自动删除之前的最少延迟时间(秒)。当AssetBundle不可访问的时候,就需要被删除。
Caching.maximumAvailableDiskSpace:确定本地存储用来存储AssetBundle的最大空间(以字节为单位)。当超过这个空间的时候,Unity就会使用LRU算法对AssetBundle进行删除。Unity会删除很久都没有使用到的AssetBundle,或者通过(Caching.MarkAsUsed)标记过的AssetBundle。Unity会一直执行删除操作,直到有足够的空间可以完成新的下载操作。
注意:基于Unity 5.3版本实现的内置缓存系统非常粗糙,不能从缓存中移除指定的AssetBundle。只有等到满足失效条件的时候,超过本地存储使用量或者调用Caching.CleanCache的时候才会删除。
Caching.CleanCache会删除掉缓存中的所有AssetBundle。Unity不会自动删除应用中不会再使用到的AssetBundle,所以在开发或者运行阶段可能会引发一些问题。
缓存启动阶段
因为AssetBundle是通过文件名称进行识别,所以可以将AssetBundle放入到应用一起打包的时候启动缓存系统,只需要将每个AssetBundle的初始版本号存入到/Assets/StreamingAssets/中即可。
缓存会在应用第一次运行的时候从Application.streamingAssetsPath中加载AssetBundle的时候被触发。从此之后,应用就可以调用WWW.LoadFromCacheOrDownload和UnityWebRequest正确使用缓存系统了。
2.4 自己实现AssetBundle的下载
自己实现的下载器可以完成更多的功能,给予了应用更多的控制权。只有比较大的项目才需要自己实现AssetBundle的下载。自己实现主要有如下的四个主要问题:
- 如何下载AssetBundle
- 如何存储AssetBundle
- 是否需要压缩AssetBundle
- 怎样更新AssetBundle
下载
对于大部分的应用,HTTP是下载AssetBundle最简单的方式。但是,实现一个基于HTTP的下载器并不是非常简单的任务。自己实现AssetBundle也需要避免大量的内存使用,线程使用和频繁唤起等操作。Unity提供的WWW类并不适合处理这些任务,因为WWW对象的内存占用过高。所以如果应用并没有使用到WWW.LoadFromCacheOrDownload接口的时候,应该尽量避免使用WWW对象。
自己实现下载主要有如下三种选择:
- 使用C#的HttpWebRequest和WebClient类
- 自定义的原生插件
- Asset商店中的插件
C#提供的类
如果应用不需要HTTPS/SSL的支持,C#的WebClient提供了最简单的下载AssetBundle的机制,这个类可以通过异步直接将AssetBundle下载到本地存储器而不需要额外分配内存。
如果需要使用WebClient下载AssetBundle,只需要分配一个该类的实例,将AssetBundle的URL地址和存储地址传给实例即可。如果需要更多的控制条件,使用C#的HttpWebRequest类可以完成:
- 通过HttpWebResponse.GetResponseStream得到字节流。
- 在堆上面分配固定大小的缓冲区。
- 将字节流的内容读取到缓冲区。
- 使用C#文件I/O相关的API将缓冲区的内容读取到本地存储,使用其他相关的I/O也行。
平台相关的注意事项:
Unity对于C# HTTP类中的HTTPS/SSL支持只有在iOS,Android和Windows Phone平台上才生效。在PC上如果使用这些方法,尝试连接HTTPS服务器会出现证书验证错误。
Asset商店插件
Asset Store中提供了使用原生代码实现HTTP、HTTPS或者其他的网络协议下载文件的方法。自己动手实现原生插件之前,可以参考这些资源。
使用原生接口完成插件
写一个根据平台提供的接口完成下载任务的插件是最费时但也是最灵活的方法。基于耗时方面和难度方面的考虑,只有当其他所有的方法都不能满足项目需求的时候才开始考虑这样做。例如,只有当应用运行在Unity不支持SSL的平台上,但是又必须要使用SSL的时候,如Windows,OSX和Linux平台。
自定义的原生插件肯定要封装目标平台提供的原生下载API。对于iOS平台而言是NSURLConnection,对于Android平台则是java.net.HttpURLConnection。需要使用这些API的时候,可以去对应的API文档中进行查询。
存储
在所有的平台上,Application.persistentDataPath都会指向一个可写入的地址,在运行中应该使用这个地址作为写入地址。当选择自定义下载器的时候,强烈使用Application.persistentDataPath的某个子目录作为存储数据的目录。
而Application.streamingAssetPath并不可以写入,所以不要将其作为AssetBundle的存储区域。不同平台的StreamingAssets分析如下:
- OSX平台:在.app包内,不可写区域
- Windows:在安装目录下(如Program Files),通常不可写
- iOS:在.ipa包内,不可写
- Android:在压缩后的.jar文件内,不可写