我们知道在Unity中通过Resources.Load可以进行加载

// 加载一个音频资源
AudioClip go = Resources.Load<AudioClip>("Example/1");

通过Resources.UnloadAsset进行卸载

// 卸载上面的音频资源
Resources.UnloadAsset(go);

那我们如何查看资源已经卸载呢?

可以通过Profile工具查看Audio Clip Count: 0,当我们加载一个音频资源时Audio Clip Count: 1,卸载后Audio Clip Count: 0

在一个脚本中,如果要对一个资源进行加载的话,那么在脚本销毁的时候就一定要对其卸载,不然该资源将一直存在于内存中,直到退出游戏为止。也就意味着如果一直不退出游戏的话,那么资源占用的内存会越来越多,游戏将会越来越卡。这对于一个游戏开发者而言显然是不允许的。所以我们需要在OnDestroy方法中对资源进行卸载。

AudioClip ac;
private void Start(){
    ac = Resources.Load<AudioClip>("aa");
}

private void OnDestroy(){
    Resources.UnLoadAsset(ac);
    ac = null;
}

这只是对一个资源的加载和卸载,但是如果资源很多的情况下,就需要写很多个UnLoadAsset,太麻烦了。对于一个懒人作者来说。有没有更好的方法去卸载资源呢?

答案是使用List,在加载的时候,我们将加载中的资源存放到List中,卸载的时候,直接通过遍历列表。代码如下:

AudioClip ac;
List<Object> list = new();
private void Start(){
    ac = Resources.Load<AudioClip>("aa");
    list.Add(ac);
}

private void OnDestroy(){
    foreach(var item in list){
        Resources.UnLoadAsset(item);
    }
    list.Clear();
}

如果重复加载一个资源或者重复卸载一个资源的话,就会导致内存的浪费甚至还可能出现一些问题。怎么避免重复加载呢?

答案是在每次加载的时候,在List中去查找这个资源,如果有,就返回这个资源。如果没有,在去通过Resources.Load进行加载。

所以我们定义一个LoadAsset方法,这个方法的核心目的就是:先找list,如果有则返回,没有才会加载

为什么需要使用泛型?而且泛型必须继承Object呢?

答:Object是Unity引擎中所有资源的父类,可以通过Object接收Unity中的所有资源,如音频、图片等。

而使用泛型是想要加载什么类型的资源就返回什么类型的资源。

List<Object> list = new();

private T LoadAsset<T>(string resname) where T : Object{
    var resAsset = list.Find((e)=>e.name == resname) as T;
    if(resAsset != null){
        return resAsset;
    }
    T t = Resources.Load<T>(resname);
    list.Add(t);
    return t;
}


private void Start(){
    LoadAsset<AudioClip>("aa");
}

private void OnDestroy(){
    foreach(var item in list){
        Resources.UnLoadAsset(item);
        item = null;
    }
    list.Clear();
}

如果每个脚本都写这样的逻辑,那么也会显得很麻烦。作为一个懒人作者,我们需要封装上面的代码,不用每个脚本都这么写,那么将非常的方便。那么怎么去实现呢?

首先我们准备一个资源加载类ResLoader,用于对资源进行加载和卸载。

《代码大全2》第6章:可以工作的类,提到设计一个类需要有一个中心目的。

而ResLoader的中心目的是对资源进行加载和卸载。

using System.Collections.Generic;
using UnityEngine;

namespace DYFramework.ResKit
{
    public class ResLoader
    {
        private List<Object> _loadedAssets = new();

        public T LoadAsset<T>(string assetName) where T : Object
        {
            var retAsset = _loadedAssets.Find((e) => e.name == assetName) as T;
            if (retAsset)
            {
                return retAsset;
            }

            retAsset = Resources.Load<T>(assetName);
            _loadedAssets.Add(retAsset);
            return retAsset;
        }

        public void UnloadAllAssets()
        {
            foreach (var item in _loadedAssets)
            {
                Resources.UnloadAsset(item);
            }
            _loadedAssets.Clear();
        }
    }
}

经过这样的设计,我们原本复杂的代码,变成了简单的3步。如下面的代码:

private ResLoader _resLoader = new ResLoader();

private void Start()
{
    _resLoader.LoadAsset<AudioClip>("Example/1");
}

private void OnDestroy()
{
    _resLoader.UnloadAllAssets();
    _resLoader = null;
}

再次打开Profile分析器,找到Audio下面的Audio Clip Count,当加载资源时Audio Clip Count: 1,删除测试脚本,会调用OnDestroy函数,卸载资源,Audio Clip Count变为了0。

说明我们的逻辑没有问题。

现在我们可以对一个脚本解决重复加载和卸载问题。但是多个脚本访问一个资源又该怎么办呢?

比如脚本A访问资源1,脚本B也访问资源1,打开Profile分析器,发现资源1还是重复加载了。

所有我们需要一个全局的容器,用来存放所有脚本所加载过的资源。

核心思路就是:先从本地的容器中找,本地没有找到,再去找全局。全局找到了就返回,并把它放入本地缓存。全局每有找到,那就通过Resources.Load加载资源,然后放入本地和全局。

using System.Collections.Generic;
using UnityEngine;

namespace DYFramework.ResKit
{
    public class ResLoader
    {
        private static List<Object> _sharedLoadedAssets = new();
        
        private List<Object> _loadedAssets = new();

        public T LoadAsset<T>(string assetName) where T : Object
        {
            // 先从本地找
            var retAsset = _loadedAssets.Find((e) => e.name == assetName) as T;
            if (retAsset)
            {
                return retAsset;
            }
            // 再从全局找
            retAsset = _sharedLoadedAssets.Find((e) => e.name == assetName) as T;
            if (retAsset)
            {
                _loadedAssets.Add(retAsset);
                return retAsset;
            }
            // 全局和本地都没有找到
            retAsset = Resources.Load<T>(assetName);
            _loadedAssets.Add(retAsset);
            _sharedLoadedAssets.Add(retAsset);
            return retAsset;
        }

        public void UnloadAllAssets()
        {
            foreach (var item in _loadedAssets)
            {
                Resources.UnloadAsset(item);
            }
            _loadedAssets.Clear();
        }

    }
}

简单引用计数器

资源能够很好的加载,但是卸载就有点问题了。为什么这么说?

假设脚本A引用一个音频资源,脚本B也引用这个音频资源。当销毁脚本A的时候,会卸载这个音频资源吗?答案是不会的,因为如果卸载了音频资源,那么脚本B就无法使用这个音频资源了。

那么怎么去判断这个音频资源有没有被其他脚本所引用呢?

其实也比较简单,就是在加载资源的时候做一个计数判断,当一个脚本加载这个资源的时候,这个资源的引用就+1。当一个脚本卸载这个资源的时候,这个资源的引用就-1。如果资源的引用=0的时候,就表示这个资源没有被任何脚本引用,所以我们需要卸载该资源。

为此,我们可以制作一个引用计数接口有:资源引用的数量、当资源加载时引用数量如何改变、当资源卸载时引用数量如何改变。

public interface IRefCounter
{
    int RefCount { get; }
    void Retain(object refOwner = null);
    void Release(object refOwner = null);
}

一个简单的引用计数器SimpleRc类就可以实现这个接口。只要继承这个SimpleRc类,那么继承SimpleRc类的这个类就拥有计数功能。

SimpleRc类的核心逻辑比较简单:加载资源时,需要调用Retain。卸载资源时,需要调用Release。当RefCount=0时,需要实现真正的卸载。

所以需要重写OnZeroRef方法。

public class SimpleRc : IRefCounter
{
    public int RefCount { get; private set; }
    public void Retain(object refOwner = null)
    {
        RefCount++;
    }

    public void Release(object refOwner = null)
    {
        RefCount--;
        if (RefCount <= 0)
        {
            OnZeroRef();
        }
    }
    
    protected virtual void OnZeroRef()
    {
        
    }
}

接着我们将资源进行一次封装。

public class Res : SimpleRc {
    // 资源
    public Object Asset { get; protected set; }
    // 资源名
    public string Name { get; protected set; }

    protected override void OnZeroRef()
    {
        base.OnZeroRef();
    }
}

那么我们实际的加载资源和卸载资源的逻辑应该放在Res类中,而不是ResLoader类。为此我们可以修改之前的代码。

所以我们需要重新定义类的功能

  • Res类:管理真正的资源,处理加载和卸载的逻辑。

  • ResLoader:加载资源,但不管加载的具体实现。

public class Res : SimpleRc {
    // 资源
    public Object Asset;
    // 资源名
    public string Name => Asset.name;
    // 资源路径
    public string AssetPath;

    public Res(string assetName)
    {
        AssetPath = assetName;
    }
    
    public T LoadSync<T>() where T : Object
    {
        Asset = Resources.Load(AssetPath);
        // 资源加载完毕后,资源引用+1
        Retain();
        return Asset as T;
    }
    

    protected override void OnZeroRef()
    {
        base.OnZeroRef();
        // 资源正在的卸载
        Resources.UnloadAsset(Asset);
        Asset = null;
    }
}

public class ResLoader
{
    private static List<Res> _sharedLoadedAssets = new();
    
    private List<Res> _loadedAssets = new();

    public T LoadAsset<T>(string assetName) where T : Object
    {
        // 先从本地找
        var res = _loadedAssets.Find((e) => e.AssetPath == assetName);
        if (res != null)
        {
            return res.Asset as T;
        }
        // 再从全局找
        res = _sharedLoadedAssets.Find((e) => e.AssetPath == assetName);
        if (res != null)
        {
            _loadedAssets.Add(res);
            return res.Asset as T;
        }
        // 全局和本地都没有找到
        res = new Res(assetName);
        var asset = res.LoadSync<T>();
        _loadedAssets.Add(res);
        _sharedLoadedAssets.Add(res);
        return asset;
    }

    public void UnloadAllAssets()
    {
        foreach (var item in _loadedAssets)
        {
            item.Release();
        }
        _loadedAssets.Clear();
    }

}

资源UI显示

为了方便测试,我们需要制作一个资源显示,其核心方法就是显示全局变量里面的资源,为了方便管理,我们需要将ResLoader中代码

private static List<Res> _sharedLoadedAssets = new();

移动到ResManager当中,当我们要查看资源时,只需要按下F2,就可以显示当前的所有资源

namespace DYFramework.ResKit
{
    public class ResManager : MonoSingletonBase<ResManager>
    {
        public static List<Res> SharedLoadedReses = new();
        

#if UNITY_EDITOR
        private void OnGUI()
        {
            if (Input.GetKey(KeyCode.F2))
            {
                GUILayout.BeginVertical("box");
                SharedLoadedReses.ForEach((e) =>
                {
                    GUILayout.Label($"Name: {e.Name}, refCount: {e.RefCount}, State: {e.State}");
                });
            
                GUILayout.EndVertical();
            }
        }
#endif

    }
}