需求环境
在上一级的【解决方案】文章中,我们设计出了动态加载资源的业务流程,而这一节,我们就通过一些简单的代码,来实现出业务流程中的效果。
吸取之前文章的经验,如果按照正式项目的规格开发,本篇文章就会非常冗余,所以我们优化一下,仅仅针对技术点进行讲解与释放,具体与工程相关的,我们就不再文章中讲解,但你可以在Github的工程中找到它们。、
现在,我们先回顾一下之前所设计出的业务流程。
那么,在这个业务流程中,我可以定义出在游戏运行时,资源有三种状态:
1、未加载
2、已经加载
3、已可以释放
三种状态了某个资源此时的最佳使用环境,也就是说,接下来需要使用的资源,我就放到池中,而接下来很长一段时间内不需要使用的资源,我就彻底释放掉。以确保程序的内存总是在可控范围之内。
设计
为了达到这样的目的,我们就需要划分三个模块去做。
1、最基础的资源加载,与池。
2、资源加载的自动记录过程。
3、资源加载的动态释放与加载过程。
池
首先,池,因为我们是模拟,所以这个就比较容易实现,在现实工程中,则可能需要考虑不同资源类型的具体逻辑。
///
/// 池
///
Dictionary Stack> PoolDict = new Dictionary();
///
/// 正在工作的资源对象
///
Dictionary int> WorkingPool = new Dictionary();
首先是2个定义,一个是回收池,一个是工作区,工作区用来反向查资源的ID,同时,也检测是否有资源是通过其他方法加载的,理论上,游戏内不应该存在其他的途径来加载资源。
接下来,就是2份逻辑代码,一个是创建资源,它用到了之前我们实现的资源管理器,另一个是回收资源。
- ///
- /// 池
- ///
- 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函数进行了一些修改,使得可以在加载资源时,把资源表信息的内容,记录下来。
- <p>///</p><p>
- </p><p>/// 得到资源,如果池子里有,直接拿,否则创建</p><p>
- </p><p>///</p><p>
- </p><p>///资源类型,方便上级使用</p><p>
- </p><p>///资源id</p><p>
- </p><p>///</p><p>
- </p><p>public T getObj(int _id)</p><p>
- </p><p>where T : Object</p><p>
- </p><p>{</p><p>
- </p><p>Object temp = null;</p><p>
- </p><p>//创建一个池子</p><p>
- </p><p>if (PoolDict.ContainsKey(_id) == false)</p><p>
- </p><p>createObejctPool(_id);</p><p>
- </p><p>//获取一个克隆体</p><p>
- </p><p>temp = getClone(_id);</p><p>
- </p><p>//加入反查字典</p><p>
- </p><p>PrePoolDict.Add(temp,_id);</p><p>
- </p><p>if (temp as T == null)</p><p>
- </p><p>{</p><p>
- </p><p>Debug.LogError("代码写错了或资源配错了,传入的资源id与希望得到的类型不匹配");</p><p>
- </p><p>Debug.Break();</p><p>
- </p><p>return null;</p><p>
- </p><p>}</p><p>
- </p><p>return (T)temp;</p><p>
- </p><p>}</p>
好,有了这些代码以后,我们就可以开始测试了记录工作了。
当然,记录流程呢还有其他代码,比如开始与结束等等,都是一些业务逻辑上的代码,如果我把他们贴上来,就会让你迷糊,所以我贴出关键点,当读者感兴趣时,自己可以查阅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流程,提供更好的游戏性能。