极客时间已完结课程限时免费阅读

26 | 实战(一):怎么设计一个“画图”程序?

26 | 实战(一):怎么设计一个“画图”程序?-极客时间

26 | 实战(一):怎么设计一个“画图”程序?

讲述:丁伟

时长11:41大小10.69M

你好,我是七牛云许式伟。
到上一讲为止,桌面程序架构设计的基本结构就讲完了。直到现在为止,我们没有讨论任何与具体的应用业务逻辑本身相关的内容。这是因为探讨的内容是普适有效的设计理念,整个讨论会显得很抽象。
今天我们结合一个实际的应用案例,来回顾一下前面我们介绍的内容。
我们选择了做一个 “画图” 程序。选它主要的原因是画图程序比较常见,需求上不需要花费过多的时间来陈述。
我们前面说过,一个 B/S 结构的 Web 程序,基本上分下面几块内容。
Model 层:一个多用户(Multi-User)的 Model 层,和单租户的 Session-based Model。从服务端来说,Session-based Model 是一个很简单的转译层。但是从浏览器端来说,Session-based Model 是一个完整的单租户 DOM 模型。
View 层:实际是 ViewModel 层,真正的 View 层被浏览器实现了。ViewModel 只有 View 层的数据和可被委托的事件。
Controller 层:由多个相互解耦的 Controller 构成。切记不要让 Controller 之间相互知道对方,更不要让 View 知道某个具体的 Controller 存在。
画图程序的源代码可以在 Github 上下载,地址如下:
今天我们讨论浏览器端的 Model,View 和 Controller。

Model 层

我们先看 Model 层。浏览器端的 Model 层,代码就是一个 dom.js 文件。它是一棵 DOM 树,根节点为 QPaintDoc 类。整个 DOM 树的规格如下:
class QLineStyle {
properties:
width: number
color: string
methods:
constructor(width: number, color: string)
}
class QLine {
properties:
pt1, pt2: Points
lineStyle: QLineStyle
methods:
constructor(pt1, pt2: Point, lineStyle: QLineStyle)
onpaint(ctx: CanvasRenderingContext2D): void
}
class QRect {
properties:
x, y, width, height: number
lineStyle: QLineStyle
methods:
constructor(r: Rect, lineStyle: QLineStyle)
onpaint(ctx: CanvasRenderingContext2D): void
}
class QEllipse {
properties:
x, y, radiusX, radiusY: number
lineStyle: QLineStyle
methods:
constructor(x, y, radiusX, radiusY: number, lineStyle: QLineStyle)
onpaint(ctx: CanvasRenderingContext2D): void
}
class QPath {
properties:
points: []Point
close: bool
lineStyle: QLineStyle
methods:
constructor(points: []Point, close: bool, lineStyle: QLineStyle)
onpaint(ctx: CanvasRenderingContext2D): void
}
interface Shape {
onpaint(ctx: CanvasRenderingContext2D): void
}
class QPaintDoc {
methods:
addShape(shape: Shape): void
onpaint(ctx: CanvasRenderingContext2D): void
}
目前这个 DOM 还是单机版本的,没有和服务端的 Session-based Model 连起来。关于怎么连,我们下一讲再讨论。
这个 Model 层的使用是非常容易理解的,也非常直观体现了业务。主要支持的能力有以下两个方面。
其一,添加图形(Shape),可以是 QLine,QRect,QEllipse,QPath 等等。
其二,绘制(onpaint)。前面我们介绍 MVC 的时候,我曾提到为了 View 层能够绘制,需要让 DOM 层把自己的数据暴露给 View 层。
但是从简洁的方式来说,是让 Model 层自己来绘制,这样就避免暴露 DOM 层的实现细节。虽然这样让 Model 层变得有那么一点点不纯粹,因为和 GDI 耦合了。但是我个人认为耦合 GDI 比暴露 DOM 的数据细节要好,因为 GDI 的接口通常来说更稳定。
依赖选择是考虑耦合的一个关键因素。在依赖选择上,我们会更倾向于依赖接口更为稳定的组件,因为这意味着我们的接口也更稳定。

ViewModel 层

我们再看 ViewModel 层。它的代码主要是一个 index.htm 文件和一个 view.js 文件。index.htm 是总控文件,主要包含两个东西:
界面布局(Layout);
应用初始化(InitApplication),比如加载哪些 Controllers。
view.js 是我们 ViewModel 层的核心,实现了 QPaintView 类。它的规格如下:
interface Controller {
stop(): void
onpaint(ctx: CanvasRenderingContext2D): void
}
class QPaintView {
properties:
doc: QPaintDoc
properties: {
lineWidth: number
lineColor: string
}
drawing: DOMElement
controllers: map[string]Controller
methods:
get currentKey: string
get lineStyle: QLineStyle
onpaint(ctx: CanvasRenderingContext2D): void
invalidateRect(rect: Rect): void
registerController(name: string, controller: Controller): void
invokeController(name: string): void
stopController(): void
getMousePos(event: DOMEvent): Point
events:
onmousedown: (event: DOMEvent):void
onmousemove: (event: DOMEvent):void
onmouseup: (event: DOMEvent):void
ondblclick: (event: DOMEvent):void
onkeydown: (event: DOMEvent):void
}
var qview = new QPaintView()
看起来 QPaintView 的内容有点多,我们归类一下:
和 Model 层相关的,就只有 doc: QPaintDoc 这个成员。有了它就可以操作 Model 层了。
属于 ViewModel 层自身的,数据上只有 properties 和 drawing。其中 properties 是典型的 ViewModel 数据,用来表示当前用户选择的 lineWidth 和 lineColor 等。drawing 则是浏览器对 HTML 元素的抽象,通过它以及 JavaScript 全局的 document 对象就可以操作 HTML DOM 了。
当然 ViewModel 层一个很重要的责任是绘制。onpaint 和 invalidRect 都是绘制相关。invalidRect 是让界面的某个区域重新绘制。当前为了实现简单,我们总是整个 View 全部重新绘制。
前面我说过, Web 开发一个很重要的优势是不用自己处理局部更新问题,为什么这里我们却又要自己处理呢?原因是我们没有用浏览器的 Virtual View,整个 DOM 的数据组织完全自己管理,这样我们面临的问题就和传统桌面开发完全一致。
剩下来的就是 Controller 相关的了。主要功能有:
registerController(登记一个 Controller),invokeController(激活一个 Controller 成为当前 Controller),stopController(停止当前 Controller),View 层并不关心具体的 Controller 都有些什么,但是会对它们的行为规则进行定义;
事件委托(delegate),允许 Controller 选择自己感兴趣的事件进行响应;
getMousePos 只是一个辅助方法,用来获取鼠标事件中的鼠标位置。
View 层在 MVC 里面是承上启下的桥梁作用。所以 View 层的边界设定非常关键。
如果我们把实际绘制(onpaint)的工作交给 Model 层,那么 View 基本上就只是胶水层了。但是就算如此,View 层仍然承担了一些极其重要的责任。
屏蔽平台的差异。Model 层很容易做到平台无关,除了 GDI 会略微费劲一点;Controller 层除了有少量的界面需要处理平台差异外,大部分代码都是响应事件处理业务逻辑,只要 View 对事件的抽象得当,也是跨平台的。
定义界面布局。不同尺寸的设备,界面交互也会不太一样,在 View 层来控制不同设备的整体界面布局比较妥当。

Controller 层

最后我们看下 Controller 层。Controller 层的文件有很多,这还是一些 Controller 因为实现相近被合并到一个文件。详细信息如下。
Menu, PropSelectors, MousePosTracker: accel/menu.js
Create Path:creator/path.js
Create FreePath:creator/freepath.js
Create Line, Rect, Ellipse, Circle: creator/rect.js
其中,menu.js 主要涉及各种命令菜单和状态显示用途的界面元素。用于创建各类图形(Shape),选择当前 lineWidth、lineColor,以及显示鼠标当前位置。
在创建图形这些菜单项上,有两点需要注意。
其一,菜单并不直接和各类创建图形的 Controller 打交道,而是调用 qview.invokeController 来激活对应的 Controller,这就避免了两类 Controller 相互耦合。
其二,虽然前面 Model 层支持的图形只有 QLine、QRect、QEllipse、QPath 等四种,但是界面表现有六种:Line、Rect、Ellipse、Circle、Path、FreePath 等等。这是非常正常的现象。同一个 DOM API 在 Controller 层往往会有多条实现路径。
选择当前 lineWidth、lineColor 操作的对象是 ViewModel 的数据,不是 Model。这一点前面几讲我们也有过交代。我们当时举的例子是 Selection。其实你把当前 lineWith、lineColor 看作是某种意义上的 Selection ,也是完全正确的认知。
鼠标位置跟踪(MousePosTracker)是一个极其简单,但也是一个很特殊的 Controller,它并不操作任何正统意义的数据(Model 或 ViewModel),而是操作输入的事件。
剩下来的几个 JavaScript 文件都是创建某种图形。它们的工作机理非常相似,我们可以随意选一个看一下。比如 QRectCreator 类,它的规格如下:
class QRectCreator {
methods:
constructor(shapeType: string)
stop(): void
onpaint(ctx: CanvasRenderingContext2D): void
onmousedown: (event: DOMEvent):void
onmousemove: (event: DOMEvent):void
onmouseup: (event: DOMEvent):void
onkeydown: (event: DOMEvent):void
}
在初始化(构造)时,QRectCreator 要求传入一个 shapeType。这是因为 QRectCreator 实际上并不只是用于创建 Rect 图形,还支持 Line、Ellipse、Circle。只要通过选择两个 points 来构建的图形,都可以用 QRectCreator 这个 Controlller 来做。
QRectCreator 接管了 View 委托的 mousedown、mousemove、mouseup、keydown 事件。
其中,mousedown 事件记录下第一个 point,并由此开启了图形所需数据的收集过程,mouseup 收集第二个 point,随后后创建相应的 Shape 并加入到 DOM 中。keydown 做什么?它用来支持按 ESC 放弃创建图形的过程。

架构思维上我们学习到什么?

通过分析这个 “画图” 程序,你对此最大的收获是什么?欢迎留言就此问题进行交流。这里我也说说我自己想强调的点。
首先,这个程序没有依赖任何第三方库,是裸写的 JavaScript 代码。关于这一点,我想强调的是:
第一,这并不是去鼓励裸写 JavaScript 代码,这只是为了消除不同人的喜好差异,避免因为不熟悉某个库而导致难以理解代码的逻辑;
第二,大家写代码的时候,不要被框架绑架,框架不应该增加代码的耦合,否则这样的框架就应该丢了;更真实的情况是,你很可能是在用一个好框架,但是是不是真用好了,还是取决于你自己的思维。
从架构设计角度来说,在完成需求分析之后,我们就进入了架构的第二步:概要设计(或者也可以叫系统设计)。这个阶段的核心话题是分解子系统,我们关心的问题是下面这些。
每个子系统负责什么事情?
它依赖哪些子系统?它能够少知道一些子系统的存在么?
它们是通过什么接口耦合的?这个接口是否自然体现了两者的业务关系?它们之间的接口是否足够稳定?
MVC 是一个分解子系统的基本框架,它对于桌面程序尤为适用。通过今天对 “画图” 程序的解剖,我们基本能够建立桌面程序框架上非常一致的套路:
Model 层接口要自然体现业务逻辑;
View 层连接 Model 与 Controller,它提供事件委托(delegate)方便 Controller 接收感兴趣的事件,但它不应该知道任何具体的 Controller;
Controller 层中,每个 Controller 都彼此独立,一个 Controller 的职责基本上就是响应事件,然后调用 Model 或 ViewModel 的接口修改数据。
当然,这里没有讨论特定应用领域本身相关的架构问题。对于桌面程序而言,这件事通常发生在 Model 层。但对于我们今天的例子 “画图” 程序而言,Model 层比较简单,基本上还不太需要讨论。在后面,我们也可能会尝试把这个 “画图” 程序需求变复杂,看架构上应该怎么进行应对。

结语

今天我们结合一个大家非常熟悉的例子 “画图” 程序来介绍 MVC 架构。虽然我们基于 Web 开发,但是我们当前给出的画图程序本质上还是单机版的。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
分享给需要的人,Ta购买本课程,你将得20
生成海报并分享

赞 10

提建议

上一篇
25 | 桌面开发的未来
下一篇
27 | 实战(二):怎么设计一个“画图”程序?
unpreview
 写留言

精选留言(37)

  • 田常发
    2019-07-23
    许老师,我想问一下,学go的话要不要去学一下,设计模式,有的话,您有没有什么资料可以推荐一下?

    作者回复: 学什么语言都要设计模式。不过设计最重要的不是模式,而是关于解耦的思考

    共 4 条评论
    42
  • xiaobang
    2019-07-24
    这一章看的有点卡,主要是因为对前端知识不熟悉。我看到js代码里面出现class关键字一度怀疑用的是什么语言。另外提一个建议,老师讲的时候能不能按照软件设计的过程来展开。比如开始做需求分析,这个画图程序要提供哪些功能,支持哪些图形等。然后列出平台对画图的支持,再描述在平台上直接硬写会写成什么样的程序,最后按mvc模式重构。我感觉这样能更好的表现设计的思路。

    作者回复: 多谢建议

    20
  • Smallfly
    2019-08-17
    下载源码并切换到 v26 分支,用浏览器打开 index.html 文件,可使用开发者工具进行断点调试。 绘制矩形的过程:  1. 点击 `Creat Rect` 按钮,激活 react.js(Controller);  2. view.js(View) 接收到 drawing 的 onmousedown 事件,传递给事件的实现者 react.js;  3. react.js 接收到 onmousedown 事件,记录起始点 p1;  4. 同理,react.js 接收到 onmousemove 事件,记录 p2, 并调用全局函数 invalidate 绘制;  5. invalidate 交给 qview.invalidateRect 处理(为什么不直接调用 qview.invalidateRect?);  6. qview.invalidateRect 调用 qview.onpaint,在 onpaint 中调用当前激活的 rect.js 的 onpaint 方法;  7. rect.js 中 onpaint 调用 buildShape() 创建 QRect(Model) 实例,然后调用该实例的 onpaint 方法绘制;  8. 绘制结束时 react.js 接收到 onmouseup 事件,把当前 model 实例存入 doc(Model),保证重绘时能够再次绘制原来的图形; MVC 角色的通信过程: V -> C -> V -> C -> M
    展开

    作者回复: react.js => rect.js

    共 4 条评论
    14
  • Linuxer
    2019-07-19
    由Controller来创建具体Shape这样会不会,model和controller又耦合了呢

    作者回复: controller本来就会耦合model和view。重点是controller之间不要耦合

    13
  • Smallfly
    2019-08-17
    五讲画图程序已经讲完了,第一次只是泛泛而读,这次打算精读,并整理一下自己的理解。 第一讲的重点架构思想包括:  1. 为了避免 Model 知道 View 的实现细节,可以让 Model 耦合 GDI 接口。模块间通信如果避免不了耦合,就耦合稳定的模块,这个模块最好是系统的,因为系统模块相对于业务模块通常更加稳定;  2. ViewModel 持有 Model,并由 Controller 来更新 Model/ViewModel;  3. ViewModel 定义 Controller 的行为规则,但并不关心 Controller 的具体行为。Controller 可以选择性的接管 ViewModel 的事件;  4. ViewModel 协调 Model 和 Controller,启到承上启下的作用,所以 ViewModel 职责的划分对程序的结构有比较大的影响;  5. Model 的结构稳定,容易做到平台无关,ViewModel 会跟平台强关联;  6. 避免 Controller 之间的耦合,可以使用 ViewModel 作为通信中介者;  7. 相同的 Model 可能在 Controller 层有不同的展现方法; 本讲中 View 应该理解为 ViewModel,View 是不应该持有 Model 数据的,文中老师也说了网页的 View 是由浏览器实现的,个人觉得从严格意义上将,这不算是 MVC 模式,也不像 MVVM,应该叫 MVMC? 下载源码并切换到 v26 分支,用浏览器打开 index.html 文件,可使用开发者工具进行断点调试。
    展开

    作者回复: 👍

    9
  • 八哥
    2019-07-19
    CEO,js写的还这么好。厉害
    8
  • Geek_88604f
    2019-08-10
    浏览器打开index.htm,首先创建canvas画布对象,后面的画图操作都是在画布对象上进行的。 接着加载dom对象,注意这里并没有实例化dom对象。 然后加载view对象,并且实例化view对象和dom对象。view对象中定义了事件处理规则,接管画布的事件处理,将onpaint委托给了doc和contraller,注意doc的onpaint和contraller的onpaint绘制的是不同的内容,还有就是定义了contraller的操作规则。 接下来就是加载contraller,调用view的方法注册自己,并将事件处理委托给contraller。 最后加载和显示菜单,定义contraller的鼠标点击事件,在点击事件中指定当前contraller。这样当鼠标在画布上移动时,通过层层委托(画布——view——contraller)最终触发contraller的invalidate,invalidate触发onpaint,onpaint触发doc.onpaint。
    展开

    作者回复: 👍

    共 2 条评论
    5
  • Geek_88604f
    2019-08-06
    看了几遍捋一下思路:view接收到用户事件,把事件处理委托给controller,由controller来操作model。是这样的吗,老师?

    作者回复: 是的

    5
  • Halohoop
    2019-08-16
    划重点:“依赖选择是考虑耦合的一个关键因素。在依赖选择上,我们会更倾向于依赖接口更为稳定的组件,因为这意味着我们的接口也更稳定。”
    2
  • kyle
    2019-07-19
    掉队了
    2
  • 果粒橙
    2021-09-15
    index.html中的<script src="creator/path.js?v=7">,v=7是怎么传给path.js的,起什么作用?没学过js不太懂

    作者回复: 这个没有用,只是用来防治浏览器不正确的缓存,在url变化后它就会重新请求服务器

    1
  • 浩然
    2020-09-23
    用户点击的时候(invokeController)激活某个Controller -> Controller接管事件 -> 通知View触发绘制 绘制完调用onmouseup事件 -> 然后view通知M(qview.doc.addShape) V-C-VM-M M层做厚 V/VM承上启下 C相互独立 供二次开发API放在VM层 为了理解老师讲的手动挪了一下代码 https://github.com/jianghaoran116/study-code/tree/mvc-26/mvc
    展开
    1
  • Geek_gooy
    2020-01-15
    听完画图的课,问题真的是一大堆。 不清楚这个画图程序最终是长什么样的,没有提前给出ui设计。 不知道程序的流程图。 不知道程序最终怎么发布,用户怎么使用。 知道用HTML.js.go语言,但不知道go语言扮演的是什么角色,对于一个没接触过go语言的人来说。 不理解所谓离线画图还要用地址加端口访问,这不是远程了吗。 ……
    展开

    作者回复: 多谢反馈

    1
  • Geek_88604f
    2019-08-04
    view.js中"let ctx = this.drawing.getContext("2d")",这段代码是什么意思?

    作者回复: 取得二维(2D)的 Canvas 上下文,用于绘制。详细参阅: https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/getContext

    1
  • Jian
    2019-08-01
    最大的收获还是解耦。首先将应用程序进行抽象/分层,然后通过中间的view层将model层和controller层串联在一起。 controller的使用,就像java中的interface,其有多重实现方式。 这是第二次读这篇文章了,因为没有接触过前端,不知道前端的实习方式。后面看懂了代码,但是如果能有个流程图样的粗略解释可能会更有利于理解吧。

    作者回复: 多谢建议

    2
  • Luke
    2019-07-19
    保持跟进,目前只能说好像有点懂了,还是得在自己项目上多思考下,不是真的懂了
    1
  • Aaron Cheung
    2019-07-19
    大前端 还是非常有价值 打卡
    1
  • FOCUS
    2022-01-22
    先是层之间的切分,算作为层级的子系统; 层,其中的构成,层里面的子系统; 层与层之间的关系; 架构指导原则: 1> 系统的稳定点与变化点; 2> 减少耦合,如:controller 之间的等,models 的构成之间; 问题: 如何思考,以减少耦合? 如何区分稳定点和变化点?
    展开
  • 静心
    2021-12-23
    感觉要想真正理解这篇文章需要先了解MVC、MVVM、MVP几种架构模型的区别。建议参考一下以下文章:https://draveness.me/mvx/
  • méng
    2021-09-21
    看了一些评论,得到一个理解,不知道对不对:这里的mvc只讨论分工,相当于是把领域的功能按职责分工。model是业务的核心,它与平台无关。controller是领域服务,即根据具体的也许行为提供调度服务。view层则是针对具体的可视化平台响应操作。 v调用c调用m

    作者回复: v不会主动感知c。m+v构成只读的软件,然后v只是无脑委托事件交给c,c来实现真正意义上的用户交互

    共 2 条评论