解构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.可移植性
通过刚刚的例子,我们可以看到ViewModel和Controller事实上是处在幕后的,它们只需要实现纯逻辑代码即可,完全不需要关心在游戏中视觉上如何展示。正是因为不必关心具体的表现如何,所以ViewModel和Controller是具备移植性的。而在U3D项目中,View需要挂载在游戏对象上,同时它也是连接具体的游戏世界和抽象的逻辑代码之间的桥梁,通过View,uFrame将ViewModel和Controller与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
6.研究总结
uFrameMVVM架构无疑是十分简洁和易拓展的。它所使用的一些架构设计的思想十分值得我们学习和借鉴。例如利用依赖注入,使整个架构面向接口编程,因而具备了很强的拓展性。引入响应式编程的思想,实现了各个部分之间基于发布订阅模式的通信方式,更加消除了各个模块之间的耦合,使得代码易于维护和测试。最后,其整体逻辑架构也有一些Manager of Managers的思想,各个模块之间能够有效的管理和组织,使得基于该架构的游戏逻辑层次清晰。
但是,由于该插件提供的设计器要依赖Unity3D的Editor进行可视化操作,因此有可能会导致Editor方面的一些潜在风险,例如游戏内部系统过多会导致Editor的可视化区域难以管理,或者是我们在开发中对Editor的不当操作导致一些未知的问题。甚至由于是第三方提供的代码,因此uFrame的版本更迭可能会带来很多问题(1.5到1.6发生了很大的变化)等等。
因此,建议重点学习和掌握工具所提供的思想和设计思路。