[Unity設計模式與遊戲開發]單例模式

前言

單例模式是我們最常用的設計模式,面試的時候如果問任何一個開發者設計模式,單例模式估計是脫口而出吧,23中常見的設計模式之中並不是所有設計模式都是很常用的,而單例模式絕對是最常用的那一個。但如果真正面試深入問到單例模式,那你確定你真的瞭解嘛?常見的面試會讓你現場寫個單例模式,如果深入一點的問的話會問單例模式有幾種實現方式?用代碼實現並說出各個方式的優缺點?想必如果面試官真這麼問的話,估計絕大多數人也hold不住吧,今天我就來深入的整理一下單例模式。

爲什麼要使用單例模式?

單例模式理解起來很簡單。一個類只允許創建一個對象(或者實例),那這個類就是一個單例類,這種設計模式就叫做單例設計模式,簡稱單例模式。

處理資源訪問衝突

我們先來看一個例子,我們定義一個往文件中打印日誌的Logger類

public class Logger {
  private FileWriter writer;

  public Logger() {
    File file = new File("/Users/dxw/log.txt");
    writer = new FileWriter(file, true); //true表示追加寫入
  }

  public void log(String message) {
    writer.write(mesasge);
  }
}

// Logger類的應用示例:
public class UserController {
  private Logger logger = new Logger();

  public void login(String username, String password) {
    // ...省略業務邏輯代碼...
    logger.log(username + " logined!");
  }
}

public class OrderController {
  private Logger logger = new Logger();

  public void create(OrderVo order) {
    // ...省略業務邏輯代碼...
    logger.log("Created an order: " + order.toString());
  }
}

上面代碼會看到,我們創建了兩個Logger對象,但寫到同一個txt文件中,就有可能存在日誌相互覆蓋的情況,因爲這裏是存在資源競爭的關係,如果我們有兩個線程同時個一個共享變量修改,往裏面寫數據,就有可能相互覆蓋了。有人會想到解決這個問題就是加一個鎖,但如果加對象鎖並不能解決多個對象競爭同一個資源的問題,我們需要加類鎖才行,這個時候我們會想到如果將Logger設計成單例就不會存在這樣的問題了。

表示全局唯一類

在業務概念上,如果有一些數據在系統中只應該保存一份,那就比較適合用單例類。比如常見的配置信息。在系統中,我們只有一個配置文件,當配置文件被加載到內存後,以對象的形式存在,也理所應當只有一份,常見的就是遊戲數據配表。再比如,唯一ID號碼生成器,如果程序中有兩個對象就會存在重複ID的情況,所以,我們應該將ID生成器設計爲單例。

單例設計模式常見寫法

  • 餓漢式(靜態常量)
//餓漢式(靜態變量)
public class Singleton1
{
    //構造器私有化,外部不能new,不寫這個構造函數則會默認有一個公有的構造函數,外部就可以new,這樣不符合單例模式
    private Singleton1()
    {

    }
    //在內部創建一個實例對象
    private static Singleton1 instance = new Singleton1();

    //提供一個公有的靜態方法,返回實例對象
    public static Singleton1 GetInstance()
    {
        return instance;
    }
}

使用測試

Singleton1 instance1 = Singleton1.GetInstance();
Singleton1 instance2 = Singleton1.GetInstance();
Debug.Log(string.Format("instance1和instance2是否相等:{0}", instance1 == instance2));
Debug.Log("instance1的hashCode:" + instance1.GetHashCode() + "     instance2的hashCode:" + instance2.GetHashCode());

測試效果
在這裏插入圖片描述

優缺點說明:
優點:
寫法簡單,在類裝在的時候就實現了實例化,避免了線程同步的問題。
缺點:
在類裝在的時候就完成實例化,沒有達到Lazy Loading的效果。如果從開始至終都沒有使用過這個實例,則會造成內存的浪費。
結論:
這種方式單例模式可用,可能造成內存的浪費。

  • 餓漢式2

實例化的操作也可以放在私有構造函數內

public class Singleton2
{
    private static Singleton2 instance;

    //將實例化放在私有構造函數裏面
    private Singleton2()
    {
        instance = new Singleton2();
    }

    //提供一個公有的靜態方法,返回實例對象
    public static Singleton2 GetInstance()
    {
        return instance;
    }
}
  • 懶漢式
public class Singleton3
{
    private static Singleton3 instance;
    private Singleton3() { }

    //提供靜態公有方法返回實例對象
    public static Singleton3 GetInstance()
    {
        //需要用到的時候再實例化單例對象,即懶漢式
        if(instance == null)
        {
            instance = new Singleton3();
        }
        return instance;
    }
}

優缺點說明:
1.起到了懶加載的效果,但只能在單線程下使用。
2.如果在多線程下,一個線程還沒進入if(instance == null)的邏輯,另外一個線程也進行了訪問,又進行了對象的創建就會產生多個實例。
結論:
在實際開發中,不要用這種方式,但Unity遊戲開發一般不使用多線程的方式,所以Unity遊戲開發中這種模式還是用的挺多的。

  • 懶漢式(線程安全,同步方法)
public class Singleton5
{
    private Singleton5(){ }

    private static readonly object syncObj = new object();
    private static Singleton5 instance = null;
    public static Singleton5 Instance
    {
        get
        {
            lock (syncObj)     //添加同步鎖
            {
                if (instance == null)
                    instance = new Singleton5();
            }
            return instance;
        }
    }
}

優缺點說明:
1.解決了線程不安全的問題
2.效率太低,每個線程想訪問的時候都需要執行一次同步,如果一個線程加鎖,第二個線程只能等待,效率太低。
結論:
實際開發中,不推薦使用這種方式。

  • 懶漢式(雙重檢測)
public class Singleton6
{
    private Singleton6() { }

    private static readonly object syncObj = new object();
    private static Singleton6 instance = null;
    public static Singleton6 Instance
    {
        get
        {
            //雙重檢測提高效率
            if (instance == null)
            {
                lock (syncObj)     //添加同步鎖
                {
                    if (instance == null)
                        instance = new Singleton6();
                }
            }
            return instance;
        }
    }
}

優點說明:
既解決了懶加載的問題,又解決了線程同步的效率問題。
總結:
在實際開發中推薦使用的方式。

Unity中常見的通用單例模式

Unity開發中最常創建單例模式的就是各種Manager,在程序啓動的時候首先實例化各種單例的Manager,而我見過一個主程是這樣寫的,每一個Manager自己內部定義一個static xxx instance,然後在GetInstance方法中實例化這個instance返回,就會顯得很重複,一般Unity中是這樣創建通用泛型單例類的

創建非MonoBehavior單例

public class Singleton<T> where T : new()
{
    protected static T _instance;
    public static T sInstance
    {
        get
        {
            if (_instance == null)
                _instance = new T();
            return _instance;
        }
    }
}

我們使用的時候

public class BattleManager : Singleton<BattleManager>
{
}

因爲Unity裏面幾乎不太使用多線程,所以這裏就沒考慮加鎖的情況。

創建MonoBehavior單例

public class MonoSingleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;
    public static T sInstance
    {
        get { return _instance; }
    }

    protected void  Awake()
    {

        if(_instance != null)
        {
            DestroyImmediate(gameObject);
            return;
        }
        _instance = gameObject.GetComponent<T>();
        InitOnAwake();
    }

    protected void OnDestroy () {
        if(_instance == this)
        {
            ReleaseOnDestroy();
            _instance = null;
        }
    }

    protected virtual void InitOnAwake() {}
    protected virtual void ReleaseOnDestroy() {}
}

使用

public class BattleCameraConfig : MonoSingleton<BattleCameraConfig>
{
}

顧名思義就是MonoBehavior的單例是可以掛在GameObject上的。

單例模式的弊端以及替代方案

1.單例模式存在的問題?

  • 單例對OOP特性的支持不友好
    OOP的四大特性是封裝、抽象、繼承、多態。單例這種設計模式對於其中抽象、繼承、多臺都支持的不好,舉例說明:
    public class Order {
      public void create(...) {
        //...
        long id = IdGenerator.getInstance().getId();
        //...
      }
    }

    public class User {
      public void create(...) {
        // ...
        long id = IdGenerator.getInstance().getId();
        //...
      }
    }

IdGenerator的使用方式違背了基於接口而非實現的設計原則,也就違背了廣義上理解的OOP的抽象特性。如果未來某一天,我們希望針對不同的業務採用不同的ID生成算法。比如,訂單ID和用戶ID採用不同的ID生成器生成。爲了應對這個需求的變化,我們需要修改到所有用到IdGenerator類的地方,這樣改動就會比較大。

    public class Order {
      public void create(...) {
        //...
        long id = IdGenerator.getInstance().getId();
        // 需要將上面一行代碼,替換爲下面一行代碼
        long id = OrderIdGenerator.getIntance().getId();
        //...
      }
    }

    public class User {
      public void create(...) {
        // ...
        long id = IdGenerator.getInstance().getId();
        // 需要將上面一行代碼,替換爲下面一行代碼
        long id = UserIdGenerator.getIntance().getId();
      }
    }

除此之外,單例對繼承、多態特性的支持也不友好。這裏"不友好"並不是"完全不支持",從理論上講,單例類也可以被繼承、也可以實現多態,只是實現起來會非常奇怪,導致代碼可讀性變差。不明白設計意圖的人,看到這樣的設計,會覺得莫名其妙。所以,一旦選擇將某個類設計成單例類,也就意味着放棄了繼承和多態這兩個強有力的面向對象特性,也就是相當於損失了可以應對未來需求變化的擴展性。

  • 單例會隱藏類與類之間的依賴關係
    我們知道代碼的可讀性非常重要,在閱讀代碼的時候,我們希望一眼就能看出類與類之間的依賴關係,搞清楚這個類依賴了哪些外部類。通過構造函數、參數傳遞等方式聲明的類之間的依賴關係,我們通過查看函數的定義,就能很容易識別出來。但是,單例類不需要顯示創建、不需要依賴參數傳遞,在函數中直接調用就可以了。如果代碼比較複雜,這種調用關係就會非常隱蔽。所以在閱讀代碼的時候,我們就需要仔細查看每個函數的代碼實現,才知道這個類到底依賴了哪些類。

  • 單例對代碼的擴展性不友好
    我們知道單例類智能有一個對象實例。如果未來某一天,我們需要在代碼中創建兩個實例或者多個實例,那就要對代碼有比較大的改動。舉個例子,軟件系統設計初期,我們可能會覺得系統中只應該有一個數據庫連接池,這樣能方便我們控制數據庫連接資源的消耗,所以數據庫連接池就被設計成了單例類。但之後發現,我們可能會用到好幾種數據庫,但不同的數據庫的訪問接口都不一樣,這樣我們就需要不同的連接池對象,也就是不能設計成單例類,實際上一些開源的數據庫連接池也確實沒設計成單例類,我一開始剛接觸.NET開發的時候就碰到過MSSQL和Oracle之間的切換。

  • 單例對代碼的可測試性不友好

  • 單例不支持有參數的構造函數

2.單例有什麼替代方案?

爲了保證全局唯一性,除了使用單例,我們還可以用靜態方法來實現。不過靜態方法這種實現思路,並不能解決上面提到的問題。如果要完全解決這些問題,我們需要從根本上尋找其他方式來實現全局唯一類,可以由程序員自己來保證不要創建兩個類的對象。

設計模式系列教程彙總

http://dingxiaowei.cn/tags/設計模式/

教程代碼下載

https://github.com/dingxiaowei/UnityDesignPatterns

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章