Unity3D 單例模式和靜態(tài)類的使用詳解
Unity3D的API提供了很多的功能,但是很多流程還是會自己去封裝一下去。當然現在網上也有很多的框架可以去下載使用,但是肯定不會比自己寫的用起來順手。
對于是否需要使用框架的問題上,本人是持肯定態(tài)度的,把一些常用方法進行封裝,做成一個功能性的框架,可以很大程度上提高代碼的效率,維護也方便。
對于網絡上很多教程上使用的“游戲通用MVC框架”,現在看來并不符合MVC這種結構性框架的設計思想:要知道,MVC最初是被設計為Web應用的框架,而游戲中的很多事件并不是通過用戶點擊UI發(fā)生的,View和Controller在游戲邏輯中的占比一般都少的可憐,而且很多教程上把Model剝離出很多“Manager”模塊,甚至有人把View和Controller合在一起寫了UIManager——連MVC的結構都沒了,為啥還要稱之為MVC框架呢?
MVC: “人紅是非多。。。?!?/p>
目前大部分的游戲框架——特別是小型項目的游戲框架——都是把一些數據的特定行為進行了一下封裝:生成一個物件,播放一個特效,進行一次隨機事件等。當然也會有一些結構性的設計或者資源管理設計如:UI的回退棧或者回退鏈,場景的載入記錄和切換,下載隊列的管理等。
在Unity的框架設計中,有一個詞會經常見到:單例模式(singleton)。單例模式就是在整個游戲中只使用某個類的一個實例,核心的一句話就是public static T Instance;即在類中定義了一個靜態(tài)的自身實例供外部使用,調用方法時就是:T.Instance.Function()。在本人最初接觸這種設計方式時經常會與靜態(tài)類弄混淆,T.Function()。中間差了一個靜態(tài)Instance,很多時候好像區(qū)別不大。。。
在接近兩周左右的時間里,我一直在糾結于自己正在寫的框架到底應該寫成單例模式的還是靜態(tài)模式的,今天剛好對這個問題有了一個新的想法:靜態(tài)可不可以理解為一種封閉性很強的單例?
首先回想一下靜態(tài)的兩個常識:
1、靜態(tài)類不能繼承和被繼承?。▏栏顸c說是只能繼承System.Object)也就是說你的靜態(tài)類不可能去繼承MonoBehaviour,不能實現接口。
2、靜態(tài)方法不能使用非靜態(tài)成員!如果你大量使用靜態(tài)方法,而方法里又需要用到這個類的成員,那么你的成員得是靜態(tài)成員。
第2點需要注意:如果你想在Unity的編輯器下調整某個參數,那么這個參數就不能是靜態(tài)的(哪怕你自定義EditorWindow去修改這個值也沒用),解決的辦法是通過UnityEngine.ScriptableObject去存放配置(生成*.asset文件),然后在運行中通過LoadAsset去加載,然后再改變靜態(tài)成員。至于原因,相信不難理解——你看到的所有Unity組件都是一個個實例,你要通過Unity的編輯器去配置,那么你就得有一個這樣的可配置實例。
從面向對象上想一下:靜態(tài)方法或者靜態(tài)類,不需要依賴對象,類是唯一的;單例的靜態(tài)實例,一般就是唯一的一個對象(當然也可以有多個)。差別嘛。。。好像也不大。。。
如果這樣考慮沒有錯,那再回頭比較一下兩種方式:
1、靜態(tài)(靜態(tài)方法或者靜態(tài)類),代碼編寫上絆手絆腳,方法調用很方便,運行效率高一丟丟。邏輯面向過程,不能很好地控制加載和銷毀。
2、單例(類的靜態(tài)實例),代碼編寫和其他類完全一樣,繼承抽象模版接口都可以,Unity里也很方便進行參數配置,不過使用麻煩有犯錯的可能性(必須通過實例調用方法),效率不如靜態(tài)(但是也不會有很大影響吧)。
如果這些說法太抽象,那我再給出一個常見的問題:如果你的框架有一個SoundManager能夠管理所有的聲音播放,那么你會怎么去實現?
(在剛接觸AudioSource這個組件的時候,我想的是每一個聲音都由一個AudioSource去播放。但是后來發(fā)現完全沒必要,AudioSource有靜態(tài)的PlayClipAtPoint方法去播放臨時3D音效,同時有實例方法PlayOneShot去播放臨時音效(2D和3D取決于當實例的SpatialBlend)。如果沒有特殊的需求,那么一個AudioSource循環(huán)播放背景音樂,上述兩種方法播放游戲中的特效音頻,這對于大部分游戲已經足夠了。)
那么問題來了:你的SoundManager播放聲音的方法如果是靜態(tài)的,那么AudioSource組件必須在代碼中通過各種方式去獲取(新建組件或者獲取特定GameObject下的組件)——因為保存這個組件的變量必須是靜態(tài)的,也就不能通過Unity的編輯器去賦值。如果不去閱讀代碼那么用戶完全不知道這是一個什么樣的組件獲取流程,如果我破壞這個流程(同名物體,包含互斥組件等),那么這個Manager很有可能會出現不可預料的異常。
而繼承MonoBehaviour并RequireComponent(typeof(AudioSource)),怎么看也比“為了靜態(tài)而靜態(tài)”的代碼要方便健壯的多。
實際上到這里已經可以基本總結出何時需要使用單例了:
1、只要你的類需要保存其他組件作為變量,那么就有必要使用單例;
2、只要你有在Unity編輯器上進行參數配置的需求,那么就有必要使用單例;
3、只要你的管理器需要進行加載的順序控制,那么就有必要使用單例(比如熱更新之后加載ResourcesManager);
當然,這里都只是“有必要”,并不是“必須”。兩者區(qū)別最大的地方,一個是方便寫,一個是方便用。方便寫的代價是每次調用加個instance,方便用的代價則是放棄了面向對象和Unity的“所見即所得”,孰輕孰重,自己抉擇。
另一方面,和“為了靜態(tài)而靜態(tài)”一樣,“為了單例而單例”同樣是一個不合理的設計。這樣的解釋仍然是那么的模糊,那么,就給自己定義一個最簡單的規(guī)則吧——如果你的單例類里沒有任何需要保存狀態(tài)的變量,那么這個類里的方法就可以全都是靜態(tài)方法,這個類也可以是個靜態(tài)類。
補充:從實例出發(fā),了解單例模式和靜態(tài)塊
就算你沒有用到過其他的設計模式,但是單例模式你肯定接觸過,比如,Spring 中 bean 默認就是單例模式的,所有用到這個 bean 的實例其實都是同一個。
單例模式的使用場景
什么是單例模式呢,單例模式(Singleton)又叫單態(tài)模式,它出現目的是為了保證一個類在系統中只有一個實例,并提供一個訪問它的全局訪問點。從這點可以看出,單例模式的出現是為了可以保證系統中一個類只有一個實例而且該實例又易于外界訪問,從而方便對實例個數的控制并節(jié)約系統資源而出現的解決方案。
使用單例模式當然是有原因,有好處的了。在下面幾個場景中適合使用單例模式:
1、有頻繁實例化然后銷毀的情況,也就是頻繁的 new 對象,可以考慮單例模式;
2、創(chuàng)建對象時耗時過多或者耗資源過多,但又經常用到的對象;
3、頻繁訪問 IO 資源的對象,例如數據庫連接池或訪問本地文件;
下面舉幾個例子來說明一下:
1、網站在線人數統計;
其實就是全局計數器,也就是說所有用戶在相同的時刻獲取到的在線人數數量都是一致的。要實現這個需求,計數器就要全局唯一,也就正好可以用單例模式來實現。當然這里不包括分布式場景,因為計數是存在內存中的,并且還要保證線程安全。下面代碼是一個簡單的計數器實現。
public class Counter { private static class CounterHolder{ private static final Counter counter = new Counter(); } private Counter(){ System.out.println("init..."); } public static final Counter getInstance(){ return CounterHolder.counter; } private AtomicLong online = new AtomicLong(); public long getOnline(){ return online.get(); } public long add(){ return online.incrementAndGet(); } }
2、配置文件訪問類;
項目中經常需要一些環(huán)境相關的配置文件,比如短信通知相關的、郵件相關的。比如 properties 文件,這里就以讀取一個properties 文件配置為例,如果你使用的 Spring ,可以用 @PropertySource 注解實現,默認就是單例模式。如果不用單例的話,每次都要 new 對象,每次都要重新讀一遍配置文件,很影響性能,如果用單例模式,則只需要讀取一遍就好了。以下是文件訪問單例類簡單實現:
public class SingleProperty { private static Properties prop; private static class SinglePropertyHolder{ private static final SingleProperty singleProperty = new SingleProperty(); } /** * config.properties 內容是 test.name=kite */ private SingleProperty(){ System.out.println("構造函數執(zhí)行"); prop = new Properties(); InputStream stream = SingleProperty.class.getClassLoader() .getResourceAsStream("config.properties"); try { prop.load(new InputStreamReader(stream, "utf-8")); } catch (IOException e) { e.printStackTrace(); } } public static SingleProperty getInstance(){ return SinglePropertyHolder.singleProperty; } public String getName(){ return prop.get("test.name").toString(); } public static void main(String[] args){ SingleProperty singleProperty = SingleProperty.getInstance(); System.out.println(singleProperty.getName()); } }
3、數據庫連接池的實現,也包括線程池。
為什么要做池化,是因為新建連接很耗時,如果每次新任務來了,都新建連接,那對性能的影響實在太大。所以一般的做法是在一個應用內維護一個連接池,這樣當任務進來時,如果有空閑連接,可以直接拿來用,省去了初始化的開銷。
所以用單例模式,正好可以實現一個應用內只有一個線程池的存在,所有需要連接的任務,都要從這個連接池來獲取連接。
如果不使用單例,那么應用內就會出現多個連接池,那也就沒什么意義了。如果你使用 Spring 的話,并集成了例如 druid 或者 c3p0 ,這些成熟開源的數據庫連接池,一般也都是默認以單例模式實現的。
單例模式的實現方法
如果你在書上或者網站上搜索單例模式的實現,一般都會介紹5、6中方式,其中有一些隨著 Java 版本的升高,以及多線程技術的使用變得不那么實用了,這里就介紹兩種即高效,而且又是線程安全的方式。
1. 靜態(tài)內部類方式
public class Singleton { private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } private Singleton (){} public static final Singleton getInstance() { return SingletonHolder.INSTANCE; } }
這種寫法仍然使用 JVM 本身機制保證了線程安全問題,由于 SingletonHolder 是私有的,除了 getInstance() 方法外沒有辦法訪問它,因此它是懶漢式的;同時讀取實例的時候不會進行同步,沒有性能缺陷;也不依賴 JDK 版本。上面的兩個例子就是用這種方式實現的。
2. 枚舉方式
public enum SingleEnum { INSTANCE; SingleEnum(){ System.out.println("構造函數執(zhí)行"); } public String getName(){ return "singleEnum"; } public static void main(String[] args){ SingleEnum singleEnum = SingleEnum.INSTANCE; System.out.println(singleEnum.getName()); } }
我們可以通過 SingleEnum.INSTANCE 來訪問實例。而且創(chuàng)建枚舉默認就是線程安全的,并且還能防止反序列化導致重新創(chuàng)建新的對象。
靜態(tài)塊
什么是靜態(tài)塊呢
1、它是隨著類的加載而執(zhí)行,只執(zhí)行一次,并優(yōu)先于主函數。具體說,靜態(tài)代碼塊是由類調用的。類調用時,先執(zhí)行靜態(tài)代碼塊,然后才執(zhí)行主函數的;
2、靜態(tài)代碼塊其實就是給類初始化的,而構造代碼塊是給對象初始化的;
3、靜態(tài)代碼塊中的變量是局部變量,與普通函數中的局部變量性質沒有區(qū)別;
4、一個類中可以有多個靜態(tài)代碼塊;
他的寫法是這樣的:
static { System.out.println("static executed"); }
來看一下下面這個完整的實例:
public class SingleStatic { static { System.out.println("static 塊執(zhí)行中..."); } { System.out.println("構造代碼塊 執(zhí)行中..."); } public SingleStatic(){ System.out.println("構造函數 執(zhí)行中"); } public static void main(String[] args){ System.out.println("main 函數執(zhí)行中"); SingleStatic singleStatic = new SingleStatic(); } }
他的執(zhí)行結果是這樣的:
static 塊執(zhí)行中...
main 函數執(zhí)行中
構造代碼塊 執(zhí)行中...
構造函數 執(zhí)行中
從中可以看出他們的執(zhí)行順序分別為:
1、靜態(tài)代碼塊
2、main 函數
3、構造代碼塊
4、構造函數
利用靜態(tài)代碼塊只在類加載的時候執(zhí)行,并且只執(zhí)行一次這個特性,也可以用來實現單例模式,但是不是懶加載,也就是說每次類加載就會主動觸發(fā)實例化。
除此之外,不考慮單例的情況,利用靜態(tài)代碼塊的這個特性,可以實現其他的一些功能,例如上面提到的配置文件加載的功能,可以在類加載的時候就讀取配置文件的內容,相當于一個預加載的功能,在使用的時候可以直接拿來就用。
以上為個人經驗,希望能給大家一個參考,也希望大家多多支持腳本之家。如有錯誤或未考慮完全的地方,望不吝賜教。