C#游戲框架uFrame


C#游戲框架uFrame兼談游戲架構設計

1.概覽

uFrame是提供給Unity3D開發者使用的一個框架插件,它本身模仿了MVVM這種架構模式(事實上並不包含Model部分,且多出了Controller部分)。因為用於Unity3D,所以它向開發者提供了一套基於Editor的可視化編輯工具,可以用來管理代碼結構等。
需要指出的是它的一個重要的理念,同時也是軟件工程中的一個重要理念就是關注分離(Separation of concern,SoC)。uFrame借助控制反轉(IoC)/依賴注入(DI)實現了這種分離,從而進一步實現了MVVM這種模式。且在1.5版本之后,引入了UniRx庫,引進了響應式編程的思想。
本文主要描述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的對象,所以我們可以接着調用Subscribe(handler)來訂閱事件T,每當T事件被發布(Publish),對應的handler就會被調用。

6.研究總結

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

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM