GameFramework解析:对象池 (Object Pool)
对象池的作用
前文引用池篇已经讲过对象池相关作用,这里就不再重复了,GF中对象池与引用池作用类似,引用池用于普通的C#对象,而对象池则一般用于储存UnityEngine下的对象(如Unity中的GameObject对象),具体区别见下文。
对象池的实现
结构
对象池的实现我们可以把他分成3部分,上图中从上到下每一行就是一部分,分别是物体信息部分(抽象类ObjectBase,泛型类Object
物体部分
ObjectBase
对象池并没有直接储存目标对象,追溯到代码最下层,发现储存的是ObjectBase对象,而ObjectBase类型有一个object类型的m_Target字段,这个字段引用的对象才是我们最终期望的、需要储存的GameObject或者继承MonoBehavior类对象(当然还可以是其他类型)。也就是说对于每一个我们想要储存的对象,我们都需要另外实现一个继承ObjectBase的类,这个类一方面作为目标对象的容器,可避免GF与具体业务的耦合,另一方面这个类也包含目标对象的信息状态,包含名字、锁定状态、优先级、自定义释放检查标记、上次使用时间。通过Initialize方法可把目标对象传递给m_Target字段,通过重写OnSpawn、OnUnspawn方法实现对象获取、回收时执行的逻辑。
ObjectBase实现了IReference接口,也就是我们在外部获得其子类时应该从引用池获取。
Object
我们已经知道对象池并不直接储存目标对象,追溯到最下层储存的是ObjectBase对象,但ObjectBase对象也不是对象池直接储存的对象,只是间接对象。对象池直接储存的是泛型类Object
Object
对象池部分
ObjectPoolBase与IObjectPool
ObjectPoolBase是个抽象类,IObjectPool
ObjectPool
- ObjectPool
类内用两个字段储存着对Object 的映射关系,分别是GameFrameworkMultiDictionary<string, Object >类型的m_Objects和Dictionary<object, Object >类型的m_ObjectMap。GameFrameworkMultiDictionary<TKey,Tvalue>是GF内封装的数据结构,与C#自身的Dictionary<TKey,Tvalue>类似,不同的是Dictionary的Tkey与Tvalue是一对一的映射关系,而GameFrameworkMultiDictionary则是一个Tkey对应一个Tvalue的集合,是一对多Tvalue的关系。其中m_Objects为以Object 的Name为Key,拥有相同Name的Object 集合为Value。m_ObjectMap为以以目标对象(ObjectBase里的Target)为Key,Object 为Value。 - 通过Register接口往对象池注册可用对象,参数类型为继承ObjectBase的类,Register内部会向引用池获取Objct
对象,并把它加入到m_Objects和m_ObjectMap中。 - 属性AllowMultiSpawn,把对象池分为两种类型,一种是允许对象被多次获取,另一种是不允许。两者区别在于,如果允许对象被多次获取,那么即使一个对象已经处于被使用状态时(即上一次获取后还没归还对象池),仍然可以再次获取,显然一般情况下是不允许这种做法的。在GF的资源模块中会使用允许对象被多次获取的对象池来管理资源对象,因为资源对象我们只需要其在内存中存在一份。这个属性会在创建对象池时从参数带入,创建对象池后无法再改变。
- Spawn方法接受一个字符串参数,对应Object
的Name,若m_Objects中存在这个Key,则取出对应的Object 集合,并检查其中是否有可用的,若存在可用的就调用Object 的Spawn->ObjectBase的Onspawn完成获取逻辑,最后返回具体的ObjectBase的子类。 - Unspawn方法接受一个要回收的对象(ObjectBase中的Target)参数,方法内部会做一个检查,如果这个对象本来没有通过Register方法注册到对象池中,也就是不在字典m_ObjectMap中,会抛出错误,若是已注册对象,则会调用bject
的Unspawn->ObjectBase的OnUnspawn,完成回收逻辑。 - GetAllObjectInfos方法返回ObjectInfo结构体数组,包含对象池内所有物体的信息,包括名字、锁定状态、自定义释放检查标记、优先级、使用状态、上次使用时间、获取计数、是否处于使用中状态。
- 对象池具有自动释放对象的功能,总的来说每过一段时间会调用一次Release执行释放逻辑,这个时间由AutoReleaseInterval属性决定,每个对象池可以有各自不一样的释放时间间隔。Release过程会先获取可释放对象序列,然后通过委托ReleaseObjectFilterCallback
对可释放物体序列进行筛选后,最后仅对筛选后的对象调用ReleaseObject进行释放。下面我们看一下相关实现:
1 | public void Release(int toReleaseCount, ReleaseObjectFilterCallback<T> releaseObjectFilterCallback) |
Release方法就是释放过程的主要逻辑,先调用GetCanReleaseObjects获取可释放对象序列,然后用releaseObjectFilterCallback对序列进行筛选,最后对筛选后的对象逐个调用ReleaseObject进行释放。
1 | private void GetCanReleaseObjects(List<T> results) |
GetCanReleaseObjects方法获取当前可进行释放的对象,会遍历m_ObjectMap的Value值,对于在处于非使用中状态、非锁定状态、以及自定义释放标记为True时,才被认为是可释放对象。
1 | private List<T> DefaultReleaseObjectFilterCallback(List<T> candidateObjects, int toReleaseCount, DateTime expireTime) |
DefaultReleaseObjectFilterCallback是ReleaseObjectFilterCallback
1 | public bool ReleaseObject(object target) |
ReleaseObject内部会把对应的Object
- 除了Release方法、对象池还提供了ReleaseAllUnused该方法会直接释放所有可释放对象,而不经过筛选。
- 对象池的ExpireTime属性决定了对象池里所有对象的过期时间,对象池每过一段间隔时间,就会自动执行释放,根据DefaultReleaseObjectFilterCallback的实现,执行释放时会优先获取距对象最后一次使用时间的时长大于过期时间的对象。另外如果执行ReleaseAllUnused,会无视这一过期规则,只要被认为是可释放对象,都会进行回收。
- SetLocked方法提供锁定某一对象的功能,即使对象处于未被使用的状态也不会被认为是可释放对象。
- CustomCanReleaseFlag提供自定义释放检查标记功能,CustomCanReleaseFlag是一个虚属性,默认返回True,也就是对象默认是依赖IsInUse这一状态来判断是否使用中,来判断是否能释放,而IsInUse这一属性是由Spawn与Unspawn的计数来判断的,当这种计数方式不适用的情况下,我们可以重写CustomCanReleaseFlag,自定义逻辑判定是否可释放。
对象池管理器部分
- ObjectPoolManager用Dictionary<TypeNamePair, ObjectPoolBase>类型的m_ObjectPools字段储存所有对象池,一个ObjectBase子类类型Type对象,与创建对象池的传入参数字符串name组成一个TypeNamePair对象作为唯一Key,如果我们希望两个对象池储存同样的类型对象,在创建对象池时传入不同的name参数即可。
- CreateSingleSpawnObjectPool和CreateMultiSpawnObjectPool方法创建对象池,分别对应一个对象同时只能被获取一次的对象,以及一个对象能被同时获取多次两种类型的对象池(区别详见上面ObjectPool
部分的介绍)。这两个创建对象池的方法,GF提供了非常丰富的重载,可以在创建时指定对象池的名字、自动释放时间间隔、容量、物体过期时间、优先级等。 - HasObjectPool、GetObjectPool、GetObjectPools、GetAllObjectPools提供对象池查询功能。
- Release、ReleaseAllUnused会对所有对象池执行Release、ReleaseAllUnused方法,作用在上文已经说明。
- DestroyObjectPool可主动销毁对象池,会回收ObjectBase、Object
对象到引用池,执行ObjectBase的Release方法。
示例
以官方Demo StarForce的HPBarComponent为示例,我们希望把血条用对象池缓存起来,HPBarItem是控制血条逻辑的类,继承自MonoBehaviour,为了能实现对HPBarItem的缓存,我们先对其定义一个对应的继承ObjectBase的类HPBarItemObject。
HPBarItemObject
1 | public class HPBarItemObject : ObjectBase |
- HPBarItemObject继承自ObjectBase,我们可以根据需要重写OnSpawn和OnUnspawn,来实现从对象池取出、返回时的逻辑,这里因为我们不需要做任何处理,所以不用重写。而Release是ObjectBase的抽象方法,我们必须实现Release方法,以实现当对象池释放对象时要做的操作,这里对应的操作就是销毁血条的GameObject了。
- 这里另外定义了一个静态的Create方法,不要忘记父类ObjectBase是实现了IReference接口的,我们需要这个类型的对象时应该从引用池取出,而不能另外实例化。这里的Create依然是GF使用引用池的风格,这样外部需要HPBarItemObject只需要调用Create即可,Create需要传入目标对象参数,这里对应的就是HPBarItem对象了,然后调用Initialize传入HPBarItem对象来初始化HPBarItemObject对象。
1 | public class HPBarComponent : GameFrameworkComponent |
上面是HPBarComponent的部分代码,为了能更容易注意到关键代码,已经把对象池无关部分代码进行删减。
- 在Start方法中用CreateSingleSpawnObjectPool方法创建对象池,传入对象池名字、对象池容量作为参数,并且用IObjectPool
类型字段来引用着对象池(注意泛型参数不是目标对象HPBarItem类,而是为其另外定义的继承自ObjectBase的HPBarItemObject类)。 - CreateHPBarItem方法中,else分支中便是往对象池注册对象的逻辑,我们先通过Unity的API实例化出HpBarItem目标对象,并进行有必要的初始化,再以此为参数创建HPBarItemObject对象,最后调用Register方法把HPBarItemObject注册到对象池中。这里的Register是有第二个参数的,类型为bool,因为对象池的对象是外部实例化后才注册进去,而不是在池子内部实例化的,此时对象池并不知道这个被注册进来的对象是否处于被使用中的状态,如果注册时就立马使用这个对象,这里参数应该传入true,如果目前暂时不用,稍后再从对象池通过Spawn取出,就应该传入false。
- HideHPBar中,当不需要对象时,调用对象池的Unspawn方法向池子归还对象,Unspawn有两个重载版本,我们传入HPBarItem或HPBarItemObject对象都可以。
Inspector面板
Inspector面板可以在运行时观察所有对象池的实时情况,包括对象池的各项属性以及池子里所有物体的ObjectInfo数据。
引用池与对象池的区别
- 引用池从池子内部通过默认构造方法创建对象,只适合普通的C#对象。对象池是在外部自行创建对象后再注册进去,能用于必须通过Unity API才能实例化的对象。
- 引用池仅提供Clear接口来清除对象状态,在移除对象时没有任何额外处理,仅仅是去掉引用,适用于受GC管理的类型。而对象池提供OnSpawn,OnUnspawn两个操作,且在移除对象时,提供Release接口,对于Unity中的GameObject需要在Release写上Destroy(gameObject)的逻辑才能销毁。
- 对象池提供自行释放的机制,可指定每个池子自动释放周期、物体过期时长、池子容量,并在可一定程度上自定义每个池子的释放策略。引用池没有以上机制,仅可通过Remove接口主动移除对象。
- 对象池提供锁定物体、自定义释放标记功能,可进一步定制释放策略。
知道以上的功能区别,相信大家对哪个类型用引用池还是对象池应该有个明确的认识了。
思考
同一个对象池中,为什么还要以Name区分对象集合,应用场景是什么
Object
一般来说同一个对象池中,我们一般储存同一类型的东西,也就是ObjectPool
参考官方Demo StarForce中的陨石对象池,虽然他们都是同一个类型,具有相同的逻辑,但他们可能有不一样的外型。我们把外型不同的陨石做成单独prefab,并在这些prefab上挂上相同的脚本,最后以他们的资源路径名字作为Name,则可在一个对象池中对不同外形的陨石进行区分,以实现向一个对象池取不同外型的陨石的需求。
在同一个对象池中以Name区分对象,与用多个对象池储存不同Name的对象有什么区别
主要区别就在于一个对象池执行同一个释放逻辑,而多个对象池是各自执行各自的释放逻辑。继续以上面的陨石为例子,我们一共有3种陨石,我希望储存陨石的对象池总容量是60,我们随机去生成不同种类的陨石,如果随机结果不均匀,最终池子里可能有种类一40个,种类二15个,种类三5个,在我们把他们放在同一对象池下管理情况下,这没有什么问题,无论怎样它都很好地以总数量为60个的策略去管理。但如果我们把不同外形的陨石分到不同的对象池去管理,我们很难去动态调整3个池子的容量平衡,以达到总数量为60的策略。
Lock的使用场景
当我们希望某个物体长期不使用都不会被释放,被需要时可以快速地响应时,我们可以使用SetLocked把这个物体锁定。例如MMO游戏的主界面,上面有非常大量的按钮入口,特效,各种各样的信息,当玩家打开一些界面隐藏了主界面,且过了比较长的时间,我们也不希望主界面被销毁,因为主界面重新加载的耗时非常感人,这时候就可以把主界面锁定,避免重新加载,以提高游戏体验。
CustomCanReleaseFlag使用场景
上文说过,当获取计数不适用于判断是否被使用中的标准时,我们可以通过重写CustomCanReleaseFlag属性来实现自定义的释放标记。Unity中的资源就是其中的例子,除了我们主动去取某个资源时,这个资源也可能会作为其他资源的依赖项被使用,这样我们即使归还了上次的主动获取,我们也不能确定这个资源是否已经没有被依赖,不能认定为可以被释放,所以需要重写CustomCanReleaseFlag加上依赖引用数的判断,详见GF中Resource模块的AssetObject、ResourceObject。
为什么既有引用池又有对象池,全部用对象池不是就可以满足需求了吗
根据区别我们可以看出,引用池适合更“轻”的对象,而对象池适合更“重”的对象,把一个对象注册进对象池,还需要用到ObjectBase、Object
最后
GameFramework解析 系列目录:GameFramework解析:开篇
个人原创,未经授权,谢绝转载!