Unity寫個多用物件池

根结点可不就是孤儿發表於2024-07-02

1.介紹

遊戲開發中我們會頻繁使用到預製體來最佳化記憶體,最佳化效能,增強遊戲表現。

當要使用的預製體次數很多,建立銷燬很頻繁時,為了方便管理、提升效能,我們需要一個物件池。

一般使用單例+一個預製體+一個儲存型別就能做出一個簡單的物件池。

但當我們需要對很多種物體進行物件池管理時、當我們需要對很多型別的物體進行物件池管理時,當我們需要對物件的使用週期進行監聽時、當我們需要一些簡便的管理以及為協作者提供介面時…這時我們就需要仔細思考一下如何升級我們的物件池了。

2.需要實現的功能

  1. 實現多物體的物件池,提供簡單方便的介面
  2. 實現多型別的物件池,提供簡單方便的介面
  3. 為每種每類物件的生命週期提供監聽方法的介面

3.拆解分析與實現

從實現的簡單程度與基礎程度,先實現基礎物件池的生命週期監聽,在此基礎上實現多物體物件池和多型別物件池的管理

3.1 物件生命週期的監聽

物件的生命週期包含物件建立時、從物件池中獲取時、回收進物件池時。正常寫需要將建立、獲取、回收方法封裝一層傳入一個委託方法,Unity有提供ObjectPool<>、LinkedPool<>、DictionaryPool<>、HashPool<>等物件池並提供監聽介面,我們使用ObjectPool<>作為基礎的元池,逐步構建一個更大的物件池。

ObjectPool類:

點選檢視程式碼
    //unity的內建類,屬性方法比較簡單,可看unityAPI手冊學習
    public class ObjectPool<T> : IDisposable, IObjectPool<T> where T : class
    {
        public ObjectPool(Func<T> createFunc, Action<T> actionOnGet = null, Action<T> actionOnRelease = null, Action<T> actionOnDestroy = null, bool collectionCheck = true, int defaultCapacity = 10, int maxSize = 10000);

        public int CountAll { get; }
        public int CountActive { get; }
        public int CountInactive { get; }

        public void Clear();
        public void Dispose();
        public T Get();
        public PooledObject<T> Get(out T v);
        public void Release(T element);
    }

3.2 多物體的物件池

當預製體很多時,我們可以使用Dictonary等帶標誌的資料結構儲存、使用一個個預製體的對應物件池,我們只需要將物件與字典聯絡起來,以後就能使用一個字典來管理所有的預製體,並且我們可以規定使用預製體的名字作為鍵,這樣在使用與理解上都十分友好,唯一需要注意的是,必須要有良好的預製體命名規範,不能有相同名字的不同預製體。

點選檢視程式碼
    //用於掛載所有預製體
    public List<GameObject> poolPrefabs;
	//管理預製體
    private Dictionary<string, GameObject> sortedPrefabs = new();

    void SortedPoolInit()
    {
        foreach (GameObject prefab in poolPrefabs)
        {
            if (!sortedPrefabs.ContainsKey(prefab.name))
                sortedPrefabs.Add(prefab.name, prefab);
            else
                Debug.LogError("----------有重複的prefab名稱");
        }
    }
	//物件池Get對應物件的介面
	public GameObject Get(string name)
    {
	}
	物件池Release回收物件的介面
	public void Release(string name, GameObject obj)
	{
	
	}

3.3 多型別的物件池

除開值型別,GameObject一類的,其他型別比如一些類WaitForSeconds、Animal、Bullet啥的,這個其實用到的並不多並且開銷不算大,還有Mono記憶體堆管理,只是都寫到這了補充上吧。和之前GameObject的處理類似,不過需要一個各型別與object的引用型別之間的裝箱與拆箱。

點選檢視程式碼
    Dictionary<Type, object> sortedCustomClass = new();
	public object GetCustom(Type t)
	{
	}
	public ReleaseCustom(Type t,object o)
	{
	}

3.4 綜合實現

個人習慣先從介面寫起,定好規範性與約束性。對於大大小小的物件池、元池、擴充池,都需要拿取Get、回收Release介面及他們的監聽OnGet、OnRelease,先照此寫個IPool.

點選檢視程式碼
public interface IPool<T> where T:class
{
    /// <summary>
    /// 從池中獲取
    /// </summary>
    /// <returns></returns>
    public T Get();
    /// <summary>
    /// 放回池中並釋放
    /// </summary>
    public void Release(T obj);
    /// <summary>
    /// 獲取監聽
    /// </summary>
    public void OnGet(T obj);
    /// <summary>
    /// 放回監聽
    /// </summary>
    public void OnRelease(T obj);
}

因為要實現監聽,而不同池需要不同的監聽,所以需要一個基礎池(元池)父類,要有對應的池的名字(等同其預製體名字)、對應的預製體以及一個ObjectPool:

點選檢視程式碼
public class PoolBase<T> : UnityEngine.Object, IPool<T> where T : class
{
    /// <summary>
    /// 元池名
    /// </summary>
    public string poolName;
    /// <summary>
    /// 元池的預製體
    /// </summary>
    protected T poolPrefab;
    /// <summary>
    /// 元池 ObjectPool
    /// </summary>
    public ObjectPool<T> pool;
    /// <summary>
    /// 從元池中獲取
    /// </summary>
    public virtual T Get()
    {
        throw new NotImplementedException();
    }
    /// <summary>
    /// 元池拿取的監聽事件
    /// </summary>
    /// <param name="obj">拿取之物</param>
    public virtual void OnGet(T obj)
    {
    }
	/// <summary>
    /// 元池回收
    /// </summary>
    /// <param name="obj">回收之物</param>
    public virtual void Release(T obj)
    {
    }
    /// <summary>
    /// 元池回收的監聽事件
    /// </summary>
    /// <param name="obj">回收之物</param>
    public virtual void OnRelease(T obj)
    {
    }
}

根據這個泛型元池,我們可以按需要寫一些具體要使用的派生類了:

點選檢視程式碼
/// <summary>
/// GameObject型別的池
/// </summary>
public class ObjPool : PoolBase<GameObject>
{
    //區別於ObjectPool的池、按需使用
    public HashSetPool<GameObject> hashpool;
    public DictionaryPool<string, GameObject> dicpool;
    public LinkedPool<GameObject> linkedpool;
    //傳入池名及預製體資料
    public ObjPool(string name, GameObject prefab)
    {
        poolName = name;
        pool = new ObjectPool<GameObject>(OnCreate, OnGet, OnRelease);
        poolPrefab = prefab;
    }
    //生成監聽
    GameObject OnCreate()
    {
        GameObject GO = Instantiate(poolPrefab);
        GO.SetActive(false);
        return GO;
    }
    public override GameObject Get()
    {
        return pool.Get();
    }
    public override void Release(GameObject GO)
    {
        pool.Release(GO);
    }
    public override void OnGet(GameObject GO)
    {
        // 自定義監聽事件
		GO.SetActive(true);
    }
    public override void OnRelease(GameObject GO)
    {
        // 自定義監聽事件
        GO.SetActive(false);
    }
}

//其他例子,但其實使用很少,也有其他的方法替代
public class MaterialPool : PoolBase<Material>
{
    public MaterialPool(string name, Material prefab)
    {
        poolName = name;
        pool = new ObjectPool<Material>(OnCreate, OnGet, OnRelease);
        poolPrefab = prefab;
    }
    Material OnCreate()
    {
        Material Mt = Instantiate(poolPrefab) ;
        return Mt;
    }
    public override Material Get()
    {
        return pool.Get();
    }
    public override void OnGet(Material mt)
    {
    }
    public override void OnRelease(Material mt)
    {
    }
    public override void Release(Material mt)
    {
        pool.Release(mt);
    }
}
這個結構在後來思考時感覺還是有些冗餘了,每多一種型別需要多一個派生類,有好有壞吧,最佳化思路可以參考下面多型別的結構,全部使用泛型與傳參,就是使用的時候比較麻煩。筆者現在暫時沒時間最佳化,以後再更新吧。讀者可以自己嘗試一下。
點選檢視程式碼
public class CustomPool<V> : IPool<V> where V : class
{
    private ObjectPool<V> _CustomPool;
	//將生成監聽傳參,其他監聽也可以套用
    private Func<V> _Creator;
    public CustomPool(Func<V> creator)
    {
        _Creator = creator ?? throw new ArgumentNullException(nameof(creator));
        _CustomPool = new ObjectPool<V>(_Creator, OnGet, OnRelease);
    }
    public V Get()
    {
        V v = _CustomPool.Get();
        return v;
    }
    public void Release(V v)
    {
        _CustomPool.Release(v);
    }
    public void OnGet(V v)
    {
    }
    public void OnRelease(V v)
    {
    }
}

搞完自動化了,現在要加上手動擋實現半自動化了。什麼?你問為什麼不做個全自動化?程式碼全自動化了程式設計師不就失業了嗎( o`ω′)ノ!好吧,只是筆者筆力不夠了...

點選檢視程式碼
//Singleton是一個泛型單例基類
public class PoolMgr : Singleton<PoolMgr>
{
    #region GO物件池
    /// <summary>
    /// GO預製體
    /// </summary>
    public List<GameObject> poolPrefabs;
    /// <summary>
    /// <summary>
    /// GameObject的總物件池
    /// </summary>
    public Dictionary<string, ObjPool> SortedPool = new();

    void Awake()
    {
        base.Awake();
        DontDestroyOnLoad(gameObject);
        SortedPoolInit();
    }
    /// <summary>
    /// 分類建立各池
    /// </summary>
    void SortedPoolInit()
    {
        foreach (GameObject prefab in poolPrefabs)
        {
            if (!SortedPool.ContainsKey(prefab.name))
            {
                SortedPool.Add(prefab.name, new ObjPool(prefab.name, prefab));
                Debug.Log("------------新增池 " + prefab.name);
            }
            else
                Debug.LogError("----------有重複的prefab名稱");
        }
    }
    /// <summary>
    /// 獲取name池中的一個節點
    /// </summary>
    public GameObject Get(string name)
    {
        if (!SortedPool.ContainsKey(name))
        {
            Debug.LogError("不存在要獲取的子物件池--" + name);
            return null;
        }
        return SortedPool[name].Get();
    }
    /// <summary>
    /// 將obj回收進name池
    /// </summary>
    public void Release(string name, GameObject obj)
    {
        if (!SortedPool.ContainsKey(name))
        {
            Debug.LogWarning("不存在要回收進的物件池--" + name );
            Destroy(obj);
            return;
        }
        obj.transform.SetParent(transform);
        SortedPool[name].Release(obj);
    }
    #endregion
    #region  其他不可序列化的物件池
    Dictionary<Type, CustomPool<object>> CustomPoolDic = new();
    public object GetCustom(Type t, Func<object> func = null)
    {
        //使用預設object構建
        if (!CustomPoolDic.ContainsKey(t) && func == null)
        {
            CustomPoolDic.Add(t, new CustomPool<object>(() => { return new object(); }));
        }
        //使用提供的function構建
        else if (!CustomPoolDic.ContainsKey(t) && func != null)
            CustomPoolDic.Add(t, new CustomPool<object>(func));
        //替換原構造的custom pool 為新的構造,可能有問題,取決於兩個構建委託的相容性
        else if (CustomPoolDic.ContainsKey(t) && func != null)
        {
            CustomPoolDic[t] = new CustomPool<object>(func);
        }
        return CustomPoolDic[t].Get();
    }
    public void ReleaseCustom(Type t, object o)
    {
        if (!CustomPoolDic.ContainsKey(t))
        {
            o = null;
        }
        else
            CustomPoolDic[t].Release(o);
    }
    #endregion
}

前一類池的使用非常簡單,配置好預製體和單例後只需要關注兩個介面:

點選檢視程式碼
    GameObject bullet = PoolMgr.Instance.Get("Bullet");
	PoolMgr.Instance.Release("Bullet",bullet);

關於後面一類池的使用以及可能出現的問題筆者以WaitForSeconds為例,大家就很好理解了

點選檢視程式碼
    //這裡第一次從池中拿,wts1為新構建的WaitForSeconds(0.1f)
    WaitForSeconds wts1 = (WaitForSeconds)PoolMgr.Instance.GetCustom(typeof(WaitForSeconds), () => { return new WaitForSeconds(0.1f); });
	//這裡放回去一個WaitForSeconds(0.1f)
    PoolMgr.Instance.ReleaseCustom(typeof(WaitForSeconds), wts);
	//這裡想要拿的是WaitForSeconds(0.2f),但返回的是WaitForSeconds(0.1f)
    WaitForSeconds wts2 = (WaitForSeconds)PoolMgr.Instance.GetCustom(typeof(WaitForSeconds), () => { return new WaitForSeconds(0.2f); });
	//這裡池中沒有了,所以會使用新的構建拿出一個WaitForSeconds(0.3f)
	WaitForSeconds wts3 = (WaitForSeconds)PoolMgr.Instance.GetCustom(typeof(WaitForSeconds), () => { return new WaitForSeconds(0.3f); });

4.總結

這是一個半成品的物件池,還有許多部分與方面可以最佳化,但在大部分情況下夠用了,時間與筆力有限,下次再與大家一起分享探討。

相關文章