Unity3d中對象池(ObjectPool)的實現

概述
什麼是對象池?
  池(Pool),與集合在某種意義上有些相似。 水池,是一定數量的水的集合;內存池,是一定數量的已經分配好的內存的集合;線程池,是一定數量的已經創建好的線程的集合。那麼,對象池,顧名思義就是一定數量的已經創建好的對象(Object)的集合[1]。
  在C/C++的程序中,如果一種對象,你要經常用malloc/free(或new/delete)來創建、銷燬,這樣子一方面開銷會比較大,另一方面會產生很多內存碎片,程序跑的時間一長,性能就會下降。這個時候,就產生了對象池。可以事先創建好一批對象,放在一個集合中,以後每當程序需要新的對象時候,都從對象池裏獲取,程序用完該對象後,再把該對象歸還給對象池。這樣,就會少了很多的malloc/free(new/delete)的調用,在一定程度上提高了系統的性能,尤其在動態內存分配比較頻繁的程序中效果較爲明顯。

Unity3d中的對象池
  在使用unity3d做遊戲時,經常有同一個Prefab用到多次,需要反覆實例化(Instantiate)。但實例化是很消耗資源的,所以在遊戲加載時把一批Prefab實例化好放在對象池中,遊戲中用的時候拿出來,不用的時候放回去,避免反覆申請和銷燬。
  存入對象池的元素應具有如下特徵:1>場景中大量使用 2>存在一定的生命週期,會較爲頻繁的申請和釋放。

關於多線程的考慮
  因爲Unity的API只能被主線程調用,我理解Unity提供的用戶空間是單線程的(腳本中寫While(true)掛在GameObject上,點運行整個Unity會卡死)。所以我們不需要將池實現支持多線程。在支持多線程的應用中,單例的初始化通常要加一個鎖,在這裏也沒有必要。

希望實現具有以下特徵的對象池
1、新增對象種類時操作簡單,能夠靈活控制每個對象池中生成的對象數量。
2、接口簡單,易於申請和回收。
3、模塊結構清晰,耦合度低。
4、申請和回收時,可以根據具體的對象類型做個性化操作。

實現
1、模塊設計


  *UML規則參考自 UML 基礎: 類圖
  模塊中主要使用以下類
  ObjectPoolMgr是對象池管理類。它對外提供接口,對內管理對象池實例。外部進行申請和釋放操作都是通過調用ObjectPoolMgr中的接口進行的。
  ObjectPool是對象池基類,它是抽象類,包含了通用的成員和方法。作爲一個基類,它提可被子類繼承的方法是virtual的,便於實現多態。
  CubePool, SpharePool是具體的對象池實現類,繼承自ObjectPool,可以override基類的alloc和recycle等方法實現個性化的操作。
  PrefabInfo掛在對象池中的每一個對象上,用來記錄對象類型等信息。

2、數據管理方式


  數據管理方式如圖,單例ObjectPoolMgr中使用字典管理着多種對象池實例,每種對象池實例中使用隊列管理一定數量的同類型對象。

3、主要接口和調用流程
  對象池管理類ObjectPoolMgr類中提供對外接口,主要是alloc和recycle,聲明如下
//回收接口。參數是待回收GameObject
public static void recycle(GameObject recycleObj);


  遊戲在使用申請到的GameObject時可能會在其中添加子物體,回收前會判斷一下recycleObj中是否嵌套有其它屬於對象池的Prefab,如果存在就分別進行回收。
  另外如果對象池中待分配對象數量超過了用戶設置的個數,直接銷燬recycleObj而不再放回對象池。

4、對象池的創建時機
  過早創建對象池,池中的對象會佔用大量內存。若等到遊戲使用對象時再創建對象池,可能因爲大量實例化造成掉幀。所以,我認爲在Loading界面創建下一個場景需使用的對象池是較爲合理的。比如天天飛車中的NPC車,金幣,賽道,在進入單局比賽後纔用到。可以在進入比賽的Loading界面預先創建金幣,NPC車,賽道的對象池,比賽中直接申請使用。

5、具體實現
1>ObjectPoolMgr
ObjectPoolMgr是對象池的管理類,提供接口,
public static GameObject alloc(string type,float lifetime = 0);
public static void recycle(GameObject recycleObj);

參數lifeTime是存活時間,以秒爲單位,定義如下
lifeTime > 0 lifeTime秒後自動回收對象。
lifeTime = 0 不自動回收對象,需遊戲主動調用recycle回收。
lifeTime < 0 創建Pool實例並實例化Pool中的對象,但不返回對象,返回值null。
當lifeTime>0時,分配出去的GameObject上掛的PrefabInfo腳本會執行倒計時協程,計時器爲0時調用recycle方法回收自己。它的適用對象如射擊遊戲中的子彈,申請時設定了lifeTime後不必關心回收的問題,當然遊戲可以計時器在到時前主動發起回收。
lifeTime < 0的目的預創建對象池,在遊戲場景Loading時可以用這個方法先把對象池創建起來,避免遊戲中創建對象池造成掉幀。

ObjectPoolMgr用成員poolDic維護已分配的對象池實例

private Dictionary<string,ObjectPool> poolDic = new Dictionary<string, ObjectPool>();

使用objectPoolList記錄面板上的用戶設置


  ObjectPoolMgr初始化時會在Unity的層次(Hierarchy)面板中創建GameObject並添加自身腳本。開發者可以在Inspector面板中直接創建新的對象池,如下圖。Pre Alloc Size是對象池創建時預申請的對象數量。Auto Increase Size是池中的對象被申請完後進行一定數量的自增。prefab對象池關聯的預製類型


比如,當遊戲需要申請Cube對象時,會調用GameObject cube = ObjectPoolMGR.alloc("Cube");方法。ObjectPoolMGR.alloc代碼如下
public static GameObject alloc(string type,float lifetime = 0){
    //根據傳入type取出或創建對應類型對象池
   ObjectPool subPool = Instance._getpool(type);
   //從對象池中取一個對象返回
   GameObject returnObj = subPool.alloc(lifetime);
   return returnObj;
}


  ObjectPoolMgr會根據傳入的類型type,調用_getpool(type)找到對應的Pool,再從其中取一個對象返回。
  代碼中_getpool(string type)是ObjectPoolMgr中的私有方法。前面說過,ObjectPoolMgr有一個成員poolDic用來記錄已創建的對象池實例,_getpool方法先去poolDic中查找,找到直接返回。如果找不到說明還未創建,使用反射創建對象池,記錄入poolDic,代碼如下



 使用_getpool獲得了對象池實例後,會調用對象池的alloc方法分配一個對象。
2>對象池基類ObjectPool
其中記錄了對象池的通用方法和成員,如下
protected Queue queue = new Queue();//用來保存池中對象
[SerializeField]
protected int _freeObjCount = 0;//池中待分配對象數量
public int preAllocCount;//初始化時預分配對象數量
public int autoIncreaseCount;//池中可增加對象數量
protected bool _binit = false;//是否初始化
[HideInInspector]
public GameObject prefab;//prefab引用
[HideInInspector]
public string objTypeString;//池中對象描述字符串


ObjectPool中的方法

public virtual GameObject alloc(float lifetime){
    //如果沒有進行過初始化,先初始化創建池中的對象
    if(!_binit){
        _init();
        _binit = true;
    }
    if(lifetime<0){
        Debug.LogWarning("lifetime <= 0, return null");
        return null;//lifetime<0時,創建對象池並返回null
    }
    GameObject returnObj;
    if(_freeObjCount > 0){//池中有待分配對象
        returnObj = queue.Dequeue();//分配
        _freeObjCount--;
    }else{//池中沒有對象了,實例化一個
        returnObj = Instantiate(prefab , new Vector3(0,0,0), Quaternion.identity) as GameObject;
        returnObj.SetActive(false);//防止掛在returnObj上的腳本自動開始執行
        returnObj.transform.parent = this.transform;
    }
    //使用PrefabInfo腳本保存returnObj的一些信息
    PrefabInfo info = returnObj.GetComponent<PrefabInfo>();
    if(info == null){
        info =  returnObj.AddComponent<PrefabInfo>();
    }
    if(lifetime > 0){
        info.lifetime = lifetime;
    }
    info.types = objTypeString;
    returnObj.SetActive(true);
    return returnObj;
}
 
public virtual void recycle(GameObject obj){
    //待分配對象已經在對象池中
    if(queue.Contains(obj)){
        Debug.LogWarning("the obj " + obj.name + " be recycle twice!" );
        return;
    }
    if( _freeObjCount > preAllocCount + autoIncreaseCount ){
        Destroy(obj);//當前池中object數量已滿,直接銷燬
    }else{
        queue.Enqueue(obj);//入隊,並進行reset
        obj.transform.parent = this.transform;
        obj.SetActive(false);
        _freeObjCount++;
    }
}


  這裏要注意的是,基類alloc和recycle方法要使用虛函數,子類override實現多態。

3>對象池子類CubePool
  子類override父類的alloc和recycle,進行個性化的申請和回收工作。

public class CubePool : ObjectPool {
    public override GameObject alloc(float lifetime){
        GameObject cubeObject= base.alloc(lifetime);
        //在這裏進行CubePool個性化的的初始化工作
        return cubeObject;
    }
}


當然也可以直接複用基類的alloc方法,甚至不寫CubePool類。當ObjectPoolMgr申請一個Cube但找不到CubePool類時,會使用通用方法進行分配和回收。

4>PrefabInfo
  PrefabInfo是掛在prefab實例上,用來記錄prefab類型和lifetime等數據。

publicclassPrefabInfo : MonoBehaviour {
    public string types;
    [HideInInspector]
    public float lifetime = 0;
    void OnEnable(){
        if(lifetime > 0){
            StartCoroutine(countTime(lifetime));
        }
    }
    IEnumerator countTime(float lifetime){
        yield return new WaitForSeconds(lifetime);
        ObjectPoolMGR.recycle(gameObject);
    }
}


新增Pool方法

  爲一個新Perfab創建對象池需要以下兩步,
1、在unity面板中把prefab掛上,並設置prefab的實例化數量和可增加數量
2、(可選)實現一個對應的Pool腳本。如果不實現,這個對象池會使用通用的申請和回收方法。

調用申請和釋放方法

//申請
GameObject obj1 = ObjectPoolMGR.alloc("Cube",5);
GameObject obj2 = ObjectPoolMGR.alloc("Sphare");
 
//回收
ObjectPoolMGR.recycle(obj2);


總結
存在的問題
  對於從對象池中申請的GameObject,目前在遊戲使用中不能改變其層次結構,不能添加新的Component,也不能在其中新增非對象池分配的GameObject。因爲這些改變在回收時無法被發現,再次複用時可能出現意想不到的結果。
  介於這種情況,我的使用方法是如果使用過程中改變了對象結構,用完後就Destroy,不再recycle。如果改變是可預期的,也可以重寫子類recycle進行處理。

待擴展
  當遊戲收到內存告警時,應該可以釋放對象池,增加可用內存。釋放的策略可以有多種:
1、釋放ObjectPoolMgr中所有的對象池(能釋放大量內存,但Destroy會造成CPU消耗,下次申請時還要重新創建對象池)
2、壓縮對象池中的對象數量(用戶在使用對象池時設置了池中GameObject的基本數量和可增加數量,可以把增加的釋放掉)
3、釋放一些不常用的對象池和其中的對象(不常用的定義可以有很多,比如被申請的次數最少,最久未被使用等)
4、釋放指定一種或幾種對象池
  等等
  同樣的,遊戲申請GameObject而對象池中可申請數量爲空,就需要擴展對象池。擴展對象池的策略可以有:
1>實例化兩個GameObject,一個返回給遊戲,一個放入池中以備下次申請
2>按照用戶設置的AutoIncreaseCount,每次池爲空時實例化相應數量的對象
  空間申請和釋放策略可以有多種,可以組合使用,但沒有萬全之策,可以根據遊戲的特點去實現。
實現時踩的一個小坑
  對象池基類ObjectPool的Start()和Update()方法最好不要使用,因爲創建的子類會自動生成這兩個方法,一不小心就覆蓋了。

對比測試
  我把對象池用於自己製作的小遊戲SpaceBattle,做了一個簡單測試。場景中有很多敵人,隕石,子彈(如圖),我把這三種prefab放入對象池。


不使用對象池:
  
場景中有1843個GameObject,cpu使用呈震盪上漲趨勢。因爲不斷有銷燬和實例化,GameObject數量抖動上漲,Total GCAlloc抖動劇烈,有較頻繁的內存回收。幀數降到了30左右。

使用對象池:


  場景中有2291個GameObject,較上一個場景稍複雜一些。cpu抖動較上面平緩,基本保持在60幀到30幀之間,播放特效時幀數降低(特效未使用對象池)。最後能保持在60幀左右。隨着Total GameObject數量緩步上漲,Total GCAlloc曲線平滑,說明內存操作不頻繁,可以達到節省系統資源的效果。
  可以看出,對象池對降低系統資源消耗是有作用的。在不使用對象池的測試中也遇到了一些極端情況:遊戲頻繁實例化和銷燬對象時cpu劇烈抖動,這種情況應該盡力避免。


      另外也要合理設置對象池中的預分配對象數量。過多會佔用大量內存,過少效果不好。
注:代碼和文章都是年初寫的,最近才整理發出來。時間久了可能會有記憶模糊或的地方,如果有疏漏或是錯誤還望大家不吝指出,謝謝

轉自:http://gad.qq.com/article/detail/7172110

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章