加载中...

本篇要回答的问题:站在应用架构而非操作系统的角度,桌面程序该怎么设计?大家耳熟能详的 MVC 范式,每一层到底该如何正确理解与划分边界?

上一讲我们从操作系统交互子系统的角度看了图形界面程序的框架。本讲换一个角度——站在应用架构层面来设计桌面程序,主线是把 MVC 这个老生常谈的范式,用"什么是好架构"的标尺重新拆解一遍。

操作系统交互子系统视角下的桌面应用程序结构

"换一个角度"这句是整讲的钥匙。 上一讲是自下而上:消息怎么从操作系统送到窗口过程里,回答的是"程序为什么能跑起来"。本讲是自上而下:假设消息循环、GDI、窗口都白送给你了,手里一堆业务需求,代码该怎么切。

坐标系变了,判据也就变了——本讲所有分层的目标都不是"让程序能跑",而是"让程序能改"。 Model 越厚越好、Controller 正交分解、事件回调,单看没有一条能让程序多出一个功能,它们全都在优化"第二年改需求时的成本"。

从 MVC 说起

MVC 即"模型 (Model)- 视图 (View)- 控制器 (Controller)"。对它有两种理解:

理解方式 Model View Controller
暗合 IPO 模型 Input Output Process
更准确的解释 数据 数据的显示结果 + 接收交互事件 处理:以"Model + View 转发的事件"为输入,输出仍是更新后的 Model

MVC 架构示意

注意"Controller 的输出是 Model"这句的分量:Controller 吐出来的不是界面,是数据。界面是被"数据变了"这件事推着变的,不是被 Controller 直接推的。这就是 DataChanged 事件存在的理由。

View 被理解为 Output,是因为 Model 数据更新后会发 DataChanged 事件,View 监听到后更新自己——从数据角度看 View 其实是 Model 的镜像。对 MVC 做微调就得到变种:

范式 DataChanged 由谁监听并 Update View
MVC View 自己监听
MVP(Model-View-Presenter) Controller / Presenter 监听

MVP 架构示意

差别就是这一根监听线挂在谁身上,不必想得太玄。

到底选哪种范式?得先有评判标准。两条基本原则:

原则 含义
最低耦合原则 子系统间交互频率最少,接口最简洁自然
单一职责原则 不让一个模块干多件事,也不让它不干事情

关键判断:原文问了"究竟该选哪一种",然后没有正面回答,而是拐去讲两条原则——这个"不回答"本身就是答案。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)。

引入 ViewModel 层的分层结构

典型例子是 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?如果是后者,业务逻辑现在散落在哪里?

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关