【技术点】Unity资源池与动态加载释放
发表于2016-10-04
需求环境
在上一级的【解决方案】文章中,我们设计出了动态加载资源的业务流程,而这一节,我们就通过一些简单的代码,来实现出业务流程中的效果。
吸取之前文章的经验,如果按照正式项目的规格开发,本篇文章就会非常冗余,所以我们优化一下,仅仅针对技术点进行讲解与释放,具体与工程相关的,我们就不再文章中讲解,但你可以在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流程,提供更好的游戏性能。