文/ Mitty

转载自微信公众号“游戏扶持by腾讯游戏学院”

本文参与“Unity游戏架构”征文活动,作者为Mitty。这篇文章分为两部分,第一部分是理论,解读PureMVC框架原理;第二部分是实践,通过一个简单的例子,演示如何在Unity中应用PureMVC框架。文章的内容很长,坚持看完,一定会有所收获的。

一、理论

我一直认为,框架的使用,需要你工作一段时间以后再去接触会比较好,就像孩子踢球一样,刚开始可以随心所欲的踢,没有什么中前、中后场的概念,球在哪儿,就一股脑儿的追上去抢,几十双脚噼里啪啦的,其实,这就是大多数人刚开始做游戏开发时候的状态,没什么框架,代码硬怼,不注重性能,扩展性,重用性等等,BUG层出不穷,只要功能出来就好,慢慢的,随着开发经验的不断丰富,你就会开始思考,当下做的这些事儿,有没有更方便,更有效的方法。

这时候你会开始注意到,“技战术”的作用有多么的重要! 说点儿题外话,如果你在工作中,碰到技术很好的大神,而且又乐于分享,一定要多向他们请教,因为你真的可以少走很多弯路,并且能够更快的提升自己,没有什么比时间更有价值了,这是我一直以来都梦寐以求的,也许是我运气不太好,也许是我的工作历程和很多人都不同的缘故......扯得有些远了,无论是有经验的大神告诉你的,还是你通过搜索引擎查找,你都会听到MVC这个框架,这是个诞生了将近40年的经典框架。

没有完美的框架,MVC也是,在MVC的基础上,又演变出来了MVP,MVVM,但在本篇文章中,我将只介绍MVC框架的原理和应用,理解了MVC框架,将会更容易的理解MVP,MVVM,甚至是一些框架的设计,比如ECS。

那么简单说,MVC (Model:数据;View:视图组件;Controller:控制逻辑)的职责是:将数据,视图组件和控制逻辑进行分离。让程序便于修改,更具有扩展性,灵活性,可重用性。高内聚,低耦合,一直是追求的目标。

但是传统经典的MVC模型虽然将数据,视图组件和控制逻辑进行了分离,但耦合性还是比较高,所以就有了今天要说的PureMVC。它在传统MVC基础上做了许多的改进,通过结合多个“设计模式”的应用,让耦合性变得更低,也变得更加的易用,在扩展性、灵活性、重用性方面也做得更好。

设计模式的存在,其实很重要的一个职责就是解决耦合性。PureMVC用到的这些设计模式,贯穿了整个游戏框架,即便你项目中使用的不是MVC框架,你都离不开这些设计模式的应用,下面是PureMVC中使用到的设计模式:

1.代理设计模式

2.中介者设计模式

3.外观设计模式

4.观察者设计模式

5.命令设计模式

6.单例设计模式

直入主题:

这是PureMVC的官方网站。

132432hunxyf3w7nigruig.png

可以看到,PureMVC支持了大部分的主流语言,你可以很容易的在项目中引入PureMVC。

132433i8waozvwatgajw42.png

这里是Best Practises,实践手册,提供了6种语言版本,中文版本的翻译还可以,能表述出来核心思想就够了。

132434tvxmnshpmghpcupn.png
从上面的截图可以看到Model和Proxy的关系图。

Model即数据(Data Object),游戏是基于数据驱动的,比如角色的数据,包括HP、MP、金币、等级、经验、技能等等,在PureMVC中,是通过Proxy来进行管理,Proxy即代理设计模式,“为对象提供代理以控制该对象的访问”,即代理人,在PureMVC中被用来控制和管理数据模型的。

*Data Object即是以任意结构存储数据的对象。

也就是说,我们不会直接和Model通信,对Model的增删改查均是通过Proxy来处理的。

关于Proxy代理模式,比如球星-C罗,他的Proxy代理就是C罗团队,有什么商业合作事宜均通过C罗团队进行接洽,这样,C罗就不需要一个人去面对来自四面八方的合作沟通成本(降低耦合性),同时,团队也可以帮助C罗处理很多的事务,不需要每件事儿都要经由C罗的过目(一定程度上隐藏了其内部的实现细节),从代码角度,也满足“改变一个不影响”,我对部分数据的修改,不应该影响到的数据。

我们继续看上面的示意图,看下Model的箭头,他只和Facade进行交互。上面提到过,这是为了降低耦合性。

旁边的一众Obj即Model,对应着Proxy,但并不是一个Model对应一个Proxy,如果是这样,就太繁琐了,比如一个模块中,可能包括很多种不同的Model数据,你可以定义多个不同的Model,但可以通过一个Proxy进行管理,这样更方便。

通常会以同步的方式取得或设置Model数据。Proxy 可能会提供访问 DataObject 部分属性或方法的 API,也可能直接提供 Data Object 的引用(但不建议这样做,我们应该保护Model,提供相应的接口来访问)。如果提供了更新 Data Object 的方法,那么在数据被修改时可能会发送一个 Notifidation 通知系统的部分。

这里Notification通知,其实就是观察者模式,当一个对象发生改变的时候,同时也需要有很多的对象要对此做出响应,这时候就要使用观察者模式了,发布-订阅的模式,比如我们订阅了某个微信公众号,公众号发表了一篇文章,所有订阅的用户都可以收到提醒,这在游戏中无处不在,当Model发生变化的时候,通知View组件进行更新。那么在View中,就会有相应的方法来处理Notification通知,并进行相应的逻辑处理。

Proxy只发送Notification通知(在数据改变的时候),他并不处理Notification通知,他并不关心View组件如何变化。

Proxy 对象不应该通过引用、操作 Mediator 对象来通知系统它的 DataObject(数据对象)发生了改变。

也就是说,Mediator可以获取Proxy,但Proxy中不应该获取Mediator,如果要通知View层进行更新,发送Notification通知即可。(Proxy 不关心这些 Notification 被发出后会影响到系统的什么。)

这样Proxy和Mediator之间只存在单向耦合。

Proxy中也包含了一定的逻辑处理的部分,我们把 Domain Logic(域逻辑)尽可能放在 Proxy 中实现,这样尽可能地做到 Model 层与相关联的 View 层、Controller 层的分离。

比如计算扣税的函数,如果你将他放在Mediator或是Command中实现,那么就相当于把这部分代码耦合了,比如你的View要重建,或是别的Command也要使用该扣税函数,那么这部分代码就无法得到复用,所以放在Proxy中是更为合适的。

04、关于数据类型的转换

因为 Model(Data Object) 通常是一个复杂的数据结构,我们经常需要引用它的一部分属性并将类型转化成我们需要的数据。

通过getter 和 setter 属性,它可以很好地帮助我们解决这种频繁的类型转换问题。

可能需要定义不同的多个类型 getter 来取得 Data Object 某部分的数据。

132435j3yemioyicfyyxfi.png

这里可以看到,通常View和Mediator是一对一的关系,但有些View会相对复杂,有多个子UI组成,Mediator中也可以有多个View Component引用(同一功能的不同子UI)。

但如果Mediator过于庞大,就要进行拆分,在拆分后的子模块的 Mediator 里处理要比全部放在一起更好。这部分工作需要慢慢的重构。

06、转化 View Component 类型

(这部分和Model是一样的处理方式。)

这个 Mediator 子类会在构造函数中把它的 View Component 传参给父类,它会在内部赋值给一个 protect 属性:viewComponent,并传化为Object 类型。

Mediator 被构造之后,你可以通过调用它的 setViewComponent 函来动态给它的 View Component 赋值(修改)。之后,每一次需要访问这个 Object 的 API 时,你都要手动把这个 Object转化成它的真正类型。这是一项烦琐重复的工作。

和上面的Model一样,Mediator中保存了View的引用,我们最缓存下来。

Mediator通常要做的事:

1.检查或修改 View Component 的属性

2.检查或修改 Proxy 对象公布的属性

3.发送一个或多个 Notification ,通知别的 Mediator 或Command 作出响应(甚至有可能发送给自身)。

但要注意一点,业务逻辑(Business Logic)应该放在Command中,而非Mediator中!

实际上Mediator并不处理复杂的逻辑。像Model那样,域逻辑的部分,可以放在Mediator中实现,减少与Controller的耦合性,也提高了重用性。

注意:不要将检测或是对VC(View Component以及Proxy)属性的修改当作是业务逻辑(Business Logic)

下面是一些有用的经验:

1.如果有多个的 Mediator 对同一个事件做出响应,那么应该发送一个 Notification,然后相关的 Mediator 做出各自的响应。(观察者模式)

(比如说,你当前屏幕上显示了3个UI,每个UI上都显示着玩家的金钱数量,当金钱发生变化的时候,Proxy应该发送一个相应的Notification通知,然后3个UI接受通知并进行View的更新)

2.如果一个 Mediator 需要和其他的 Mediator 进行大量的交互,那么一个好方法是利用 Command 把交互步骤定义在一个地方。

3.不应该让一个 Mediator 直接去获取调用其他的 Mediator,在Mediator 中定义这样的操作本身就是错误的。可以看上面的概念图,Mediator和Mediator之间不会直接进行通信的,这样就违背了降低耦合性的初衷。

当一个View的改变会影响到另外一个View组件,发送Notification通知即可。

4.Proxy 是有状态的,当状态发生变化时发送 Notification 通知Mediator,将数据的变化反映到视图组件上。

将这些多次使用到的“请求”,通过command实现,使之更加的独立,提高重用性。

07、Proxy设计模式 vs Mediator设计模式?

前面提到的两个设计模式,两者所做的事情非常的相似,但定义上,Proxy更侧重于控制数据的访问,相当于真实数据的代表,而Mediator则更侧重于数据的交互(封装了一系列对象的交互),强逻辑,比如AB之间交互的中间人,那么对于UI的交互是相对复杂繁琐的,所以使用Mediator来负责处理View上的操作。

在《大话设计模式》中举了个蛮不错的例子来说明Mediator,即联合国,类似的还有环境保护组织,我们日常能接触的房产中介,负责房屋的勘察,审核,买卖,缴税,过户等(交互,强逻辑)工作。

如果直接让我们和房东联系,很多不懂的知识外,还有法律上的风险。

另一个例子,4S店,我们买卖,售后等都要去4S店进行处理,在Mediator中,A和B进行交互,A和B都“认识”Mediator中介者,我们去找4S店,4S店负责和汽车的生产商沟通。

08、什么是Controller 与 Command ?

Controller保存了所有Command的映射,Command 类是无状态的,只在需要时才被创建。

这里使用到了Command命令设计模式,即将一个“请求”,“行为”封装为一个对象,将逻辑的部分进行独立封装,提高复用性,对View或Mediator的修改也不会影响到Command本身。通过Facade顶层接口,可以在Proxy、Mediator、Command之间,相互访问和通信。

Command 可以获取 Proxy 和Mediator对象并与之交互,发送 Notification,执行其他的 Command。经常用于复杂的或系统范围的操作,如应用程序的“启动”和“关闭”。应用程序的业务逻辑应该在这里实现。

Facade 需要在启动时初始化 Controller,建立 Notification 与 Command的映射。

Controller 会注册侦听每一个 Notification,当被通知到时,Controller 会实例化一个该 Notification 对应的 Command 类的对象。最后,将 Notification 作为参数传递给execute 方法。具体可以参考Command基类的实现。

也就是说,Command的执行是通过发送Notification通知操作的。

Command 对象是无状态的,只有在需要的时候( Controller 收到相应的Notification)才会被创建,并且在被执行(调用 execute 方法)之后就会被删除。所以不要在那些生命周期长的对象(long-living object)里引用 Command 对象。

在运行中,可以通过Command来初始化Proxy和Mediator,即注册到Facade中。

比如:

132436peyby66sge586aie.png

例子的规则:

程序启动后,随机12个道具,玩家点击“随机获得”按钮后,会从12个道具中随机出来一个,然后更新游戏次数和游戏总奖励(随机出来的道具价格累加),并弹出奖励窗口,显示具体获得的道具信息,点击Back按钮返回后,重新随机道具池。

首先,从View组件上,我们需要2个View,一个是上面截图的View,我定义为MainPanelView:

MainPanelView包含了如下UI元素:

1.一个随机道具列表(这里,每个随机道具其实都是一个View,但我们并不会为此对应创建一个Mediator,而是放在MainPanelViewMediator中统一管理)

2.“随机获取”按钮

3.游戏次数的标签

4.奖励金额的标签

第二个View是显示游戏的奖励窗口,如下图:

132436b883iqhupzu4a1tq.png

这个View的内容更简单,一个显示奖励的Text文本,一个返回到MainPanelView的按钮。我定义为RewardTipView.

那么现在,我们有两个View组件:

MainPanelView

RewardTipView

View和外部的通信我们都是通过Mediator来负责的,那么我们也需要创建2个Mediator类:

MainPanelViewMediator

RewardTipViewMediator

这两个Mediator中分别定义了View的引用。

这里,我们将MainPanelView和RewardTipView制作成两个Prefab预设,并进行动态加载。然后定义

MainPanelView.cs

RewardTipView.cs

两个脚本附加在预设上,绑定我们需要“操作”的控件。

如图:

132436f7b4aacoioozr6kc.png

132436drya16vb86qr1u6b.png

在MainPanelView中,包含了12个随机的道具,我定义为BonusItem,我们事先创建好一个BonusItem对象并隐藏,在实际生成中,我克隆BonusItem即可。我新建了一个BonusItem.cs 挂在它上面,初始化需要“操作”的控件。

132437zrhubhwutzthubie.png

View和Mediator部分就完成了。

下面,我们再看看Model和Proxy,即数据的部分(Data Object)

首先,我们要随机12个道具,每个道具的数据结构很简单,只包含三个字段:

1.ID

2.名称

3.价格

所以,我们定义Bonus的Model如下:

132437v2l032h92wh9lits.jpg

(Tips:如果表示纯数据类型的Model,建议声明成struct,内存开销更小。)

Model定义完成以后,在对应的Proxy中,我们定义Model的引用,在这里,我们维护一个BonusModel的列表,动态的生成,刷新,以及相关Model的操作,我们均在Proxy中实现,对应的Proxy,定义为BonusProxy。

但只有BonusModel还不够,我们还需要保存玩家的数据(游戏次数和奖励金额),所以,还需要定义另一个Model来保存他们,这里我定义为PlayerDataModel:

定义如下:

132437rhqs4yqhppqtpop5.jpg

对应的Proxy,定义为PlayerDataProxy,负责对PlayerDataModel的操作。

现在,Mode、View部分都完成了,下一步就是分析Controller(Command)逻辑控制的部分了。

逻辑控制部分有以下几种行为:

1.初始化

启动程序后要做的工作,我们通过Command命令来做一些初始的操作,操作有:

(1) 加载MainPanelView,并注册绑定Mediator

(2) 发送Notification通知“开始随机12个道具”,这个行为我封装在另外一个Command中

初始化操作的Command,我们定义为StartUpCommand:

132437mz46z5geemexm6me.jpg

StartUpCommand 中,首先创建MainPanelView UI组件并完成Mediator的绑定。初始化完成以后,SendNotification (MyFacade.REFRESH_BONUS_ITEMS);

发送REFRESH_BONUS_ITEMS通知,开始随机12个道具。

步骤(2)是发送Notification通知“开始随机12个道具”(REFRESH_BONUS_ITEMS),这部分业务逻辑不适合放在StartUpCommand中实现,因为玩家玩过一次后,要重新再次随机12个道具,所以这部分的业务逻辑要复用,那么我们将他定义在另一个叫RefreshBonusPoolCommand中实现,专门用于重新随机12个道具。(代码就不贴了)

2.开始随机

并完成初始化操作以后,我们现在就可以点击“随机获取”按钮,开始玩了。

点击按钮以后:执行一个Command(即发送Notification通知),这里定义为PlayCommand,在PlayCommand类中,生成一个随机数(0-12),然后通过随机数,获取PlayerDataProxy中保存的BonusModel数组指定引用,再获取PlayerDataProxy,修改玩家的数据(游戏次数,奖励金额)。

代码如下:

132438ncwnafh2bmuyo627.jpg

在PlayCommand中,获取随机到的道具信息,然后再获取PlayerDataProxy ,完成数据更新操作。

在上面的代码中:

132438r6jiv1sr17v19zvz.jpg

GetReward 方法中,我们传入随机生成道具的奖励金额和道具名称,那么在GetReward方法中要做哪些操作呢?

更改PlayerData数据,然后发送通知,让MainPanelView UI组件进行更新。

代码如下:

132438mcdn1hjz448thr94.jpg

我们发送了一个UPDATE_PLAYER_DATA通知,并将info+reward拼接在一起,传递出去。

谁会接受UPDATE_PLAYER_DATA通知?

1)某个View组件

2)某个Command

在这里,只有MainPanelView View组件会接受UPDATE_PLAYER_DATA通知,它是如何配置的呢?

我上面并没有贴出MainPanelViewMediator的代码,大家可以参考github上的查看。

在Mediator中需要override一个父类的方法:

132438lotnr6dxttg057n5.jpg

在List中加入我们要进行的所有事件。

然后我们在MainPanelViewMediator中,还要override另外一个方法:

132439d5f6mt67f75jiq74.jpg

在HandleNotification 完成UI的更新后,看看最后一行代码:

132439wkzb2y40yvv8zvp9.jpg

我又发送了另外一个Notification通知REWARD_TIP_VIEW,这是启动了另外一个Command,用于弹出奖励窗口,并显示奖励信息。

代码如下:

132439y1eo7iogooooqkwa.jpg

在RewardTipCommand 当中,我们要先判断我们是否创建过RewardTipView组件,如果没有,则通过Resource.Load进行加载,并绑定Mediator。

在代码的最后一行:

SendNotification (MyFacade.UPDATE_REWARD_TIP_VIEW, notification.Body);

我发送了另外一个Notification通知,这里是为了演示功能,因为你可以直接调用Mediator来进行UI更新,notification.Body就是我们传递过来的奖励信息。

在RewardTipViewMediator中,注册UPDATE_REWARD_TIP_VIEW通知:

132439g4oicxrr3laxgk9m.jpg

并完成内容的更新。

最后一步,我在RewardTipView UI组件上点击Back按钮时,要发送一个Notification通知,让我们重新再次随机12个道具,这个行为已经封装到了一个Command中了,前面提到的RefreshBonusPoolCommand。

132440ev8wcv784y7rryc9.jpg

这样,例子的整个流程就都走完了。

现在,看看我们定义了哪些类:

Model和Proxy:

BonusModel->BonusProxy

PlayerDataModel->PlayerDataProxy

View和Mediator:

MainPanelView->MainPanelViewMediator

RewardTipView->RewardTipViewMediator

Controller和Command:

PlayCommand

RefreshBonusPoolCommand

RewardTipCommand

StartUpCommand

上面只是讲到了这些类的职责,但我如何绑定Proxy、Mediator、Controller? 如何在Mediator中获取Proxy,又如何在Command中同时获取Mediator和Proxy呢,如果你还记得上一部分讲到的Facade的话,它就是用来管理上面我们增加的这些“类”的。

(当然,我们需要创建一个自定义的Facade来做这些操作。)

在使用之前,我们需要通过Facade进行注册绑定,将他们保存在对应的哈希表中,绑定的方式有几种?

1.直接在自定义的Facade类中注册绑定:

在Facade基类中有三个virtual方法:

InitializeModel();

InitializeController();

InitializeView();

通过函数名就可以知道,分别是初始化Model、Controller、View的,这也是代码的执行顺序,View是基于数据驱动,总是要在最后才完成初始化。

在自定义的Facade中,我们可以将Model、View、Controller的类在这些方法中完成绑定。

如下:

132440dqm6m7h116tqpqfh.jpg

通常在游戏开发的过程中,我不会在初始化的时候,就把所有用到的UI都加载到内存当中(当然,UI不多的情况是可以的,在例子中我们也可以这样做),所以采用动态加载的形式,比如放在Command中。

2.通过Command进行注册

实际上,我们总是要这样去使用,我们总会有这样的需求,要动态的加载和释放一些Model、View和Controller,比如游戏战斗中的相关数据,你在启动的时候就加载,显然是不合理的,比如我们在战斗Loading的时候,通过Command来注册战斗中需要的所有Model、View、Controller,在退出战斗回到菜单的时候,我们也要通过Command来Remove掉那些Model、View和Controller(Facade中提供了注册,移除,获取等等方法)。

下面是Facade自定义类的代码:

132440waxj4o26go47sau2.jpg

在最上面,我们定义了一些常量:

132441ndr0alhnddrhdxll.jpg

这些常量来自于Event和Notification,这些建议放在一个单独的常量类中定义。

如何启动PureMVC框架?

PureMVC和MonoBehaviour是无关的,所以,我们需要启动PureMVC框架,来执行StartCommand命令。

可以简单的创建一个继承自MonoBehaviour的类:

132441i1i5u91qm1ml9q5m.jpg

在Launch方法中,即是发送了一条通知:

132441y8431h0sh2ju93u1.jpg

执行StartCommand命令。

这样,整个PureMVC就运转起来啦!

Demo结构图示:

132441aee1jjsmjim89csv.jpg

最后,说说PureMVC在使用过程中,需要注意哪些地方?

1.装箱和拆箱

发送Notification通知时,赋带的参数是object类型的,如果你传递的实参是值类型,比如struct,那么就要注意装箱过程带来的性能消耗,每一次装箱的过程都要在拖管堆中进行内存分配有字段的复制,如果传递的数据比较大,建议使用引用类型。

2.反射

反射是非常好用的功能,但却要付出性能的代价,他要在程序集中进行搜索,在上面的例子中并没有提到反射,因为他足够简单,但在实际开发中,我们通常要有一个UIManager类来统一的管理UI的创建,删除,显示,隐藏等等操作,我们通过定义的一些枚举来加载指定的UI,问题是如何去动态的绑定UI对应的Mediator(我们不考虑switch这种做法),Class本身不能作为参数,所以有的代码是通过反射的形式来动态获取类型的特征,完成类型的创建并绑定。

比如:

现在有一个UIViewA组件,对应的Mediator就约定为 “View组件名称”+Mediator(也可以自行配置,这里主要是方便使用),所以UIViewA对应的Mediator就是UIViewMediator,有了Mediator的字符串名称,通过反射查找到具体的类型特征,创建并绑定,使用起来很方便,虽然这种情况下使用反射对性能影响不是很大,但勿以善小而不为,性能开销的地方要尽量的避免,反射可以通过将类型信息存储在字典中来代替。

但一定要注意在释放时,也要从字典中删除掉。

3.Proxy、Mediator、Command创建

在使用的过程中会发现,Proxy、Mediator、Command每次的创建,重复添加一些要override的方法,定义相关常量,绑定等等操作会有些繁琐,建议使用模板生成Template Generator和代码片断snippet这两个可以提高开发效率的工具,让这些繁琐的工作变得更加的快捷方便。

好啦,PureMVC的解读到此结束,感谢阅读,如文中有误,欢迎指正。


锐亚教育

锐亚教育,游戏开发论坛|游戏制作人|游戏策划|游戏开发|独立游戏|游戏产业|游戏研发|游戏运营| unity|unity3d|unity3d官网|unity3d 教程|金融帝国3|8k8k8k|mcafee8.5i|游戏蛮牛|蛮牛 unity|蛮牛