解構 C# 遊戲框架 uFrame 兼談遊戲架構設計

1.概覽

uFrame是提供給Unity3D開發者使用的一個框架插件,它自己模仿了MVVM這種架構模式(事實上並不包含Model部分,且多出了Controller部分)。由於用於Unity3D,因此它向開發者提供了一套基於Editor的可視化編輯工具,能夠用來管理代碼結構等。git

須要指出的是它的一個重要的理念,同時也是軟件工程中的一個重要理念就是關注分離(Separation of concern,SoC)。uFrame藉助控制反轉(IoC)/依賴注入(DI)實現了這種分離,從而進一步實現了MVVM這種模式。且在1.5版本以後,引入了UniRx庫,引進了響應式編程的思想。github

本文主要描述uFrame的這種設計思路以及uFrame自己的一些重要概念,且文中的uFrame版本爲1.6。編程

2.基本概念

2.1 清晰且簡單

uFrame自己實現了一套MVVM的架構模式。咱們以前更熟悉MVC架構模式,雖然MVC分層方式清楚,可是若是使用不當極可能讓大量代碼都集中在Controller之中,從而形成Controller的臃腫,甚至不少時候Controller和View會產生不少耦合。網絡

而MVVM和MVC最大的一個區別是引入了ViewModel的概念。從名字上看,ViewModel是一種針對視圖的模型。因爲引入了ViewModel,從而解放了Controller。具體到Unity3D項目,使用uFrame咱們能夠將U3D中和視覺相關的內容和真正的核心邏輯剝離。數據結構

在uFrame中,使用Element這個概念將業務分拆成三部分:架構

  • ViewModel:保存遊戲中對象的數據結構,例如血量、經驗、金錢等等。框架

  • Controller:處理遊戲業務邏輯。例如加血、減血之類的。異步

  • View:遊戲世界中能夠見的對象,和ViewModel綁定,以在遊戲中進行展示。模塊化

其中ViewModel和Controller是屬於Element的,View是配合Element而產生的遊戲世界中的可見對象。
下面是一個的名爲「Player」的Element在uFrame中的樣子:函數

2.2 可移植性

經過剛剛的例子,咱們能夠看到ViewModelController事實上是處在幕後的,它們只須要實現純邏輯代碼便可,徹底不須要關心在遊戲中視覺上如何展現。正是由於沒必要關心具體的表現如何,因此ViewModel和Controller是具有移植性的。而在U3D項目中,View須要掛載在遊戲對象上,同時它也是鏈接具體的遊戲世界和抽象的邏輯代碼之間的橋樑,經過View,uFrame將ViewModelController與U3D鏈接。

所以,咱們不能經過Controller來訪問View,由於正常狀況下它們是不知道彼此的存在的,Controller將只和ViewModel進行交互,這樣才能保持總體結構的清晰。

同時,咱們也不該該經過ViewModel直接獲取View,這是由於ViewModel應該只關心它本身的數據,而不關心究竟是哪一個View綁定了本身。

2.3 MVVM和Controller

既然說uFrame模仿了MVVM的架構,可是和傳統的MVVM相比,uFrame卻多出了一個Controller。

所以須要在這裏指出,uFrame中的Controller用來配合ViewModel封裝邏輯。 這是由於在uFrame中邏輯並不在ViewModel中,相反,當咱們執行一條命令時,是對應的Controller來執行相應的邏輯。遊戲邏輯有時有可能會十分複雜,可是因爲將遊戲邏輯移到了Controller中,所以ViewModel是十分輕量級的。

3.依賴注入

3.1 面向接口編程

在介紹依賴注入以前,咱們先來看一段項目中的代碼。

class EquipDevelopPanelScript : IPanelScript
{
    ...

    public void SetType(DevelopType Type)
    {
        ...
        if(Type == DevelopType.Split)
        {
            TODO
        }
        else if(Type == xxx)
        {
            TODO
        }
        else if(Type == xxxx)
        {
            TODO
        }
        ...
    }
    ...
}

能夠看到:

首先,在這段代碼中咱們設計的EquipDevelopPanelScript類(處在UI層的類!)的SetType方法很長(170+行),而且方法中有一個冗長的if…else結構,且每一個分支的代碼的業務邏輯很類似,只是不多的地方不一樣,無非是根據不一樣的類型來設置顯示內容。

再者,我認爲這個設計比較大的一個問題是違反了OCP原則(開放關閉原則,指設計應該對擴展開放,對修改關閉。)。在這個設計中,若是之後咱們增長一個新的UI類型,咱們就要打開EquipDevelopPanelScript,修改SetType方法。而咱們的代碼應該是對修改關閉的,當有新UI加入的時候,應該使用擴展完成,避免修改已有代碼。

通常來講,當一個方法裏面出現冗長的if…else或switch…case結構,且每一個分支代碼業務類似時,每每預示這裏應該引入多態性來解決問題。而這裏,若是把不一樣的UI類型當作一個策略,那麼引入策略模式(Strategy Pattern,即將邏輯分別封裝起來,讓他們之間能夠相互替換,此模式使得邏輯的變化獨立於使用者。)是明智的選擇。

最後,說一個小的問題,UI層主要是用來對數據進行展示,不該該包含過多的邏輯。

所以咱們採用這樣的思路:面向接口而不是具體的類(或邏輯)編程,使得咱們能夠輕鬆的替換具體的實現。因此,咱們能夠定義一個接口Interface:

public interface IDevelopType
{
    void SetInfoByType();
}

該接口將以前代碼中TODO的部分概括爲了一個方法SetInfoByType,而只須要實現該接口的不一樣類(例如SplitTypeClass)重寫SetInfoByType方法,便實現了在UI層中去除具體邏輯的功能。以後,咱們只須要根據不一樣的要求,提供實現了IDevelopType接口的不一樣的類便可。
因此以前的100多行代碼能夠變成了這樣的2行代碼:

IDevelopType typeInfo = XXXX.GetInfoByType(Type);
teypInfo.SetInfoByType();

使用這種思路將以前的代碼重構以後,咱們能得到什麼好處呢?

  1. 代碼結構變得很清晰了,雖然類的數量增長了(由於if...else塊中的邏輯被封裝成了類),可是每一個類中方法的代碼都很是短,沒有了之前SetType方法那種很長的方法,也沒有了冗長的if…else。

  2. 類的職責更明確了,UI層的類的主要做用是來將數據展現出來,具體的邏輯交給別的類來處理。

  3. 引入Strategy策略模式後,不但消除了重複性代碼,更重要的是使得設計符合了開閉原則。若是之後要加一個新UI類型,只要新建一個類,實現IDevelopType接口,當須要使用這個UI類型時,咱們只要實例化一個新UI類型類,並賦給局部變量typeInfo便可,已有的EquipDevelopPanelScript代碼不用改動。這樣就實現了對擴展開放,對修改關閉。

3.2 依賴注入的本質

好了,說了這麼多依賴注入在哪裏呢?其實它早就存在了。

咱們再仔細看看剛剛的設計,通過這樣設計以後,有個基本的問題被解決了:如今EquipDevelopPanelScript類的SetType方法再也不依賴具體的UI類型,而僅僅依賴一個IDevelopType接口,接口是不能實例化的,但最終仍是會被賦予一個實現了IDevelopType接口的具體UI類型類。

這裏,實例化一個具體的UI類型類,並賦給變量typeInfo的過程,就是依賴注入,這裏要清楚,依賴注入其實只是一個過程的稱謂。

經過閱讀uFrame的源代碼,最直觀的印象是:一個良好的設計必須作到將變化隔離,使得變化部分發生變化時,不變部分不受影響。只有這樣纔有可能適用於各類狀況。爲了作到這一點,就要利用面向對象中的多態性,使用多態性後,類和類之間便再也不直接存在依賴,取而代之的是依賴於一個抽象的接口,這樣,客戶類就不能在內部直接實例化具體的服務類。

可是這樣作的結果是客戶類在運做中又客觀須要具體的服務類提供服務,由於接口是不能實例化去提供服務的,因而就產生了「客戶類不能依賴具體服務類」和「客戶類須要具體服務類」這樣一對矛盾。爲了解決這個矛盾,開發人員提出了一種模式:客戶類(如上例中的EquipDevelopPanelScript)定義一個注入點(臨時變量typeInfo),用於服務類(實現IDevelopType接口的具體類,如SplitTypeClass等等)的注入,以後根據具體狀況,實例化服務類,注入到客戶類中,從而解決了這個矛盾。

uFrame的基本思想即是使用依賴注入、面向接口編程使代碼解耦,這些也是值得咱們學習的地方。
例以下面這段uFrame的核心代碼,大量的使用面向接口的思路,解除耦合:

//參數只要實現IDisposable接口便可,不是具體的類型
public IDisposable AddBinding(IDisposable binding)
{
    if (!Bindings.ContainsKey(-1))
    {
        Bindings[-1] = new List<IDisposable>();
    }
    Bindings[-1].Add(binding);
    return binding;
}

4.Manager of Managers

若是在Unity3D項目開發中沒有考慮過架構的問題,那麼最多見也最直接的一種作法就是在遊戲場景中建立一個空的GameObject,而後掛上全部與GameObject無關的邏輯控制的腳本,而且使用GameObject.Find()訪問對象數據。這樣作最直接,但這個選擇卻十分糟糕,由於邏輯代碼散落在各處,基本沒有可維護性。

以後,咱們可能會考慮將代碼放在不一樣的單例中,可是有可能會致使一個單例的代碼過多的問題,且和剛剛那個最直接的作法沒有本質的區別,雖然存在不少單例,可是因爲缺乏組織,代碼仍是散落在各處,不適宜維護拓展。所以,咱們須要一種能夠組織代碼的方式來架構咱們的項目。

一個更好的思路是將代碼按照業務劃分紅一些子系統,並經過相應的管理器來管理,例如UISysManager、GameStateSysManager等等。一個子系統內能夠封裝不少內容,可是隻經過管理器對外暴露一些接口,使得整個子系統成爲一個黑箱,外部調用者經過子系統暴露在外的接口進行操做。而這些Manager又須要被更高層級的Manager進行管理,使得整個遊戲架構按照邏輯構形成了樹狀的結構,以下圖:

Fox(遊戲最高層管理器或者稱爲總入口)
                       /            \
                      /              \
                     /                \
              LogicMgr(邏輯管理)        HttpMgr(網絡管理)
              /    |   \                /     \
             /     |    \              /       \
            /      |     \            /         \
       UISysManager XXXXMgr XXXXMgr YYYMgr      YYYYMgr

這樣作的優勢即是代碼的邏輯層次清晰,將邏輯模塊化易於管理,且將對邏輯對象的訪問都經過管理器的接口實現,從而規範了對遊戲內對象的操做方式。例如我想要獲取一個UI,只須要這樣調用:

UIClass ui = Fox.LogicMgr.UISysManager.GetUI(id);

做爲UI子系統外的調用者無需關心GetUI內部發生了什麼,他須要作的僅僅是使用UI系統管理器提供的接口來獲取目標UI。

uFrame中也包含相似的思想,它爲咱們提供了一個稱爲SubSystem的控件,在uFrane的Editor設計器中SubSystem是這樣子的:


且每一個SubSystem在設計器中都會對應一個System Loader類的實例,用來在運行時對子系統進行初始化等工做。

5.利用UniRX實現響應式編程

uFrame框架1.6版本中處理View的綁定時大量的使用了響應式編程的思想。

所謂的響應式編程指的是:使用異步數據流進行編程,而所謂的異步數據流簡單的說就是按時間排序的事件序列。而咱們須要作的就是監聽或者訂閱(Subscribe)事件流,當事件觸發(Publish)時響應便可。換句話說,這是一種觀察者模式或者說訂閱發佈模式的實現。

uFrame實現響應式編程的方式是引入了UniRx庫。須要說明的是Rx庫是微軟推出的一個響應式拓展的框架,可是因爲Rx庫沒法在Unity3D中運行且存在iOS中IL2CPP兼容性的問題,所以後來有人爲Unity3D重寫了Rx庫,也就是UniRx庫。

爲了實現觀察者模式,UniRx提供了兩個關鍵接口:IObservable和IObserver。

IObservable接口定義以下:

public interface IObservable<out T>
{
    IDisposable Subscribe(IObserver<T> observer);
}

IObserver接口定義以下:

public interface IObserver<in T>
{
    void OnCompleted();

    void OnError(Exception error);

    void OnNext(T value);
}

在uFrame中,不少地方會使用這兩個接口以實現觀察者模式,例如在ViewModel中的訂閱方法Subscribe的參數就是一個IObserver的集合:

public IDisposable Subscribe(IObserver<IObservableProperty> observer)
{
    PropertyChangedEventHandler propertyChanged = (sender, args) =>
    {
        var property = sender as IObservableProperty;
        //if (property != null)
            observer.OnNext(property);
    };

    PropertyChanged += propertyChanged;
    return Disposable.Create(() => PropertyChanged -= propertyChanged);
}

天然IObserver集合是基於觀察者模式設計的。觀察者模式的關鍵在於被觀察的對象有一些行爲或者屬性,觀察者能夠註冊某些感興趣的屬性或者行爲。當被觀察者發生狀態改變時,會通知觀察者(一般是發起一個事件),以後會有相應該事件的方法被調用,uFrame藉助UniRx實現了這種模式。

下面咱們就經過一個小例子來看看這種觀察者模式在uFrame中的實現:

View中將指定的LevelSelectButton和RequestMainMenuScreenCommand進行綁定:

this.BindButtonToHandler(LevelSelectButton, () =>
  {
      Publish(new RequestMainMenuScreenCommand()
      {
          ScreenType = typeof (LevelSelectScreenViewModel)
      });
  });

綁定的代碼能夠重寫成如下形式可能更容易理解,即Publish發佈一個事件:

var evt= new RequestMainMenuScreenCommand();
evt.ScreenType = typeof(LevelSelectScreenViewModel);
Publish(evt);

Controller中訂閱/監聽RequestMainMenuScreenCommand,並註冊回調函數:

this.OnEvent<RequestMainMenuScreenCommand>().Subscribe(this.RequestMainMenuScreenCommandHandler);

其中this.OnEvent方法會返回一個IObservable<T>的對象,因此咱們能夠接着調用Subscribe(handler)來訂閱事件T,每當T事件被髮布(Publish),對應的handler就會被調用。

6.研究總結

uFrameMVVM架構無疑是十分簡潔和易拓展的。它所使用的一些架構設計的思想十分值得咱們學習和借鑑。例如利用依賴注入,使整個架構面向接口編程,於是具有了很強的拓展性。引入響應式編程的思想,實現了各個部分之間基於發佈訂閱模式的通訊方式,更加消除了各個模塊之間的耦合,使得代碼易於維護和測試。最後,其總體邏輯架構也有一些Manager of Managers的思想,各個模塊之間可以有效的管理和組織,使得基於該架構的遊戲邏輯層次清晰。

可是,因爲該插件提供的設計器要依賴Unity3D的Editor進行可視化操做,所以有可能會致使Editor方面的一些潛在風險,例如遊戲內部系統過多會致使Editor的可視化區域難以管理,或者是咱們在開發中對Editor的不當操做致使一些未知的問題。甚至因爲是第三方提供的代碼,所以uFrame的版本更迭可能會帶來不少問題(1.5到1.6發生了很大的變化)等等。

所以,建議重點學習和掌握工具所提供的思想和設計思路。

相關文章
相關標籤/搜索