{T}

实战-怎么设计一个"画图"程序

导言:从需求到架构的完整推演

在桌面开发篇的理论铺垫之后,我们需要一个实战案例将架构思维落地。画图程序(类似 Windows 画笔或 macOS 的画图板)是一个经典的桌面应用——它既具备足够的复杂度来验证架构设计的有效性,又足够直观,便于我们逐步推演从需求到架构的完整过程。

本系列的核心问题是:如何从一个模糊的产品需求出发,推导出合理的程序骨架,并逐步演进为一个支持联网协作的完整架构? 这不是编码问题,而是架构思维的问题。

图表渲染中…

关联阅读:本实战系列是桌面开发篇的核心实践,建议结合第 21 讲"图形界面程序的框架"、第 22 讲"桌面程序的架构建议"中 MVC 模式的讨论来理解。


一、需求分析与领域建模

1.1 功能边界的确立

画图程序的核心功能可以用一句话概括:用户通过鼠标操作,在画布上绘制和编辑各种图形元素。但这句话过于笼统,我们需要将其分解为可操作的需求边界。

图表渲染中…

1.2 需求的优先级分层

许式伟强调,架构设计的第一步不是画架构图,而是对需求做减法。我们需要将需求分为三层:

层次需求架构含义
P0 核心需求绘制多种图形、选择/移动/删除图形决定数据模型与核心交互架构
P1 重要需求撤销/重做、修改属性、保存/加载决定状态管理与持久化架构
P2 增强需求导出图片、多选、图层决定扩展性设计

架构洞察:P0 需求决定架构骨架,P1 需求验证架构弹性,P2 需求考验架构边界。过早为 P2 设计是过度工程,但架构不能让 P2 变得不可能。

1.3 识别核心概念

从需求出发,我们识别出以下领域概念:

  • Document(文档):一个画图文件,是持久化的基本单位
  • Shape(图形):文档中的基本元素,是操作的基本单位
  • Tool(工具):用户交互的入口,决定了鼠标操作的含义
  • Canvas(画布):图形的显示与交互区域
图表渲染中…

1.4 Shape 继承体系的设计考量

Shape 的继承体系是画图程序领域模型的核心。这里有一个关键的架构决策:Shape 的公共接口应该包含什么?

许式伟的观点是:接口应当反映"所有图形共有的行为",而非"所有图形共有的属性"。属性因图形类型而异,但行为是一致的:

  • draw(Graphics):所有图形都能绘制自己
  • contains(Point):所有图形都能做命中测试
  • move(Vector):所有图形都能移动
  • clone():所有图形都能复制

这种设计遵循了行为抽象优于数据抽象的原则——接口定义的是"能做什么"而非"有什么属性"。

1.5 组合模式的应用

更高级的设计可以考虑组合模式(Composite Pattern),允许图形之间的组合:

图表渲染中…

关联参考:组合模式在架构层面的意义,参见第 19 讲对组合优于继承的讨论。

1.6 MVC 架构的引入

画图程序天生适合 MVC 架构,因为它的三个核心关注点天然分离:

MVC 组成画图程序对应职责
ModelDocument + Shapes管理图形数据与业务逻辑
ViewCanvas渲染图形、响应绘制请求
ControllerTool将用户输入转化为对 Model 的操作
图表渲染中…

以"绘制一个矩形"为例,完整的 MVC 交互流程如下:

图表渲染中…

许式伟特别强调 Model 的独立性:

Model 必须是完全自洽的——它不应该知道 View 和 Controller 的存在。Model 的正确性不依赖于任何特定的 UI 呈现。

这意味着:

  1. Model 不持有 View 的引用
  2. Model 通过观察者模式(Observer)通知变化
  3. 所有业务逻辑(命中测试、图形编辑)都在 Model 内完成

1.7 从最小可运行版本开始

架构设计不是纸上谈兵,需要通过代码验证。第一版应该:

  1. 实现 Shape 基类和 RectShape
  2. 实现 Document 的基本增删操作
  3. 实现 RectangleTool 的完整流程
  4. 实现 Canvas 的基本渲染
  5. 让整个 MVC 闭环跑通

在第一版完成后,需要验证:

  • 新增一种图形类型,需要改动几个文件?(应只加一个 Shape 子类)
  • 新增一种工具,是否影响 Model?(不应影响)
  • 修改渲染方式,是否影响 Model?(不应影响)
  • 支持撤销/重做,Model 的接口需要怎么变?

二、MVC架构与渲染系统

2.1 渲染架构:立即模式 vs 保留模式

维度立即模式(Immediate Mode)保留模式(Retained Mode)
数据所有权渲染时重建持久化保存
绘制触发每帧全量绘制按需局部重绘
状态管理无状态有状态
典型代表ImGui、HTML Canvas 2DDOM、WPF、Cocoa
适合场景工具类 UI复杂文档类应用

画图程序的 View 必须采用保留模式,原因有二:

  1. Model 即数据源:Document 中持久保存了所有 Shape,View 无需每帧重建
  2. 按需重绘:只有变化的区域需要重绘,这是性能的基本保障
图表渲染中…

保留模式下的渲染流程:

图表渲染中…

2.2 重绘策略:从全量重绘到局部重绘

全量重绘(最简方案):每次 Model 变更后,清除画布并重新绘制所有图形。

c
void onModelChange() {
    canvas.clear();
    for (shape in document.shapes) {
        shape.draw(canvas.graphics);
    }
}

优点:实现简单,不会出现渲染不一致的 bug。缺点:图形数量多时性能差。

局部重绘(脏区域方案):核心思路是只重绘变化的区域。

图表渲染中…

多个变更可能在一次事件循环中发生,需要合并脏区域:

策略方法适合场景
边界框合并取所有脏区域的包围矩形变更区域集中时
区域列表维护脏区域列表,分别处理变更区域分散时
帧合并一帧内所有变更合并为一个大脏区域简单实现

许式伟的建议:先用帧合并策略,性能不够再优化。过早优化是万恶之源。

2.3 双缓冲与闪烁消除

当重绘涉及多个步骤(清除 + 重绘)时,用户可能看到中间状态——画面闪烁。这本质上是因为屏幕呈现与绘制过程没有同步

图表渲染中…

双缓冲的实现要点:

  1. 创建与屏幕区域等大的后台缓冲(Back Buffer)
  2. 所有绘制操作都在后台缓冲上进行
  3. 绘制完成后,将后台缓冲一次性拷贝到前台
  4. 现代 GUI 框架通常已内置双缓冲支持

2.4 Shape 的渲染职责

在 MVC 架构下,Shape(Model 的一部分)是否应该知道如何绘制自己?这涉及一个经典的架构权衡:

方案优点缺点
Shape 自己绘制封装好,新增图形类型只需加子类Model 依赖 Graphics API
独立 RendererModel 与渲染完全解耦新增图形类型需改两处
Visitor 模式解耦且扩展点统一复杂度高,双分派不易理解

许式伟的倾向:Shape 自己绘制。理由是画图程序的渲染逻辑与图形数据高度耦合,强行分离反而增加复杂度。Model 依赖抽象的 Graphics 接口(而非具体渲染 API),是更务实的解耦方式。

图表渲染中…

通过 Graphics 抽象层,Shape 依赖的是绘制能力的抽象而非具体平台——这比完全分离 Renderer 更务实。

2.5 坐标系统与变换

画图程序必须区分两种坐标系:

坐标系含义用途
文档坐标图形在文档中的位置Model 存储、命中测试
视口坐标图形在屏幕上的位置鼠标事件、渲染
图表渲染中…

变换的应用:

  • 鼠标事件 → 命中测试:视口坐标通过逆变换转为文档坐标,再对 Model 做命中测试
  • Model → 渲染:通过正向变换将文档坐标映射到屏幕坐标
  • 缩放与滚动:只需修改变换参数,无需修改 Model 数据
c
// 正确:坐标转换在 Controller 层完成
void onMouseDown(viewX, viewY) {
    Point docPoint = viewportToDocument(viewX, viewY);
    Shape hit = document.hitTest(docPoint);
    ...
}

// 错误:让 Model 知道视口变换
void onMouseDown(viewX, viewY) {
    Shape hit = document.hitTest(viewX, viewY, viewportTransform);  // Model不应依赖View
}

三、交互设计与工具系统

3.1 Tool 体系的设计

Tool 的本质是一个策略模式(Strategy Pattern) 的实例——它将用户输入(鼠标/键盘事件)翻译为对 Model 的操作。不同的 Tool 就是不同的翻译策略。

图表渲染中…

3.2 Tool 的生命周期

图表渲染中…

3.3 Tool Manager

需要一个 Tool Manager 来管理工具的切换和生命周期:

图表渲染中…

Tool Manager 的关键职责:

  1. 工具切换:deactivate 当前工具 → activate 新工具
  2. 事件分发:将用户输入转发给当前活跃的工具
  3. 工具注册:管理可用工具的注册表

3.4 状态机:交互的精确建模

以 SelectionTool 为例,看似简单的"选择-拖拽"操作实际上包含多种状态:

  • 未选中任何图形 → 点击空白处
  • 未选中任何图形 → 点击某个图形
  • 已选中图形 → 点击空白处(取消选择)
  • 已选中图形 → 点击另一个图形(切换选择)
  • 已选中图形 → 拖拽选中图形(移动)
  • 已选中图形 → 拖拽控制点(缩放)

如果用 if-else 硬编码,代码将迅速退化成"箭头反模式"(Arrow Anti-pattern)。状态机将复杂的条件逻辑转化为清晰的状态转换。

图表渲染中…

状态机的实现模式:

图表渲染中…

实现状态机的三种模式对比:

模式实现方式优点缺点
switch-case状态+事件二维分支直观状态多了不可维护
State Pattern每个状态一个类符合开闭原则类数量膨胀
状态转换表表驱动的状态机数据驱动,可序列化调试不直观

许式伟的建议:简单工具用 switch-case,复杂工具用 State Pattern,跨工具状态协调用状态转换表

3.5 绘制类工具的交互模式

绘制类工具(如 RectangleTool)需要一个关键机制:实时预览。用户在拖拽过程中需要看到正在绘制的图形。

图表渲染中…

预览图形不应写入 Model,否则会污染 Document 数据。两种实现策略:

策略方法优点缺点
叠加层渲染预览图形在 View 层独立绘制不侵入 ModelView 需要额外的渲染逻辑
临时 Shape在 Model 中标记为"临时"统一渲染管线Model 需要过滤临时对象

推荐叠加层渲染——它保持了 Model 的纯净性,同时预览的渲染逻辑也可以复用 Shape 的 draw 方法。

更高级的绘制工具可以提供交互约束:

  • Shift 键约束:矩形 → 正方形,椭圆 → 正圆
  • 对齐辅助线:自动吸附到其他图形的边界
  • 网格对齐:坐标对齐到网格

这些约束应该在 Tool 层实现,而非 Model 层——约束是交互行为,不是数据属性。

3.6 选择与编辑的交互设计

命中测试(Hit Test)——判断鼠标点击是否在某个图形上——应该由谁负责?

python
// 方案 A:Model 负责命中测试(推荐)
class Document {
    Shape hitTest(Point docPoint) {
        for (shape in shapes.reverse()) {  // 从顶层开始
            if (shape.contains(docPoint)) return shape;
        }
        return null;
    }
}

// 方案 B:View 负责命中测试(不推荐)
class Canvas {
    Shape hitTest(Point viewPoint) {
        // 需要访问Model数据来做判断
        // View不应该有这种业务逻辑
    }
}

推荐方案 A:命中测试是业务逻辑,属于 Model。Controller 负责坐标转换后调用 Model 的 hitTest。

选中图形后需要显示控制手柄(Handle)——这些是纯 UI 元素,不属于 Model:

图表渲染中…

关键原则:选中状态的视觉表示属于 View 层,不影响 Model 数据。

图表渲染中…

Handle 是一种特殊的"子 Controller"——它将 SelectionTool 的拖拽逻辑进一步细化,避免 SelectionTool 自身过于复杂。

Controller 的核心原则:

  1. Controller 不持有业务数据——所有数据在 Model 中
  2. Controller 可以有 UI 状态——拖拽起点、预览图形等是 UI 状态
  3. Controller 负责坐标转换——视口坐标到文档坐标的映射
  4. Controller 是可替换的——切换工具不应影响 Model 和 View

四、命令模式与撤销重做

4.1 撤销/重做的本质:操作的可逆化

撤销/重做不仅是用户体验的刚需,更是验证 MVC 架构健壮性的试金石——如果撤销/重做需要大幅修改现有架构,说明 Model 的接口设计存在缺陷。

撤销/重做的核心需求是:任何对 Model 的修改操作都必须可以被逆转。这意味着 Model 不能只有"修改"接口,还必须有"逆修改"接口。

三种实现策略对比:

策略方法优点缺点
Memento 模式保存每次操作前后的完整状态快照实现简单,100% 可逆内存消耗大
Command 模式每个操作封装为可逆的 Command 对象内存消耗小Command 设计复杂
差异记录只记录状态差异(delta)内存最优恢复逻辑复杂

许式伟的倾向:Command 模式。它将操作语义化,不仅支持撤销/重做,还为网络协议的设计奠定了基础。

4.2 Command 模式详解

图表渲染中…

每个 Command 必须遵循以下规则:

  1. 自描述性:Command 内部包含所有执行和逆操作所需的数据
  2. 独立性:Command 不依赖外部状态(除了 Model 本身)
  3. 幂等性:execute 和 undo 可以反复调用,结果一致
  4. 原子性:要么完全执行,要么完全回退

AddShapeCommand 为例的执行流程:

图表渲染中…

4.3 Command History 的管理

两种历史栈模型:

图表渲染中…
图表渲染中…
模型优点缺点
双栈直观易懂新操作清空 Redo栈
单栈+指针支持分支历史实现稍复杂

许式伟选择双栈模型——简单直观,且新操作清空 Redo栈正是用户期望的行为(撤销后做新操作,不应重做旧操作)。

图表渲染中…

关键问题:

  • 栈大小限制:设上限(如100步),防止内存溢出
  • 复合操作:多个原子 Command 合并为一个 Macro Command
  • 跨文档操作:Command History 应随文档而非全局

4.4 复合操作与 Macro Command

用户操作往往包含多个原子步骤:

  • "粘贴" = 创建新图形 + 添加到文档
  • "删除选中图形" = 逐个删除所有选中图形
  • "组合图形" = 创建 GroupShape + 删除原始图形 + 添加 GroupShape

这些应被视为一个撤销/重做单元。

图表渲染中…

执行顺序:execute 按正序执行,undo 按逆序执行——这是复合操作可逆性的关键。

在交互过程中,操作可能跨多个鼠标事件。Transaction 机制确保整个交互过程作为一个原子操作:

图表渲染中…

4.5 Command 与网络协议的桥梁

许式伟提出:网络协议的定义应尽可能映射 Command 的语义。因为 Command 天然携带了操作的全部信息。

图表渲染中…

许式伟特别强调网络协议的重试友好性:同一操作执行两遍,结果与执行一遍一致。

操作类型重试友好性改造方案
查询操作(GET)天然友好无需改造
创建操作(POST)可能不友好客户端指定 ID 或 UUID
删除操作(DELETE)天然友好无需改造
修改操作(POST)相对值不友好用绝对值或附加 RequestUUID
图表渲染中…

如果撤销/重做难以实现,说明 Model 的接口设计有问题:

  • Model 的修改接口缺乏对称的逆操作
  • Model 的状态缺乏足够的可观测性

Command 模式是 MVC 架构的完整性检验器


五、完整架构与总结

5.1 从桌面到云端:架构的质变

联网带来的本质变化是:Model 不再是单一数据源,而变成分布式数据的多副本。由此产生三大核心问题:

  1. 数据一致性:多个副本如何保持一致?
  2. 冲突解决:并发修改如何合并?
  3. 离线支持:断网时如何正常编辑?
图表渲染中…

5.2 离线优先(Offline-First)架构

许式伟的核心观点是:离线优先,而非在线优先。也就是说,应用应该先假设网络不可用,设计出完全离线可用的架构,再在此基础上添加同步能力。

图表渲染中…

5.3 数据同步的核心模型

维度操作同步(Operation-based)状态同步(State-based)
传输内容Command/操作完整状态快照
带宽消耗
冲突处理OT/CRDTLast-Write-Wins
适合场景实时协作简单同步
实现复杂度

许式伟的倾向:画图程序采用操作同步,因为 Command 体系天然适配。

图表渲染中…

每个客户端维护一个逻辑时钟,服务端通过版本向量判断操作之间的因果关系:

图表渲染中…

因果一致性的规则

  • 如果操作 A happened-before 操作 B,则所有节点必须先看到 A 再看到 B
  • 如果 A 和 B 并发,则可以任意顺序,但需要冲突解决

5.4 冲突解决:OT 与 CRDT

Operational Transformation(OT) 的核心思想:当两个并发操作冲突时,通过变换(Transformation)使操作在对方执行后的上下文中仍然有效。

图表渲染中…

CRDT(Conflict-free Replicated Data Types) 的核心思想:通过数学结构保证,无论操作到达顺序如何,最终状态一定一致。

CRDT 类型适用场景画图程序应用
G-Counter只增计数图形ID生成
LWW-Register最后写入获胜图形属性(颜色、位置)
OR-Set可增可删集合Shape 列表管理
RGA有序列表图形层叠顺序
图表渲染中…

OT 与 CRDT 的选择:

维度OTCRDT
理论保证依赖中心化服务端去中心化,数学保证
实现复杂度极高(每对操作需变换函数)中等(数据结构设计)
服务端角色必须有OT引擎可选
性能传输数据量小数据结构有冗余
成熟度Google Docs等大规模验证新兴,生态较弱

许式伟的建议:小型团队优先考虑 CRDT,因为 OT 的变换函数矩阵随操作类型组合爆炸。

5.5 Model 层"厚度"的演进规律

这是本系列最深刻的架构洞察:Model 层的"厚度"随应用复杂度而增长,且增长方向是可预测的。

图表渲染中…
阶段Model 包含典型代码量占比
纯桌面数据结构 + CRUD + 命中测试20%
+文件系统+ 序列化 + 版本兼容 + 错误恢复35%
+网络同步+ Command + 历史管理 + 同步引擎 + 冲突解决60%+

这个规律的意义在于:架构设计时必须为 Model 层的增厚预留空间。具体做法:

  1. Model 与 View/Controller 保持严格边界
  2. Model 的内部结构分层(核心数据 / 持久化 / 同步)
  3. 新增 Model 能力时,通过"内聚地增厚"而非"向外泄漏"

5.6 画图实战系列的架构全貌

图表渲染中…

设计原则与权衡(Trade-off 分析)

将五讲中的核心架构决策汇总如下:

决策点方案 A方案 B选择与理由
Shape 体系继承体系组合模式+ComponentP0 用继承,P2 引入组合——渐进复杂度
图形存储扁平列表树形结构(图层)先扁平后树形——需求驱动
渲染方式立即模式保留模式保留模式——Model 即数据源
重绘策略全量重绘局部重绘先全量后局部——渐进优化
Shape 绘制Shape 自绘制独立 RendererShape 自绘制 + Graphics 抽象
状态管理Tool 自管全局状态机Tool 自管 + 有限状态协调
坐标系统视口坐标文档坐标文档坐标为主——支持缩放与滚动
交互建模if-else状态机简单用 if-else,复杂用状态机
预览机制叠加层临时 Model叠加层——保持 Model 纯净
命中测试Model 负责View 负责Model 负责——业务逻辑归 Model
撤销/重做Memento(状态快照)Command(操作封装)Command——语义化、内存优
历史栈模型双栈单栈+指针双栈——简单直观
同步模型操作同步状态同步操作同步——Command 天然适配
离线策略在线优先离线优先离线优先——更健壮
冲突解决OTCRDT小团队 CRDT,大团队 OT
网络协议RESTfulCommand 映射Command 映射——语义一致

画图程序应采用事件驱动 + 数据驱动的混合架构风格:

图表渲染中…

架构演进的节奏感——许式伟强调的不仅是架构设计本身,更是架构演进的节奏:

  1. 第一版:纯桌面,MVC 跑通
  2. 第二版:加入文件系统,Model 层增厚但接口不变
  3. 第三版:加入网络同步,Model 层继续增厚

每一步都是在前一步的基础上内聚地扩展,而非推翻重来。这种节奏感是架构师的核心能力。


实践案例或反模式

反模式一:巨型 Controller

初学者最容易犯的错误是将所有逻辑塞进 Controller(Tool)中:

c
// 反模式:Tool 承担了太多职责
class RectangleTool {
    void onMouseDown(x, y) {
        // 创建图形
        // 修改属性
        // 直接操作 Canvas 绘制
        // 直接操作 Document 保存
    }
}

正确做法:Tool 只负责将输入翻译为对 Model 的操作,渲染交给 View,数据交给 Model。

反模式二:View 直接操作 Model

c
// 反模式:View 既渲染又修改数据
class Canvas {
    void onPaint() {
        // 渲染
        for (shape in document.shapes) { shape.draw(...); }
        // 同时修改?View不应该修改Model!
        document.shapes[0].color = RED;
    }
}

View 的唯一职责是将 Model 的状态映射为像素。任何对 Model 的修改都必须通过 Controller 进行。

反模式三:God Tool

将所有交互逻辑放在一个"超级工具"中:

c
// 反模式:一个Tool处理所有情况
class SuperTool {
    void onMouseDown(event) {
        if (mode == "select") { ... }
        else if (mode == "rect") { ... }
        else if (mode == "ellipse") { ... }
        else if (mode == "freehand") { ... }
        // 无限膨胀...
    }
}

正确做法:每个工具类型一个独立类,通过 Tool Manager 统一调度。

反模式四:直接修改不可逆

text
// 反模式:直接修改属性,无法撤销
document.shapes[0].position = newPoint;
// 如果要撤销,怎么知道原来的位置?

正确做法:所有对 Model 的修改都通过 Command 封装:

text
command = new MoveShapeCommand(shape, oldPos, newPos);
commandHistory.execute(command);
// 撤销:command.undo() → shape回到oldPos

反模式五:Model 职责泄漏

c
// 反模式:同步逻辑泄漏到Controller
class SelectionTool {
    void onMouseUp(event) {
        document.moveShape(shape, delta);
        syncEngine.pushCommand(new MoveCommand(...)); // Controller不该管同步
    }
}

// 正确做法:同步逻辑内聚在Model层
class Document {
    void moveShape(shape, delta) {
        shape.move(delta);
        this.commandHistory.execute(new MoveCommand(shape, delta));
        this.syncEngine.notifyChange();  // Model内部协调同步
    }
}

小结与关键要点

要点说明
需求驱动架构先做需求分层(P0/P1/P2),P0 决定骨架,P1 验证弹性,P2 考验边界
领域模型先行识别 Document、Shape、Tool、Canvas 四大核心概念,行为抽象优于数据抽象
MVC 天然适配画图程序的交互模式与 MVC 三角完美对应,Model 必须自洽
保留模式渲染画图程序的数据天然持久化,保留模式天然适配,先全量重绘再优化局部重绘
Graphics 抽象层Shape 依赖绘制能力的抽象而非具体平台,比完全分离 Renderer 更务实
Tool = 策略模式 + 状态机每个 Tool 封装一种交互策略,复杂交互用状态机表达,预览不侵入 Model
Command 模式是正道操作封装为可逆对象,双栈管理历史,MacroCommand+Transaction 处理复合操作
离线优先先设计离线可用的架构,再添加同步能力,Command 体系是操作同步的天然载体
Model 厚度演进Model 层随复杂度增厚(纯桌面 → +文件 → +同步),但必须内聚而非泄漏
架构节奏感逐步演进,每步内聚扩展,不推翻重来——从 MVC 骨架到联网同步,每一步都为下一步铺路

关联阅读:画图实战系列至此结束。下一篇将讨论辅助界面元素的架构设计,回到桌面开发的核心议题。