【Unity技巧】使用单例模式Singleton 开场白 实现 例子 结束语

这几天想把在实习里碰到的一些好的技巧写在这里,也算是对实习的一个总结。好啦,今天要讲的是在Unity里应用一种非常有名的设计模式——单例模式



单例模式的简单介绍请看前面的链接,当然网上还有很多更详细的介绍,有兴趣的童靴可以了解一下。其实设计模式对于一个程序员来说还是非常有用的,这点随着学习的深入感受越来越深。

好啦,现在说一下Unity里的单例模式。什么时候需要使用单例模式呢?正如它的名字一样,你认为一些东西在整个游戏中只有一个而你又想可以方便地随时访问它,这时你就可以考虑单例模式了。例如,你的游戏可能需要一个管理音乐播放的脚本,或者一个管理场景切换的脚本,或者一个管理玩家信息的通用脚本,又或者是管理游戏中各种常用UI的脚本。事实上,这些都是非常常用而且必要的。


实现


庆幸的是,单例模式的代码非常简单。下面是Singleton.cs的内容:
using System;
using System.Collections;
using System.Collections.Generic;
 
 
public class Singleton : MonoBehaviour
{
    private static GameObject m_Container = null;
    private static string m_Name = "Singleton";
    private static Dictionary<string, object> m_SingletonMap = new Dictionary<string, object>();
    private static bool m_IsDestroying = false;
     
    public static bool IsDestroying
    {
        get { return m_IsDestroying; }
    }
     
    public static bool IsCreatedInstance(string Name)
    {
        if(m_Container == null)
        {
            return false;
        }
        if (m_SingletonMap!=null && m_SingletonMap.ContainsKey(Name)) 
        {
            return true;
        }
        return false;
         
    }
    public static object getInstance (string Name)
    {
        if(m_Container == null)
        {
            Debug.Log("Create Singleton.");
            m_Container = new GameObject ();
            m_Container.name = m_Name;    
            m_Container.AddComponent (typeof(Singleton));
        }
        if (!m_SingletonMap.ContainsKey(Name)) {
            if(System.Type.GetType(Name) != null)
            {
                m_SingletonMap.Add(Name, m_Container.AddComponent (System.Type.GetType(Name)));
            }
            else
            {
                Debug.LogWarning("Singleton Type ERROR! (" + Name + ")");
            }
        }
        return m_SingletonMap[Name];
    }   
     
    public void RemoveInstance(string Name)
    {
        if (m_Container != null && m_SingletonMap.ContainsKey(Name))
        {
            UnityEngine.Object.Destroy((UnityEngine.Object)(m_SingletonMap[Name]));
            m_SingletonMap.Remove(Name);
			
            Debug.LogWarning("Singleton REMOVE! (" + Name + ")");
        }
    }
 
    void Awake ()
    {
        Debug.Log("Awake Singleton.");
        DontDestroyOnLoad (gameObject);
    }
     
    void Start()
    {
        Debug.Log("Start Singleton.");
    }   
     
    void Update()
    {
    }
     
    void OnApplicationQuit()
    {
        Debug.Log("Destroy Singleton");
        if(m_Container != null)
        {
            GameObject.Destroy(m_Container);
            m_Container = null;
            m_IsDestroying = true;
        }           
    }
     
}

代码大部分都比较容易看懂,下面介绍几点注意的地方:
  • 当我们在其他代码里需要访问某个单例时,只需调用getInstance函数即可,参数是需要访问的脚本的名字。我们来看一下这个函数。它首先判断所有单例所在的容器m_Container是否为空(实际上就是场景中是否存在一个Gameobject,上面捆绑了一个Singleton脚本),如果为空,它将自动创建一个对象,然后以“Singleton”命名,再捆绑Singleton脚本。m_SingletonMap是负责维护所有单例的映射。当第一次访问某个单例时,它会自动向m_Container上添加一个该单例类型的Component,并保存在单例映射中,再返回这个单例。因此,我们可以看出,单例的创建完全都是自动的,你完全不需要考虑在哪里、在什么时候捆绑脚本,这是多么令人高兴得事情!
  • Awake函数中,有一句代码DontDestroyOnLoad (gameObject);,这是非常重要的,这句话意味着,当我们的场景发生变化时,单例模式将不受任何影响。除此之外,我们还要注意到,这句话也必须放到Awake函数,而不能放到Start函数中,这是由两个函数的执行顺序决定的,如果反过来,便可能会造成访问单例不成功,下面的例子里会更详细的介绍;
  • OnApplicationQuit函数中,我们将销毁单例模式。
  • 最后一点很重要:一定不要在OnDestroy函数中直接访问单例模式!这样很有可能会造成单例无法销毁。这是因为,当程序退出准备销毁单例模式时,我们在其他脚本的OnDestroy函数中再次请求访问它,这样将重新构造一个新的单例而不会被销毁(因为之前已经销毁过一次了)。如果一定要访问的话,一定要先调用IsCreatedInstance,判断该单例是否存在。


例子


下面,我们通过一个小例子来演示单例模式的使用。
首先,我们需要创建如上的Singleton脚本。然后,再创建一个新的脚本SingletonSample.cs用于测试,其内容如下:
using UnityEngine;
using System.Collections;

public class SingletonSample : MonoBehaviour {

	// Use this for initialization
	void Start () {
		TestSingleton();
	}
	
	// Update is called once per frame
	void Update () {
	
	}
	
	private void TestSingleton() {
		LitJsonSample litjson = Singleton.getInstance("LitJsonSample") as LitJsonSample;
		
		litjson.DisplayFamilyList();
	}
	
//	void OnDestroy() {
//		LitJsonSample litjson = Singleton.getInstance("LitJsonSample") as LitJsonSample;
//		
//		litjson.DisplayFamilyList();
//	}
}

注意,为了方便,我使用了上一篇博文里使用的Litjson的代码,并做了少许修改。下面是修改后的LitJsonSample.cs:
using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Collections.Generic;
using LitJson;

public class FamilyInfo {
	public string name;
	public int age;
	public string tellphone;
	public string address;
}

public class FamilyList {
	public List<FamilyInfo> family_list;
}

public class LitJsonSample : MonoBehaviour {
	
	public FamilyList m_FamilyList = null;
	
	// Use this for initialization
	void Awake () {
		ReloadFamilyData();
	}
	
	private void ReloadFamilyData()
    {
		//AssetDatabase.ImportAsset("Localize/family.txt");
			
        UnityEngine.TextAsset s = Resources.Load("Localize/family") as TextAsset; 
        string tmp = s.text;
        m_FamilyList = JsonMapper.ToObject<FamilyList>( tmp );
        if ( JsonMapper.HasInterpretError() )
        {
            Debug.LogWarning( JsonMapper.GetInterpretError() );
        }
    }
	
	public void DisplayFamilyList() {
		if (m_FamilyList == null) return;
		
		foreach (FamilyInfo info in m_FamilyList.family_list) {
			Debug.Log("Name:" + info.name + "		Age:" + info.age + "		Tel:" + info.tellphone + "		Addr:" + info.address);
		}
	}
	
	// Update is called once per frame
	void Update () {
	
	}
}


然后,将SingletonSample.cs添加到场景中的一个对象上。我偷懒就直接添加到了摄像机上。注意,其他两个代码不要添加到任何对象上。

运行结果如图:
【Unity技巧】使用单例模式Singleton
开场白
实现
例子
结束语

为了证明之前所说的不要在 OnDestroy函数里访问单例模式,我们把SingletonSample.cs脚本里注释掉得OnDestroy函数解开注释,然后再次运行。结果如下:
【Unity技巧】使用单例模式Singleton
开场白
实现
例子
结束语

我们注意到,除了Log页面里出现了错误信息外,右侧的场景面板里也多了一个Singleton对象(这是我已经停止运行了)。从Log信息里,我们可以发现,在第一次销毁掉单例模式后,单例模式又再次被创建,但却没有被销毁,由此便残留在了面板里。

正确的做法是,在OnDestroy函数里加一层安全性判断,如下:
void OnDestroy() {
	if (Singleton.IsCreatedInstance("LitJsonSample")) {
		LitJsonSample litjson = Singleton.getInstance("LitJsonSample") as LitJsonSample;
			
		litjson.DisplayFamilyList();
	}
}


这样,就可以得到正确结果了。

结束语


最后,还有几句话要啰嗦一下,虽然和单例模式的关系不大,嘿嘿。我们需要注意一下Start函数和Awake函数的执行顺序。在这个例子里,我在LitJsonSample.cs的 Awake函数里调用了 ReloadFamilyData来初始化数据,细心的童鞋可以发现,在上一篇博文里,初始化数据是在Start函数里完成的。之所以要把它挪到Awake函数里,是为了在我们访问单例时,可以保证数据一定已经被初始化了,因此把初始化函数放到Awake函数里,访问单例的代码放在Start函数里。同样的原因,在Singleton.cs的脚本里 DontDestroyOnLoad (gameObject);需要放在Awake函数,而不是Start函数里。
关于Awake函数和Start函数的执行顺序,可以详见脚本说明。简单来说,Awake函数在这个脚本在场景中加载时就会调用,至于所有脚本的Awake函数的调用顺序是未知的。然后,在所有的Awake函数调用完毕后,才开始调用Start函数。需要注意的是,Start函数也不是一定立即执行的,它是在该脚本第一次调用Update函数之前调用的,也就是说,如果这个脚本一开始的状态是disable的,那么直到它变成enable状态,在Update函数第一次执行前,才会执行Start函数。两个函数的执行顺序是时间有时正是某些Bug的产生原因!而且这些Bug往往很难发现。
哈,我这次实习的面试时,面试的姐姐就问过我这个问题,希望大家也可以搞清楚,如果我这里有说的不对的,请指正。

好啦,这次就到这里,谢谢阅读!