MVC在游戏前端的应用,第一篇之问题篇

关于mvc的第一篇–问题篇

游戏前端的代码一般可以分为以下几类:

  • 保存服务器数据(也包括本地数据)的代码。
  • 逻辑代码(数据处理逻辑和业务逻辑)。
  1. 数据处理逻辑。界面要根据数据显示,而这些数据一般都是要经过处理或者重新组织的。
  2. 业务逻辑。例如,某个按钮的响应,可能需要检测系统是否开启、是否有其他系统的限制,材料是否足够、金币是否足够,然后再调用服务器协议向服务器发送数据等。业务逻辑,一般是多个步骤的结合,也很可能涉及多个模块。
  • 界面逻辑代码。
  1. 根据策划案和数据来展示界面。
  2. 接受玩家输入,并将输入转交控制器(controller)处理。

一、一般游戏项目的mvc职责。

mvc中的m指的是model,view指的是view,c指的是controller。mvc一般按模块划分,一个模块只有一个model,一个controller,和多个view。一般游戏项目的mvc的做法,会根据mvc的职责,将mvc的代码分开写。例如:

图1. 一般游戏项目的mvc职责

二、一般游戏项目mvc的几个问题和讨论

1、mvc的演变–mvc中的model和controller被合并了?

  • 在图1中,对于model的职责,不少项目都没有第2点的职责,model只是单纯的保存服务器的数据,另外一些项目可能会提供一些数据处理逻辑的方法供外部调用。

  • 当view显示的时候,需要数据,会有以下几种情况:a. 有些数据可能直接用model的数值就可以了;b. 而更多时候,数据要经过一定的处理才能满足策划案的需求;c. 数据也可能是需要来自其他模块的数据。这个时候数据的处理逻辑是应该写在model里面呢,还是应该写在controller里面呢?

  • 对于以上的b和c,数据处理的代码,有些会放到model里面,有些会放到controller里面。而对于同一个项目,不同的人,可能理解不同,有些人会放到model,有些人会放到controller里面,甚至放到view里面进行处理。

  • 大多数人的想法或做法趋向于,数据处理逻辑既可以放到controller里面,也可以放到model里面,而一般不会放在view里面。

  • 这说明了一个问题,大多数项目,对于mvc来说,只有代码的分离的功能而已,对mvc各自的职责只是做了一个大致的区分。

  • 基于以上理解上和做法上的混乱,有些项目后面就变成了model与controller合并了。也就是对于一个模块来说,以前的结构是一个model、一个controller和多个view,共三部分。而现在的结构变成一个model(或一个controller)和多个view,只保留了两部分了。笔者所经历或者换皮过的项目,就有两三个是后面这种结构的,究其原因,是某些主程认为mvc的作用只有代码的分离而已。如图:!

    图2. mvc的某种演变

2、是否有界面管理器(view manager)
笔者所经历的项目,有些有界面管理器,有些则没有界面管理器。

  • 有界面管理器的项目,根据功能将view抽象成一系列的派生类(基本view,多页签的view等)。界面的显示和关闭,由界面管理器控制。界面完全由界面管理器进行统一管理,每个界面都有一个对应的配置。
  • 没有界面管理器的项目,界面的显示和关闭,由模块内的controller控制。每个界面的创建和销毁,由controller的一个方法控制。
    笔者觉得第1种做法,是比较正确和简洁的做法。从依赖关系的角度上来说,有界面管理器的项目,只有view单向依赖controller,界面管理器对view只有弱依赖的关系(界面管理器,会持有view的引用,但并不依赖具体的view);而没有界面管理器的项目,view会依赖controller,而controller也会依赖view(controller负责具体view的创建和依赖)。

3、 不注重mvc之间依赖
model和controller一般被设计成单例,可以全局调用。如果不注意,这可能使得mvc之间的某些依赖关系的混乱,从而造成代码重用的困难。
例如,在view中随意的调用各个controller的方法。

class controller_a { //控制器a
    /**单例模式,省略了实现 */
    static get_instance() { }
    ca_func_a() { }
}

class controller_b { //控制器b
    /**单例模式,省略了实现 */
    static get_instance() { }
    cb_func_b() { }
}

class view_a {  //界面a
    v_func_a() {
        // ...省略其他代码
        controller_a.get_instance().ca_func_a();
    }
    v_func_b() {
        //...省略其他代码
        controller_b.get_instance().cb_func_b();
    }
}
  • 以上代码,你们是否看着眼熟呢?
  • 在上面这段代码,view_a依赖controller_a和controller_b,而且这种依赖分散在类view_a的各个地方。那么请问,view_a的代码还能怎么重用呢?
  • 假如现在有一个新的界面view_b,显示逻辑一样,但是不再依赖controller_a和controller_b,而是依赖controller_c和controller_d。如果view_b要重用view_a的代码,我们可以直接复制一份view_a的代码,类名改为view_b,然后再去改具体的代码。另外一个重用的方法,是view_b继承于view_a,然后只重写v_func_a和v_func_b的方法,这样能够重用一部分view_a的代码。
  • 对于上面的依赖关系,有一种改进的方法,是将依赖集中在view_a类的一个函数中,例如下面的initDep方法中。
class view_a {  //界面a
    controller_a;
    controller_b;
    constructor(){
      this.initDep();
    }
    initDep(){
        /**将依赖集中在当前函数中 */
        this.controller_a = controller_a.get_instance();
        this.controller_b = controller_b.get_instance()
    }
    v_func_a() {
        // ...省略其他代码
        this.controller_a.ca_func_a();
    }
    v_func_b() {
        //...省略其他代码
        this.controller_b.cb_func_b();
    }
}

那么对于前一种情况,view_b可以直接继承view_a,而controller_c和controller_d分别实现ca_func_a和cb_func_b方法。这样view_b的代码如下:

class view_b extends view_a {
  initDep(){
        /**将依赖集中在当前函数中 */
        this.controller_a = controller_c.get_instance(); //controller_c实现了ca_func_a方法
        this.controller_b = controller_d.get_instance(); //controller_d实现了cb_func_b方法
  }
}

如此,能比较大限度的重用view_a的代码,这样做的修改比较少。但最好的方法,应该是加入依赖注入的实践,笔者在后面会提出一种做法。

  1. mvc按模块划分。
    mvc一般按模块划分,一个模块只有一个model,一个controller,和多个view。这种做法,笔者认为也是不对的,很难达到代码复用的功能,mvc为什么不是按界面区分呢?笔者后面会提出一种按界面划分mvc的实践,能够更好的实现代码复用。

综上,大多数项目mvc,只是起到一个代码分离的功能,代码复用的功能很难发挥。在设计模式方面,它们只是加入了单例模式和观察者模式,但其实,我们还可以加入组合模式、策略模式、中介者模式,如果再加入依赖注入的思想,整个框架的复用性、健壮性等会得到很大的改善。

下一篇再见。

作者qq/wx:408293635
qq交流群:798568957
ps,作者正在寻找base广州的机会

5赞

不用这些架构的原因就是因为开发效率低,而且并不是每个人都有这个架构意识导致结构混乱还不如不用

1赞

对于模块, 我已经到了无所谓地步, 一般倾向于model , server, view, controler是啥(压根就不存在), 跨模块交互时,数据,状态都放在model,model是持久层,谁都可以访问,它最适合,

我们现在项目也是一个m和一个view使用

1.似乎你对controller理解的和我的不太一样,其实controller 最主要的作用还是给模块一个对外的接口,处理各种外部事件和协议响应,驱动数据模型进行业务,这样也不好说Model和Controller合并了。因为完全属于2个职责,合并不了。
Model: 模块数据模型和大部分逻辑
View:界面逻辑
Controller:模块对外接口,处理外部模块的全局事件,处理本模块的协议回调。也包括你说的model里面的向服务器发送协议。
实际上独立Controller的原因就是Model的数据模型和逻辑,需要和外部隔离,保持本模块数据模型的逻辑内聚。
至于为什么老是在Controller里面保留view的引用或者持有view,是因为view打开/关闭的事件,属于跨模块的全局事件,例如点击icon,发送openxxview的事件,再加上controller有统合模块对外的作用,索性就把view给controller引用了,早期的没有view manager的时候,也是controller拥有view。
你的Controller理解其实算是ViewController了,不算模块的总Controller。所以这种可以和model合并(其实更加靠近view),也可以像你说的单独界面controller。但总Controller就只能一个模块一个了。

2.关于ViewManager的,我建议还是需要,最主要的作用是View管理在某些情况下太过分散导致逻辑复杂,例如这样的逻辑:关闭所有Popup层的界面,对界面的Cache处理等,有的功能没有ViewManager是没法很好处理的。依赖关系确实有改善,但是属于稍微次要的原因。

3.代码复用相关,其实现在的业务代码,代码复用的级别基本上都是一些基础函数了。那些应该提到基础库,组件库,或者专门的utility里面去。模块之间最好少复用业务逻辑代码,因为每个模块的需求不一样,在修改的时候,代码复用的越多,牵连的范围越广。

1赞

这种大驼峰写法,IDE 不会警告吗

引入的对象越多,对象间的“沟通”成本越高,就越复杂。
多人协作,就要引入多种概念,这样可以把分工细化。但个人开发讲究效率。Component + Manager 模式其实最好。界面复用的地方其实很少,除了基础的一些小部件,所以别老想着复用。你这款游戏都不赚钱,想着给下款复用?

2赞

一千个读者就有一千个哈姆雷特。
每个人对于mvc的理解和用法多多少少都有不一样的地方。
我个人的看法是:不要纠结于各种条条框框的,有时候规则就是用来打破的。不管代码怎么写,自己用的顺手,别人易读,改起来不会乱七八糟的,性能不会有太大的额外消耗,基本上就可以了。

引入的对象越多,对象间的“沟通”成本越高,就越复杂。

这里随便抬一下杠。
关于复杂度,其实不是对象越多越复杂,而是对象之间责任混乱导致的复杂。举个例子,例如一个Player 20000行复杂,还是Player 有10个组件,每个组件2000行复杂?
对象责任单一,划分清楚,代码就会变得更加简单,这个就是好的架构要做到的一点。在好的架构下,需要修改或者增加功能的时候,可以立即确定在哪里加最合适,最好有一个唯一的地方比较合适,这样代码就比较简单了。
一般来说好的架构都是迭代重构出来的,不过mvc这种老是出现的架构模式,基本上就不需要迭代直接就可以用上了。

1赞

【对象多是引起复杂度的一个重要维度】这个本身没错。
你说【责任混乱是导致复杂的另一个维度】那也没错。
但你要说,因为责任混乱是主要原因,因此对象多不是引起复杂度的维度。那肯定是你错了。你的论证方法出了问题。

我讨论的前提是责任划分清晰的情况下讨论模式。2 个对象,在沟通成本上绝对是低于 3 对象。逻辑也更简单。
你要说单文件里的代码量问题。那是你 manager 的划分维度问题。不是模式问题。

我现在就是单文件开发,如果严格遵守组件式开发(一个子功能一个组件,例如列表的 item),其实代码最多也就500-700行左右。偷懒那就另说了

很多情况下,责任清晰代表着对象内聚,而内聚的对象一般负责单一的责任,按责任划分本来就会产生不少中间对象,这些中间层都是有意义的,很多设计模式就是从这里来的。
所以中间对象多了,责任清晰了,按你说的沟通成本增加了,但整体复杂度却降低了。例如面向接口编程,为什么要抽象一个接口呢,就是减少对实际实现的依赖,降低替换实现的复杂度。

我指的复杂度是工程的复杂度,是阅读代码的清晰度,以及添加修改功能可以明确知道位置的清晰度。不是说文件少类少就不复杂了。
如果看过一些游戏的Player,一个文件一大坨代码,一大堆变量纠缠在一起实现状态机,就知道什么是复杂的代码了。所以要按责任拆组件和划分状态机,但一拆对象数量就多了,那是不是更复杂了呢?
不过这个本来也就是看项目,如果本身工程复杂度不高,责任聚合在一起能看懂,那为了方便,聚合也可以。还是举player状态机为例,一个状态变量 + 每个状态的逻辑,如果全聚合到Player本身,但是按函数划分的清晰,那也是可以用的。一般架构都是迭代的,如果感觉控制不住了再抽都是可以的,不过像MVC或者组件这种模式一般是根据经验会先抽出来。

编辑:借楼再补充一下另外的,关于MVC的。
MVC这个架构,还只能是做UI的时候才能迭代出来,做核心玩法(战斗)的时候是迭代不出来MVC的,因为核心玩法(战斗)的MV是结合的比较紧密的,特别是在复杂的战斗玩法中,V中可能带有M需要的逻辑数据,例如规格规范常量,动画事件,碰撞等。所以一般核心玩法(战斗)不会用MVC,都是把MV结合一起做一个场景Object(顶多把V抽出来做个单独组件),并且由聚合/Manager进行管理。(不过有特殊的例子是需要服务器跑战斗的,那只能把V内的数据抽出来做抽象)
而UI就类似于应用程序开发,就要抽MVC了,UI我试过不抽MVC就做不下去,已经习惯了数据和界面分离,模块分离了。

不如直接用PureMVC,commad事件走天下

从来没有觉得游戏的界面适合MVC
1.model、view、controller 三层解耦合,在游戏开发过程中,基本上没有复用的情况。
2.界面通过事件通信,读代码,跟代码的时候,没法直接点击跟代码,断点也不方便。
3.一个简单的逻辑,多个文件管理,中小团队,不会有人只负责其中的某一层,经常照成很多不必要的代码和文件,无法享受到多层级代码,多文件带来的,不造成冲突的优势。
4.团队成员水平参差不齐,很容易有人瞎几把写,把本来mvc写的重度耦合,你特么还得花大量时间给他搽屁股。
所以,我在7年就彻底放弃MVC,中间尝试了MVVM,MVP。最后发现,就保留MV两层觉得够了。开发效率也时最高的。

1赞

游戏代码是屎山最多的一类工程代码,知道为什么吗?

很容易有人瞎几把写

这个也深有感触,针对瞎几把写的情况,一般除了规范一下以外,主要是检查模块对外接口Controller。
非关键模块,只要对外接口(事件处理,协议等)没问题,性能没问题,不要影响到其他模块。管里面怎么写都行。擦屁股耗时费劲还不讨好。

同意123

问题是怎么划分到 M, C 这些都是人工去做的且没有判错机制导致不同的人很容易写混

我这边的Controller就是模块对外接口。对于我这边的MC分离,判断的标准比较简单,逻辑是否和外部相关。
监听外部事件,外部事件handler,协议handler,openView Hander,对外直调接口,跨模块Model协同业务等,都是controller的。由controller再调model的多个接口完成数据处理。
其他内部数据和逻辑都是Model的,比较内聚的领域逻辑。

所以我这边检查就检查对外接口都在Controller就可以了。就很快可以看出来有没有问题。

所以MC并没有一个统一的标准判错

用你的方式,就像处理逻辑我依旧可以写在M,要加接口的时候再搬到C或者C直接调用M的接口,导致M和C的职责混乱

就连V也一样可以把数据和逻辑写在V里面,C调用V,V处理全部的逻辑,你也无法避免