会动态优化的资源加载与释放框架

2018-05-14
参赛说明

这是这个月在Gad上写完的资源加载与释放的一个解决方案,经过朋友提醒刚好可以拿来参加比赛,于是转载过来。

u3d的性能会有几个层级的考虑:

1、设计层,资源面数,使用技术消耗,法线多少,shader如何,是否程序材质,动态减面。

2、资源层,资源是否预加载,动态加载,动态释放,是否缓存,是否泄漏。

3、代码层,是否使用了过多的低效代码。

而本篇,则是在资源预加载与动态释放下手,尝试着用一种自动化的方案,去优化大型项目中的资源加载与释放过程。

需求环境

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

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

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


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

1、未加载

2、已经加载

3、已可以释放

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

设计

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

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

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

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



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

  1. ///


  2.   /// 池
  3.   ///

  4.   Dictionary<int, stack<object="">> PoolDict = new Dictionary<int, stack<object="">>();

  5.   ///

  6.   /// 正在工作的资源对象
  7.   ///

  8.   Dictionary<object, int=""> WorkingPool = new Dictionary<object, int="">();object,>object,>int,>int,>
复制代码

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

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

  1. ///


  2.     /// 得到资源,如果池子里有,直接拿,否则创建
  3.     ///

  4.     ///资源类型,方便上级使用
  5.     ///资源id
  6.     ///
  7.     public T getObj(int _id)
  8.         where T : Object
  9.     {
  10.         Object temp = null;
  11.         //池子里有就取一个
  12.         if (PoolDict.ContainsKey(_id) &&
  13.             PoolDict[_id].Count > 0)

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

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

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

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

  25.         return (T)temp;
  26.     }

  27.     ///

  28.     /// 回收资源
  29.     ///

  30.     public void recObj(Object _obj)
  31.     {
  32.         if (WorkingPool.ContainsKey(_obj))
  33.         {
  34.             //正常回收
  35.             int id = WorkingPool[_obj];
  36.             WorkingPool.Remove(_obj);

  37.             if (PoolDict.ContainsKey(id) == false)
  38.                 PoolDict.Add(id, new Stack<object>());

  39.             PoolDict[id].Push(_obj);
  40.         }
  41.         else
  42.         {
  43.             //不属于池管理的资源直接删除掉。不过得打出警告,按理说不应该存在
  44.             Debug.LogWarning("检测到非法创建的资源:" + _obj.name);
  45.             Destroy(_obj);
  46.         }
  47.     }object>
复制代码

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

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

池测试

现在池已经弄好了,我们就需要简单的做一个池子的小测试。打开项目工程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("资源加载")进行标记,这里就不在额外扩展。


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. }
复制代码

自动记录

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


代码如下:

  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. ///


  2.     /// 得到资源,如果池子里有,直接拿,否则创建
  3.     ///

  4.     ///资源类型,方便上级使用
  5.     ///资源id
  6.     ///
  7.     public T getObj(int _id)
  8.         where T : Object
  9.     {
  10.         Object temp = null;

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

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

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

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

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

当然,记录流程呢还有其他代码,比如开始与结束等等,都是一些业务逻辑上的代码,如果我把他们贴上来,就会让你迷糊,所以我贴出关键点,当读者感兴趣时,自己可以查阅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) ""             preobj(config.assetid);=""             preindex +=" 1;"             ="" 判断之后该资源是回收还是删除=""             if (config.nexttime ="= -1 ||" config.nexttime ="">DESTROTYTIME)
  12.             {
  13.                 PoolDict[config.AssetId].isDestroty = true;
  14.             }
  15.         }
  16.     }
  17.   

  18. ///


  19.     /// 不管池子里有多少,再生成一个放到池子里
  20.     ///

  21.     ///
  22.     public void preObj(int _id)
  23.     {
  24.         //创建一个池子
  25.         if (PoolDict.ContainsKey(_id) == false)
  26.             createObejctPool(_id);

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

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

上面的代码一个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秒。导致三次结果都不同,不过觉得这种用例用来展现“自动优化”的过程更好,所以就保留了下来。

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

第一次

第一次

此时记录表内的内容

第二次


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

第三次


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

彻底完成了优化的过程。

结束语

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

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