原文位址:C# Memory Management for Unity Developers (part 3 of 3),
其實從原文标題可以看出,這是一系列文章中的第三篇,前兩篇講解了從C#語言本身優化記憶體和Unity3D Profiler的使用,都很精彩,有興趣的童鞋可以參考一下。
C# Memory Management for Unity Developers (part 1 of 3)
C# Memory Management for Unity Developers (part 2 of 3)
從一個簡單的對象池類開始說起
對象池背後的理念其實是非常簡單的。我們将對象存儲在一個池子中,當需要時在再次使用,而不是每次都執行個體化一個新的對象。池的最重要的特性,也就是對象池設計模式的本質是允許我們擷取一個“新的”對象而不管它真的是一個新的對象還是循環使用的對象。該模式可以用以下簡單的幾行代碼實作:
public class ObjectPool<T> where T : class, new()
{
private Stack<T> m_objectStack = new Stack<T>();
public T New()
{
return (m_objectStack.Count == 0) ? new T() : m_objectStack.Pop();
}
public void Store(T t)
{
m_objectStack.Push(t);
}
}
很簡單,也很好地展現了該模式的核心。如果你不太了解”where T”,沒關系,稍後會解釋的。如何使用呢?很簡單,你隻需要找到之前使用new操作符的表達式,例如:
void Update()
{
MyClass m = new MyClass();
}
然後将其替換為New()和Store()。
ObjectPool<MyClass> poolOfMyClass = new ObjectPool<MyClass>();
void Update()
{
MyClass m = poolOfMyClass.New();
// do stuff...
poolOfMyClass.Store(m);
}
增加點複雜度
我是簡潔主義的忠實信徒,但就目前而言ObjectPool類或許過于簡單了。如果你搜尋下用C#實作的對象池類庫,你會發現其中很多是相當複雜的。我們先暫停一下,仔細想想在一個通用的對象池中到底哪些是我們需要的,哪些是不需要的:
- 很多類型的對象被重新使用前,在某些情況下,需要被reset。至少,所有的成員變量都要設定成初始值。這可以在池中實作而不需要使用者處理。何時和如何重置需要考慮以下兩個方面:
- 重置是立即的(例如,在存儲對象時即重置)還是延遲的(例如,在對象被重新使用後重置)。
- 重置是被池管理(例如,對于被放入池中的對象來說是透明的)還是聲明池對象的類。
- 在上面的例子中,poolofMyClass池對象需要顯示申明在類級别作用域。顯然,當我們需要一個其它類型的對象池時就需要重新申明一個。或許我們可以實作一個對使用者透明。
- 建立管理所有類型池的ObjectPool。
- 一些對象池類庫管理了太多種類的可怕的資源(如記憶體,資料庫連接配接,遊戲對象,外部資産等)。這無疑增加了對象池的代碼複雜度。
- 某些類型的資源是很珍貴的(如資料庫連接配接),池需要顯示上限并提供一個針對配置設定對象失敗的安全措施;
- 當池中對象很多卻很少使用時,或許需要收縮的功能(不管是自動的還是強制的)。
- 最後,池可以被多個線程共享,是以需要實作為線程安全的。
那麼其中那些是必需的呢?你的答案或許和我的不一樣,但請允許我闡述我的觀點:
- 重置是必需的。但是正如你将在下面看的那樣,我并沒有強制到底是在池中還是被管理類中處理重置邏輯。你可能兩種都需要,之後的代碼中我将向你展示各自兩個版本。
- Unity強制限制多線程。你可以在主線程中定義工作者線程,但隻有主線程可以調用Unity API。以我的經驗看來,我們并不需要将池實作為支援多線程。
- 僅個人而言,我并不介意每次為一個類型申明一個新的池。可選的方案是采用單例模式:建立一個新的對象池并放置于存儲池的字典中,該字典放置在一個靜态變量中。為了安全使用,你需要将将你的對象池實作為支援多線程。但就我看到的對象池而言沒有一個是100%安全的。
- 在本篇文章中我重點處理記憶體。其它類型資源池也是很重要的,但超出本篇文章的範圍。這很大程度上減少了以下的需求:
- 不需要一個作限制用的最大值。如果你的遊戲使用太多的資源,你已經陷入麻煩了,對象池也救不了你。
- 我們也可以假設沒有其它程序等待你盡快釋放記憶體。這就意味着重置可以是延遲的,也不需要提供收縮功能。
基本的實作初始化和重置的池(A basic pool with initialization and reset)
修訂後的版本如下:
public class ObjectPool<T> where T : class, new()
{
private Stack<T> m_objectStack;
private Action<T> m_resetAction;
private Action<T> m_onetimeInitAction;
public ObjectPool(int initialBufferSize, Action<T>
ResetAction = null, Action<T> OnetimeInitAction = null)
{
m_objectStack = new Stack<T>(initialBufferSize);
m_resetAction = ResetAction;
m_onetimeInitAction = OnetimeInitAction;
}
public T New()
{
if (m_objectStack.Count > 0)
{
T t = m_objectStack.Pop();
if (m_resetAction != null)
m_resetAction(t);
return t;
}
else
{
T t = new T();
if (m_onetimeInitAction != null)
m_onetimeInitAction(t);
return t;
}
}
public void Store(T obj)
{
m_objectStack.Push(obj);
}
}
該實作非常簡單直白。參數T被指明為”where T:class,new()”,意味着有兩個限制。首先,T必須為一個類(畢竟,隻有引用類型需要被obejct-pool);其次,它必須要有一個無參構造函數。
構造函數将池可能的最大值作為第一個參數。另外兩個是可選的閉包,如果傳入值,第一個閉包将用來重置池,第二個初始化一個新的對象。除了構造函數外,ObjectPool<T>隻有兩個方法:New()和Store()。因為池使用了延遲政策,主要的工作在于New()。其中,新的和循環使用的對象要麼被執行個體化,要麼被重置,這兩個操作通過傳入的閉包實作。以下是池的使用方法:
class SomeClass : MonoBehaviour
{
private ObjectPool<List<Vector3>> m_poolOfListOfVector3 =
//32為假設的最大數量
new ObjectPool<List<Vector3>>(32,
(list) => {
list.Clear();
},
(list) => {
//初始化容量為1024
list.Capacity = 1024;
});
void Update()
{
List<Vector3> listVector3 = m_poolOfListOfVector3.New();
// do stuff
m_poolOfListOfVector3.Store(listVector3);
}
}
被管理類型自重置的池(A pool that lets the managed type reset itself)
上述的對象池實作了基本功能,但還是有瑕疵。它将初始化和重置對象在對象定義中分開了,在一定程度了違反了封裝原則。導緻了緊耦合,這是需要盡可能避免的。在上述SomeClass中,我們是沒有真正的替代方案的,因為我們不能修改List<T>的定義。然而,當你用自定義類時,你可以實作IResetable接口作為代替。對應的ObjectPoolWithReset<T>也可以不需要指明兩個閉包了(請注意,為了靈活性我還是留下了)。
public interface IResetable
{
void Reset();
}
public class ObjectPoolWithReset<T> where T : class, IResetable, new()
{
private Stack<T> m_objectStack;
private Action<T> m_resetAction;
private Action<T> m_onetimeInitAction;
public ObjectPoolWithReset(int initialBufferSize, Action<T>
ResetAction = null, Action<T> OnetimeInitAction = null)
{
m_objectStack = new Stack<T>(initialBufferSize);
m_resetAction = ResetAction;
m_onetimeInitAction = OnetimeInitAction;
}
public T New()
{
if (m_objectStack.Count > 0)
{
T t = m_objectStack.Pop();
//自行重置
t.Reset();
if (m_resetAction != null)
m_resetAction(t);
return t;
}
else
{
T t = new T();
if (m_onetimeInitAction != null)
m_onetimeInitAction(t);
return t;
}
}
public void Store(T obj)
{
m_objectStack.Push(obj);
}
}
集體重置池(A pool with collective reset)
有一些類型不需要在一系列幀中存留,僅在幀結束前就失效了。在這種情況下,我們可以在一個合适的時機将所有已經池化的對象(pooled objects)再次存儲于池中。現在,我們重寫該池使之更加簡單高效。
public class ObjectPoolWithCollectiveReset<T> where T : class, new()
{
private List<T> m_objectList;
private int m_nextAvailableIndex = 0;
private Action<T> m_resetAction;
private Action<T> m_onetimeInitAction;
public ObjectPoolWithCollectiveReset(int initialBufferSize, Action<T>
ResetAction = null, Action<T> OnetimeInitAction = null)
{
m_objectList = new List<T>(initialBufferSize);
m_resetAction = ResetAction;
m_onetimeInitAction = OnetimeInitAction;
}
public T New()
{
if (m_nextAvailableIndex < m_objectList.Count)
{
// an allocated object is already available; just reset it
T t = m_objectList[m_nextAvailableIndex];
m_nextAvailableIndex++;
if (m_resetAction != null)
m_resetAction(t);
return t;
}
else
{
// no allocated object is available
T t = new T();
m_objectList.Add(t);
m_nextAvailableIndex++;
if (m_onetimeInitAction != null)
m_onetimeInitAction(t);
return t;
}
}
public void ResetAll()
{
//重置索引
m_nextAvailableIndex = 0;
}
}
相比于原始的ObjectPool<T>,改動還是蠻大的。先不管類的簽名,可以看到,Store()已經被ResetAll()代替了,且僅在所有已經配置設定的對象需要被放入池中時調用一次。在類内部,Stack<T>被List<T>代替,其中儲存了所有已配置設定的對象(包括正在使用的對象)的引用。我們也可以跟蹤最近建立或釋放的對象在list中索引,由此,New()便可以知道是建立一個新的對象還是重置一個已有的對象。
拓展閱讀(非譯文部分)
原文位址:http://www.gamasutra.com/blogs/WendelinReich/20131127/203843/C_Memory_Management_for_Unity_Developers_part_3_of_3.php
上文講解了ObjectPool的基本原理及其實作。下面推薦一個更加成熟的插件——PoolManager,該插件功能十分強大,是以才敢賣那麼貴,30美刀...國内有位牛人已經寫了一份不錯的教程,有興趣的童鞋可以參考下:Unity3D研究院之初探PoolManager插件。
物有所不足,智有所不明。