Unity3D開發中如何用好單例模式?

我本人覺得單例在客戶端開發中還是有許多好處的,就我過去的經驗來看,只要控制好創建和銷毀順序,單例模式在存儲一些全局數據的時候是非常有用的,但是今天看到puzzy3d寫的文章:永航科技-技術交流。產生了一些疑惑,求諸位解答


我覺得要想用好單例,最重要的是想怎麼樣用少單例。越少越好。

遊戲軟體處理的對象,一般分「資源對象」與「實例對象」。


「資源對象」:在程序運行時,儲存一種遊戲資源的數據。這個對象內的數據用於遊戲程序中的渲染,聲音播放等。一般而言,資源對象內的資源數據在程序運行時很少變動。比如貼圖,3D網格,音頻數據等。資源數據往往比較占內存空間。 資源對象可以被多個「實例對象」所共享。以節約內存。

「實例對象」:代表遊戲世界中的任意類型的實體。這個實體有自己的一些屬性。並且在遊戲時經常會變動。實例對象內部含有一個其共享的「資源對象」的索引(指針)。

這篇文章的這部分我覺得總結的很對,而我對用不用單例的情景劃分也是以這個為主要依據的。

有些情景是不得不用單例的:

  • 比如各種無狀態Utils,各種輔助函數。本來就是無狀態的,還實例化的話簡直就不可理喻了。
  • 比如配表數據,當然前提你的配錶轉數據方案是直接轉成代碼,這種是靜態數據,無並發問題,最好單例。

有些情景是用單例更方便的:

  • 像bundle管理器、聲音/音效管理器這種,按文章里的劃分,是屬於資源對象,是一定在渲染線程中調用的,而且並不會影響gameplay,單例用起來代碼字數少很多,何樂不為。
  • 像一些全局的狀態管理,跟具體scene無關的,單例也可。
  • 還有就是unity幫你確保線程安全的一些東西,比如log這些的。

那麼什麼情景下不建議用單例呢?

最簡單的,需要跨調用上下文的實例最好不要做成單例。

一般情況用unity寫的遊戲是無需考慮多線程問題的,基本都是跑在渲染線程的,大家都在直接接觸各種GameObject,但是如果你的遊戲要支持一些比較特殊的功能,比如重放、比如邊玩邊更新、比如windows上要鎖屏斷線重連,那你的邏輯主循環放在渲染線程中是很要命的,寫的時候蛋疼不說還會出各種意想不到的bug。

這個時候你相當於要通過一個邏輯線程來驅動整個遊戲邏輯,渲染線程只是拿到數據並畫出來。這樣也更符合遊戲開發的模式,摘掉了用unity寫遊戲=小作坊的帽子。

還有一種情況,就是很多單例之間總會產生依賴的,有依賴那就需要你有人肉維護的創建/銷毀順序嚴格的一坨邏輯,這種肯定沒有非單例的對象依賴樹看的那麼直白。比如我有一套運行時推翻重來的邏輯,後來人新加一個單例,順序沒加好,動不動就能讓運行時推翻重來這類邏輯爆掉,開發期沒爆掉那就更可怕了。

單例的優勢究竟是什麼?

同一個調用上下文內,只有這麼一份的某個東西S,我有很多實例需要S,但是這些實例不需要都存對S的一份引用,可以通過一個約定好的入口直接拿到S。

其他的優勢我真的想不到了,有想到的朋友請補充。

舉個實際例子

還是以之前說的邏輯線程+渲染線程模式為基礎:

  • 為了從根本上避免邏輯線程訪問到渲染對象,我們的gameplay全放在一個assembly里。
  • 機制來確保這個assembly的gameplay一定是跑在同一個調用上下文中的。
  • 這個assembly內部,不會出現race condition。

那對於gameplay部分來說,assembly internal的單例是可以接受的,也基本上是只要約定好初始化順序(銷毀不考慮),典型如網路模塊和gameplay實體管理模塊創建時間就是差很多的。public的單例肯定是不能接受的,只要你public了,團隊里一定會有人去用你的這個單例的。

對於Assembly-CSharp這個assembly里的邏輯來說,單例用的最多的應該還是我之前提到過的不得不用的情況和用了更方便的情況。


算是自問自答吧,我從事Unity3D開發有一段時間了,見了不少代碼,先講出自己的看法,算是拋磚引玉吧:

該博客中的代碼均出自我的開源項目 : 迷你微信

為什麼需要單例模式

遊戲中需要單例有以下幾個原因:

  • 我們需要在遊戲開始前和結束前做一些操作,比如網路的鏈接和斷開,資源的載入和卸載,我們一般會把這部分邏輯放在單例里。
  • 單例可以控制初始化和銷毀順序,而靜態變數和場景中的GameObject都無法控制自己的創建和銷毀順序,這樣就會造成很多潛在的問題。
  • Unity3D的GameObject需要動態創建。而不是固定在場景里,我們需要使用單例來創建GameObject。
  • Unity3D的場景中的各個GameObject需要從單例中存取數據。

單例的設計原則

在設計單例的時候,我並不建議採取延遲初始化的方案,正如雲風所說:

對於單件的處理,採用靜態對象和惰性初始化的方案,簡直就是 C++ 程序員的陋習。Double Checked Locking is broken,相信很多人都讀過了。過於依賴語法糖,通常就會造成這種結果。其實讓程序有明顯的初始化和退出階段,是很容易被規划出來的。把單件(singleton) 的處理放在正確的時機,以正確的次序來處理並非難事。

我們應該在程序某處明確定義單例是否被初始化,在初始化執行完畢後再執行正常的遊戲邏輯

  • 盡量避免多線程創建單例帶來的複雜性
  • 在某處定義了一定的初始化順序後,可以在遊戲結束的時候按照相反的順序銷毀這些單例

設計單例的基類

在Unity中,我們需要一個基類來為所有單例的操作提供統一的介面,同時,我們還要讓所有單例繼承MonoBehaviour,只有這樣才能讓單例自由使用協程這一特性。

基類設計如下,代碼鏈接

using System;
using UnityEngine;

namespace MiniWeChat
{
[RequireComponent(typeof(GameRoot))]
public class Singleton& : MonoBehaviour where T : Singleton&
{
private static T _instance;

public static T GetInstance()
{
return _instance;
}

public void SetInstance(T t)
{
if (_instance == null)
{
_instance = t;
}
}

public virtual void Init()
{
return;
}

public virtual void Release()
{
return;
}
}
}

設計單例的管理類

除了設計基類之外, 還需要設計一個讓所有基類初始化和銷毀的類,我們把這個類叫做GameRoot,並且把它綁定在一個名為GameRoot的GameObject上,並且把這個GameObject放在遊戲進入的Main場景中。

GameRoot類設計如下,代碼鏈接

namespace MiniWeChat
{
public class GameRoot : MonoBehaviour
{
private static GameObject _rootObj;

private static List& _singletonReleaseList = new List&();

public void Awake()
{
_rootObj = gameObject;
GameObject.DontDestroyOnLoad(_rootObj);

StartCoroutine(InitSingletons());
}

/// & /// 在這裡進行所有單例的銷毀
/// &
public void OnApplicationQuit()
{
for (int i = _singletonReleaseList.Count - 1; i &>= 0; i--)
{
_singletonReleaseList[i]();
}
}

/// & /// 在這裡進行所有單例的初始化
/// &
/// &&
private IEnumerator InitSingletons()
{
yield return null;
// Init Singletons
}

private static void AddSingleton&() where T : Singleton&
{
if (_rootObj.GetComponent&() == null)
{
T t = _rootObj.AddComponent&();
t.SetInstance(t);
t.Init();

_singletonReleaseList.Add(delegate()
{
t.Release();
});
}
}

public static T GetSingleton&() where T : Singleton&
{
T t = _rootObj.GetComponent&();

if (t == null)
{
AddSingleton&();
}

return t;
}
}
}

我的單例的缺陷

- 單例的參數沒有做成可以在UnityEditor狀態下可配置的

- 單例創建銷毀完成沒有發出消息通知所有對象

這兩點大家應該稍作拓展就可解決

如何拓展新的單例

有了以上兩個類之後,當我們需要新創建一個類的時候,就可以繼承Singleton&來創建新的單例,重寫Init和Release方法,同時在GameRoot的InitSingleton方法的適當順序執行AddSingleton&方法即可。具體的使用可以參考該類代碼鏈接


我給的簡單的說法吧。

單例是用來取代以前的全局函數變數的。

相比全局函數,單例不會重名,應用域明確,可以管理生命周期,可封裝,可以通過繼承擴展(重要!)

和全局函數比除了要多寫幾個結構體外沒有任何缺點。可以完全取代。

所以要用全局函數的情景,就應該用單例。否則就不應該。

順便說句,@王遠易你這非得用MonoBehaviour的想法是極端錯誤的。你知道這玩意有多慢么?攜程為何一定要有?單例本身是個簡單高效的東西,你搞成這樣……

在開發經驗到達一定程度前切勿自以為是進行架構,這樣的架構基本都是過度架構,甚至功能本身就是弊大於利的。

unity並不是什麼特殊的東西,單例這些用標準寫法是沒有問題的。至於延遲實際化也沒問題,耗時長的實際化可以使用前手動執行和寫法無矛盾。

延遲化是一門美妙的技術。比如說你反序列化讀取一個表,數據量很大,進遊戲時會卡很長一段時間。你把它做成讀取特定數據時反序列化那部分內容,這樣進遊戲就不卡了,讀取時只用一小部分內容花的時間也感覺不到。唉,好像我舉的這個例子就是雲風的代碼。

你要明白那些人說話都是有特定條件和語境的。

另外我不認為會有任何一個項目禁用單例,只會出現主程禁止其手下程序員使用單例,因為他判斷他們的代碼並不應該使用單例。有一點,因為單例通常是全生命周期永不銷毀的,所以承擔的也是全局功能。如果你做的不是全局功能,你本來就不該用單例。局部功能必定有入口和出口以及自己的生命周期,你就該把需要的東西放自己身上隨自己銷毀而不是單例出去。硬生生搞個單例管理類也是錯誤的做法。你管理了又如何?放進入不刪不也一樣?就為了容易發現錯誤?那還不如簡單的禁用單例。


不大會用U3D,偶然點進來的,但我覺得他說的明顯有問題,比如第二段:

"對於大型複雜軟體開發,軟體中對象的初始化順序和銷毀順序非常重要。單鍵對象的初始化,有時是在第一次使用此單鍵類對象的時候才進行。...............造成銷毀後又重新初始化。........每個人自行使用Singleton模式,造成這些對象的初始化順序與銷毀不可控。.........沒有一個地方統一控制它們的初始化順序和銷毀順序..............."

這些問題不都是一個合格單例必須解決的問題嗎? 連這些都不能避免還算什麼單例?


利益相關: 曾經是puzzy3d 手下的程序員

puzzy3d 文章里提到的 「禁止」 單例模式的使用,是有上下文的

「開發維護一個持續10-15年,百萬量級C++代碼」的情況下

項目各模塊如果大量使用單例模式,最終必將導致災難

不使用單例,而是精確維護生命期,遵照項目設計準則,進行設計

是可以避免使用單例,也並不會增加開發工作量

至於題主問的 Unity3D 下單例如何

個人意見:99%的unity3d項目連下一年怎樣都不知道...

先怎樣快怎樣來吧:D


推薦閱讀:

開發遊戲的時候 UI是由哪些人來拼的 ?
為什麼用Unity3D開發遊戲是用C#,JS開發而不是用C++?U3D的設計者是怎樣考慮的?
像缺氧、環世界那些小人自動分工機制是怎麼做到的?
unity 2d 點光源/路燈/手電筒效果怎麼做?
有什麼龍珠相關的遊戲嗎?

TAG:遊戲開發 | Unity遊戲引擎 |