GameRes游资网

 找回密码
 立即注册
查看: 1254|回复: 0

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

[复制链接]
发表于 2018-5-11 16:36:43 | 显示全部楼层 |阅读模式
文/邪让多杰

需求环境

在上一级的【解决方案】文章中,我们设计出了动态加载资源的业务流程,而这一节,我们就通过一些简单的代码,来实现出业务流程中的效果。

吸取之前文章的经验,如果按照正式项目的规格开发,本篇文章就会非常冗余,所以我们优化一下,仅仅针对技术点进行讲解与释放,具体与工程相关的,我们就不再文章中讲解,但你可以在Github的工程中找到它们。、

现在,我们先回顾一下之前所设计出的业务流程。

image001.1475572739.png

那么,在这个业务流程中,我可以定义出在游戏运行时,资源有三种状态:

1、未加载

2、已经加载

3、已可以释放

三种状态了某个资源此时的最佳使用环境,也就是说,接下来需要使用的资源,我就放到池中,而接下来很长一段时间内不需要使用的资源,我就彻底释放掉。以确保程序的内存总是在可控范围之内。

设计

为了达到这样的目的,我们就需要划分三个模块去做。

1、最基础的资源加载,与池。

2、资源加载的自动记录过程。

3、资源加载的动态释放与加载过程。



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

///

/// 池

///

Dictionary Stack> PoolDict = new Dictionary();

///

/// 正在工作的资源对象

///

Dictionary int> WorkingPool = new Dictionary();

首先是2个定义,一个是回收池,一个是工作区,工作区用来反向查资源的ID,同时,也检测是否有资源是通过其他方法加载的,理论上,游戏内不应该存在其他的途径来加载资源。

接下来,就是2份逻辑代码,一个是创建资源,它用到了之前我们实现的资源管理器,另一个是回收资源。

  1.     ///


  2.     /// 池
  3.     ///
  4.     Dictionary Stack> PoolDict = new Dictionary();

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


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

  24.             temp = PoolDict[_id].Pop();

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

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

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

  35.         return (T)temp;
  36.     }

  37.     ///
  38.     /// 回收资源
  39.     ///
  40.     public void recObj(Object _obj)
  41.     {
  42.         if (WorkingPool.ContainsKey(_obj))
  43.         {
  44.             //正常回收
  45.             int id = WorkingPool[_obj];
  46.             WorkingPool.Remove(_obj);

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

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


要注意的是,池仅仅负责资源的状态转换,并没有处理资源的开关,与销往逻辑,具体工程中可以根据资源类型分类编写,也可以给资源挂在统一的逻辑脚本去处理自己的销毁回调。

还有另一种方法,则是在使用池子进行资源销毁之前,自己动手对资源进行回收相关的处理,这样更依赖于人,不推荐团队使用,但此时我们做范例,就不额外引入更多的业务逻辑

池测试

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

  1. ///


  2.     /// 测试池
  3.     ///
  4.     private void testPool()
  5.     {
  6.         Profiler.BeginSample("资源加载");
  7.         DateTime time = DateTime.Now;
  8.         //加载干物妹
  9.         var obj = DJPoolManager.GetInstance().getObj(0);

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

  11.         //释放干物妹
  12.         DJPoolManager.GetInstance().recObj(obj);
  13.         Profiler.EndSample();
  14.     }
复制代码

使用之后这个函数测试之后,我们可以发现,第一次加载花费了9毫秒,而第二次,则只用了2毫秒。

具体的花费,我们也需要通过性能分析器去查看,使用 Profiler.BeginSample("资源加载")进行标记,这里就不在额外扩展。

image002.1475572739.png

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

资源生命周期的自动记录

要记录资源的生命周期,首先我们得确定自己的游戏形势,如果是大世界类型的游戏,我们需要根据区域范围来确定资源表,那么如果是副本类型的,我们就需要以副本为单位记录一份资源表。

并且,有的资源我们希望是动态加载的,而有的资源,比如主角的特效,模型,音频等等,我们更希望它们是常驻的。所以,我们还需要区分一份资源是否需要动态加载。



知道了需求后,我们就可以对自动记录表进行设计。为了讲解清晰,我尽量的保持任何一个元素都只是为了测试,不与业务逻辑挂钩。

在工程中,你可以到之前我们创建过的DJAssetsDefine 命名空间,里面我们新添加了这一次需要使用到的记录表。

  1. [System.Serializable]
  2.     public class AssetPreConfig
  3.     {
  4.         ///


  5.         /// 资源ID
  6.         ///
  7.         public int AssetId;

  8.         ///
  9.         /// 加载时间
  10.         ///
  11.         public float LordTime;


  12.         ///
  13.         /// 下一个次同类资源的加载时间,-1 就是再也没有加载过了
  14.         ///
  15.         public float NextTime = -1;
  16.     }
复制代码

字段很简单,也有注释说明,大家看注释就好。

之后我们要让它成为一张表,所以需要再创建一个文件。在工程里可以找到名为:DJAssetPreLoadTable.cs 的代码文件。只有一个List,我打算直接使用List的索引来表示资源加载的前后关系,所以就不需要其他信息了。

  1. public class DJAssetPreLoadTable : DJTableBase
  2. {
  3.     ///


  4.     /// 预加载列表
  5.     ///
  6.     public List Datas = new List();
  7. }
复制代码

自动记录

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

image003.1475572739.png

代码如下:

  1. ///


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

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

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

  19.         //返回一个新的
  20.         return Object.Instantiate(PoolDict[_id].pre);
  21.     }
复制代码

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

  1. ///


  2.     /// 记录资源
  3.     ///
  4.     public void AutoLog(int _id)
  5.     {
  6.         if (isAutoPre == false) return;

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

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

  11.         currentTable.Datas.Insert(currentIndex,config);
  12.         currentIndex += 1;
  13.         PreIndex += 1;
  14.     }
复制代码

有了上面两个函数后,我对之前我们的资源getObj函数进行了一些修改,使得可以在加载资源时,把资源表信息的内容,记录下来。

  1. <p>///</p><p>
  2. </p><p>/// 得到资源,如果池子里有,直接拿,否则创建</p><p>
  3. </p><p>///</p><p>
  4. </p><p>///资源类型,方便上级使用</p><p>
  5. </p><p>///资源id</p><p>
  6. </p><p>///</p><p>
  7. </p><p>public T getObj(int _id)</p><p>
  8. </p><p>where T : Object</p><p>
  9. </p><p>{</p><p>
  10. </p><p>Object temp = null;</p><p>
  11. </p><p>//创建一个池子</p><p>
  12. </p><p>if (PoolDict.ContainsKey(_id) == false)</p><p>
  13. </p><p>createObejctPool(_id);</p><p>
  14. </p><p>//获取一个克隆体</p><p>
  15. </p><p>temp = getClone(_id);</p><p>
  16. </p><p>//加入反查字典</p><p>
  17. </p><p>PrePoolDict.Add(temp,_id);</p><p>
  18. </p><p>if (temp as T == null)</p><p>
  19. </p><p>{</p><p>
  20. </p><p>Debug.LogError("代码写错了或资源配错了,传入的资源id与希望得到的类型不匹配");</p><p>
  21. </p><p>Debug.Break();</p><p>
  22. </p><p>return null;</p><p>
  23. </p><p>}</p><p>
  24. </p><p>return (T)temp;</p><p>
  25. </p><p>}</p>
复制代码

好,有了这些代码以后,我们就可以开始测试了记录工作了。

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

资源回收判定

大部分的资源被创建出来后,都有生命周期结束的时刻,当它的生命周期结束时,我们就需要决定是删除它还是仅仅回收到池中。

在我们的解决方案中,我定义了一个规则,并且为了测试,改变了参数。

1、当一份资源创建时,根据下一次同类资源调用时间决定是否删除

2、 为了测试,调用间隔为10秒

3、因为要知道同类资源下次调用时间,但又不希望运行时循环表,在自动记录结束时,循环一次表进行判定。

4、如果一份资源被预加载了但是很久没被使用过,则从记录表中删除该条信息。(代码中未实现)。

代码如下:

  1. ///


  2.     /// 回收资源
  3.     ///
  4.     public void recObj(Object _obj)
  5.     {
  6.         if (PrePoolDict.ContainsKey(_obj))
  7.         {
  8.             int id = PrePoolDict[_obj];
  9.             //清空反查
  10.             PrePoolDict.Remove(_obj);

  11.             PoolDict[id].Count -= 1;
  12.             if (PoolDict[id].isDestroty== false)
  13.             {
  14.                 //正常回收
  15.                 Debug.Log("回收了:" + id);
  16.                 PoolDict[id].Pools.Push(_obj);
  17.             }
  18.             else
  19.             {
  20.                 Debug.Log("删除了:" + id);
  21.                 if (PoolDict[id].Count == 0)
  22.                 {
  23.                     //删除回收
  24.                     Destroy(_obj);
  25.                     //回收预制体
  26.                     Resources.UnloadAsset(PoolDict[id].pre);
  27.                     //去掉该资源的池信息
  28.                     PoolDict.Remove(id);
  29.                 }
  30.                 else
  31.                 {
  32.                     //删除回收
  33.                     Destroy(_obj);
  34.                 }
  35.             }
  36.         }
  37.         else
  38.         {
  39.             //不属于池管理的资源直接删除掉。不过得打出警告,按理说不应该存在
  40.             Debug.LogWarning("检测到非法创建的资源:" + _obj.name);
  41.             Destroy(_obj);
  42.         }
  43.     }
复制代码


主要逻辑都有注释,所以读者应该可以看清楚关于资源回收的逻辑判定过程。至于额外的代码,就不贴出来,以免脑袋混乱。

资源自动预加载

当我们有了表,也自动记录了,还有了资源回收机制以后,就可以开心的自动预加载记录好的资源了。

在工程中,我直接把这个过程写在了Update函数中,每一帧都检测当前是否有资源需要加载,同时为了性能考虑,同一帧绝对不加载1份以上的资源。

这里还有优化的空间,我们完全根据性能来决定什么是否集中预加载,什么时候不预加载,比如(战斗过程)。

  1. ///


  2.     /// 预加载更新帧
  3.     ///
  4.     void PreLoadUpdate()
  5.     {
  6.         //没东西可预加载了
  7.         if (PreIndex >=currentTable.Datas.Count)
  8.             return;

  9.         AssetPreConfig config = currentTable.Datas[PreIndex];

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


  21. ///


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

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

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

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

测试

把功能点写完后,我们还需要对自己的代码进行测试,判断是否达到了预期的目标。因为这次测试比较复杂,所以我写了一个简单的测试代码来帮我们完成这个过程。

在场景10-2PooL中,可以找到脚本PoolTest.cs ,里面包含了这次的测试过程,具体规则如下:

1、第一次测试,没有任何记录存在,每一次资源加载都经过克隆的过程。

2、第二次测试,前部分资源拥有记录,所以在回收的时候进行删除。

3、第三次测试,因为第二次检测到了后面10秒内还有同类资源,所以前面资源不释放。

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

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

下面,就是三次测试的结果。

image004.1475572740.png

第一次

此时记录表内的内容

image005.1475572740.png

第二次

image006.1475572740.png

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

第三次

image007.1475572740.png

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

结束语

如果和业务逻辑相结合,我们所演示的功能是不够的,但却构建了整个自动化的资源加载与释放的核心框架,使得我们在项目后续的开发过程中,尽可能的不会在IO方面遇到困难。

同时,如果我们能继续对这部分的工作进行优化,还能制作出更平缓的游戏资源IO流程,提供更好的游戏性能。

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|稿件投递|广告合作|关于本站|GameRes游资网 ( 闽ICP备05005107-1 )

GMT+8, 2018-6-18 19:54

快速回复 返回顶部 返回列表