在GUI应用开发中,开发者关注的重点模块主要有3个:UI的展示
UI和用户的交互逻辑
业务逻辑
在早期,人们还未针对这种开发场景做优化时,可能习惯于将这三者杂糅在一起。虽然开发起来很快,但是后期维护麻烦,对于大型项目也不利于多人并行开发。 到了70年代,挪威计算机科学家Trygve Reenskaug在SmallTalk-76中引入了一种新的模式,MVC,确切说因该是MVC-Model-1,因为后来为了适应Web开发,该模式又衍生出了MVC-Model-2,也就是我们现在后端常用的各种MVC框架。如果没有明确说明,下面所说的MVC指代的都只是MVC-Model-1
MVC(Model-View-Controller)
MVC贯彻了SoC(Separation of Concerns),将UI展示抽为View层,业务逻辑抽为Model层,UI和用户的交互逻辑则抽为了Controller层。 3者的交互流程大致如下:MVC模式View层订阅了Model的修改事件,同时将用户交互代理到Controller上
Controller对交互做一些处理,如果有需要,修改Model数据
Model层数据修改之后,通知View层响应更改
优点关注分离,并且交互逻辑基本都抽到了Controller,对于Model层来说可以提高代码的复用性
分模块之后各个模块可以并行开发
View的更新只能来自于Model的修改,单向的数据流提高了可回溯性
缺点View层过度依赖Model层,不利于测试和代码复用
Controller逻辑过重,不利于后期维护
Controller层依赖View,不利于测试
MVP(Model-View-Presenter)
MVP是MVC模式的一个变种,主要提高了代码的可测试性。
MVP中View需要定义好一个抽象接口,View和Presenter都需要依赖这个抽象接口来开发,所以Presenter就能通过自行mock一个View对象来提高可测试性。
同时,Presenter作为衔接View和Model的桥梁,负责修改Model以及更新View,这样就可以移除View对Model层的直接依赖,进一步提高了View层的可测试性。
MVP的交互流程如下:MVP模式
优点分离View和Model,提高代码可复用性和可测试性
为View和Presenter定义明确的接口,利于View和Presenter测试
缺点View的更新全由Presenter来维护,会导致presenter过于庞大
MVVM(Model-View-ViewModel)
最后一个就是老生常谈的MVVM了。最早(不一定准确)应该是由我软在2005年提出的,随着WPF和SilverLight被发扬光大(其实死的很早)。相比较于MVP,Presenter被换成了ViewModel。
从View层考虑,ViewModel其实就是View的一个抽象,包含了他所需的数据和行为。 从Model层来看,ViewModel又像是一个值转换器,将Model转换成View层需要的一种格式,所以有时候MVVM又被称为Model-View-Binder模式。
借助于Data-Binding技术,MVVM完成了Model和View的双向绑定,也不再包含对View层的依赖,让开发者可以更专注于和状态和行为的管理。
在我软技术栈上,Data-Binding主要由XACML来实现,而前端的主流框架,例如React,Vue则是借助于单向数据流+V-DOM diff实现类似效果的。
优点省去Presenter中Model和View之间的手动同步,移除了对View的依赖
代码更简洁,可读性更强
缺点在大型项目上MVVM的性能可能会有问题
下面就让我们假设一个场景来实践一下MVC和MVP 2种模式。假设做一个很简单的界面:
顶部是一个下拉框,用于选择歌手;底部是一个专辑列表,显示当前歌手所有的专辑列表。
在实践上述2种模式之前,我们需要做一些准备工作:示例的语言选用Typescript(本来想用C#,无奈已经忘得差不多了...)
不管MVC、MVP,都涉及到单向的数据流动,实现方式很多种,在这里我就采用了观察者模式
最后开始之前,郑重声明,代码不一定能正常跑起来(<_>
首先贴一下简陋的观察者模式(类似C#事件)实现:
export class UIEvent {
subs: Array>;
constructor() {
this.subs = [];
}
on(cb: UIEventHandler) {
this.subs.push(cb);
}
off(cb: UIEventHandler) {
let index: number = this.subs.findIndex(handler => handler === cb);
if (index > -1) {
this.subs.splice(index, 1);
}
}
emit(args: T) {
this.subs.forEach(handler => {
handler(args);
});
}
}
通过on/off来添加或者移除订阅,通过emit来通知订阅。在这里我把每个UIEvent的回调形式都固定成了如下形式:
export type UIEventHandler = (args: T) => void
接受一个派生于UIEventArg的入参。
同时定义一个下拉选择事件参数的类型:SelectEventArg,后面订阅View的下拉事件会用到
export class SelectEventArg implements UIEventArg {
target: object;
name: string;
}
然后我们的数据格式也定义了,比较简单:
export interface IRecord {
name: string;
date: string;
author: string;
}
export interface IAuthor {
name: string;
age: number;
}
还有需要对2个控件做一个简单的抽象:
export interface IDropdownList extends IControl {
selectedEvent: UIEvent;
data: Array
}
export interface IList extends IControl {
data: Array
}
其中data属性表示控件的数据源,我们可以认为设置了之后控件会自动绑定属性,然后具体的类型由使用者来确定,所以设计成了泛型类。
下拉菜单接口还有另一个属性:selectedEvent。这就是刚才说的观察者模式的应用,事件会在选择项改变的时候触发。
至此,我们的准备工作都已经介绍完了,就要进入正题了。
先实现一下MVC模式:View层的主要功能就是展示,将交互转发到Controller,同时订阅Model层的数据变化,实现如下:
export class View {
private recordModel: ObservableRecords;
private authorModel: ObservableAuthors;
private dropdownList: IDropdownList;
private list: IList;
selectChange: UIEvent
constructor() {
this.recordModel.changeEvent.on(this.bindRecords);
this.dropdownList.selectedEvent.on(this.onSelectChange);
this.dropdownList.data = this.authorModel.getAuthors();
this.list.data = this.recordModel.getRecords();
}
bindRecords(args: RecordChangeEventArg) {
this.list.data = args.data;
}
onSelectChange(args: SelectEventArg) {
this.selectChange.emit(args);
}
}
2. Model相对简单一些,除了数据处理之外,还需要是一个可以被观察的对象,让多个View可以同时订阅。
export class ObservableRecords {
private static data: Array;
changeEvent: UIEvent;
getRecords(): Array {
return ObservableRecords.data;
}
getRecordsByAuthorName(name: string): Array {
return ObservableRecords.data.filter(r => r.name === name);
}
updateRecordsByAuthorName(name: string): void {
this.changeEvent.emit(new RecordChangeEventArg(this.getRecordsByAuthorName(name)));
}
}
export class ObservableAuthors {
private static data: Array;
getAuthors(): Array {
return ObservableAuthors.data;
}
}
3. Controller层依赖了View和Model层,因为需要响应View层的事件,同时又要修改Model层数据:
export class Controller {
private recordModel: ObservableRecords;
private view: View;
constructor() {
this.view.selectChange.on(this.updateRecords);
}
updateRecords(args: SelectEventArg) {
this.recordModel.updateRecordsByAuthorName(args.name);
}
}
实践完后,给人第一的直觉就是是不是太麻烦了?因为现在这个界面太简单了,用不用MVC差别不大,甚至直接不分层,全部写在一起或许更省力。
但是想象一下,如果让你开发一网易云那样的应用,不分离关注点,完全不好分工;同样是歌单,有我的歌单,我喜欢,年度歌单。业务模型完全是一致的,如果分层之后就完全可以重用。还有一些组件,不分层完全不好测试。
还有一个MVP的实现,我们就先留着下篇继续。