解构 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发生了很大的变化)等等。

所以,建议重点学习和掌握工具所提供的思想和设计思路。

相关文章
相关标签/搜索