UI架构的历史与基础入门
本笔记的目的是通过一系列连贯的例子来探讨“事物-模型-视图-编辑器”这一隐喻。
这些例子都来自我的规划系统(planning system),用于解释上述四个概念。所有例子都已实现,但并未在本文描述的清晰类结构中实现。
这些隐喻对应于《关于DynaBook需求的笔记》中提出的现实世界-模型-视图-工具(Thing-Model-View-Tool)的概念。
——摘自1979年挪威数学家Trygve Reenskaug的笔记
什么是架构?
事实上,从韩国、中国、美国到西方的案例可以看出,大多数情况下,代码结构被用作架构的代名词。然而,正因为如此,初学者很容易对编程架构感到困惑。
尽管关注点的范围和焦点不同,但由于它们经常在同一层级上混用,因此容易引发混淆。
也就是说,诸如结构架构、UI架构、流程控制等具有不同关注点和范围的概念被当作平等的内容讨论。
无论是在韩国、中国、西方还是日本,这种现象经常出现在所谓的资深开发者中。
那么,为了便于理解,简单来说,
架构是什么?
架构是指软件系统中的高层次设计,它定义了系统的组成部分以及这些部分之间的关系和交互方式。架构的核心目标是解决特定问题,同时满足系统的功能性需求和非功能性需求(如可扩展性、可维护性和性能)。根据不同的关注点,架构可以分为以下几类:
架构分类与示例
类别 | 描述 | 代表性架构 |
---|---|---|
整体系统结构 | 层级依赖方向、领域隔离、业务核心保护 | Clean Architecture, Hexagonal (Ports & Adapters), Onion, Layered (3-tier) |
UI结构与视图流架构 | 视图-逻辑-模型间的数据流、用户事件处理 | MVC, MVP, MVVM, MVI, VIPER, Redux |
内部逻辑控制架构 | 状态转换/消息驱动的控制逻辑 | FSM, Saga, Workflow, RuleEngine |
部署与基础设施架构 | 物理部署/通信结构 | Microservices, Monolith, Serverless, Service Mesh |
各类架构的主要目标通常如下:
- 系统结构架构 → 控制依赖方向、提升测试便利性、保护核心业务逻辑。
- UI结构架构 → 整理用户输入 → 状态变更 → 渲染流程。
- 行为控制架构 → 内部状态与流程控制。
- 部署架构 → 根据网络/服务分布设计物理结构。
本文主要讨论的是UI结构,即视图架构(View Architecture) 。
视图架构是什么?
视图架构是一种专注于如何连接视图与逻辑的局部架构模式。
它与实际的领域逻辑、基础设施或部署无关,是整体系统架构的一部分。
该架构的起源可以追溯到将UI视为机器的尝试,其核心在于如何理解单向状态流。
(Trygve Reenskaug)
MVC(Model - View - Controller)
视图架构(以下简称架构)的起源可以追溯到挪威数学家Trygve Reenskaug在施乐帕洛阿尔托研究中心(Xerox PARC)开发Smalltalk GUI系统时首次提出的概念。
其目的是将用户界面(UI)逻辑与领域逻辑分离。
最初的名称是TMVE(Thing-Model-View-Editor),但后来经过多次演变,最终形成了MVC(Model-View-Controller)模式。MVC通过马丁·福勒(Martin Fowler)的经典著作《企业应用架构模式》(Patterns of Enterprise Application Architecture)等广泛传播。(虽然他不是最初的发明者,但在推广方面做出了巨大贡献。)
MVC的构成要素如下:
构成要素 | 角色 | 说明 |
---|---|---|
Model | 数据状态管理 | 包含应用程序的核心逻辑和状态,例如数据库、内存状态、业务规则等。View和Controller引用或操作Model。 |
View | 视觉显示 | 负责向用户展示Model的状态。它负责屏幕上的信息、布局和样式,自身的逻辑最少化。 |
Controller | 用户输入处理 | 接收用户输入(如按钮点击、键盘输入等),操作适当的Model,并请求更新View。充当View和Model之间的中介角色。 |
MVC最具影响力的案例之一是基于Java的Spring框架(2002年至今)。
尽管Spring仍然被广泛使用,但实际上许多最初基于MVC的框架已经发生了很大的变化。例如,虽然Spring被称为MVC框架,但如今的Spring已经有了很大的演变:它不再将View视为Java的一部分,甚至可以在没有View的情况下仅保留Controller。此外,Controller也逐渐演变为更像是HTTP处理器的角色,而非传统意义上的控制器。因此,MVC在某种程度上已经成为一种“品牌化”的概念。
除此之外,AngularJS和React等框架也被归类为MVC,但实际上它们并不是传统的MVC,而是变体:
- AngularJS更接近于MVVM模式,其中Controller扮演ViewModel的角色,并支持双向绑定,使得View和Model之间的界限变得模糊。
- React则并非MVC,而是基于单一View的设计,与传统MVC相去甚远。
那么,什么时候应该使用MVC?
MVC适用于结构简单、视图和逻辑相对明确分离的情况,特别是在以服务器端渲染为中心的架构中非常适合。
然而,随着现代架构设计的多样化发展,MVC更适合用于小规模个人项目,而不是大型复杂系统。
传统的小型对话(Smalltalk)控制器功能被提升到了应用层面,
同时考虑了中间过程的选择(selection)、命令(command)和交互(interactor)等概念。为了捕捉这一差异,我们将这种类型的控制器称为“Presenter(展示器)”。因此,我们将这种编程模型整体称为Model-View-Presenter(MVP) ,并承认它是MVC的一种泛化形式。——摘自1996年Taligent文档
MVP (Model - View - Presenter)
MVC有一个问题:Controller在处理View事件和Model时承担了过多的职责,导致所谓的“Fat Controller”问题。
此外,View和Controller之间的耦合性较强,事件循环与UI框架紧密结合,使得测试变得困难。
最终,为了解决这些问题:
引入了一个新的中介角色——Presenter,并将View抽象为接口,从而可以在Presenter中进行测试。
MVP的构成要素如下:
构成要素 | 角色 | 说明 |
---|---|---|
Model | 状态及数据处理 | 负责处理应用程序的状态和数据,包括数据存储、业务规则应用、外部API调用等纯粹的业务逻辑层,与UI分离。 |
View | UI呈现及用户输入传递 | 负责向用户显示UI并将输入传递给Presenter。View应设计为尽可能简单的“Dumb View”,不包含复杂的逻辑或状态处理,通常通过接口与Presenter连接。 |
Presenter | 接收View事件 → 操作Model → 将结果反馈给View | 接收来自View的用户事件,操作Model,并将结果返回给View。充当View和Model之间的中介,负责调整数据流和管理流程,同时通过接口松散依赖于View,以便于单元测试。 |
MVP在2010年代初期被广泛应用于Android开发中。
原因是Activity/Fragment结构承担了过多的职责,因此作为最佳实践,将其职责分离到Presenter中。
- View : Activity/Fragment(实现View接口)
- Presenter : 向View传递结果
- Model : Repository、UseCase等
以这种方式,许多项目采用了MVP架构。
不过,目前只有在使用.NET的WinForm时才倾向于采用MVP架构。
这是因为UI事件循环与UI对象紧密绑定,而通过抽象化可以更方便地进行单元测试,因此MVP成为了一个常见的选择。
MVP主要用于客户端应用程序,但随着技术发展,许多框架逐渐转向MVVM架构。
那么,什么时候应该使用MVP?
当复杂UI事件处理非常重要且需要高测试便利性的移动应用时,MVP是一个很好的选择。
它主要应用于客户端开发,尤其是状态管理较为简单的客户端应用时,推荐使用MVP。
自从人们开始开发软件用户界面以来,就出现了一些流行的设计模式来简化这一工作。
例如,MVP(Model-View-Presenter)模式在各种UI编程平台中广受欢迎。
MVP是几十年来使用的Model-View-Controller模式的变体。对于不熟悉MVP模式的读者,简单来说:屏幕上看到的是View,显示的数据是Model,而将两者连接起来的是Presenter。View通过Model数据填充界面,响应用户输入,并提供输入验证(例如委托给Model),同时使用Presenter来处理这些任务。
——MSDN杂志第09期
(John Gossman)
(笔者个人是WPF等C#技术的忠实用户,因此作为程序员,我一直希望能有机会得到John Gossman的签名。他可以说是许多微软技术粉丝的偶像。)
MVVM(Model-View-ViewModel)
2005年,John Gossman在他的博客中首次撰写了关于MVVM的文章。
MVP模式在分离View和逻辑、提升测试便利性以及控制依赖性方面无疑是非常有效的模式。
然而,由于结构性、生产力以及绑定方面的局限性,MVVM逐渐成为自然的选择。
在MVP中,View和Presenter之间的手动连接会产生大量的模板代码,并且在View与Presenter之间传递数据时需要编写大量重复的过程。
此外,由于MVP缺乏数据绑定机制,频繁调用Presenter会导致较大的开销。而在MVVM中,ViewModel负责状态绑定并实现自动更新,即具备“响应式”特性。
最终,现代UI框架大多转向了MVVM架构,例如MAUI、WPF等基于数据绑定的UI框架,MVVM成为了最理想的选择。
MVVM的构成要素如下:
构成要素 | 角色 | 说明 |
---|---|---|
Model | 数据状态管理 | 负责应用程序的核心数据和业务逻辑,包括服务器API、数据库、业务规则等。它直接与ViewModel交换数据。 |
View | UI显示及绑定 | 以视觉方式呈现用户界面(UI)。View通过绑定到ViewModel,在状态发生变化时自动更新。自身没有复杂的逻辑,通常以声明式方式构建。 |
ViewModel | 状态保持及中介 | 接收来自View的用户输入,与Model交互以更新状态,并以可绑定的形式将状态提供给View。ViewModel与View分离,便于进行单元测试。 |
实际上,在大多数使用C#的开发环境中,如WPF、Xamarin、MAUI等,MVVM被采用为默认架构。
这不仅仅是惯例问题,而是因为这些框架本身假定了基于XAML和数据绑定的UI声明方式。
因此,在C#生态系统中,考虑到UI与逻辑的分离、状态同步的自动化以及测试便利性,MVVM几乎成为了“默认选项”。
开发者也自然而然地熟悉了ViewModel这一中间层,MVVM也因此成为了C#开发者中最广为人知的UI模式之一。
那么,什么时候应该使用MVVM?
-
使用XAML基础的UI时特别有效:
在WPF、MAUI、Xamarin.Forms等框架中,基于数据绑定的UI结构很自然地会导向MVVM。 -
状态频繁变化且需要实时反映到UI时:
例如表单状态、过滤条件、网络响应结果、有效性验证等场景中推荐使用。 -
希望通过可测试的ViewModel层验证业务逻辑时:
ViewModel与View分离,使其易于进行单元测试。 -
设计师与开发者需要分工协作时:
View(XAML)与ViewModel(C#)明确分离,从而提高了协作效率。
当然,MVVM并不总是最佳选择。当界面非常简单且状态仅有一两个时,反而使用代码后置或MVP结构可能会更加简洁。
虽然MVVM无疑是一个非常优秀的模式,但在开发简单的工具类应用时,它可能会引入不必要的复杂性,可以说是一把双刃剑。
(André Staltz)
MVI(Model-View-Intent)
2015年,André Staltz在JSConf Budapest上发布了Cycle.js框架。
MVI是一种以单向状态流为核心的现代UI架构,而Cycle.js则是将MVI概念提升到结构化层面的代表性框架。Cycle.js通过基于流(Stream)的方式完全抽象了Intent → Model → View → Intent的循环结构,并将副作用(Side Effect)分离到Driver中,尝试实现函数式UI的实际应用。
MVI不仅仅是一个简单的模式,更是改变了编程模型的历史性演变路径的一部分。
MVI的构成要素如下:
构成要素 | 角色 | 说明 |
---|---|---|
Model (State) | UI的单一状态 | 表示当前View状态的单一不可变对象。所有UI状态都包含在这个State中,且状态始终保持完整形式。 |
View | 基于状态渲染界面 | 根据状态(State)绘制UI,并将用户的交互转换为Intent传递出去。View是无状态的,以声明式方式运行。 |
Intent | 定义用户事件 | 将用户的输入(如点击、输入等)语义化表达的对象。例如:AddTodoClicked、SearchTextChanged、RetryButtonPressed。 |
Reducer | 状态转移函数 | 接收前一个状态和Intent,返回新的状态。这是一个纯函数,不包含副作用,仅计算状态。 |
Effect/Side Effect(可选) | 外部系统调用等非确定性操作 | 如API调用、文件I/O、数据库访问等非纯操作被单独分离处理。在Redux或Elm中通常以Eff |
虽然看起来复杂,但实际上可以更简单地理解:
Intent → Reducer → State → View → Intent 结构
通过单向状态流转,路径变得可预测,从而使得调试更加容易。
Swift、Kotlin StateFlow、Jetpack Compose等技术也很有名,但最重要的是,Flutter中经常使用这种技术。
MVI是继MVVM之后出现的一种基于单向状态流的声明式UI架构。
它通过单一不可变对象管理所有UI状态,并基于用户的意图(Intent)执行清晰的状态转移。
强调测试能力、状态可预测性和副作用分离的结构,已成为现代声明式UI框架中的事实标准。
那么,什么时候应该使用MVI?
-
当UI状态复杂且难以追踪时:
通过将状态定义为单一对象,并通过意图(Intent)控制状态转移,使调试变得更加容易。 -
分离副作用以便更容易区分非确定性操作:
通过分离副作用,调试变得更加直观。
然而,这些优点也表明,如果UI简单或未基于函数式编程(FP)进行思考,则可能会显得复杂。
也就是说,由于其自身具有一定的复杂性以及较高的学习曲线,随意引入该架构可能会面临困难。
“简单的事情应该简单,复杂的事情应该可能。” — Alan Kay
结语
架构就像一门没有语法的语言。
我们选择架构并不是因为它是我们的主观决定,而是所使用的技术、试图解决的问题以及所属组织的哲学对我们提出了结构上的要求。
从MVC、MVP、MVVM、MVI,到最近的VIPER、RIBs等,无数种架构存在。
所有这些架构都不过是对“如何更好地将人类的意图反映到代码中”这一古老问题的回答罢了。
本文旨在展示架构不仅仅是简单地应用,而是随着需求的变化不断演化的。
编程并不仅仅是记忆答案并复制粘贴的人的工作。
编程是一种思维方式,永远追求更好的答案。
架构不是理论,而仅仅是一个解决现场问题的工具。
即使今天选择了一种架构,明天又改变了自己的决定,只要这个判断是基于上下文考虑的,那这就是一个好的选择。
希望这篇文章能在你做出判断时提供一些帮助。