MVC、MVP和MVVM
背景
互联网时代的应用,往往采用C/S架构或者B/S架构[1](当然B/S是C/S的一种实现)。基于此开发的应用一般可分为如下三步:
- 基于App(web页面或者手机应用)收集大量C端交互,这些交互往往复杂而繁琐。
- 基于C端交互的数据,进行S端计算,并将结果回传给C端。
- App反馈S端的计算结果。
从抽象层面看,上述应用可分为,用户视图、应用逻辑和应用数据,对于这种应用,业界存在好多种解决方案。本文将主要选取其中的三种MVC[3]、MVP[5]和MVVM[6]进行介绍。
一、MVC(model-view-controller)
1.1. MVC模式介绍
MVC最早可以追溯到上世界八十年代,为了降低GUI(graphical user interface )软件设计过于复杂的状况,Trygve Reenskaug于1979年提出了MVC模式。在当时,MVC属于不温不火,默默发展中。直到1988年,Smalltalk[7]将之引入,作为GUI开发的解决方案,MVC变得广为人知。到现在,世界上所有网站,都逃不开MVC模式或者其变体的身影。
典型的MVC架构模式将一个交互式服务拆解成三部分(可见图1):
- 模型(model):数据存储层。代表数据存储的对象,以及相应的数据逻辑(CRUD)。
- 视图(view):交互层。渲染数据流,展示给客户;监听客户的交互行为,例如点击、输入等。
- 控制器(controller):控制层。模型和视图的中间接口,作用如下:
- 将视图监听的交互事件转换成对应的模型更改命令。
- 将模型更改后的数据更新到视图。
笔者在软件设计一文中提到业界沉淀下了23种设计模式。MVC正是设计模式的践行者,它巧妙得将观察者模式[8]和中间者模式[9]结合起来,提供了优雅的解决方案(可见图2)。
1.2. MVC模式样例
本节,将使用Go实现一个MVC应用的简单demo。
根据上节内容,笔者对MVC模型进行了一定分析,得出如下结论:
- 控制器是视图操作模型的中间人。
- 模型不知道视图的存在,视图需要明确知道自身对应的模型。
- 视图随着模型的更改而变更,视图是模型数据的订阅者,控制器是视图更改事件的观察者,控制器将模型打包成事件通知给视图。
转换成代码如下:
……
type MVCController interface {
OnEvent(ctx context.Context) // 中间人模式提供给视图使用操作接口
SetView(v MVCView) // 被观察的控制器提供给视图的
}
type MVCView interface {
Show(m *ExampleMVCModel) // 更新view给客户,view知道相关模型
}
……
// 模型结构体
type ExampleMVCModel struct {
content string
}
func (m *ExampleMVCModel) Content() string {
return m.content
}
func (m *ExampleMVCModel) SetContent(content string) {
m.content = content
}
……
// 中间人模式提供的统一访问模型的接口
func (c *ExampleMVCController) OnEvent(ctx context.Context) {
t, ok := ctx.Value(GLabelEventType).(int)
if !ok {
return
}
switch t {
case EventOnInput: // 修改模型
content, ok := ctx.Value(GLabelContent).(string)
if ok {
c.m.SetContent(content)
}
default:
return
}
// 通过SetView注册成为模型数据的监听者,Show方法是接受通知的函数,直接获取模型整体
if c.v != nil {
c.v.Show(c.m)
}
}
……
以上结构的具体实现,这里就不展开了,有兴趣的源码走起。
最终,可以得到一个仿真例子如下:
……
dev := &ClientViewDev{} // 设备模拟器
output := &ClientViewDev{} // 输出设备模拟器
m := patterns.NewExampleMVCModel() // ExampleMVCModel
c := patterns.NewExampleMVCController(m) // MVCController
v := patterns.NewExampleMVCView(dev, output, c) // MVCView
// observer set subscriber
c.SetView(v)
// 模拟客户输入
if err := v.OnInput(context.WithValue(eventOnTextChanged, patterns.GLabelContent, msg)); err != nil {
t.Error(err)
}
// 模拟客户输出
if err := v.OnClick(eventOnInput); err != nil {
t.Error(err)
}
……
viewMsg := fmt.Sprintf("View:%s", msg)
if output.ToString() != viewMsg { // 检验输出值是否符合预期
t.Errorf("Get(%s) != %s", dev.ToString(), viewMsg)
}
……
1.3. MVC总结
-
MVC优势:
- 模块化,易于扩展和复用。
- 可维护性高。
- 易于软件工程管理。
- 拆分后各模块可独立测试。
-
MVC劣势:
- 软件设计复杂化。
- 视图依赖于模型,耦合性过大。
- 视图对控制器依赖过重,难以单独使用。
- 视图对模型的访问非常低效(访问粒度过大)。
二、MVP
2.1. MVP模式介绍
MVP模式起源于上世界九十年代的Taligent公司。1998年,Andy Bower和Blair McGlashan将MVP引入到Smalltalk中,作为用户接口框架的解决方案。2006年,微软的.Net框架引入MVP作为用户接口框架的解决方案。
典型的MVP模式将业务拆解成三个部分(如图3):
- model:模型,代表数据存储的对象,以及相应的数据逻辑(CRUD)。
- view: 视图,渲染数据流,展示给客户;监听客户的交互行为,例如点击、输入等。
- presenter:展示器,负责能力如下:
- 将视图监听的交互事件转换成对应的模型更改命令。
- 提供给视图访问数据的接口。
- 将模型更改后的数据更新到视图。
笔者认为,MVP模式是MVC模式的一个变种,针对MVC中视图对模型过于强大的依赖性,MVP将视图对于数据的访问收拢回控制器,将控制器的能力进一步扩展,赋予了一个新的概念展示器。展示器引入了访问者模式[10],将视图作为访问器,从而隔离了视图对于模型的认知。自此,MVP中的模型和视图完全拆分开,相互隔离(如图4)。
2.2. MVP模式样例
本节,将使用Go实现一个MVP应用的简单demo。
根据上节内容,笔者对MVP模型进行了一定分析,得出如下结论:
- 展示器是视图操作模型的桥梁,是视图操作模型的中间人。
- 视图是模型的展现,视图是模型数据的订阅者。
- 模型和视图相互间隔离,全部由展示器提供服务:
- 展示器是视图变更消息的观察者,视图通过展示器间接得知数据变更。
- 展示器是模型数据的容器,模型作为展示器的元素,视图则是特定元素的访问器。
转换成代码如下:
……
type MVPView interface {
SetContent(content string) // 作为model的visitor访问content用
Show() // 更新view给客户,view知道相关模型
}
type MVPPresenter interface {
OnEvent(ctx context.Context) // 中间人模式提供的model修改接口
SetView(v MVPView) // observer提供向的注册监听消息的接口,同时也是Element的accept接口
}
……
type ExampleMVPModel struct {
content string
}
func (m *ExampleMVPModel) SetContent(content string) {
m.content = content
}
func (m *ExampleMVPModel) Content() string {
return m.content
}
……
// 展示器作为中间人提供的模型访问接口
func (p *ExampleMVPPresenter) OnEvent(ctx context.Context) {
t, ok := ctx.Value(GLabelEventType).(int)
if !ok {
return
}
if p.m == nil || p.v == nil {
return
}
switch t {
case EventOnInput: // 修改模型
content, ok := ctx.Value(GLabelContent).(string)
if ok {
p.m.SetContent(content) // 变更模型数据
p.v.SetContent(p.m.Content()) // 视图作为访问器访问m对应的元素
p.v.Show() // 视图作为数据变更观察者接收数据变更事件通知
}
default:
return
}
}
……
最终,可以得到一个仿真用例如下:
……
dev := &ClientViewDev{}
output := &ClientViewDev{}
m := patterns.NewExampleMVPModel()
p := patterns.NewExampleMVPPresent(m)
v := patterns.NewExampleMVPView(dev, output, p)
p.SetView(v) // 视图作为通知对象
v.OnInput(msg) // 用户交互
if m.Content() != msg {
t.Errorf("Get(%s) != %s", m.Content(), msg)
}
viewMsg := fmt.Sprintf("View:%s", msg)
if output.ToString() != viewMsg {
t.Errorf("Get(%s) != %s", dev.ToString(), viewMsg)
}
……
2.3. MVP总结
- 优点:
- 模块化明确,低耦合、易扩展、可复用。
- 模块相较于MVC,更易于模块独立测试。
- 相较于MVC,模型被进一步隐藏。
- 缺点:
- 视图和展示器交互频繁,视图的变更会导致展示器的变更。
- 相较于MVC,设计变得更加复杂。
- 展示器相较于控制器,职责更重。
三、MVVM
3.1. MVVM模式介绍
MVVM(model–view–viewmodel)是Ken Cooper和Ted Peters在设计WPF时所发明,旨在简化数据驱动的UI编程,最早由John Gossman在2005年发表于个人博客,而为人所知。
典型的MVVM除了承担逻辑的model、view、viewmodel外,还包含了一个DataBindler的模块,主要是建立viewmodel和view数据联系(如图5)。
- model:模型,代表数据存储的对象,以及相应的数据逻辑(CRUD)。
- view: 视图,渲染数据流,展示给客户;监听客户的交互行为,例如点击、输入等。
- data binder:数据绑定器,数据绑定器根据显式声明的数据和命令绑定关系,建立MVVM中模型视图和视图的联系。
- viewmodel:视图模型,视图模型反映了模型的数据,通过数据绑定器和视图进行属性和命令的双向同步。视图模型不需要持有视图的引用。
笔者认为,MVVM相较于MVC和MVP,一大创新就是使用数据绑定器,通过声明式的方式,建立视图和数据之间的关系。视图需要自己处理客户的输入,并将之转换为相应的数据变更,数据绑定器会将数据同步到数据视图,最终达到操作模型的能力。模型修改后,相应的视图模型也会变化,数据绑定器又将这个变更同步到视图上。(如图6)
3.2. MVVM样例
本节,将使用Go实现一个MVP应用的简单demo。
根据上节内容,笔者对MVVM模型进行了一定分析,得出如下结论:
- 视图和模型相互完全解偶,是独立的模块。
- 视图需要自己处理各种用户的交互事件:
- 视图可以作为中间者向UI框架提供统一接口。
- 视图可作为订阅者订阅不同UI事件。
- 数据绑定器需要根据申明式的定义,建立视图和视图模型的属性、命令对应关系,这是个双向的观察者模型:
- 视图是视图模型数据变更的订阅者。
- 视图模型是视图属性变更的订阅者。
MVVM的核心在于数据绑定器,它需要做到(源码传送门):
- 能够识别显样申明:需要用到golang的反射。
- 能够双向绑定视图和视图模型的关系:属性的绑定必须要又中间媒介,笔者选取go的闭包特性,采用回调函数的方式进行双向同步。
- MVVM模式需要运作在一种特定的运行时机制(数据绑定器能够完成工作)下,这机制是一种非常强的编码约束。笔者采用OOP中的继承(也就是go中的嵌入特性),提供数据绑定器能够认知的基视图,任意需要使用MVVM框架的视图都必须作为基视图的子类。
数据绑定器的实现:
type ViewModelBinder struct {
}
func (b *ViewModelBinder) BindModel2ViewModel(view ExampleMVVMBaseViewHandler, viewModel interface{}) error {
// 获取传入的视图和视图模型的实际值(主要是pointer和数据不同)
viewType := reflect.TypeOf(view)
viewVal := reflect.ValueOf(view)
if viewType.Kind() == reflect.Ptr {
viewType = viewType.Elem()
viewVal = viewVal.Elem()
}
nf := viewType.NumField()
viewModelType := reflect.TypeOf(viewModel)
viewModelVal := reflect.ValueOf(viewModel)
if viewModelType.Kind() == reflect.Ptr {
viewModelType = viewModelType.Elem()
viewModelVal = viewModelVal.Elem()
}
// 扫描视图的所有属性,查找声明式绑定关系
for i := 0; i < nf; i++ {
// 绑定关系由go的tag来声明,内容对应绑定的视图模式的属性
viewField := viewType.Field(i)
viewTag := viewField.Tag.Get("mvvm")
if viewTag == "" {
continue
}
// 保证绑定的视图模型的属性1. 存在且export 2. 是函数
if unicode.IsLower([]rune(viewTag)[0]) {
return fmt.Errorf("tag field %s not exported", viewTag)
}
vmTagField, ok := viewModelType.FieldByName(viewTag)
if !ok || vmTagField.Type.Kind() != reflect.Func {
return fmt.Errorf("field=%s not exist as func", viewTag)
}
// 获取视图模型要绑定的属性的值
vmF := viewModelVal.FieldByName(viewTag)
if !vmF.CanSet() {
return fmt.Errorf("ViewModel Field %s Can't Be Set", viewTag)
}
vmI := vmF.Interface()
// 获取视视图要绑定的值,并校验其类型
onUpdate, ok := viewVal.Field(i).Interface().(OnUpdate)
if !ok {
return fmt.Errorf("%s's tag mvvm not OnUpdate Callback", vmTagField.Name)
}
// 根据视图模型被绑定值的类型进行双向绑定
switch f := vmI.(type) {
case OnNotifyTextChanged:
wrapF := func(ctx context.Context, title string) error {
if err := f(ctx, title); err != nil {
return err
}
return onUpdate(view)
}
// 绑定view->viewmodel
if err := b.BindOnNotifyTextChanged(view, wrapF); err != nil {
return err
}
// 绑定viewmodel->view
vmF.Set(reflect.ValueOf(wrapF))
case OnNotifyScrollAdded:
// 同上
wrapF := func(ctx context.Context, texts []string) error {
if err := f(ctx, texts); err != nil {
return err
}
return onUpdate(view)
}
if err := b.BindOnNotifyScrollAdded(view, wrapF); err != nil {
return err
}
vmF.Set(reflect.ValueOf(wrapF))
default:
return fmt.Errorf("ViewModel tag %s Attr Not Support", viewTag)
}
}
return nil
}
// BindOnNotifyScrollAdded 绑定OnNotifyScrollAdded到view对应事件
func (b *ViewModelBinder) BindOnNotifyScrollAdded(view ExampleMVVMBaseViewHandler, onAdded OnNotifyScrollAdded) error {
return view.RegisterListener(EventOnScrollAdded, func(c context.Context) error {
addedTexts, ok := c.Value(GLabelContent).([]string)
if !ok {
return fmt.Errorf("content %v", c.Value(GLabelContent))
}
return onAdded(c, addedTexts)
})
}
// BindOnNotifyTextChanged 绑定OnNotifyTextChanged到view对应事件
func (b *ViewModelBinder) BindOnNotifyTextChanged(view ExampleMVVMBaseViewHandler, onChanged OnNotifyTextChanged) error {
return view.RegisterListener(EventOnTextChanged, func(c context.Context) error {
title, ok := c.Value(GLabelContent).(string)
if !ok {
return fmt.Errorf("content %v", c.Value(GLabelContent))
}
return onChanged(c, title)
})
}
……
笔者实现的绑定器,主要基于go的闭包特性、go函数的一等公民特性和观察者模式:
- 视图->视图模型:采用的绑定方式是将双向同步的匿名函数注册到视图对象中,视图作为中间人提供单一接口处理UI的事件,通过事件分发触发不同的视图模型处理函数。原因在于,实际场景下,数据的变化是由客户的输入数据驱动的,视图自己的属性变动,在未经逻辑处理的情况下,不应该有能力改动到视图模型乃至模型。
- 视图模型->视图:采用的绑定方式是函数属性的直接调用,因为视图模型是模型数据的准确描述,是视图渲染数据的来源,有最高的变更权限,任意更改都需要同步到视图。
数据绑定器对需要绑定的视图有很强的约束,体现到代码里,就是要求视图实现了ExampleMVVMBaseViewHandler
接口。笔者在demo中设计了一个可以被嵌入的基视图,以提高代码复用率:
type ExampleMVVMBaseView struct {
rwMu sync.RWMutex // 并发安全
eventMap map[int]OnEvent // 事件分发器
}
// 注册双向绑定的回调函数
func (bv *ExampleMVVMBaseView) RegisterListener(eventType int, f OnEvent) error {
bv.rwMu.Lock()
defer bv.rwMu.Unlock()
if _, ok := bv.eventMap[eventType]; ok {
return fmt.Errorf("%d Already Register", eventType)
}
bv.eventMap[eventType] = f
return nil
}
// 销毁
func (bv *ExampleMVVMBaseView) Destroy() {
bv.rwMu.Lock()
defer bv.rwMu.Unlock()
bv.eventMap = nil
}
// 事件分发的中间人接口
func (bv *ExampleMVVMBaseView) OnEvent(ctx context.Context) error {
eventType, ok := ctx.Value(GLabelEventType).(int)
if !ok {
return fmt.Errorf("event type %v", ctx.Value(GLabelEventType))
}
bv.rwMu.RLock()
defer bv.rwMu.RUnlock()
// 分发
if f, ok := bv.eventMap[eventType]; ok {
return f(ctx)
}
return nil
}
// 更新视图的中间人接口
func (bv *ExampleMVVMBaseView) Update() error {
return nil
}
最终,可以得到一个仿真用例如下:
……
dev := &ClientViewDev{}
m := patterns.NewExampleMVVMModel(constTitle1, []string{constText1})
vm, err := patterns.NewExampleMVVMViewModel(m)
……
……
v, err := patterns.NewMVVMBilateralBindMiddleware(patterns.NewExampleMVVMView)(dev, vm)
……
defer v.Destroy()
for i, testCase := range testCases {
……
if testCase.ctx != nil {
if err := v.OnEvent(context.WithValue(testCase.ctx, patterns.GLabelContent, testCase.content)); err != nil {
t.Errorf("case %d: %v", i, err)
break
}
} else if testCase.mod == 1 {
……
if err := vm.OnNotifyTextChanged(testCase.ctx, c); err != nil {
t.Errorf("case %d: %v", i, err)
break
}
} else if testCase.mod == 2 {
……
if err := vm.OnNotifyScrollAdded(testCase.ctx, cs); err != nil {
t.Errorf("case %d: %v", i, err)
break
}
}
……
3.3. MVVM总结
- 优点:
- 视图模型和视图全面解偶,视图模型可以绑定到不同的视图上,视图可以绑定不同的视图模型。
- 模块化明确,低耦合、易扩展、可复用。
- 不仅模块可以单独测试,实际业务逻辑也只需要测试视图模型。
- 相较于MVP和MVC优化了数据访问效率,模型不再需要全量同步数据。
- 缺点:
- 比MVP和MVC更复杂。
- 框架接管了数据同步相关数据,调试栈更深。
四、MVC、MVP和MVVM:who is the best one?
从MVC到MVVM,其实都围绕着一个核心问题,解偶显示层、模型层和逻辑层。不难看出,是一个模块化和依赖最小化的过程,到MVVM的时候,模型和视图最终到了相忘于江湖的地步。
那按照软件设计一文中的设计原则,是不是MVVM就是开发者在开发面向客户的交互式程序的首选方案呢?
那并不是,MVVM有着其局限性,完全松耦合也有其局限性。MVVM在框架层做了太多工作,导致改动和特别的一些需求显得很难(大型软件的老大难问题)。
MVC、MVP和MVVM都不是银弹,它们都是优秀的解决方案,针对不同的业务场景,做出取舍,甚至于改动以更好的解决团队的问题才是一个软件工程师的最大目标。
五、参考文献
[1] C/S vs B/S:https://www.zhihu.com/question/21803672
[2] MVC Tuturial:https://www.guru99.com/mvc-tutorial.html#2
[3] MVC模式:https://en.wikipedia.org/wiki/Model–view–controller
[4] MVC模式和基本设计模式的关系:https://stackoverflow.com/questions/9119657/how-do-gang-of-four-design-patterns-fit-into-the-mvc-paradigm
[5] MVP:https://baike.baidu.com/item/MVP模式
[6] MVVM:https://en.wikipedia.org/wiki/Model–view–viewmodel
[7] Smalltalk:https://zh.wikipedia.org/wiki/Smalltalk
[8] 观察者模式:https://en.wikipedia.org/wiki/Observer_pattern
[9] 中间者模式:https://en.wikipedia.org/wiki/Mediator_pattern
[10] 访问者模式:https://www.cnblogs.com/adamjwh/p/10968634.html
[11] MVC模式的缺点:https://www.jianshu.com/p/e538c86c5f91
[12] MVX:https://draveness.me/mvx/
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?