本系列文章的第一部分讨论了.Net/Mono和unity内存管理的基本知识。第二部分深入介绍了Unity Profiler和CIL相关知识,以发现C#代码中不必要的内存分配。
 
第三篇文章即本文将介绍对象池。到目前为止,我们一直关注的是堆分配。现在我们还想要避免不必要的内存释放,以至于在游戏运行时不会因为垃圾回收器(GC)回收内存而产生帧率下降。对象池是解决这个问题的理想方案。我将展示三种对象池的完整代码(你可以在GithubGist中找到这些代码)。
 
 
从一个非常简单的对象池类开始
 
 
对象池背后的理念非常简单。不再使用new创建新的对象,而是在对象池中储存用过的对象,允许他们之后再被回收,从而在需要时重复使用他们。对象池最重要的一个特性、真正的对象池设计模式的本质是当我们需要获得一个新的对象时,不需要关心对象是重新创建的还是循环使用的原来对象。下面几行代码就是对象池设计模式的具体实现:
[C#] 纯文本查看 复制代码

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);

}

}

本帖最后由 小刺刺 于 2015-8-31 15:33 编辑
 
本系列文章的第一部分讨论了.Net/Mono和unity内存管理的基本知识。第二部分深入介绍了Unity Profiler和CIL相关知识,以发现C#代码中不必要的内存分配。
 
第三篇文章即本文将介绍对象池。到目前为止,我们一直关注的是堆分配。现在我们还想要避免不必要的内存释放,以至于在游戏运行时不会因为垃圾回收器(GC)回收内存而产生帧率下降。对象池是解决这个问题的理想方案。我将展示三种对象池的完整代码(你可以在GithubGist中找到这些代码)。
 
 
从一个非常简单的对象池类开始
 
 
对象池背后的理念非常简单。不再使用new创建新的对象,而是在对象池中储存用过的对象,允许他们之后再被回收,从而在需要时重复使用他们。对象池最重要的一个特性、真正的对象池设计模式的本质是当我们需要获得一个新的对象时,不需要关心对象是重新创建的还是循环使用的原来对象。下面几行代码就是对象池设计模式的具体实现:
[C#] 纯文本查看 复制代码
 
01
02
03
04
05
06
07
08
09
10
11
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操作符来创建类:
[C#] 纯文本查看 复制代码

void Update() {
    
      MyClass m = new MyClass();

}

而是成对使用 New() 和 Store()方法:
[C#] 纯文本查看 复制代码

ObjectPool<MyClass> poolOfMyClass = new ObjectPool<MyClass>();

void Update()

{

MyClass m = poolOfMyClass.New();

// do stuff...    

poolOfMyClass.Store(m);

}

这样比较繁琐,因为你需要记住在New()方法之后在正确的位置调用Store()方法。不幸的是,没有一种通用的方法来简化此设计模式的使用,因为不管是ObjectPool还是C#编译器都不知道对象什么时候可以被重新使用。恩,其实还有一种通过垃圾回收器自动管理内存的方法。这种方法的缺点在文章开始处你已经读过。也就是说,在幸运的情况下,你可以使用文章最后说明的“对象池全部重置”模式。那里,所有的Store()调用都会被替换为调用ResetAll()方法。
 
 
增加ObjectPool 类的复杂度
 
 
我是简洁代码(simplicity)的粉丝,大道至简。但是,ObjectPool 类现阶段可能有些太简单了。如果你搜索C#的对象池库,会找到很多解决方案,其中有些方案相当精妙且复杂。因此,退一步来思考我们需要或者不需要什么功能,比如通用的对象池查找功能。
 
  •   许多对象类型在被重新使用之前需要以某种方式“重置”。至少,所有的成员变量都可以被设置为其默认状态。这些都是由对象池透明处理而非使用者。重置调用的时机和方式由下面两个设计特征决定:
    积极重置(每次存储时重置)或者延迟重置(对象使用前重置)。
    重置由池(由池处理,对类来说透明)或者类(对池对象的声明者透明)来管理。
  • 在上面的例子中,对象池“poolOfMyClass”被显示声明为类成员变量。很明显,这样的对象池需要为每个新类型资源声明一个实例(My2ndClass等)。还有一种方案,ObjectPool类可以创建和管理这些对象池,而用户不用关心这些。
  • 你在这里还有这里可以找到几个对象池库,用它们来管理各种类型的资源(内存、数据库连接、游戏对象、外部assets等)。这往往会增加对象池代码的复杂度,因为,不同资源的处理逻辑差别很大。
  • 一些稀缺资源类型(例如,数据库连接)对象池需要强制设定使用上限,并提供一种安全的方式来分配一个新的或者循环使用的对象。
  • 如果对象池在某些时刻创建了大量对象,我们可能希望对象池有能力减少对象的创建(自动或者按命令)。
  • 最后,对象池可以由多个线程共享,在这种情况下它必须是线程安全的。

 
 
上面这些特性哪些是值得实现的呢?我们都有自己的看法。但是,我来解释一下我自己的优先级。
 
 
  • 重置功能必须有。但是,下面你会发现,完全没必要纠结重置逻辑是由对象池还是托管类来处理。你可能两者都需要,后文的代码分别实现了两种情况。
     
 
  • Unity强加了多线程限制。基本上,除了游戏主线程之外你还可以创建一个工作线程。但是,只有游戏主线程可以调用Unity的API。在我看来,这意味着我们可以为所有线程单独创建对象池,因此也可以移除“多线程支持”的需求。
     
 
  • 就个人而言,我不介意为每个对象类型声明一个新的对象池。但是,还有一种方案:单例模式。ObjectPool类通过存储在静态变量中的对象池Dictionary创建和保存对象池实例。要让其正常工作,你必须保证ObjectPool类可以在多线程环境正常工作。然而,我至今为止也没有看到一个100%安全的多线程对象池解决方案。
     
 
  • 在本篇教程中,我只关心对象池处理的一种稀缺资源类型:内存。但是,其它类型的对象池也很重要。只是超出了本教程的范围。
     
           这里对象池不会强制设置最大限制。如果游戏消耗太多内存,说明游戏存在问题,这不是对象池要解决的问题。
           同样,我们假定没有其它进程正在等待你尽快释放内存。这意味着重置是可以延迟的,而且对象池没有动态减少占用内存的功能。
 
 
 
带有初始化和重置功能的基本对象池
 
 
我们修正的ObjectPool <T>类如下所示:
[C#] 纯文本查看 复制代码

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通过“whereT:class, new()”指定了两种限制方式。第一,T必须是一个类(毕竟,只有引用类型需要对象池),第二,它必须有无参构造函数。
 
 
构造函数使用估测的最大值作为对象池的第一个参数。其他两个参数都是可选参数。如果有值,则第一个用来重置对象池,第二个用来初始化一个新的对象池。ObjectPool<T>除构造函数外只有两个方法:New()、Store()。因为,对象池使用的是延迟重置方法,所以,所有工作都在New()方法中,即重新创建或者循环使用对象被初始化或重置之后。这就是两个可选参数的设计目的。下面是继承自MonoBehavior的对象池类:
[C#] 纯文本查看 复制代码
class SomeClass : MonoBehaviour
{    
      private ObjectPool<List<Vector3>> m_poolOfListOfVector3 =        
           new ObjectPool<List<Vector3>>(32, (list) =>{            
                 list.Clear();
           },        
           (list) => {            
                 list.Capacity = 1024;        
      });     
  
void Update()    
{        
List<Vector3> listVector3 = m_poolOfListOfVector3.New();         
// do stuff         
m_poolOfListOfVector3.Store(listVector3);    
}
}
如果你看过本系列教程的第一篇,就会知道从内存角度来说,在poolOfListOfVector3定义两个匿名委托函数是可以的。一方面,它们并非真的闭包而是“局部定义函数”,另一方面,这不重要因为对象池有类级别的作用范围。
 
 
可以让托管类型重置自身的对象池
 
 
对象池的基础版有了它应该有的功能,但它有一个概念性的缺陷。它违反了封装原则,没有把初始化/重置对象和对象类型的定义分开,导致了代码的紧耦合,这是应该要避免的。在上面的SomeClass 例子中,没有备选方案,因为我们不能改变List<T>的定义。然而,当你在对象池中使用了自定义类型对象,你可能希望它们执行IResetable 接口。相应地ObjectPoolWithReset<T>类也因此可以无需使用两个闭包作为参数(为了灵活性而保留的)。
 
[C#] 纯文本查看 复制代码
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);     } }
带有整体重置功能的对象池
 
 
游戏中有些数据结构可能绝不会持续超过一个序列帧,而是在每帧结束前被释放。在这种情况下,如果很好的定义了所有对象重新存入对象池的时间点,那么这个对象池将更易用,效率也会更高。让我们先看看代码。
 
[C#] 纯文本查看 复制代码
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;     } }
改写之后的版本与最初版本基本一致。只是Store()被ResetAll()取代,这样当所有创建的对象被存入对象池时只需要调用一次。在类的内部,存储所有(甚至是正在被使用的)对象引用的Stack<T>被替换为List<T>,我们在list中也跟踪记录了最近被创建或释放对象的索引。那样的话,New()可以知道是要创建一个新的对象还是重置一个已存在对象。
 
原文作者: Wendelin Reich


 
 
感谢蛮牛译员“Jingli”对本文翻译所做的贡献~~~
 

本文由蛮牛译馆倾情奉献,除 合作社区 及 合作媒体 外,禁止转载。