本篇要回答的问题:站在应用架构而非操作系统的角度,桌面程序该怎么设计?大家耳熟能详的 MVC 范式,每一层到底该如何正确理解与划分边界?
上一讲我们从操作系统交互子系统的角度看了图形界面程序的框架。本讲换一个角度——站在应用架构层面来设计桌面程序,主线是把 MVC 这个老生常谈的范式,用"什么是好架构"的标尺重新拆解一遍。
"换一个角度"这句是整讲的钥匙。 上一讲是自下而上:消息怎么从操作系统送到窗口过程里,回答的是"程序为什么能跑起来"。本讲是自上而下:假设消息循环、GDI、窗口都白送给你了,手里一堆业务需求,代码该怎么切。
坐标系变了,判据也就变了——本讲所有分层的目标都不是"让程序能跑",而是"让程序能改"。 Model 越厚越好、Controller 正交分解、事件回调,单看没有一条能让程序多出一个功能,它们全都在优化"第二年改需求时的成本"。
从 MVC 说起
MVC 即"模型 (Model)- 视图 (View)- 控制器 (Controller)"。对它有两种理解:
| 理解方式 | Model | View | Controller |
|---|---|---|---|
| 暗合 IPO 模型 | Input | Output | Process |
| 更准确的解释 | 数据 | 数据的显示结果 + 接收交互事件 | 处理:以"Model + View 转发的事件"为输入,输出仍是更新后的 Model |
注意"Controller 的输出是 Model"这句的分量:Controller 吐出来的不是界面,是数据。界面是被"数据变了"这件事推着变的,不是被 Controller 直接推的。这就是 DataChanged 事件存在的理由。
View 被理解为 Output,是因为 Model 数据更新后会发 DataChanged 事件,View 监听到后更新自己——从数据角度看 View 其实是 Model 的镜像。对 MVC 做微调就得到变种:
| 范式 | DataChanged 由谁监听并 Update View |
|---|---|
| MVC | View 自己监听 |
| MVP(Model-View-Presenter) | Controller / Presenter 监听 |
差别就是这一根监听线挂在谁身上,不必想得太玄。
到底选哪种范式?得先有评判标准。两条基本原则:
| 原则 | 含义 |
|---|---|
| 最低耦合原则 | 子系统间交互频率最少,接口最简洁自然 |
| 单一职责原则 | 不让一个模块干多件事,也不让它不干事情 |
关键判断:原文问了"究竟该选哪一种",然后没有正面回答,而是拐去讲两条原则——这个"不回答"本身就是答案。MVC 和 MVP 谁好谁坏是伪问题,真问题是"每一层的职责边界画在哪"。边界画对了,叫 MVC 还是 MVP 只取决于那根线怎么挂:多个 View 要同步更新时 MVP 更顺手,View 各自独立时 MVC 更省事。
辨析:桌面 MVC ≠ Web MVC。 这是读 MVC 读到精神分裂的根源——两套同名的东西,机制完全不同。
原始 MVC Web MVC 出处 Trygve Reenskaug,1978–79 年 Xerox PARC,为 Smalltalk 设计 Rails / Spring MVC / Django,学名 Model 2 形态 一堆对象长期活在内存里,靠观察者模式互相通知 请求-响应:Controller 接请求、调 Model、挑模板渲染 有无 DataChanged 有,整个设计的枢纽 没有,也没有长期存活的 View 对象 本讲从头到尾讲的是前者。
理解 Model 层
关键判断:真正理解 Model 层的价值,架构水平就达到了较高水准——因为 Model 层太重要了。
Model 层不只是"数据",更准确说是承载业务逻辑的 DOM(文档对象模型)——是"面向对象"意义上的数据,既有数据结构,也有访问接口。
插叙:DOM 到底是什么
字面:Document Object Model。出身是浏览器——HTML 送到浏览器是一串文本,浏览器把它解析成一棵在内存里长期活着的对象树,再开放接口让 JS 操作它(document.getElementById()、el.appendChild())。你改的不是那串文本,是这棵树;树变了,界面跟着变。后来 W3C 把这套接口标准化,DOM 就成了通用名词。
本讲用的是引申义:不是要你去用浏览器那套 API,而是借这个词表达一种形态。拆开是三件事:
| 要素 | 含义 | 反例 |
|---|---|---|
| 有结构 | 通常是一棵树,因为文档天然嵌套 | 一张扁平的表 |
| 有身份 | 每个节点是一个对象,可被引用、被持有指针 | 一坨 JSON,只能整个传来传去 |
| 有行为 | 有方法,不只有字段 | getter/setter 集合 |
对比一下就清楚了:
{"title": "报告", "paragraphs": [...]} ← 数据结构,不是 DOM
doc.InsertParagraph(pos, text) ← 有业务动作
para = doc.ParagraphAt(3) ← 节点有身份,能被单独持有
para.SetBold(True) ← 对节点操作,不是重写整棵树
doc.OnDataChanged += view.Refresh ← 能被订阅
为什么偏偏叫"文档"对象模型:桌面软件的主战场就是文档类应用,而它们的数据天生是树——Word(文档→节→段落→文字 run)、PPT(演示→幻灯片→形状)、Photoshop(文档→图层组→图层)、Figma、CAD 全是。树 + 节点是对象 + 对象有方法 = DOM。这个词是从这类应用里长出来的,浏览器只是把它标准化并出了名。
最容易被忽略的一条性质:DOM 是内存里长期存活的对象图。
这一条决定了它跟 Web 后端"请求来了 new 一个 DTO、响应完就销毁"的数据根本不是一类东西。也正因为它一直在,它才能被订阅——View 才有对象可挂监听,DataChanged 才有落脚点。
倒过来说:如果你的 Model 是用完就扔的一次性数据结构,那本讲后面关于事件、关于 View 是 Model 镜像的设计,一条都用不上。"Model 是 DOM"其实是整讲架构成立的前提。
两类常见误区
以"基于数据库实现 Model 层"为例:
| 误区做法 | 问题 |
|---|---|
| Controller 直接操作数据库读写接口 | 让 Model 层啥事不干,违背单一职责 |
| Controller 直接操作 ORM | 看似高级,本质同上,仍是 Model 层失职 |
为什么 ORM 也不行:ORM 的接口形状是表和字段,不是业务动作。
user.balance -= 100; order.status = 'paid'; save()暴露的是存储结构;order.Pay(amount)暴露的才是业务。
前者一旦散落在十个 Controller 里,"支付"这个概念在代码里就不存在了——它只活在程序员脑子里,靠人肉在十处保持一致。
关键判断:Model 层的使用接口最重要的是自然体现业务需求。只有这样,它的边界才稳定,与底层用 MySQL 还是 NoSQL、裸 SQL 还是 ORM 无关——以后想改都能改。
这条可以直接拿去做代码评审:看着一个 Model 接口,如果你能猜出它底下用的是什么存储,这个接口就漏了。
为什么"越厚越好"
从界面编程角度看,Model 层越厚越好。原文给了三个理由,注意它们其实是同一件事的三个说法:
| 理由 | 实质 |
|---|---|
| 与操作系统界面框架最无关 | ← 根 |
| 最容易测试 | 不用起窗口就能跑单测 |
| 跨平台最容易 | 换 Windows/Mac/Web 只需重写 View 和少量 Controller |
根就一条:Model 层不 import 任何 UI 头文件。做到这点,后两条自动成立。
所以"越厚越好"不是审美偏好,是成本计算:往 Model 里挪一行逻辑,等于这行逻辑在 N 个平台上只写一遍、且能被单测直接覆盖;留在 Controller 里,就是 N 份拷贝 + 只能靠点界面来测。
一句话概括 Model 层职责:负责业务需求的内核逻辑,即"DataCore"。
单一职责的另一半:让 Model 退化成一堆贫血的 struct(DTO / getter-setter 集合),在教科书意义上"职责很单一",但它是失职——逻辑不会消失,只会流到 Controller 里堆成一坨。几乎所有讲 SRP 的书都不讲这半句。
DataChanged 事件
为何 Model 层要发 DataChanged 事件?理由不是"为了刷新界面",而是独立性——它作为最底层,不需要知道上层是 MVC 还是 MVP。
事件是唯一一种"我通知你,但我不认识你"的通信方式。 Model 调 View 的方法 = Model 认识 View(依赖倒过来了);Model 发事件 = 谁想听谁听。
| Model 层的两面 | 性质 | 对策 |
|---|---|---|
| 接口自然体现业务需求 | 稳定点(核心价值) | 直接暴露使用接口 |
| 用户交互(与 PC/手机/屏幕尺寸/地区人文有关) | 变化点 | 用 DataChanged 事件回调 解决 |
用事件回调解决需求变化点,CPU 干过、操作系统干过,今天做业务架构也这么干,就很赞。
——这是全课的母题:同一套手法在不同抽象层反复出现。中断、消息循环、观察者、webhook,是一个东西穿了四件衣服。
理解 View 层
View 层有两大责任,一主一被动:
| View 的责任 | 说明 |
|---|---|
| 界面呈现(主动) | 要么自己调 GDI 画,要么创建子 View 让别人画 |
| 响应交互事件的入口(被动) | 不是设计选择,是操作系统塞给它的——事件只会送到窗口上 |
正因为第二条是被迫的,所以理想情况下 View 应把所有事件**委托(delegate)**出去:View 是被迫当了收发室,收发室不该自己拆信办事。
四个设计细节:
| # | 细节 | 要点 |
|---|---|---|
| 1 | 并非所有 View 都归 View 层 | Controller 逻辑过程中临时生成的 View,应属于 Controller |
| 2 | 需要友好的委托机制 | 例如一组界面元素的交互事件共同 delegate |
| 3 | 与 Model 层关系紧密 | Model 可能要为 View 提供专享只读接口,但不要扩散使用 |
| 4 | 局部更新优化 | 复杂时不得不在 Model 与 View 间引入 ViewModel 层 |
逐条展开:
- ① 的判据:这个 View 是跟着某个交互的生命周期生灭,还是跟着文档的生命周期生灭?拖拽虚框、旋转手柄、右键菜单属于前者,归 Controller。这条直接决定后面"删掉一个 Controller 只需注释一行"能不能做到——辅助 View 若被放进 View 层,就删不干净了。
- ② 的痛点:一个工具条二十个按钮,若逐个绑事件,Controller 会被绑定代码淹没。需要的是"把这一组事件整体转给我"的能力。
- ③ 是全篇唯一一处主动承认要开口子的地方。理由是效率——画界面要遍历数据内部结构,层层走业务接口太慢。态度是"合乎情理,只是确保不要扩散使用"。划重点:只读、专享、不扩散。这是有意识的妥协不是随便开的后门,实践中最好在命名上就标出来(统一前缀),让扩散一眼可见。
- ④:收到 onPaint 就整屏重画,代码最简单;业务一复杂性能就崩。要做局部更新,就得知道"这次数据变化影响了屏幕上哪一块",这个映射关系无处安放,于是长出 ViewModel。
ViewModel 是为界面呈现而设计的 Model:数据组织更接近 View 的表达,与 View 自身数据呈一一对应(双向数据绑定 Bidi-data-binding)。
典型例子是 Word(作者当年在金山做 WPS Office 架构,这是亲历不是举例):
| Model | 流式文档:一串段落,没有"页"的概念 |
| View | 页面视图:分页显示,有边距、页眉页脚、跨页表格与图片环绕 |
| 难点 | 从"一串段落"算到"第 7 页第 3 行从哪个字开始",计算量巨大且必须增量 |
| 干这事的模块 | 排版引擎,维持 Model 与 ViewModel 的一致性 |
改了第 2 页一个字,排版引擎要算出影响范围——多数时候只影响本页,于是只重画本页。ViewModel 的全部价值就是:它是"数据变化 → 屏幕区域"这个映射的落脚点。
关键判断:作者倾向于认为 ViewModel 是 View 层的一部分(View 太复杂而再拆分的结果),并不存在所谓"Model-View-ViewModel"这个独立模式。
辨析:这一条可以商榷。 MVVM 是 John Gossman 2005 年在微软为 WPF 提出的,重点其实不在局部更新,而在声明式数据绑定:View 用 XAML 声明"这个文本框绑到 ViewModel 的那个属性",之后同步交给框架,View 侧几乎不写代码。
ViewModel 的动机 于是它像什么 本讲 性能——增量重绘的映射表 View 的内部拆分 微软 MVVM 解耦 + 可测——把界面逻辑从 View 里榨出来 View 的替身 两种都对,看你为什么引入它。
放到今天:这是全讲被时代改写最多的一节。React 的 virtual DOM diff,本质是把"局部更新优化"从每个应用自造的 ViewModel 里抽出来做成了框架的通用能力——你写全量渲染,框架帮你算最小更新集。原文说"往往不得不额外引入一层",今天变成"框架自带一层"。
但排版引擎那种 ViewModel 至今没被替代,因为 Word / Figma / CAD 的"数据→屏幕位置"映射是业务专有的,没有通用框架能替你算分页。判据:你的映射是"结构对结构"(框架能做),还是"结构对空间"(只能自己做)。
理解 Controller 层
Controller 负责用户交互,与 Model/View 不同的是它可以也应该被正交分解:
| 层 | 整体性 | 分解方式 |
|---|---|---|
| Model | 一个整体(共同构成 DOM) | 不分解 |
| View | 一个整体(DOM 的镜像) | 不分解 |
| Controller | 多个,彼此完全无耦合 | 正交分解 |
为什么前两者是整体、后者是复数:Model 和 View 描述的是**“文档是什么”——一个文档只有一种样子,天然是整体;Controller 描述的是"用户能干什么"**——这是一个开放列表,可以无限加。开放列表就该做成互不相干的插件式模块。
一个 Controller 模块的标准形状:
自己的辅助 View(可选)
↓ 接受 View 层委托的事件
事件驱动自身状态机
↓ 调用 Model 的业务接口
完成一项业务
辅助 View 分两类:持续可见的(菜单、工具条)与临时的(Office 里图形的旋转控制点)。后者若有 ViewModel 层,也可交给 ViewModel + View 去做,因为 ViewModel 里可以有 Selection 这种"当前选中了什么"的概念。
关键判断:做得恰当时,干掉某个交互特别容易——不用删 Controller 代码,只需把创建它的那一行注释掉即可。这正是正交分解的检验标准。
这是本讲唯一一条能立刻拿去验的判据,比任何原则都实在。它同时反过来解释了前面所有约束的用意:
- Controller 不能被别人依赖(否则删了会编译错)
- Controller 的辅助 View 必须归它自己(否则界面上会留一个死按钮)
- Controller 之间不能互相调(否则删一个塌一片)
分层与依赖方向:
Controller ──知道──▶ View、Model (调 DOM 接口改数据;对 View 只订阅事件)
▲
View ──持有──▶ Model 的 DOM 指针
▲
Model ──谁都不知道── (只往外发 DataChanged)
关键的那句:Controller 不操作 View 去改变数据,只监听自己感兴趣的事件。 界面更新走的是"Controller 改 Model → Model 发事件 → View 自己刷新"这条链,Controller 从不直接命令 View 显示什么。守住这条,"Controller 大部分逻辑与操作系统界面框架无关、是跨平台的"才成立——也就是说,理想架构里跟平台绑死的代码只剩 View 层加少量辅助 View。这正是"Model 越厚越好"那条的延伸收益。
串联各模块的是 Application——它在启动时把 Model、View、若干 Controller 都创建好并建立关联。
这句容易被跳过,其实是整个设计的收口:所有模块间的"认识"关系集中在这一个地方,所以"注释掉一行"才能生效。散在各处的
new会毁掉整个设计。今天叫它依赖注入的组装根(composition root),是一回事。
兼顾 API 与交互
MVC 擅长支持用户交互,但不是桌面程序的全部。另一关键需求是提供二次开发接口(API)——有了 API,应用就身处生态之中,可与其他应用协作。(桌面时代这是生死问题:Office 靠 VBA 和插件生态锁死企业市场;今天换成插件市场和开放 API,性质没变。)
关键判断:提供 API 的最佳层是 ViewModel 层。Model 层也容易提供 API,但可能缺少 Selection 这类重要的东西。
这句需要展开才明白。脚本作者写的是这种代码:
Selection.TypeText "Hello" ' 在"当前选中的位置"插入文字
Selection.Font.Bold = True ' 把"当前选中的内容"加粗
ActiveWindow.View.Type = wdPrintView
“当前选中”、“当前视图”、"当前页"这些概念在 Model 层根本不存在——Model 只知道文档内容,不知道用户此刻在看哪、选了什么。而这些用户视角的状态恰好住在 ViewModel 层。
所以这不是"哪层更方便",而是:API 的使用者是人,人思考的单位是"我选中的这段",不是"文档第 1732 到 1749 个字符"。ViewModel 的抽象刚好对齐人的心智。
代价:API 开在 ViewModel 上,意味着 ViewModel 的接口从此也是对外契约,不能随便改。这笔账要提前算。
总结
本讲用"什么是好架构"的标尺重新解剖 MVC:核心是把业务内核沉到厚 Model 层,让 View 当胶水、Controller 正交解耦,并用 DataChanged 事件应对变化点。
结语那句"一千个人眼中有一千个哈姆雷特"不是客套。 MVC 是软件史上被讲坏得最彻底的名词——原始 Smalltalk MVC、Web 的 Model 2、iOS 的 MVC、Android 的各种变体,机制彼此不兼容却同名。所以作者全程没说"MVC 应该长这样",只说"用最低耦合和单一职责这两把尺子去量每一层"。
这一讲真正要教的不是 MVC,是那两把尺子。 记住 MVC 的图没用,记住"Model 是不是在干活"、"注释掉一行能不能删掉一个功能"才有用。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| DOM | 内存里长期存活的对象树:有结构、有身份、有行为——"Model 是 DOM"是整讲成立的前提 |
| Model 层 | 承载业务逻辑的 DOM(DataCore),接口要自然体现业务,越厚越好 |
| View 层 | 界面呈现 + 事件入口,理想是把事件全委托出去 |
| ViewModel 层 | 为界面呈现设计的 Model,与 View 一一对应,承接局部更新优化 |
| Controller 层 | 负责交互,可正交分解,彼此无耦合 |
| DataChanged 事件 | Model 层应对交互变化点的对策,保持 Model 独立性 |
| Application | 把 MVC 各模块创建好并串起来的人(今天叫 composition root) |
一句话速记
把业务逻辑全部沉到厚 Model 层,View 当胶水、Controller 正交解耦、变化点用 DataChanged 事件吸收——这就是用好架构标准重读 MVC 的结论。
几条值得记住的判断
- 单一职责不只是"别干多件事",也包括"别不干事"——Controller 直接操作 DB/ORM 就是让 Model 失职。
- Model 层越厚越好:最跨平台、最易测、与界面框架最无关。根只有一条——Model 不 import 任何 UI 头文件。
- 看着一个 Model 接口能猜出底下用什么存储,这个接口就漏了。
- 事件是唯一一种"我通知你,但我不认识你"的通信方式——这才是 DataChanged 的真正理由,不是为了刷新界面。
- 干掉一个 Controller 只需注释掉创建它的一行代码——这是正交分解是否到位的试金石,也反推出"辅助 View 必须归 Controller 自己"。
- 桌面 MVC 和 Web MVC(Model 2)是两套同名而机制不同的东西,别混着读。
思考题
回到你手头的桌面或前端项目:你的 Model 层是"厚"的(自然体现业务、与存储技术无关),还是被 Controller 直接穿透到了数据库/ORM?如果是后者,业务逻辑现在散落在哪里?




