上次增加资源半自动释放机制之后,根据log显示已经有30%~40%的资源被释放掉了(大部分为UI资源)。然而内存问题,并没有明显改善。
我们的资源管理是通过一个叫做ABM的模块进行管理的,ABM采用标准的引用计数方案来管理资源和AssetBundle的生命周期。
当使用ABM.load_asset时会首先检查这个资源的引用计数是否为0,如果为0则会触发加载相应AssetBundle操作。
加载AssetBundle时会首先检查AssetBundle的引用计数是否为0,如果不为0则直接将引用计数加1.并将结果返回即可。
如果AssetBundle的引用计数为0,则除了要对引用计数加1之外,还需要递归加载其所依赖的其它AssetBundle。
如果所需要加载的AssetBundle有依赖于其他AssetBundle,则先递归加载所有依赖的AssetBundle,最后再加载自身。
当卸载一个资源时,首先将其引用计数减1,然后检查其引用计数是否为0. 如果引用计数为0,则触发AssetBundle卸载操作。
卸载AssetBundle时,同样将其引用计数减1,如果引用计数为0,则调用Unity接口AssetBundle.Unload(true)真正释放AssetBundle. 同时递归卸载依赖的所有AssetBundle。
由于担心资源泄漏不可控,所以我们并没有使用AssetBundle.Unload(false)来进行AssetBundle释放。
在整个流程,只有AssetBundle的引用计数为0时,才会被调用Unity接口进行释放。而仅在AssetBundle被释放时,其内部被加载过的资源才会被释放。
换句话说,假如一个AssetBundle有10个资源,如果先加载其中9个,再加载任意2个,最后再将上次加载的9个资源进行释放。这10个资源就会同时存在于内存之中。
因此一个AssetBundle的利用率(即AssetBundle中同时引用计数大于0的资源数量除以AssetBundle中的资源数量)越低,这个AssetBundle中的资源同时出现数量就会越少,相邻两次同时存在内中的资源集合的交集就可能会越小,资源内存泄漏的概率就越大。
当然事情并不是绝对的,如上面的例子,如果先卸载9个资源,并且此AssetBundle没有被其他AssetBundle所依赖,就会产生一次AssetBundle.Unload(true),此时再加载此AssetBundle中的2个资源时,这个AssetBundle会被重新加载,并从AssetBundle加再次加载所需的2个资源。这样就不会有内存泄漏。
但是实际的AssetBundle依赖关系及资源加载顺序都比较复杂,很难保证上述良好的情况及顺序。因此我认为AssetBundle的利率用还是有很大的参考价值的。
为此,我写了一个AssetBundle Profiler,通过折线图来实时显示当前所有AssetBundle的平均利用率。
当选中某个采样点时,可以详细看到此时每个AssetBundle的利用率,并可以查看任意一个AssetBundle中所有资源的引用计数情况,及所在的图集(如果是图片的话)。
相信通过连续观察某一个AssetBundle的资源的详细加载情况,可以为我们如果优化拆分AssetBundle有不错的帮助。比如,我们可以通过资源的使用频次,根据频次的多少来做成树状态倚赖的AssetBundle关系,或者将逻辑上互斥的资源拆分成多个AssetBundle, 即使这些资源只属于同一个模块使用。
然而,上面的方法只能一定程度上改善内存泄漏问题,并不能真正解决问题。因为我们真的很难保证AssetBundle中的所有资源一起加载一起释放。
最好的办法就是,在一个资源的引用计数为0触发卸载AssetBundle之前,先调用Resources.UnloadAsset,将这个资源进行释放。
上述ABM的实现中,没有执行此操作的原因在于,当被加载的资源是一个Prefab时,他可以引用各种其他资源,这些资源会被AssetBundle.LoadAsset(Async)时, 会被Unity底层进行加载。
如果这个Prefab所依赖的资源在其他AssetBundle中,Unity就会通过AssetBundle的依赖关系告诉我们,需要加载这个被依赖AssetBundle。 至于Prefab所用的资源则由Unity底层自动加载,上层的ABM是感知不到的。
换句话说,ABM中的资源的引用计数是不准确的,它的精度仅能够正确指示AssetBundle的生命周期,而不能正确指示其自身的生命周期。
如果要想使资源的引用计数准确指示出资源的生命周期,就必须在加载Prefab这类引用其他资源的资源时,修正所有被Prefab依赖的资源的引用计数。
如果要这么做,就必须要做好两件事。
-
如何运行时获取资源之间的倚赖关系
-
如何时处理资源与AssetBundle的引用问题
对于第一件事,Unity并没有提供运行时获取资源之间的依赖关系。幸运的是,AssetDatabase.GetDependencies提供了一个非运行时接口。因此我们可以在生成AssetBundle时,通过生成一个文件来存储资源间的依赖关系并在运行时解出。
如果一个资源被“引用”之后引用计数为1,则递归对它所依赖的所有资源引用计数加1。同样如果一个资源被卸载后,如果引用计数为0,则递归对他所依赖的资源引用计数减1。
由于Unity底层会自动加载被依赖的资源。为了避免无谓的开销,在操作引用计数时,我们仅仅去修正资源的引用计数而并不实际去加载资源,同样也不需要去加载相应的AssetBundle(也不去操作其引用计数)。
这对我们做好第二次事,造成了困扰。因为此时资源的引用计数与AssetBundle的引用计数完全割裂了。我们需要重新找到一个判断何时加载AssetBundle的依据。
最开始我认为应该将资源间依赖产生的引用计数单独记录,可以叫depend_ref, 而原先的引用计数ref含义及触发的操作保持不变。
如此,我们就可以在产生资源依赖时修正depend_ref,而在资源释放时,如果ref和depend_ref同时为0,则调用Resources.UnloadAsset。
这样做一定是对的,因为其实depend_ref与AssetBundle之间的依赖关系其实是重合的,所以就算ref的值为0而触发AssetBundle卸载操作,只要depend_ref不为0,这个AssetBundle的引用计数一定大于1,从而不可能被真正释放。
经过观察发现,而在释放时,depend_ref+ref一起产生作用,在加载时,ref单独产生作用,在处理依赖关系时depend_ref单独产生作用。
而ref产生的惟一作用就是当它为0时,进行加载和卸载AssetBundle。
前面说过就算ref等于0只要depend_ref大于0,AssetBundle是不可能被卸载的,因为depend_ref本质是反应的是AssetBundle之间的依赖关系,同时因为depend_ref大于0,说明依赖他的资源还在内存中,因此资源不能释放,他所在的AssetBundle也不能释放。因此depend_ref和ref逻辑是统一的,可以进行合并。
而在加载时,ref的作用仅仅是在等于0时进行AssetBundle加载。这是一个2值变量,就是说要么是true, 要么是false。恰好我们有一个相似的变量可以使用,每个被加载成功且没有卸载资源,ABM必须要保留其引用,以避免重复加载。当我们卸载此资源后,为了避免被误为,一般会将资源引用置为null。终于,ref可以和depend_ref进行合并了,我们的抽象更完美了一些。
修改后ABM流程如下。
当加载一个资源时先通过资源间的依赖关系修正相关资源的引用计数,以正确表明资源的生命周期。
判断需要通过ABM加载的资源的引用是否为null,如果不为null则直接返回。如果为null则加载相应的AssetBundle(处理AssetBundle及其依赖的引用计数逻辑不变)。
当卸载一个资源时同样根据资源间的依赖关系修正相关资源的引用计数,如果引用计数为0,且ABM持有的资源引用不为null,则调用Resources.UnloadAsset同时触发AssetBundle的卸载操作。然后将资源引用置为null。
ps. "释放"指通过AssetBundle.Unload(true),而"卸载"指处理AssetBundle的引用计数,如果为0,则真正"释放"
pps. 如果多张图片在一张图集里,只要图集中任意一张图片没有Resources.UnloadAsset,整张图集也不可能被卸载。