【Unity】工具類系列教程——對象池!

【為什麼使用對象池】

遊戲製作避免不了做遊戲優化,讓遊戲達到60分容易,但從60分到90分就是一個漫長的優化路程,因此提前接觸到優化的知識在遊戲開發設計的時候就能規避很多坑點(說是接觸而不是開展優化是因為項目初期功能不明確,做太多優化的功能其實是沒有意義的)。

例如遊戲中會經常用到很多個相同類型的物體,比如子彈特效,比如一個UI的背包。如果每次要用的時候都去創建用完就刪除掉,會造成頻繁的資源回收(GC),部分遊戲玩著玩著就卡頓就源於此。

對象池是一種設計模式,是一種遊戲經常用到的腳本類型,了解對象池對遊戲性能優化非常有幫助。

【對象池初識】

對象池的核心思想是:預先初始化一組可重用的實體,而不是按需銷毀然後重建

就像做遊戲一樣的,我們先做一個對象池原型的實現具體功能。

比如說一個界面:

界面的Item是隨著遊戲進程增加而增加的。

我們利用對象池的核心思想就是「當我們第一次實例化好多個Item物體後,下一次打開如果界面更新就沒必要再創建第二次重複的資源物體了」,因此如果這個界面關閉的時候應該把資源臨時存放在一個「池」裡面。

像這樣:

/*資源保存在「池」中,外部表現無非是Item物體修改了父物體,然後關閉激活,這是非常簡單的操作*/

當再打開界面的時候,因為池中有資源,所以就跳過了讀取資源實例化的步驟(資源載入和實例化是最消耗性能的)。

/*這裡有必要強調的是,我們沒必要單獨去做第一次對象池的實例化,我們只需要知道,這個物體是重複的,所以這個物體我們都在對象池中去取。而對象池也只關注,當別人調用了我的創建實例函數,我必須返回給它一個創建好的物體,而池子裡面有沒有資源讓對象池自己判斷,如果沒有則創建,如果有則直接拿出來給它,這種設計方法叫做空池觸發*/

以下是我們做空池觸發的對象池腳本:

using System.Collections;nusing System.Collections.Generic;nusing UnityEngine;nnpublic class ObjectsPool : MonoBehaviour {nn [SerializeField]n private GameObject _prefab;nn private Queue<GameObject> _pooledInstanceQueue = new Queue<GameObject>();nn public GameObject GetInstance()n {n if (_pooledInstanceQueue.Count>0)n {n GameObject instanceToReuse = _pooledInstanceQueue.Dequeue();n instanceToReuse.SetActive(true);n return instanceToReuse;n }nn return Instantiate(_prefab);n }nn public void ReturnInstance(GameObject gameObjectToPool)n {n _pooledInstanceQueue.Enqueue(gameObjectToPool);n gameObjectToPool.SetActive(false);n gameObjectToPool.transform.SetParent(gameObject.transform);n }n}n

腳本解析:

_prefab:我們要載入和實例化的資源對象

_pooledInstanceQueue :對象池存儲的實質,利用隊列思想來存取物體

GetInstance():得到對象函數,內部判斷當前隊列數量是否為0(是否空池),如果空池則創建資源,否則從池子中取得對象返回。取的對象後,對象池不會在對該對象處理,因此是移除了隊列。

ReturnInstance():返回對象函數,對象池有進有出,當外部功能用完資源後,通過該函數重新讓資源入池。這裡處理了讓對象重新進入隊列,同時關閉物體激活和設置父物體。

然後我們做一個測試腳本:

using System.Collections;nusing System.Collections.Generic;nusing UnityEngine;nnpublic class TestScript : MonoBehaviour {nn public GameObject Tran_Content;nn public ObjectsPool mPool;nn private List<GameObject> itemList = new List<GameObject>();nn public void OnEnable()n {n for (int i = 0; i < 8; i++)n {n var obj = mPool.GetInstance();n obj.transform.SetParent(Tran_Content.transform);n itemList.Add(obj);n }n }nnn public void OnDisable()n {n for (int i = 0; i < itemList.Count; i++)n {n mPool.ReturnInstance(itemList[i]);n }n }n}n

這個測試腳本有一個問題,以下的內容會看到。

當前效果:

【一些基礎規範】

做完基礎原型後,先普及一些概念。很多知識點都來自infoq.com/cn/news/2015/,這裡我提取一些方便大家理解。

兩種基本的對象池回收模式

「借用(borrowing)」和引用計數。前者更清晰,而後者則意味著要實現自動回收。

借用模式:和我們剛才做的UI功能相似,將對象從對象池中借用出來,對象不在和對象池有任何關係,之後由消費者返回對象池。借用和返回都由消費者來實現。

引用計數模式:引用計數用於同時有多個消費者訪問已分配對象的情況,只有當所有的消費者都釋放了對象引用時,對象才可以被回收。這個模式可以用Unity的內存池舉例,Unity內存存放了遊戲資源,但是有部分資源如果沒有被當前遊戲的功能塊引用,則會在某段時間自動清理掉該部分內存。而判斷是否被引用的方法是通過給每一個內存資源加一個引用計數,當沒有對象用到該資源時(計數為0)即開始釋放資源, Unity中的 Resources.UnloadUnusedAssets()介面可以主動調用釋放無用的資源。

分配觸發方式:

空池觸發:任何時候,只要池空了,就分配對象。這是一種最簡單的方式。

水位線:空池觸發的缺點是,某次對象請求會因為執行對象分配而中斷。為了避免這種情況,可以使用水位線觸發。當從池中請求新對象時,檢查池中可用對象的數量。如果可用對象小於某個閾值,就觸發分配過程。

Lease/Return速度:大多數時候,水位線觸發已經足夠,但有時候可能會需要更高的精度。在這種情況下,可以使用lease和return速度。例如,如果池中有100個對象,每秒有20個對象被取走,但只有10個對象返回,那麼9秒後池就空了。開發者可以使用這種信息,提前做好對象分配計劃。

常常有遊戲在載入進度條時,給對象池注入水位線,比如提前存入10個模型的資源,在載入進入戰鬥的時候就可以流暢的全部載入出來。這種觸發方式就像緩存作用,可以把遊戲中用到的資源提前緩存準備好,避免遊戲運行中動態載入。

避免問題的規範:

引用混亂:對象在系統中某個地方註冊了,但沒有返回到池中。

過早回收:消費者已經決定將對象返還給對象池,但仍然持有它的引用,並試圖執行寫或讀操作,這時會出現這種情況。

隱式回收:當使用引用計數時可能會出現這種情況。

大小錯誤:這種情況在使用位元組緩衝區和數組時非常常見:對象應該有不同的大小,而且是以定製的方式構造,但返回對象池後卻作為通用對象重用。

重複下單:這是引用泄露的一個變種,存在多路復用時特別容易發生:一個對象被分配到多個地方,但其中一個地方釋放了該對象。

就地修改:對象不可變是最好的,但如果不具備那樣做的條件,就可能在讀取對象內容時遇到內容被修改的問題。

縮小對象池:當池中有大量的未使用對象時,要縮小對象池。

對象重新初始化:確保每次從池中取得的對象不含有上次使用時留下的臟欄位。

我們剛才的UI測試腳本就犯了回收的錯誤。 我們將對象池的對象臨時存儲在了itemList 中,方便界面關閉的時候將對象回收。但是我們回收完畢對象後並沒有將itemList 清理。這樣就會造成對象池中的對象在外部仍然能夠獲取到,這樣沒法安全的清理對象池回收內存,同時如果我們加入界面數據後,對象的不正確存儲會造成功能問題。

【進階功能】

剛才我們所做的對象池只能存儲一種對象,現在我們要擴展功能,讓對象池能存儲多種對象。

思路:

將Queue<GameObject>轉換成Dictionary<string, Queue<GameObject>>處理做為存儲對象功能,同時我們需要讓對象池能識別不同的對象,因此加入Dictionary<GameObject, string>類型的變數存儲物體的Tag。

代碼如下:

using System.Collections;nusing System.Collections.Generic;nusing UnityEngine;nnpublic class NewObjectPool : MonoBehaviour { nn private GameObject CachePanel;nn private Dictionary<string, Queue<GameObject>> m_Pool = new Dictionary<string, Queue<GameObject>>();nn private Dictionary<GameObject, string> m_GoTag = new Dictionary<GameObject, string>();nnn /// <summary>n /// 清空緩存池,釋放所有引用n /// </summary>n public void ClearCachePool()n {n m_Pool.Clear();n m_GoTag.Clear();n }nn /// <summary>n /// 回收GameObjectn /// </summary>n public void ReturnCacheGameObejct(GameObject go)n {n if (CachePanel == null)n {n CachePanel = new GameObject();n CachePanel.name = "CachePanel";n GameObject.DontDestroyOnLoad(CachePanel);n }nn if (go == null)n {n return;n }nn go.transform.parent = CachePanel.transform;n go.SetActive(false);nn if (m_GoTag.ContainsKey(go))n {n string tag = m_GoTag[go];n RemoveOutMark(go);nn if (!m_Pool.ContainsKey(tag))n {n m_Pool[tag] = new Queue<GameObject>();n }nn m_Pool[tag].Enqueue(go);n }n }nn /// <summary>n /// 請求GameObjectn /// </summary>n public GameObject RequestCacheGameObejct(GameObject prefab)n {n string tag = prefab.GetInstanceID().ToString();n GameObject go = GetFromPool(tag);n if (go == null)n {n go = GameObject.Instantiate<GameObject>(prefab);n go.name = prefab.name + Time.time;n }nnn MarkAsOut(go, tag);n return go;n }nnn private GameObject GetFromPool(string tag)n {n if (m_Pool.ContainsKey(tag) && m_Pool[tag].Count > 0)n {n GameObject obj = m_Pool[tag].Dequeue();n obj.SetActive(true);n return obj;n }n elsen {n return null;n }n }nnn private void MarkAsOut(GameObject go, string tag)n {n m_GoTag.Add(go, tag);n }nn private void RemoveOutMark(GameObject go)n {n if (m_GoTag.ContainsKey(go))n {n m_GoTag.Remove(go);n }n elsen {n Debug.LogError("remove out mark error, gameObject has not been marked");n }n }nn}n

腳本解析:

m_GoTag :相比第一版對象池我們增加了這個變數,這裡利用對象的InstanceID是唯一的,讓InstanceID作為標記。

RequestCacheGameObejct(GameObject prefab):這裡增加傳入prefab,因為對象池需要能存儲多個對象,對象池通過外部傳入的資源對象來判斷。函數內部增加對取出的物體標記的功能。

MarkAsOut(GameObject go, string tag)和RemoveOutMark(GameObject go):將取出的資源添加標記,避免返回的資源不是從對象池創建的資源,返回對象池後資源去除標記。

ReturnCacheGameObejct(GameObject go):增加判定返回對象的功能。

【總結】

對象池算是遊戲優化必定會用到的設計模式,網上對對象池有很多的資源,但是針對遊戲行業的比較少,要麼太過複雜,要麼太過偏門。其實總的來說對象池並不難,是一個花一點時間就能掌握的技巧。當你使用對象池來做功能優化的時候,你會開始逐漸脫離引擎寫代碼,這是程序進階的必經之路。


對遊戲開發感興趣的同學,歡迎圍觀我們:【皮皮關遊戲開發教育】 ,會定期更新各種教程乾貨,更有別具一格的線下小班教育。在你學習進步的路上,有皮皮關陪你!~

我們的官網地址:levelpp.com/

我們的遊戲開發技術交流群:610475807

我們的微信公眾號:皮皮關


推薦閱讀:

報名 | UWA優化日廈門站
塞爾達風之杖技術分析-水體渲染
淺談使用NGUI的界面架構(二)關於NData
Unity3D熱更新LuaFramework入門實戰(7)——PureMVC
Unity自動化構建之iOS打包(另有Android篇)

TAG:游戏 | 游戏开发 | Unity游戏引擎 |