对象池使用详解

前言

在运行时进行节点的创建(cc.instantiate)和销毁(node.destroy)操作是非常耗费性能的,因此我们在比较复杂的场景中,通常只有在场景初始化逻辑(onLoad)中才会进行节点的创建,在切换场景时才会进行节点的销毁。

如果制作有大量敌人或子弹需要反复生成和被消灭的动作类游戏,我们要如何在游戏进行过程中随时创建和销毁节点呢?

这里就需要对象池的帮助了。


# 一. 对象池的概念


对象池 就是 一组可回收的节点对象,我们通过创建 cc.NodePool 的实例来初始化一种节点的对象池。

通常当我们有多个 prefab 需要实例化时,应该为每个 prefab 创建一个 cc.NodePool 实例。


当我们需要创建节点时: 向对象池申请一个节点,如果对象池里有空闲的可用节点,就会把节点返回给用户,用户通过 node.addChild 将这个新节点加入到场景节点树中。

当我们需要销毁节点时: 调用对象池实例的 put(node) 方法,传入需要销毁的节点实例,对象池会自动完成把节点从场景节点树中移除的操作,然后返回给对象池。

这样就实现了少数节点的循环利用。


假如玩家在一关中要杀死 100 个敌人,但同时出现的敌人不超过 5 个,那我们就只需要生成 5 个节点大小的对象池,然后循环使用就可以了。


# 二. 代码实现

# 1. 初始化对象池

一般在 onLoad 时 将对象池初始化好:

onLoad () {

	this.enemyPool = new cc.NodePool('poolName');
	let initCount = 5;
	for (let i = 0; i < initCount; ++i) {
		let enemy = cc.instantiate(this.enemyPrefab); // 创建节点
		this.enemyPool.put(enemy); // 通过 put 接口放入对象池
	}
}

对象池里需要的初始节点数量可以根据游戏的需要来控制,即使我们对初始节点数量的预估不准确也不要紧,后面我们会进行处理。

# 2. 从对象池请求对象

createEnemy (parentNode) {

	let enemy = null;
	
	// 通过 size 接口判断对象池中是否有空闲的对象
	if (this.enemyPool.size() > 0) { 
		// 对象池中有对象,直接取出
		enemy = this.enemyPool.get();
		
	} else { 
		// 没有空闲对象(对象池中备用对象不够时),我们就用 cc.instantiate 重新创建
		enemy = cc.instantiate(this.enemyPrefab);
	}
	
	enemy.parent = parentNode; 				// 将生成的敌人加入节点树
	enemy.getComponent(‘Enemy’).init(); 	//接下来就可以调用 enemy 身上的脚本进行初始化
}

安全使用对象池的要点就是在 get 获取对象之前,永远都要先用 size 来判断是否有可用的对象,如果没有就使用正常创建节点的方法。虽然会消耗一些运行时性能,但总比游戏崩溃要好!

另一个选择是直接调用 get,如果对象池里没有可用的节点,会返回 null,在这一步进行判断也可以。

# 3. 将对象返回对象池

当我们不在需要使用当前节点时,需要将节点退还给对象池,以备之后继续循环利用,我们用这样的方法:

remove () {
	// 和初始化时的方法一样,将节点放进对象池,这个方法会同时调用节点的 removeFromParent
	this.enemyPool.put(this.node); 
}

# 4. 清除对象池

如果对象池中的节点不再被需要,我们可以手动清空对象池,销毁其中缓存的所有节点:

clear () {
	// 调用这个方法就可以清空对象池
	this.enemyPool.clear(); 
}

当对象池实例不再被任何地方引用时,引擎的垃圾回收系统会自动对对象池中的节点进行销毁和回收。

但这个过程的时间点不可控,另外如果其中的节点有被其他地方所引用,也可能会导致内存泄露,所以最好在切换场景或其他不再需要对象池的时候手动调用 clear 方法来清空缓存节点。

# 5. 事件监听

创建对象池时, cc.NodePool 传入 节点名称 :

let enemyPool = new cc.NodePool(‘EnemyItem’);

当使用 enemyPool.get() 获取节点后,就会调用 EnemyItem.js 里的 reuse 方法。

当使用 enemyPool.put(node) 回收节点后,会调用 EnemyItem.js 里的 unuse 方法。

// EnemyItem.js

reuse (data) {
	console.log('获取节点时触发:', data);
},

unuse () {
	console.log('回收节点时触发');
},

另外 cc.NodePool.get() 可以传入任意类型的参数,这些参数会被原样传递给 reuse 方法。


// 传入字符串参数
let enemy = this.enemyPool.get('name'); 


// EnemyItem.js

reuse (data) {
	console.log('获得的参数:', data);
}

这样我们就完成了一个完整的循环!将节点放入和从对象池取出的操作不会带来额外的内存管理开销,因此只要是可能,应该尽量去利用。


# 三. 优势

  • cc.NodePool 除了可以创建多个对象池实例,同一个 prefab 也可以创建多个对象池,每个对象池中用不同参数进行初始化,大大增强了灵活性;

  • cc.NodePool 针对节点事件注册系统进行了优化,用户可以根据自己的需要自由的在节点回收和复用的生命周期里进行事件的注册和销毁。而之前的 cc.pool 接口是一个单例,无法正确处理节点回收和复用时的事件注册。不再推荐使用。

  • 对象池的基本功能其实非常简单,就是使用数组来保存已经创建的节点实例列表。


# 四. 注意

使用对象池回收时,会将节点原封不动的回收到对象池中,下一次再取出时,仍然保持回收的状态;

例如: 放回对象池时:节点的透明度为:200, 那么下次取出时, 仍然是 200;

我们要在 合适的时机(reuseunuse 时) 对节点进行 初始化操作。