技巧 | AssetBundle进阶内存优化(Unity 4.x)

技巧 | AssetBundle进阶内存优化(Unity 4.x)

众所周知,在Unity 4.x版本中,当需要打包成AssetBundle的资源存在层级引用的时候,需要使用BuildPipeline.PushAssetDependencies等相关函数将资源拆分并建立引用关系,然后在加载的时候按照相反的顺序,先加载依赖文件,再加载目标文件的顺序,将数据还原出来,并自动关联好引用关系。在此过程中第一个加载的文件不能调用AssetBundle.UnLoad(false),即在该过程中,会一直有第一个文件的内存映射冗余在内存中,这对于宝贵的手机内存来说是十分致命的。当然我们也可以自我管理引用关系来规避这种冗余。下文我们将举例来说明。

这是侑虎科技第81篇原创文章,感谢作者邓陶然(QQ:20545018)供稿。欢迎转发分享,未经作者授权请勿转载。同时如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群465082844)


测试环境(Unity 4.7 + NGUI)

在项目中,我们用NGUI制作了2个UI,分别是a.prefab 和b.prefab,它们都使用了一张名为c.png的贴图(这种现象在开发中非常常见,很多时候可能是数十个UI Prefab使用了同一张贴图)。我们使用依赖项打包,打包的结果分别是3个文件:
0.png

我们可以看到,在文件层面上已经没有冗余了,a.bundle及b.bundle都没有贴图的资源,贴图的资源都在c中。

按照常规的加载过程使用如下代码:

WWW wwwc = new WWW("file://c.bundle");
        yield return wwwc;
        wwwc.assetBundle.LoadAll();
        WWW wwwa = new WWW("file://a.bundle");
        yield return wwwa;
        WWW wwwb = new WWW("file://b.bundle");
        yield return wwwb;
        GameObject goa = GameObject.Instantiate(wwwa.assetBundle.mainAsset) as GameObject;
        GameObject gob = GameObject.Instantiate(wwwb.assetBundle.mainAsset) as GameObject;

这样的代码能成功加载出我们想要的资源,但这个时候如果我们去看Memory,就会发现冗余数据:
请输入图片描述

我们发现,c的纹理已经被加载出来了,并且已经占用了内存,但是c.bundle的文件映射依然还躺在内存中。一份数据出现了2份内存开销,这对于我们来说是不可饶恕的。按照Unity的要求如果要释放掉c.bundle的文件映射内存,需要调用AssetBundle.UnLoad(false),可是调用过这个函数之后对它有依赖的文件就没有办法再生成出来了,即你会发现纹理那个位置纹理指向的是null。比如这个时候还有其他的Prefab也使用了这张纹理,你会发现使用Bundle加载出来后材质指向的纹理为空,即使这个时候c的纹理已经在内存中。当然你还可以再次加载c.bundle,然后再加载其他Prefab,但你会发现内存中出现了两个叫c的纹理,这个更扯了,很明显不是一个合格的解决方法。

这个时候我们可以采用自我管理引用的方法来解决这个问题。这里以修改UIAtlas为例,同样的原理可以解决其他复杂引用的问题,比如UIFont,甚至3D场景的纹理管理等)。

首先我们调整UIAtlas的代码,在编辑器模式下存下UIAtlas的纹理及与材质的引用关系。这样我们在Bundle中就可以获得这个UIAtlas的纹理引用关系。

[SerializeField] public string[] propertiesName;    //材质中对应使用的纹理PropertyName
    [SerializeField] public string[] propertiesTextureName;     //纹理中的名字
public void MarkAsChanged ()
    {
#if UNITY_EDITOR
        NGUITools.SetDirty(gameObject);
        ReFlushTextureName();
    …..
#endif
}
public void ReFlushTextureName()
    {
        #if UNITY_EDITOR            
        if(material.shader)
        {
            List<string> pns = new List<string>();
            List<string> tns = new List<string>();
            for(int i = 0 ; i < ShaderUtil.GetPropertyCount(material.shader);i++)
            {
                if(ShaderUtil.GetPropertyType(material.shader,i) == ShaderUtil.ShaderPropertyType.TexEnv)
                {
                    string propertyname = ShaderUtil.GetPropertyName(material.shader,i);
                    Texture t = material.GetTexture(propertyname);
                    pns.Add(propertyname);
                    tns.Add(t.name);
                }
                
            }
            propertiesName = pns.ToArray();
            propertiesTextureName = tns.ToArray();
        }
        #endif
    }

正如代码所示,这个UIAtlas所使用的纹理信息已经被我们保存到 PropertiesName 和 PropertiesTextureName 这两个变量中。这些变量名将会被打包到Bundle中,并且在我们重新解开Bundle的时候可以得到。

其次,我们在c.bundle加载出来后,建立纹理名字对纹理的关系表,然后Unload(false)掉。

Texture2DBundlerCache 是一个简单的名字纹理的查找表,提供名字对纹理的添加、查找 、删除等功能,这里不再重复代码了,相信各位都能搞定。

最后我们再修改UIAtlas的代码,使得当材质在使用的时候,会重新将材质与纹理的引用关系恢复。是的,纹理已经在内存中了。

public Material spriteMaterial
    {
        get
        {
            if(material.mainTexture == null)
            {
                if(propertiesName != null)
                {
                    for(int i = 0;i < propertiesName.Length;i++)
                    {
                        material.SetTexture(propertiesName[i], Texture2DBundlerCache.Instance.Get(propertiesTextureName[i]) );
                    }
                }
            }
}
}

这样,我们就可以通过一个很小的表的开销及数个字符串的开销,来避免大量的文件内存占用,特别是在一些复杂引用关系中可以游刃有余地Unload掉资源,非常有效地控制住内存开销,而且由于是自己做的引用表,因而更加可控。