索引
这是Unity基础框架从0到1的第六篇文章,框架系列的项目地址是:https://github.com/tang-xiaolong/SimpleGameFramework
文章最后有目前框架系列的思维导图,前面的文章和对应的视频我一起列到这里:
文章
Unity基础框架从0到1 开篇
Unity游戏框架从0到1 (二) 单例模块
Unity基础框架从0到1(三)高效的全局消息系统
Unity基础框架从0到1(四)资源加载与管理模块
Unity基础框架从0到1(五)延时任务调度模块
视频
一 开篇
二 单例模块
三 消息模块
四 资源加载模块
五 延时任务调度模块
正文
下面这个图来源于游戏《吸血鬼幸存者》,在游戏中会存在满屏的怪物、子弹、伤害飘字等等,它们被创建,被销毁,爽感十足。
但是CPU吐槽道:你倒是爽了,有没有考虑过我的感受???为啥它要吐槽呢?因为你每次创建一个对象时,它都需要到内存里去为它找到一块没人用且连续的区间,为它做初始化工作,最后把这个对象丢给你,然后你为这个对象设置一些信息并展示出来,你这个对象用完了其实也没有告诉它说你不用了,它可能要等到后面做清理时才会知道这个对象真的没用了,然后把这块连续的区间拿回去。
创建一个对象可以简单地分为这几个步骤:
1、找到并分配对象所需要的内存。
2、对象做原始的初始化工作。
3、对象本身设置信息。
这里主要是前两个步骤比较费(3也有可能费,但是这与业务有关,这里不讨论),找到内存其实在正常情况下是不费的,但是由于你疯狂地索要内存,还是不一样的东西,杂乱无章,即使你不用了,但是它还是不能简单把这个内存拿回去重新分配给其他地方(判断内存是否被使用代价大),这个期间你要分配新的内存,就不得不继续往后找,找到一个合适的地方。直接这么说可能大家不是很理解,接下来我来给大家举个🌰
示例
摆摊占位
比如去市场上摆摊,每个人摆摊所需要的空间大小不同,当你需要1米的摊位时,你走到街头一看,欸这两米被人占了,继续走,这里只有70厘米不够用,为啥后面的人要跨着占摊位导致这里有个小空间呢???噢原来他想把摊位摆到某个店旁边方便吃饭。
继续往前走咯,终于找到了一个位置把东西放下,在这里贴一个条。
有些人东西少,很快就卖完走了,但是他也没打招呼说自己不用这个摊位了,条还在上面贴着呢,有的人卖完一批回店里又去拿新的过来卖。所以后面再有人来找位置,也不敢就在这个贴了条但没人的地方摆,只能继续往后找。很快这条街就都被占满了,再有新的人过来摆摊,会发现没地方可以摆,只能找管理员问了,说前面有这么多没人的摊位,是不是可以清理一下给我摆呀?
管理员就屁颠屁颠去帮忙查看,看那些摊位是不是真的没人了,他可能是打电话,可能是去找人确认,总之记住,这个步骤比较麻烦,所以管理员也不会轻易帮你这么做。经过一顿猛操作后,终于给腾出来一些空闲摊位了,把上面的条撕掉表示这里真没人了。
面临的问题
从上面的步骤不难看出,由于大家摊位都不一样,并且需求也不同,所以很容易导致出现这种小的空间,加上大家摊位用完也不会说一声,所以你也没法简单直到某个空位是不是可以摆,只能往后找没贴条的空位。在内存中其实也是一样的,大家需要使用的内存大小不同,用完也不会告知这块内存用完了,分配时也会面临空隙太小不够用以及空白内存不敢用的情况。
你可能要说了,那大家用完把条撕了不就行了?那你这就对摆摊的人提出了较高的要求,并且肯定会有人忘记这么做,那这个摊位就会被一直占着了。在内存分配时,我们通常会由内存系统来为大家分配内存,并且在内部通过一些手段管理分配的内存,不需要大家用完告知(当然你也可以动态分配内存,这里不细讨论)。
解决方案——对象池技术
前面铺垫了这么长,抛出了两个问题,我们要怎么解决这个问题呢?摊主们自发组织了一个会议,决定大家直接申请一段较长的街道,并培养自己用完就撕条的习惯。由于1米的摊位使用量很大,并且卖的东西不多,很快就能卖完走人,而卖鱼的摊位设备布置麻烦,带走也麻烦,大家直接把一块长10米和长15米的连续空地都贴上条,约定了某个特殊标记表示摊位不用了,要用摊位时直接根据这个标记来识别。
于是情况就变成这样了:一个参会过且摊位是1米的人过来了,他直接来到这10米摊位这里,被分配了一个摊位,另一个是摊位1米的人来了,发现这剩下的9米都被占了,因为不认识那个特殊标记,所以就往后走找其他摊位了。经过一段时间,某些参会过且摊位是1米的人用完了,把特殊标记还原回来走了,后面参会过且摊位是1米的人就知道,这个摊位虽然贴了条,但是实际没人用所以自己可以用。最后市场可能就是下图这样的,特殊标记不连续可能是有些人用完走了把标记抹了。
通过这种方式,减少了呼叫管理员的次数(用完改标记,复用率高了),也不要求所有人都有用完撕条的意识,仅仅针对使用量大,且摊主更替比较频繁的摊位使用者。这其实就是对象池思路的一种应用,当然实际情况会比例子要稍微复杂一点,这里略去了一些细节,比如当有特殊标记的摊位满了怎么办?管理员真的只是沟通后把这些没人的摊位腾出来,会让其他人挪位置吗等等,感兴趣的可以自行探索下内存怎么分布的,系统怎么管理内存怎么做GC的。
对象池
概念
前面都是半讲故事的形式解释对象池技术,现在我们回归正题来给它一个标准定义,对象池技术是一种很常用的优化方法,它可以有效地减少内存的分配和回收。它适用于那些被频繁创建与销毁的物体、创建或销毁时开销很大的对象,文章开头图中的子弹与怪物无疑满足这个要求。
对象池技术的基本思想是,当需要创建一个新的对象时,先从一个预先创建好的对象集合中查找是否有可用的对象,如果有,就直接使用它,如果没有,就创建一个新的对象,并将它加入到对象集合中。当一个对象不再使用时,不是立即销毁它,而是将它标记为闲置状态,放回到对象集合中,等待下次使用。
优点
对象池技术有以下几个优点:
- 减少了内存的分配和回收,避免了内存碎片化和垃圾回收带来的性能损耗。
- 减少了对象的创建和销毁的开销,提高了游戏的运行效率。
- 方便了对象的管理和复用,提高了代码的可读性和可维护性。
缺点
那对象池技术有什么缺点吗?再回到我们前面的示例,当我们有了这个制度后,需要购买更多的贴纸来作为特殊标记,同时,摊主需要掌握额外的知识,了解特殊标记的含义,最后,如果参会人忘记把特殊标记改回来,那这个摊位就永远被占用了,因为后续使用者不会去找管理员核对,只会无条件相信特殊标记。另外,前面提到了划分10米空地给1米的摊主,那如果每次都只有五个1米的摊主同时在卖东西,剩下的5米岂不是就白占浪费了吗?因此我们可以总结出对象池技术的缺点:
- 需要额外的内存空间来存储对象集合,可能会占用较多的内存资源。
- 需要维护对象集合的状态,可能会增加逻辑的复杂度和出错的风险。
- 需要使用者严格遵循使用规则,只用不还会导致内存泄漏,还了还用会导致逻辑错误。
- 需要根据不同的场景和需求来设计合适的对象池策略,可能会增加开发的难度和工作量,池子大小分配不合理可能导致内存浪费。
需要注意的点
设置初始数量
为对象池设置初始数量,并在某个时机提前创建好一定数量的对象便于某些系统可以直接拿去用,这样可以避开内存分配高峰从而减少卡顿。比如在进入战斗前读条时,我们事先创建好五十个子弹,实际战斗时则可获得子弹去显示,无需在复杂战斗场景中还进行开销比较大的创建行为。
设置最大数量
设置合适的最大数量一方面可以避免占用过多内存却不使用(一般会先创建一个能容纳最大数量对象的池),另一方面也避免了部分系统无限制索要内存使得卡顿。在达到最大数量后,继续从池中获取对象时,如果没有可用的对象,通常需要采取一定措施,比如:
- 比较重要的对象池可以设置一个很大的最大数量;
- 一些对象创建出来不太显眼,可以选择不创建;
- 一些对象创建后可以掩盖现有的对象,可以对现有的对象做清理,提前进入回收环节。
由于设置最大数量具备主观性,我们还可以考虑增加一些机制来对对象池做检查,如果某些对象池池中有很多对象,但实际游戏中往往只有几个对象会被使用,则可以动态调整对象池大小,减少其占用的内存。
做好清理工作
同一个池中的对象可能用在不同地方,在其他地方使用时可能会残留一些数据,干扰到新使用的地方,因此需要做好清理工作。比如我们建立了一个Vector3的列表池,在某个系统中我们从池中拿到了一个列表,并把寻路的结果存进去。如果这个时候忘记做清理工作,可能在列表中已经存在几个Vector3的数据了,从而导致寻路出问题。
避免用完不还,还完还(hai)用
如果某个对象从池中取出后不用了,但是没有归还,会导致对象池一直以为这个对象被使用,从而无法再回收这个对象让其他人用。如果一个对象还了又继续使用,可能导致其他地方的数据出问题,别人已经在用这个对象了,你又继续修改对象内容。
实现
本文旨在实现一个通用的对象池和一个Unity的对象池,前者用于创建通用的对象,后者用于创建Unity的对象。文章中的代码均已上传到Github,感兴趣的可以看看,觉得有用也不妨给个Star~ [https://github.com/tang-xiaolong/SimpleGameFramework]
由于对象池可能会存在很多个,我们不可能在每个使用的地方都事先去创建一个对象池,然后再从对应池中取需要的对象,因此本文提供了对象池的工厂类,当需要获取一个对象时,不直接与对象池交互,而是直接与工厂交互,由工厂来维护对象池,对象池本身来维护对象。
在游戏开发过程中,经常会有一些需要自动回收的资源,比如某个粒子播放时长是3s,那它可能就是需要在3秒后回收,因此本文提供了一个自动回收的设置,让某些对象可以在创建后被对象池自动回收,自动回收任务是使用了前一节中的延时任务调度模块实现的,在本节中使用的延时任务调度模块已做了改进,为任务增加了唯一Id使得任务不会被错误使用,改进目的与方案后续会出文章来阐述,本文不赘述。
在处理Unity Object对象时,无可避免需要加载游戏资源,本文提供一个简单的资源加载类,实际放入项目中应该是接入项目的资源加载类。
对象池模块的主要脚本及功能
下面列举对象池模块的主要脚本以及功能,具体使用请下载项目查看,这里不做说明了,后续也会有专门的视频来解读。
资源加载
TestAssetLoad
负责加载资源,内部实现了一个资源加载的方法。
自动回收
AutoRecycleConfigItem
配置了每个回收的对象名字和回收时间。
AutoRecycleConfig
负责在解析时临时保存AutoRecycleConfigItem
配置。
AutoRecycleConf
负责维护自动回收的配置,内部配置使用字典形式保存。
AutoRecycleItem
是运行时自动回收对象实例,当存在一个自动回收对象时,会创建一个自动回收的任务,任务的uid保存在这个类中。
对象池
IObjectPool
定义了对象池的几种行为,包括获取对象,回收对象,清理对象,进池离池处理。
ObjectPool
是一个通用的对象池,其实现了IObjectPool
定义的行为。
ObjectPoolFactory
是通用对象池的工厂类,负责对外提供不同的对象,外界通过工厂类直接取得产品,由工厂类来创建生产产品的对象池并维护行为。
UnityObjectPool
是一个Unity Object对象池,实现了IObjectPool
定义的行为。
UnityObjectPoolFactory
是Unity Object对象池的工厂类,负责对外提供Unity Object的产品。在此类中还维护了自动回收配置的读取,自动回收任务的创建与销毁。实际资源的加载通过外界设置的资源加载方法加载。
思维导图
图片估计看不清楚,可以下载下来查看:https://img-bucket11.oss-cn-hangzhou.aliyuncs.com/BlogImage/20230602013119.png