【技术点】Unity资源池与动态加载释放

发表于2016-10-04
评论1 4.9k浏览
需求环境
        在上一级的【解决方案】文章中,我们设计出了动态加载资源的业务流程,而这一节,我们就通过一些简单的代码,来实现出业务流程中的效果。
       吸取之前文章的经验,如果按照正式项目的规格开发,本篇文章就会非常冗余,所以我们优化一下,仅仅针对技术点进行讲解与释放,具体与工程相关的,我们就不再文章中讲解,但你可以在Github的工程中找到它们。、
       现在,我们先回顾一下之前所设计出的业务流程。


      那么,在这个业务流程中,我可以定义出在游戏运行时,资源有三种状态:
      1、未加载
      2、已经加载
      3、已可以释放
      三种状态了某个资源此时的最佳使用环境,也就是说,接下来需要使用的资源,我就放到池中,而接下来很长一段时间内不需要使用的资源,我就彻底释放掉。以确保程序的内存总是在可控范围之内。

设计
      为了达到这样的目的,我们就需要划分三个模块去做。
      1、最基础的资源加载,与池。
      2、资源加载的自动记录过程。
      3、资源加载的动态释放与加载过程。

      首先,池,因为我们是模拟,所以这个就比较容易实现,在现实工程中,则可能需要考虑不同资源类型的具体逻辑。

    ///


    /// 池
    ///
    Dictionary Stack> PoolDict = new Dictionary();

    /// 
    /// 正在工作的资源对象
    /// 
    Dictionary int> WorkingPool = new Dictionary();
       首先是2个定义,一个是回收池,一个是工作区,工作区用来反向查资源的ID,同时,也检测是否有资源是通过其他方法加载的,理论上,游戏内不应该存在其他的途径来加载资源。
      接下来,就是2份逻辑代码,一个是创建资源,它用到了之前我们实现的资源管理器,另一个是回收资源。 

///


    /// 得到资源,如果池子里有,直接拿,否则创建
    ///
    ///资源类型,方便上级使用
    ///资源id
    ///
    public T getObj(int _id)
        where T : Object
    {
        Object temp = null;
        //
池子里有就取一个
        if (PoolDict.ContainsKey(_id) &&
            PoolDict[_id].Count > 0)

            temp = PoolDict[_id].Pop();

        //
如果池子里没有,就创建一个新的
        temp = DJAssetsManager.GetInstance().Load(_id);

        if (temp as T == null)
        {
            Debug.LogError("
代码写错了或资源配错了,传入的资源id与希望得到的类型不匹配");
            Debug.Break();
            return null;
        }

        //
加入工作池
        WorkingPool.Add(temp,_id);

        return (T)temp;
    }

    /// 
    /// 回收资源
    ///
    public void recObj(Object _obj)
    {
        if (WorkingPool.ContainsKey(_obj))
        {
            //
正常回收
            int id = WorkingPool[_obj];
            WorkingPool.Remove(_obj);

            if (PoolDict.ContainsKey(id) == false)
                PoolDict.Add(id, new Stack());

            PoolDict[id].Push(_obj);
        }
        else
        {
            //
不属于池管理的资源直接删除掉。不过得打出警告,按理说不应该存在
            Debug.LogWarning("检测到非法创建的资源:" + _obj.name);
            Destroy(_obj);
        }
    }

        要注意的是,池仅仅负责资源的状态转换,并没有处理资源的开关,与销往逻辑,具体工程中可以根据资源类型分类编写,也可以给资源挂在统一的逻辑脚本去处理自己的销毁回调。
      还有另一种方法,则是在使用池子进行资源销毁之前,自己动手对资源进行回收相关的处理,这样更依赖于人,不推荐团队使用,但此时我们做范例,就不额外引入更多的业务逻辑

池测试
      现在池已经弄好了,我们就需要简单的做一个池子的小测试。打开项目工程10-2PooL场景,我们能找到Test对象,它身上有脚本PoolTest.Cs 。当游戏运行时,我们就可以通过它去检查池子是否生效。 

///


    /// 
测试池
    /// 

    private void testPool()
    {
        Profiler.BeginSample("资源加载");
        DateTime time = DateTime.Now;
        //
加载干物妹
        var obj = DJPoolManager.GetInstance().getObj(0);

        Debug.Log("
加载花费了:" + (DateTime.Now -time).TotalMilliseconds);

        //
释放干物妹
        DJPoolManager.GetInstance().recObj(obj);
        Profiler.EndSample();
    }
 
        使用之后这个函数测试之后,我们可以发现,第一次加载花费了9毫秒,而第二次,则只用了2毫秒。
        具体的花费,我们也需要通过性能分析器去查看,使用 Profiler.BeginSample("资源加载")进行标记,这里就不在额外扩展。

 

        PS: 在文章代码中并没有对预制体进行管理,这其实是不好的,最好手动的控制他们的加载与释放。

资源生命周期的自动记录
        要记录资源的生命周期,首先我们得确定自己的游戏形势,如果是大世界类型的游戏,我们需要根据区域范围来确定资源表,那么如果是副本类型的,我们就需要以副本为单位记录一份资源表。
       并且,有的资源我们希望是动态加载的,而有的资源,比如主角的特效,模型,音频等等,我们更希望它们是常驻的。所以,我们还需要区分一份资源是否需要动态加载。

      知道了需求后,我们就可以对自动记录表进行设计。为了讲解清晰,我尽量的保持任何一个元素都只是为了测试,不与业务逻辑挂钩。
      在工程中,你可以到之前我们创建过的DJAssetsDefine 命名空间,里面我们新添加了这一次需要使用到的记录表。

[System.Serializable]
    public class AssetPreConfig
    {
        ///


        /// 资源ID
        /// 
        public int AssetId;

        ///
        /// 加载时间
        /// 
        public float LordTime;


        ///
        /// 下一个次同类资源的加载时间,-1 就是再也没有加载过了
        /// 
        public float NextTime = -1;
    }
        字段很简单,也有注释说明,大家看注释就好。
        之后我们要让它成为一张表,所以需要再创建一个文件。在工程里可以找到名为:DJAssetPreLoadTable.cs 的代码文件。只有一个List,我打算直接使用List的索引来表示资源加载的前后关系,所以就不需要其他信息了。

public class DJAssetPreLoadTable : DJTableBase
{
    ///


    /// 预加载列表
    ///
    public List Datas = new List();
}


自动记录
       有了表以后,我们就可以在游戏运行时,把被加载的资源记录到表中。这里面包含了一个逻辑过程。


代码如下:

///


    /// 得到一个克隆体
    ///
    ///资源id
    /// 是否预加载
    ///
    private Object getClone(int _id, bool _isPre = false)
    {
        //
预加载直接返回新的
        if (_isPre) return Object.Instantiate(PoolDict[_id].pre); ;

        //
池里有从池里拿
        if (PoolDict[_id].Pools.Count > 0)
        {
            currentIndex+= 1;
            return PoolDict[_id].Pools.Pop();
        }

        //
记录下这次加载
        AutoLog(_id);

        //返回一个新的
        return Object.Instantiate(PoolDict[_id].pre);
    }

 

        AutoLog就是我们记录代码,在PoolManager中,我定义一个新的字典,用来在运行时候读取与存储与自动记录有关的信息。下面是具体的AutoLog代码。

///


    /// 记录资源
    ///
    public void AutoLog(int _id)
    {
        if (isAutoPre == false) return;

        Debug.Log("
记录了资源,index " + currentIndex + "资源ID: " + _id);

        AssetPreConfig config = new AssetPreConfig();
        config.AssetId = _id;
        config.LordTime = Time.time - startTime;

        currentTable.Datas.Insert(currentIndex,config);
        currentIndex += 1;
        PreIndex += 1;
    }
     有了上面两个函数后,我对之前我们的资源getObj函数进行了一些修改,使得可以在加载资源时,把资源表信息的内容,记录下来。

///


    /// 得到资源,如果池子里有,直接拿,否则创建
    ///
    ///资源类型,方便上级使用
    ///资源id
    ///
    public T getObj(int _id)
        where T : Object
    {
        Object temp = null;

        //
创建一个池子
        if (PoolDict.ContainsKey(_id) == false)
            createObejctPool(_id);

        //
获取一个克隆体
        temp = getClone(_id);

        //加入反查字典
        PrePoolDict.Add(temp,_id);

        if (temp as T == null)
        {
            Debug.LogError("
代码写错了或资源配错了,传入的资源id与希望得到的类型不匹配");
            Debug.Break();
            return null;
        }
        return (T)temp;
    }

 

       好,有了这些代码以后,我们就可以开始测试了记录工作了。
       当然,记录流程呢还有其他代码,比如开始与结束等等,都是一些业务逻辑上的代码,如果我把他们贴上来,就会让你迷糊,所以我贴出关键点,当读者感兴趣时,自己可以查阅github上的工程代码。

资源回收判定
       大部分的资源被创建出来后,都有生命周期结束的时刻,当它的生命周期结束时,我们就需要决定是删除它还是仅仅回收到池中。
     在我们的解决方案中,我定义了一个规则,并且为了测试,改变了参数。
1、当一份资源创建时,根据下一次同类资源调用时间决定是否删除
2、 为了测试,调用间隔为10秒
3、因为要知道同类资源下次调用时间,但又不希望运行时循环表,在自动记录结束时,循环一次表进行判定。
4、如果一份资源被预加载了但是很久没被使用过,则从记录表中删除该条信息。(代码中未实现)。
代码如下:

///


    /// 回收资源
    ///
    public void recObj(Object _obj)
    {
        if (PrePoolDict.ContainsKey(_obj))
        {
            int id = PrePoolDict[_obj];
            //
清空反查
            PrePoolDict.Remove(_obj);

            PoolDict[id].Count -= 1;
            if (PoolDict[id].isDestroty== false)
            {
                //
正常回收
                Debug.Log("回收了:" + id);
                PoolDict[id].Pools.Push(_obj);
            }
            else
            {
                Debug.Log("
删除了:" + id);
                if (PoolDict[id].Count == 0)
                {
                    //
删除回收
                    Destroy(_obj);
                    //回收预制体
                    Resources.UnloadAsset(PoolDict[id].pre);
                    //去掉该资源的池信息
                    PoolDict.Remove(id);
                }
                else
                {
                    //删除回收
                    Destroy(_obj);
                }
            }
        }
        else
        {
            //不属于池管理的资源直接删除掉。不过得打出警告,按理说不应该存在
            Debug.LogWarning("检测到非法创建的资源:" + _obj.name);
            Destroy(_obj);
        }
    }
       主要逻辑都有注释,所以读者应该可以看清楚关于资源回收的逻辑判定过程。至于额外的代码,就不贴出来,以免脑袋混乱。

资源自动预加载
       当我们有了表,也自动记录了,还有了资源回收机制以后,就可以开心的自动预加载记录好的资源了。
      在工程中,我直接把这个过程写在了Update函数中,每一帧都检测当前是否有资源需要加载,同时为了性能考虑,同一帧绝对不加载1份以上的资源。
      这里还有优化的空间,我们完全根据性能来决定什么是否集中预加载,什么时候不预加载,比如(战斗过程)。

    ///


    /// 预加载更新帧
    /// 
    void PreLoadUpdate()
    {
        //
没东西可预加载了
        if (PreIndex >=currentTable.Datas.Count)
            return;

        AssetPreConfig config = currentTable.Datas[PreIndex];

        //
如果预加载的index所指向的内容在预加载时间内,就加载
        if (config.LordTime - (Time.time - startTime) 
        {
            preObj(config.AssetId);
            PreIndex += 1;
            //
判断之后该资源是回收还是删除
            if (config.NextTime == -1 || config.NextTime >DESTROTYTIME)
            {
                PoolDict[config.AssetId].isDestroty = true;
            }
        }
    }

 

///


    /// 不管池子里有多少,再生成一个放到池子里
    ///
    ///
    public void preObj(int _id)
    {
        //
创建一个池子
        if (PoolDict.ContainsKey(_id) == false)
            createObejctPool(_id);

        PoolDict[_id].Pools.Push(getClone(_id, true));

        Debug.Log("
预加载了:" + PoolDict[_id].pre.name + "。 池中大小:" + PoolDict[_id].Pools.Count);
    }

 

        上面的代码一个Update中运行的,当判断接下来2秒有一份资源请求时,就对其进行预加载。而下面的代码,就是生成一份资源,再直接丢入到池中。这样,当2秒后这份资源需要使时,它就可以直接从池子里获取。

测试
      把功能点写完后,我们还需要对自己的代码进行测试,判断是否达到了预期的目标。因为这次测试比较复杂,所以我写了一个简单的测试代码来帮我们完成这个过程。
     在场景10-2PooL中,可以找到脚本PoolTest.cs ,里面包含了这次的测试过程,具体规则如下:
     1、第一次测试,没有任何记录存在,每一次资源加载都经过克隆的过程。
     2、第二次测试,前部分资源拥有记录,所以在回收的时候进行删除。
     3、第三次测试,因为第二次检测到了后面10秒内还有同类资源,所以前面资源不释放。

private void test()
    {
        
自动测试 = false;
        //
设置测试资源
        LoadID = 0;
        //1
、3秒时加载资源,5秒释放,12秒后加载资源。
        //预测结果。
        //第二次运行,加载资源时都只用从池里取出。
        DJPoolManager.GetInstance().BeginAutoPreLoad("自动测试");
        wait(1, () => { load(); });
        wait(3, () => { load(); });
        wait(5, () => { Rec(); Rec(); });//
此时第二次运行时应该是删除资源
        wait(12, () => { load(); });//此时第二次运行也应该已有预制体
        wait(15, () =>
        {
            DJPoolManager.GetInstance().EndAutoPreLoad();
            Debug.Log("
自动测试完成");
        });
    }

       原本我希望第三次测试的时候,应该是再次预加载,前2份资源应该被删掉,但估算时间的时候算错了1秒。导致三次结果都不同,不过觉得这种用例用来展现“自动优化”的过程更好,所以就保留了下来。
      下面,就是三次测试的结果。


第一次
此时记录表内的内容

                              



 

第二次


                                    

        可以看到,前两次的资源都有预加载,所以时间上间断了。而第三次资源,却比第一次还要多,因为中间发生了资源删除事件。

 第三次


  这一次,没有任何资源是在使用时才被加载的,前2份资源也不会“轻易”的放弃了自己生命,而是等待这第3份的调用。彻底完成了优化的过程。

结束语
      如果和业务逻辑相结合,我们所演示的功能是不够的,但却构建了整个自动化的资源加载与释放的核心框架,使得我们在项目后续的开发过程中,尽可能的不会在IO方面遇到困难。
       同时,如果我们能继续对这部分的工作进行优化,还能制作出更平缓的游戏资源IO流程,提供更好的游戏性能。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

0个评论