代码改变世界

Unity AssetBundle打包与资源更新

2015-05-23 17:57 by truenight, ... 阅读, ... 评论, 收藏, 编辑

Unity的AssetBundle打包是一件让人头疼的事情,当我接手这项工作时,我以为最多只用两个周就可以把整个打包和资源热更新的流程搞定,结果还是花了一个月,期间踩坑无数,总结出来希望能够节约别人的时间。

(一)你的游戏项目是什么类型的?

在开始写打包的Editor脚本之前,你最好先详细考察一下你们的游戏项目是什么类型?是端游,手游还是页游?因为这三者涉及到bundle包的资源管理策略截然不同,如果你们是跨平台发布,那我建议你最好用宏来切换管理策略。

 

(二)采用什么样的bundle包加载策略?

AssetBundle加载有以下几种方式:

     (1)CreateFromMemory/CreateFromMemoryImmediate

     这种方式直接从内存构建,可同步可异步,可以先通过C#的IO函数从磁盘加载进内存,再用这个API构建AssetBundle内存镜像,占用内存大。不仅有构建出来的AssetBundle内存镜像,还有用来构建的bundle包的那部分托管堆内存byte[],要等待垃圾回收。

同步构建速度比较快,异步构建的速度非常慢,但是多个bundle包一起异步构建在Unity底层有优化,测试要快过一个个的构建。

      (2)WWW加载

这种方式为异步加载到内存,多个www对象有多线程优化。相比CreateFromMemory少掉了托管堆那部分内存。

      (3)WWW.LoadFromCacheOrDownload 

     这种方式占用内存小,是因为Unity会在硬盘上开辟一块空间,用于缓存解压后(时间主要浪费在解压这一步)的AssetBundle,然后再从这块硬盘缓存上构建AssetBundle包,这种方式占用内存较小,因为构建出来的AssetBundle包主要是对磁盘文件的引用,只有在实例化的时候才会分配资源占用的内存。但是磁盘缓存有上限的,超过了上限之后仍然会变成普通的www全部加载到内存。而且你要有个版本号文件管理传入的version参数,否则有可能加载到老的assetbundle。

(4)CreateFromFile

直接从硬盘构建,也是只构建引用,所以速度快且AssetBundle包本身占用内存最小。推荐这种方式,因为同步的代码比较好写,尤其是对于项目后期才引用bundle包机制的,把以前的所有资源加载都改成异步的逻辑工作量太大。

(三)从构建好的assetbundle里load资源

AssetBundle.Load/AssetBundle.LoadAssetAsync

在PC上纹理的上传就发生在这一步。我测试过一个1024*1024的纹理上传所花费的时间往往10倍于512*512的上传时间,所以减小纹理大小才是性价比最高的优化。对于2d mmorpg经常使用的大图2048*2048,如果你使用同步load一个多帧动画,可以明显的感觉到卡一下。如果使用异步则完全不掉帧,估计Unity是采用sub-image的方式一次锁定一小块区域的纹理上传显卡,但是比较慢,且没有方法调整哪个参数来加速这个步骤。像大型2d微端这种需要在场景上实时加载很多大图的效果不能令人满意,可以采用切图的方式来优化。

还有unity对象的构建花费的时间也很长,很多游戏过关卡时间太长,主要就是prefab的构建和实例化。可以采用pool manager的方式将实例化出来的对象保存起来,过关卡时只卸载其占用内存较大的纹理音效等资源,下次需要时再加回来。这样可以大大减少过关卡的时间,但是这种方式却会给assetbundle的管理带来一些麻烦,我会在后面bundle包卸载那里提到。

(四)Assetbundle打包

(1)依赖打包

最头痛的就是这一步了,你要考虑怎样处理资源间的依赖,以避免产生资源冗余。Unity提供了PushDependencies和PopDependencies来处理依赖包的共享资源问题,例如你有如下依赖关系

(A,B)->C->D

则打包脚本为

push

    build D

    push

           build C

           push

                  build A

                  build B

            pop

     pop

pop

这是一个栈结构,后入栈的资源如果有包含先入栈的资源,则不会重复打包进去,而是依赖于这个包。加载时你要确保先加载被依赖的包,再加载最后的包才不会出错。但是被依赖的包是可以不分先后乱序加载的,如果你使用www加载,可以考虑几个www一起加。

还要你要搞清楚pushDependencies/popDependencies打包时设置的依赖关系和加载时的依赖关系其时是两码事,这也是一开始困惑我的地方。比如你有如下的依赖结构

A->(B1 B2 B3 B4)->C

D->(B3,B4,B5,B6)->G

则你的打包脚本应该是这样的

           push

                   build C,build G

                    push

                              build B1,B2,B3,B4,B5,B6

                               push

                                         build A,D

                                 pop

                      pop

                pop

看起来好像A和D都依赖于B1-B6了,其实不然,这样打包出来A包和D包还是只会依赖于包含相同资源的那些包,比如加载D包的时候你也只需要加载B3-B4    只要你打包的参数设置正确,当B1,B2变动时,走这个流程打包出来的D包二进制仍然没有变化的。

(2) 打包时的参数设置

BuildAssetBundleOptions.DeterministicAssetBundle 

设置了这个参数每次打包出来的包才能确保二进制不变,只要被依赖的包不变,打包的流程不变。所以要做资源更新,这个参数不可少,否则在资源不变化的情况下重复打包出来的MD5都不一样,怎么确保更新功能的正常?

BuildAssetBundleOptions.CollectDependencies

这个参数用来收集所有依赖的包,虽然我们会手动收集依赖关系用于push/pop dependencies,但是仍然需要加上这个参数,因为你不会把一个包依赖的所有资源都收集完,你只会先push几个它依赖的资源,然后再用collectDependencies打最后这个包,确保这个包依赖的所有资源都打进去了。

BuildAssetBundleOptions.CompleteAssets

强制包含整个资源

BuildAssetBundleOptions.UncompressedAssetBundle

采用不压缩的方式打包一个bundle包

我们打包的时候这四个参数都用了,只有最后一个参数视情况而定。

(3)收集依赖关系

打包前先使用AssetDatabase.CollectDependencies遍历所有资源收集他们间的依赖关系,在后面打包的时候按照每个资源被依赖的深度进行分级,先打包级别较低的,如shader,script这些资源被其他资源依赖但不会依赖别的资源,级别最低。如prefab依赖前面的所有资源,级别最高,放在最后打包。一般是按照资源的类型(prefab,mesh,animator,texture,script…)进行分级。即使这样按类型分好级后仍是不够的,因为同一级的资源也有可能产生相互依赖的关系。比如使用NGUI,一个面板prefab依赖于几个挂UIAtlas的prefab,这种同级的依赖需要用深度优先遍历对他们进行排序以确定依赖关系。这个依赖关系使用序列化文件记录下来,供后面加载包的时候先加载所有被依赖的包使用。每次更新的时候这个依赖关系的序列化文件也要同其他资源一起更新。

(4)打包时可能遇到的一些问题

如果你使用www.LoadFromCacheOrDownload 请在调试的时候游戏开始时调用一次ClearCache。即使你的代码有动态更新LoadCache时传入的version参数的机制,调试的时候还是要谨慎,如果BUG导致你传的version跟上次一样,相互依赖的包缓存的版本不匹配,就可能引起一些稀奇古怪的问题。

检查你的打包流程所记录的依赖结构是否稳定。这里的稳定是指,在CollectDependencies的时候有没有处理到的被依赖的资源,有可能在打包同级资源的时候出现相互吃资源的情况。比如

A->(B c)->D                     

E->(F c)->G

打包脚本

push

     build D,G

           push

                    build B,F

                    push

                             build A,E

                     pop

      

在收集依赖关系的时候,c是我们忽视的资源,打包时B和F放在同一级打包,A和E在同一级,由于使用了CollectDependencies,A包会把c给收进去,但是由于B包在同一级跟A一起打的,就会出现c打进A了就不再打进B了,但你加载B的时候又没有加载A,所以B就工作不正常。

排查这个BUG的方式就是先打一两个角色或面板,备份,再打全部资源。把两份资源用二进制工具做比较(推荐BeyondCompare,可以对比目录),如果有不稳定的结构立马就能发现。

还有texture的宽高请使用2的倍数,我在测试不标准的图的时候发现Unity对于这种图会产生一个fmt-512*512(sprite)的临时资源,这个资源get他的硬盘地址时get不到,所以也没有记录进依赖关系文件。当有两张图不规范时,一张图的bundle包收录了临时资源另一张图就没有,加载出来就会不正常。当然一般游戏项目为了优化使用的图都比较规范,不会遇到这个问题。

  在IOS真机调试时报Could not produce class with ID..这是因为你勾选了strip code,有些脚本类是被Resource下的资源引用的,打包后将Resource下的资源移除出去了,一些代码由于检测不到引用就被strip掉了,但是从AssetBundle里加载出来又需要根据ID打到对应代码。解决办法在这里http://docs.unity3d.com/Manual/ClassIDReference.html找到ID对应的class,然后在Assets目录下新建文件link.xml,把不该strip掉的类加进去就行了。我的link.xml文件

<?xml version="1.0" encoding="utf-8"?>
<linker>
    <assembly fullname="System">
        <type fullname="System.Net.HttpRequestCreator" preserve="all"/>        
    </assembly>

    <assembly fullname="UnityEngine">
        <type fullname="UnityEngine.CircleCollider2D" preserve="all"/>
    </assembly>
</linker>

有些类比如 AnimatorController(ID 91)属于Editor包里的,不能用link.xm加回来,可以在Resource下建一个空的prefab,在上面挂一个AnimatorController,打包时留下这个prefab就可以确保这个类不被strip掉了。

(五)更新机制

更新机制比较简单,收集所有bundle包的md5码和文件size,做成一个列表。进游戏时先比对游戏版本号提示更新游戏程序,再比对资源版本号,如果发现新版本号就开始下载md5列表,与本地的md5列表做对比,找出需要更新的资源用http下载就行了。

这个过程还是有许多东西要考虑,比如你的http下载要有下载失败重试几次的机制,要有超时的检测,要知道在下载哪几个资源时整个更新流程卡住了,记录日志。即使遇到更新过程中出错,对于已经更新的资源下次进不用再重复更新,所以最好每更新10条资源就写回一次md5,而不是全更完再写回。

md5列表的比较,以前有的PC游戏会在远端先做好与上一个版本的对比,然后生成一个ver x 到ver x+1 的资源更新文件。在更新的时候如果游戏的资源版本号是ver x-1 就先下载 ver x的资源更新文件更新,再从ver x更新到ver x+1。但是这一套用在Unity手机资源更新上有风险,假设有些手机的清理软件提示这个程序的资源占用过大,一不小心点了导致清掉了部分资源,但你的ver x文件还在,那你更新时被清掉的这部分资源就找不回来了。所以还是每次在客户端对比所有md5比较稳妥,在提取本地的md5列表中的一项时同时检测本地是否存在这个资源文件,不存在的加入更新列表,这样即使被意外清掉的资源也可以找回。

(六)压缩bundle包

因为我们使用的是CreateFomeFile的同步机制加载包,而CreateFromFile只能用BuildAssetBundleOptions.UncompressedAssetBundle,打包出来后自己压缩再在更新时解压。所以采用什么压缩算法就是一个值得商榷的问题。

压缩你要考虑两个方面:压缩率与解压时间。

(待续)

(十)AssetBundle包卸载