在運行時進行節點的創建(cc.instantiate
)和銷燬(node.destroy
)操作是非常耗費性能的,因此我們在比較複雜的場景中,通常只有在場景初始化邏輯(onLoad
)中才會進行節點的創建,在切換場景時纔會進行節點的銷燬。如果製作有大量敵人或子彈需要反覆生成和被消滅的動作類遊戲,我們要如何在遊戲進行過程中隨時創建和銷燬節點呢?這裏就需要對象池的幫助了。
對象池的概念
對象池就是一組可回收的節點對象,我們通過創建 cc.NodePool
的實例來初始化一種節點的對象池。通常當我們有多個 prefab 需要實例化時,應該爲每個 prefab 創建一個 cc.NodePool
實例。 當我們需要創建節點時,向對象池申請一個節點,如果對象池裏有空閒的可用節點,就會把節點返回給用戶,用戶通過 node.addChild
將這個新節點加入到場景節點樹中。
當我們需要銷燬節點時,調用對象池實例的 put(node)
方法,傳入需要銷燬的節點實例,對象池會自動完成把節點從場景節點樹中移除的操作,然後返回給對象池。這樣就實現了少數節點的循環利用。 假如玩家在一關中要殺死 100 個敵人,但同時出現的敵人不超過 5 個,那我們就只需要生成 5 個節點大小的對象池,然後循環使用就可以了。
關於 cc.NodePool
的詳細 API 說明,請參考 cc.NodePool API 文檔。
流程介紹
下面是使用對象池的一般工作流程
準備好 Prefab
把你想要創建的節點事先設置好並做成 Prefab 資源,方法請查看 預製資源工作流程。
初始化對象池
在場景加載的初始化腳本中,我們可以將需要數量的節點創建出來,並放進對象池:
//...
properties: {
enemyPrefab: cc.Prefab
},
onLoad: function () {
this.enemyPool = new cc.NodePool();
let initCount = 5;
for (let i = 0; i < initCount; ++i) {
let enemy = cc.instantiate(this.enemyPrefab); // 創建節點
this.enemyPool.put(enemy); // 通過 putInPool 接口放入對象池
}
}
對象池裏需要的初始節點數量可以根據遊戲的需要來控制,即使我們對初始節點數量的預估不準確也不要緊,後面我們會進行處理。
從對象池請求對象
接下來在我們的運行時代碼中就可以用下面的方式來獲得對象池中儲存的對象了:
// ...
createEnemy: function (parentNode) {
let enemy = null;
if (this.enemyPool.size() > 0) { // 通過 size 接口判斷對象池中是否有空閒的對象
enemy = this.enemyPool.get();
} else { // 如果沒有空閒對象,也就是對象池中備用對象不夠時,我們就用 cc.instantiate 重新創建
enemy = cc.instantiate(this.enemyPrefab);
}
enemy.parent = parentNode; // 將生成的敵人加入節點樹
enemy.getComponent('Enemy').init(); //接下來就可以調用 enemy 身上的腳本進行初始化
}
安全使用對象池的要點就是在 get
獲取對象之前,永遠都要先用 size
來判斷是否有可用的對象,如果沒有就使用正常創建節點的方法,雖然會消耗一些運行時性能,但總比遊戲崩潰要好!另一個選擇是直接調用 get
,如果對象池裏沒有可用的節點,會返回 null
,在這一步進行判斷也可以。
將對象返回對象池
當我們殺死敵人時,需要將敵人節點退還給對象池,以備之後繼續循環利用,我們用這樣的方法:
// ...
onEnemyKilled: function (enemy) {
// enemy 應該是一個 cc.Node
this.enemyPool.put(enemy); // 和初始化時的方法一樣,將節點放進對象池,這個方法會同時調用節點的 removeFromParent
}
這樣我們就完成了一個完整的循環,主角需要刷多少怪都不成問題了!將節點放入和從對象池取出的操作不會帶來額外的內存管理開銷,因此只要是可能,應該儘量去利用。
使用組件來處理回收和複用的事件
使用構造函數創建對象池時,可以指定一個組件類型或名稱,作爲掛載在節點上用於處理節點回收和複用事件的組件。假如我們有一組可點擊的菜單項需要做成對象池,每個菜單項上有一個 MenuItem.js
組件:
// MenuItem.js
cc.Class({
extends: cc.Component,
onLoad: function () {
this.node.selected = false;
this.node.on(cc.Node.EventType.TOUCH_END, this.onSelect.bind(this), this.node);
},
unuse: function () {
this.node.off(cc.Node.EventType.TOUCH_END, this.onSelect.bind(this), this.node);
},
reuse: function () {
this.node.on(cc.Node.EventType.TOUCH_END, this.onSelect.bind(this), this.node);
}
});
在創建對象池時可以用:
let menuItemPool = new cc.NodePool('MenuItem');
這樣當使用 menuItemPool.get()
獲取節點後,就會調用 MenuItem
裏的 reuse
方法,完成點擊事件的註冊。當使用 menuItemPool.put(menuItemNode)
回收節點後,會調用 MenuItem
裏的 unuse
方法,完成點擊事件的反註冊。
另外 cc.NodePool.get()
可以傳入任意數量類型的參數,這些參數會被原樣傳遞給 reuse
方法:
// BulletManager.js
let myBulletPool = new cc.NodePool('Bullet'); //創建子彈對象池
...
let newBullet = myBulletPool.get(this); // 傳入 manager 的實例,用於之後在子彈腳本中回收子彈
// Bullet.js
reuse (bulletManager) {
this.bulletManager = bulletManager; // get 中傳入的管理類實例
}
hit () {
// ...
this.bulletManager.put(this.node); // 通過之前傳入的管理類實例回收子彈
}
清除對象池
如果對象池中的節點不再被需要,我們可以手動清空對象池,銷燬其中緩存的所有節點:
myPool.clear(); // 調用這個方法就可以清空對象池
當對象池實例不再被任何地方引用時,引擎的垃圾回收系統會自動對對象池中的節點進行銷燬和回收。但這個過程的時間點不可控,另外如果其中的節點有被其他地方所引用,也可能會導致內存泄露,所以最好在切換場景或其他不再需要對象池的時候手動調用 clear
方法來清空緩存節點。
使用 cc.NodePool 的優勢
cc.NodePool
除了可以創建多個對象池實例,同一個 prefab 也可以創建多個對象池,每個對象池中用不同參數進行初始化,大大增強了靈活性;此外 cc.NodePool
針對節點事件註冊系統進行了優化,用戶可以根據自己的需要自由的在節點回收和複用的生命週期裏進行事件的註冊和反註冊。
而之前的 cc.pool
接口是一個單例,無法正確處理節點回收和複用時的事件註冊。不再推薦使用。
對象池的基本功能其實非常簡單,就是使用數組來保存已經創建的節點實例列表。如果有其他更復雜的需求,你也可以參考 暗黑斬 Demo 中的 PoolMng 腳本 來實現自己的對象池。