用对象池管理游戏物体
对象池要实现的是对对象的复用,就好像是把一堆东西放在一个地方,用的时候就拿一个出去,再用就再拿一个,用完了再放回来。在Unity中可以用SetActive方法将游戏物体关闭与开启来代替Instantiate实例化与Destroy销毁,拿出去的东西既是激活的游戏物体,放回来的东西既是关闭的游戏物体。这种方法性能消耗小,可以把不同地点,不同事件,不同角色调用的同一种游戏物体放在一个列表中(对象池)管理,减少了指令中的内存相关操作。
下面一个简单的例子:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class ObjectPooler : MonoBehaviour {
//之后会通过这个静态变量来访问下面的获取对象池中游戏物体的方法
public static ObjectPooler current;
//可以在Editor界面中通过给此变量赋值设置需要被管理的游戏物体
public GameObject pooledObject;
//对象池初始时实例化游戏物体的数量的上限,如果下面的poolGrow变量为false。那么场景中激活的对象池物体不会超过此数量。
public int pooledAmount=20;
//对象池在游戏进程中是否可动态扩展
public bool poolGrow=true;
//一个对象池。可以是list,stack或其他集合。
public List<GameObject> pool=new List<GameObject>();
void Awake(){
current=this;
}
void Start () {
//实例化游戏物体,暂时关闭游戏物体,加入游戏物体到列表中
for(int i=0;i<pooledAmount;i++){
GameObject obj=(GameObject)Instantiate(pooledObject);
obj.SetActive(false);
pool.Add(obj);
}
}
//其他类通过此方法获取对象池中的游戏物体,此方法只会将未使用的(既是未激活的)游戏物体返回。如果列表中的游戏物体已经全部在场景中,就实例化新的游戏对象,并开始动态扩展列表。
public GameObject getPooledObject(){
for(int i=0;i<pool.Count;i++){
if(!pool[i].activeInHierarchy){
pool[i].SetActive(true);
return pool[i];
}
}
if(poolGrow){
GameObject obj=(GameObject)Instantiate(pooledObject);
pool.Add(obj);
return obj;
}
return null;
}
}
图1:场景中设置两个几何物体。假设立方体为飞机,球体为子弹。
球体子弹上的脚本:
using UnityEngine;
using System.Collections;
public class SpereTest : MonoBehaviour {
//假设子弹的生命周期为两秒,把Invoke命令放在了OnEnable方法内,开启两秒后消失。
void OnEnable(){
Invoke("Destroy",2f);
}
//用setActive(false)代替销毁,以使它可以重复使用
void Destroy(){
gameObject.SetActive(false);
}
//销毁或关闭一个游戏物体并不会自动取消Invoke方法,所以如果有其他途径关闭了这个子弹,可以在这里手动取消Invoke方法。
void OnDisable(){
CancelInvoke();
}
void Update () {
//子弹移动
transform.Translate(Vector3.forward*Time.deltaTime*50f);
}
}
Cube飞机上的脚本:
using UnityEngine;
using System.Collections;
public class CubeTest : MonoBehaviour {
// Use this for initialization
void Start () {
}
// Update is called once per frame
void Update () {
if(Input.GetKey(KeyCode.Q)){
Fire();
}
}
void Fire(){
//通过对象池类中的静态变量获取对象池中的游戏物体
GameObject obj=ObjectPooler.current.getPooledObject();
//如果对象池类中的动态扩展变量为false,则只会获取pooledAmount个游戏物体。有可能在某个时间点对象池中暂无可用的子弹。
if(obj==null){
return;
}
//将子弹移动到出现的地点。
obj.transform.position=transform.position;
obj.transform.rotation=transform.rotation;
}
}
图2:飞机发射子弹截图
对象池类可以继续扩展一些功能,例如可以在某个方法内销毁(真正的Destroy)所有对象池中游戏物体并清空列表,并在游戏空闲场景中调用这个方法以释放堆空间。
单例模式
单线程场景解决方案
某些类的设计初衷是不适合出现两个实例的,例如上文的对象池类,如果出现了两个子弹对象池,势必出现某些混乱现象。单例模式既是对类的实例数量进行限制,保证在系统中只会有一个实例对象,下面就会将上文的对象池类改造成单例模式。
首先要解决的是限制AddComponent方法。由于Unity自身的机制,当我们写一个继承自MonoBehaviour的类,它是可以通过AddComponent()方法在任意一个GameObject身上变为它的组件的,现在通过将它定义在一个C#类之中,或者说对它进行包装,并将签名改为private,这样它就无法再通过AddComponent实例化了。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SingletonPool {
private class ObjectPooler : MonoBehaviour
{
......
}
}
由于现在只有SingletonPool可以访问到ObjectPooler,所以对于ObjectPooler的单例化问题实际上变为了如何对SingletonPool进行单例限制,由于它是一个纯C#类,对它进行限制相比继承自Unity MonoBehaviour的类要简单一些。
下面通过加入静态自身引用与GetInstance函数实现单线程的单例化。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SingletonPool {
//通过instance引用单例
private static SingletonPool instance = null;
private static GameObject go = null;
private static ObjectPooler pool= null;
//值可以通过GetInstance访问到instance
public static SingletonPool GetInstance(){
//第一次访问时实例化
if(instance==null){
instance = new SingletonPool();
go = new GameObject();
pool = go.AddComponent<ObjectPooler>();
return instance;
}else{
return instance;
}
}
//wrapper方法,接入ObjectPooler的方法并对外开放。
public GameObject getPooledObject()
{
return pool.getPooledObject();
}
//...也许还会有其他wrapper方法...
//
private class ObjectPooler : MonoBehaviour
{
......
}
}
多线程场景线程同步解决方案
以上的单例化适用于单线程场景。虽然Unity的设计是单线程的,非主线程调用上面的wrapper方法是会出错的,但是在多线程下是可以对SingletonPool进行实例化的。下面要考虑多线程的场景下的一些问题。
假设在一个多线程的场景下,if(instance==null) 对于单例的限制是不够鲁棒的。因为一旦在线程A完成instance==null之后且全部完成instance==new SingletonPool()的指令之前发生了线程切换,那么就会有最少两个逻辑流同时进入到if判断之内。一个简单的解决方法是对if(instance==null)加入锁,进行线程同步。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
public class SingletonPool {
private static SingletonPool instance = null;
private static Object s_lock = new Object();
private static GameObject go = null;
private static ObjectPooler pool= null;
public static SingletonPool GetInstance(){
//这里在同步前提前判断,大幅减少同步线程的次数
if(instance!=null){
return instance;
}
//线程同步
Monitor.Enter(s_lock);
if(instance==null){
instance = new SingletonPool();
go = new GameObject();
pool = go.AddComponent<ObjectPooler>();
}
Monitor.Exit(s_lock);
return instance;
}
public GameObject getPooledObject()
{
return pool.getPooledObject();
}
private class ObjectPooler : MonoBehaviour
{
......
}
}
C#的Monitor.Enter/Exit的使用规避了一个有名的双检琐问题。双检琐问题起源于JAVA,是说如果在线程A通过了第一个instance!=null的判断之后且进入线程同步前发生线程切换,线程B进入线程同步并成功实例化,再切换回线程A,那么线程A在第二个instance==null的判断中返回的会是true(虽然实际上instance已经引用了单例),原因是JVM会根据优化思想直接访问cpu缓存的上次比较结果(既是第一次instance!=null后的结果),一个解决方法是在instance前加入volatile关键字(JAVA,C#均可),但是会加大每次对instance的访问时的性能损耗。而CLR的Monitor从底层机制上限制了线程同步后对缓存的访问,既是所有线程进入同步代码后,必须对其中的的变量重新读取,不可以读取缓存。
这样,以上的代码表面上解决了对单例的实例化的线程竞争,但是如果系统在堆中给SingletonPool分配内存之后,Monitor.Exit(s_lock)退出同步之前的期间,其他线程的逻辑流是有可能正好走到第一个instance!=null的判断处的,那样它就会使用一个未完全初始化的instance(已经分配内存,但是没有完成构造函数以及同步块之后的代码)并造成错误。
以下再引入一个Volatile.Write()方法,保证在SingletonPool构造函数完成后才会让其他线程读取instance变量,这样我们还可以利用这个特性将new GameObject和AddComponent方法也放入到构造函数内,保证instance的完全初始化。这个方法与instance声明加入volatile关键字是一样的,但它更优化的地方在于不用牺牲每次访问instance时的性能,只有在第一次实例化以及第一次实例化发生竞争时才会发生阻塞和性能损失。
private SingletonPool(){
go = new GameObject();
pool = go.AddComponent<ObjectPooler>();
}
public static SingletonPool GetInstance(){
//这里在同步前提前判断,大幅减少同步线程的次数
if(instance!=null){
return instance;
}
//线程同步
Monitor.Enter(s_lock);
if(instance==null){
var temp = new SingletonPool();
Volatile.Write(ref instance,temp);
}
Monitor.Exit(s_lock);
return instance;
}
但可惜的是在Unity中Threading命名空间中没有Volatile类,看来是不支持。因此只能在instance声明中加入volatile关键字以牺牲性能才能做到100%的鲁棒性。
多线程场景类型构造器解决方案
另一个解决方案是利用CLR的类构造器。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
public class SingletonPool
{
private static SingletonPool instance = null;
private static GameObject go = null;
private static ObjectPooler pool = null;
//线程安全的类型构造器
static SingletonPool(){
instance = new SingletonPool();
go = new GameObject();
pool = go.AddComponent<ObjectPooler>();
}
private SingletonPool(){
}
public static SingletonPool GetInstance(){
return instance;
}
public GameObject getPooledObject()
{
return pool.getPooledObject();
}
private class ObjectPooler : MonoBehaviour
{
......
}
}
在CLR中,所有的类在堆中都会有一个唯一的类型对象,对类静态成员的第一次访问会促使CLR对类型对象进行初始化,从而调用类型构造函数,这个调用从底层机制保证了线程安全。
多线程场景利用GC与Interlocked.CompareExchange解决方案
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
public class SingletonPool
{
private static SingletonPool instance = null;
private static GameObject go = null;
private static ObjectPooler pool = null;
private SingletonPool(){
}
public static SingletonPool GetInstance(){
if(instance!=null){
return instance;
}
SingletonPool temp = new SingletonPool();
Interlocked.CompareExchange(ref instance, temp, null);
return instance;
}
public GameObject getPooledObject()
{
return pool.getPooledObject();
}
private class ObjectPooler : MonoBehaviour
{
......
}
}
Interlocked.CompareExchange()方法确保只有instance为null时,才会将temp赋值给instance,并且保证线程安全,这样当发生竞争时,除了被赋值给instance的temp,其他的temp变为了局部变量,当方法结束时就会被释放,对应的堆中实例将会在下一次GC垃圾回收中被回收。这个方法的优点是无阻塞,缺点是在GC回收前有可能会在短时间内浪费堆中内存(多个线程同时new SingletonPool,但是几率很小)。
以上,实现了单例模式的三个最重要的功能:
1,保证在单线程/多线程场景中只有一个实例。单例对象池类无法在系统中通过AddComponent或者是new进行实例化,只能通过GetInstance来访问唯一的instance。
2,自行自动创建。在第一次访问getInstance方法时会自动创建实例。
3,全局访问。游戏中各个场景中的所有类皆随时可通过静态属性Instance来访问对象池类。
参考:
http://unity3d.com/cn/learn/tutorials/topics/scripting/object-pooling --Unity Technology
CLR via C# --Jeffrey Richter
维护:
2020-6-21:review