mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 15:43:44 +08:00
mod
This commit is contained in:
121
极客时间专栏/许式伟的架构课/桌面开发篇/20 | 桌面开发的宏观视角.md
Normal file
121
极客时间专栏/许式伟的架构课/桌面开发篇/20 | 桌面开发的宏观视角.md
Normal file
@@ -0,0 +1,121 @@
|
||||
<audio id="audio" title="20 | 桌面开发的宏观视角" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/64/21/64434279393dc4e62a4f9d5bd6b6fb21.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。今天开始,我们进入第二章,谈谈桌面软件开发。
|
||||
|
||||
从架构的角度,无论你在什么样的终端设备(比如:PC、手机、手表、手机等等),也无论你在做 Native 应用,还是基于 Web 和小程序,我们都可以统一称之为桌面程序。
|
||||
|
||||
如前文所述,一个桌面程序完整的架构体系如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/c7/3af7a4830566a5b3e1058f409422b7c7.png" alt="">
|
||||
|
||||
对于桌面程序,最核心的话题是交互。为了把关注点收敛到交互上,我们下面重点讨论操作系统对交互范式的设计。
|
||||
|
||||
从需求角度看,桌面程序的交互方式并不稳定,它的交互范式经历了很多次的迭代。
|
||||
|
||||
## 命令行交互
|
||||
|
||||
最早出现的交互范式是命令行交互程序。使用的交互设备为**键盘+显示器**。
|
||||
|
||||
输入被抽象为一段以回车(Enter键)为结束的文本(通常是单行文本,要输入多行文本,需要在行末输入“ \ ”对回车进行转义)。
|
||||
|
||||
**输入方式有二:一是命令行,二是标准输入(stdin)。**输出也是文本,但输出目标可能是标准输出(stdout),也可能是标准错误(stderr)。
|
||||
|
||||
正常情况下,标准输出(stdout)和标准错误(stderr)都是向屏幕输出。这种情况下,肉眼并不能区分输出的内容是标准输出,还是标准错误。
|
||||
|
||||
命令行交互程序的输入输出可以被重定向。一个程序的输出,可以被重定向写入到文件(标准输出和标准错误可以输出到不同的文件以进行区分),也可以通过管道功能重定向为另一个程序的输入。
|
||||
|
||||
总结一下,命令行交互程序的结构可示意如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/06/ca658cf1f5801f9b70c966eac71acf06.png" alt="">
|
||||
|
||||
但命令行程序的限制过大了,人们很容易发现,在很多需求场景下这是非常反人类的,最典型的就是编辑器。稍微想象一下,你就会为怎么做好交互设计而头疼不已。
|
||||
|
||||
## 字符界面
|
||||
|
||||
于是,字符界面程序出现了。使用的交互设备仍然是**键盘+显示器**,但是输入不再是一段文本,而是**键盘按键事件**(KeyDown 和 KeyUp)。
|
||||
|
||||
输出也不是一段文本,而是可以修改屏幕任何位置显示的字符(屏幕被分割成M*N的格子,每个格子可以显示一个字符)。
|
||||
|
||||
这个时候,键盘的功用在需求上分化为两个:一是输入文本,二是输入命令(通常通过扩展键比如方向键,或者组合键比如Ctrl-A、Alt-X)。从输入文本的角度,需要有当前输入的光标(Caret)位置。
|
||||
|
||||
字符界面程序保留命令行输入的方式,但一般不太使用标准输入。其结构示意如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/4a/4d6ef7de18f1dc46f770e2155184204a.png" alt=""><br>
|
||||
上图的 TDI 含义是字符设备接口(Text Device Interface),它指的是一组向屏幕绘制文本的方法集合。大体看起来是这样的:
|
||||
|
||||
```
|
||||
func ClearScreen()
|
||||
func DrawText(x, y int, text string)
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
但是,字符界面程序也有很大的局限。最典型的需求场景是游戏。一些简单的游戏比如俄罗斯方块是可以基于字符界面做出来的,但大部分情况下,字符界面对于游戏类场景能够做的事情非常有限。
|
||||
|
||||
## 图形界面
|
||||
|
||||
于是,图形界面程序出现了。使用的交互设备是**键盘+鼠标+显示器+音箱**。从交互演进角度,这是一个划时代的变化。
|
||||
|
||||
与字符界面时期相比,图形界面时代变化的根源是输出的变化:从字符变成像素。屏幕被分割为精度更高的M * N的格子,每个格子显示的是一个很小很小的像素,每个像素可以有不同的颜色。
|
||||
|
||||
**为什么会出现鼠标?**因为屏幕精度太高,用键盘的方向键改变当前位置,不只是看起来非常笨拙,而且操作上也很不自然。
|
||||
|
||||
**为什么出现音箱**的原因则比较平凡,它只不过是声音设备演进的自然结果。事实上在字符交互时期声音设备就已经有了,计算机主板上有内置的喇叭。
|
||||
|
||||
这个喇叭最大的用途是出现重大错误(比如计算机启动失败)的时候会响几声给予提示。
|
||||
|
||||
开发人员可以通过向标准输出(stdout)或标准错误(stderr)输出一个特殊的字符让喇叭响一声。
|
||||
|
||||
前面我们说过,输出到标准输出和标准错误对肉眼来说不可区分,所以如果我们向标准错误输出文本前让喇叭响一声,也是一个不错的一种交互范式。
|
||||
|
||||
与字符界面程序相比,图形界面程序还有一个重大变化,是多窗口(当然,部分复杂的字符界面程序也是多窗口的,比如 Turbo C++ 3.0,它用的是 Turbo Vision 这个知名的字符界面库)。
|
||||
|
||||
窗口(Window),也有人会把它叫视图(View),是一个独立可复用的界面元素。复杂的窗口可以切分出多个逻辑独立的子窗口,以降低单个窗口的程序复杂性。
|
||||
|
||||
窗口有了父子和兄弟关系,就有了窗口系统。一旦界面涉及复杂的窗口系统,交互变得更为复杂。例如,键盘和鼠标事件的目标窗口的确定,需要一系列复杂的逻辑。
|
||||
|
||||
为了降低编程的负担,窗口系统往往接管了桌面程序的主逻辑,提供了一套基于事件驱动的编程框架,业务代码由窗口系统提供的界面框架所驱动。整个程序的结构如下所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/c5/b8063e7ac32e854676b640c86d4628c5.png" alt="">
|
||||
|
||||
上图的 GDI 含义是图形设备接口(Graphic Device Interface),它指的是一组向指定窗口(注意不是屏幕)绘制图形的方法集合。绘制的对象包括有几何图形、图像、文本等。
|
||||
|
||||
此后,到了移动时代,手机成了最主流的计算机。使用的交互设备发生了变化,变成了**触摸屏+麦克风+内置扬声器**。
|
||||
|
||||
鼠标交互方式被淘汰,变成了多点触摸。**键盘+鼠标+显示器**的能力被融合到触摸屏上。
|
||||
|
||||
音箱也被内置到手机中,变成内置扬声器。这些变化都因移动设备便携性的述求引起。从架构的角度,它们并没有引起实质性的变化,只是鼠标事件变成了触摸事件。
|
||||
|
||||
## 智能交互
|
||||
|
||||
**麦克风让计算机多了一个输入:语音。**有三种典型的用法。
|
||||
|
||||
一是在应用内把语音录下来,直接作为类似照片视频的媒体消息,或者识别为文本去应用(比如语音搜索)。
|
||||
|
||||
二是作为语音输入法输入文本(逻辑上可以认为是第一种情况的特例,只不过输入法在操作系统中往往有其特殊的地位)。
|
||||
|
||||
三是基于类似 Siri 语音助手来交互。
|
||||
|
||||
语音助手是被寄予厚望的新的交互范式。它可能开启了新的交互时代,我们不妨把它叫智能交互时代。但当前它与图形界面程序结构并不相容,而是完全自成体系,如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/78/d2fcb17480e88fcc398b6f702f7ea578.jpg" alt="">
|
||||
|
||||
为什么语音交互和图形界面交互没法很好地融合在一起?我认为有两个原因。
|
||||
|
||||
**一是语音交互有很强的上下文,所以语音交互程序通常其业务代码也由语音交互系统提供的框架所驱动。**框架的特点是侵入性强,框架与框架之间很难融合。
|
||||
|
||||
**二是语音交互还不成熟,所以独立发展更简单,如果有一天成熟了,完全可以重写框架,把语音和触摸屏结合起来,形成全新的交互范式。**
|
||||
|
||||
未来交互会怎样?智能交互很可能不会止步于语音,而是视频(同是兼顾视觉和听觉),且与触摸屏完美融合。使用的交互设备有**触摸屏+摄像头+麦克风+内置扬声器**。整个程序的结构如下所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/ff/b9ad3b924ecbe054325da1d4243b39ff.png" alt="">
|
||||
|
||||
## 结语
|
||||
|
||||
通过以上对交互演化历程的回顾,我们看到交互范式的演进是非常剧烈的。交互体验越来越自然,但从编程的角度来说,如果没有操作系统支持,实现难度也将越来越高。
|
||||
|
||||
这也说明了一点,桌面操作系统和服务端操作系统的演进方向非常不一样。桌面操作系统的演进方向主要是交互范式的迭代,在向着越来越自然、越来越智能的交互前进。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将介绍:“图形界面程序的框架”。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
127
极客时间专栏/许式伟的架构课/桌面开发篇/21 | 图形界面程序的框架.md
Normal file
127
极客时间专栏/许式伟的架构课/桌面开发篇/21 | 图形界面程序的框架.md
Normal file
@@ -0,0 +1,127 @@
|
||||
<audio id="audio" title="21 | 图形界面程序的框架" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/5e/f90d6f443a775b1cfcb5fc23byy6e95e.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
上一讲我们回顾了交互的演化历程。今天,我们将关注点收敛到现在仍然占主流地位的图形界面程序。它的结构如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/c5/b8063e7ac32e854676b640c86d4628c5.png" alt="">
|
||||
|
||||
实现一个图形界面程序,最大的复杂性在于不同操作系统的使用接口完全不同,差异非常巨大。这给开发一个跨平台的图形界面程序带来巨大挑战。
|
||||
|
||||
好在,尽管操作系统的使用接口有异,但基本的大逻辑差不多。今天我们从统一的视角来看待,谈谈图形界面程序的框架。
|
||||
|
||||
## 事件
|
||||
|
||||
无论是什么桌面操作系统,每个进程都有一个全局的事件队列(Event Queue)。当我们在键盘上按了一个键、移动或者点击鼠标、触摸屏幕等等,都会产生一个事件(Event),并由操作系统负责将它扔到进程的事件队列。整个过程大体如下。
|
||||
|
||||
- 键盘、鼠标、触摸屏等硬件产生了一个硬件中断;
|
||||
- 操作系统的硬件中断处理程序收到对应的事件(Event);
|
||||
- 确定该事件的目标进程;
|
||||
- 将事件放入目标进程的事件队列(Event Queue)。
|
||||
|
||||
## 窗口与事件响应
|
||||
|
||||
窗口(Window),也有人会把它叫视图(View),是一个独立可复用的界面元素(UI Element)。一个窗口响应发送给它的事件(Event),修改内部的状态,然后调用 GDI 绘制子系统更新界面显示。
|
||||
|
||||
**响应事件的常见机制有两种。**
|
||||
|
||||
**一种是事件处理类**(EventHandler,在 iOS 中叫 Responder)。通常,我们自定义的窗口类会直接或间接从事件处理类继承。Windows 平台有些特殊,为了让窗口类可复用,且与语言无关,它将事件处理做成了回调函数,术语叫窗口过程(WindowProc)。这只是形式上的不同,并无本质差异。
|
||||
|
||||
**另一种是用委托**(delegate)。顾名思义,用委托的意思是事件处理不是收到事件的人自己来做,而是把它委托给了别人。这只是一种编程的手法。比如,在 Web 编程中我们给一个界面元素(UI Element)实现 onclick 方法,这可以理解为是一种委托(delegate)。
|
||||
|
||||
有一个事件比较特殊,它往往被叫做 onPaint 或 onDraw。为什么会有这样的事件?我们想象一下,当一个窗口在另一个窗口的上面,并且我们移动其中一个窗口时,部分被遮挡的窗口内容会显露出来。
|
||||
|
||||
这个过程我们可能觉得很自然,但实际上,操作系统并不会帮我们保存被遮挡的窗口内容,而是发送 onPaint 事件给对应的窗口让它重新绘制。
|
||||
|
||||
另外,不只是窗口可以响应事件,应用程序(Application)也可以。因为有一些事件并不是发送给窗口的,而是发给应用程序的,比如:本进程即将被杀死、手机低电量告警等等。
|
||||
|
||||
当然如果我们约定一定存在一个主窗口(Main Window),那么把应用程序级别的事件理解为是发给主窗口的也可以。
|
||||
|
||||
## 事件分派
|
||||
|
||||
事件是怎么从全局的事件队列(Event Queue)到窗口的呢?
|
||||
|
||||
这就是事件分派(Event Dispatch)过程,它通常由一个事件分派循环(Event Dispatch Loop)来完成。一些平台把这个过程隐藏起来,直接提供一个类似 RunLoop 这样的函数。也有一些平台则让你自己实现。
|
||||
|
||||
例如,对于 Windows 平台,它把事件叫消息(Message),事件分派循环的代码看起来是这样的:
|
||||
|
||||
```
|
||||
func RunLoop() {
|
||||
for {
|
||||
msg, ok := winapi.GetMessage() // 从事件队列中取出一个消息
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
winapi.TranslateMessage(msg)
|
||||
winapi.DispatchMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
大体来说,就是一个简单的取消息(GetMessage)然后对消息进行分派(DispatchMessage)的过程。其中 TranslateMessage 函数你可能比较陌生,它负责的是将键盘按键事件(onKeyDown、onKeyUp)转化为字符事件(onChar)。
|
||||
|
||||
窗口有了父子和兄弟关系,就有了窗口系统。一旦界面涉及复杂的窗口系统,交互变得更为复杂。事件分派过程怎么知道应该由哪个窗口响应事件呢?
|
||||
|
||||
这就是事件处理链(EventHandler Chain)。
|
||||
|
||||
不同事件的分派过程并不一样。
|
||||
|
||||
对于鼠标或者触摸屏的触摸事件,事件的响应方理应是事件发生处所在的窗口。但也会有一些例外的场景,比如拖放。为了支持拖放,Windows 系统引入了鼠标捕获(Mouse Capture)的概念,一旦鼠标被某个窗口捕获,哪怕鼠标已经移出该窗口,事件仍然会继续发往该窗口。
|
||||
|
||||
对于键盘事件(onKeyDown/onKeyUp/onChar),则通常焦点窗口先响应,如果它不感兴趣再逐层上升,直到最顶层的窗口。
|
||||
|
||||
**键盘从功能上来说,有两个不同的能力:其一是输入文本,其二是触发命令。**从输入文本的角度来说,要有一个输入光标(在Windows里面叫Caret)来指示输入的目的窗口。目的窗口也必然是焦点窗口,否则就会显得很不自然。
|
||||
|
||||
但是从触发命令的角度来说,命令的响应并不一定是在焦点窗口,甚至不一定在活跃窗口。比如Windows下就有热键(HotKey)的概念,能够让非活跃窗口(Inactive Window)也获得响应键盘命令的机会。一个常见的例子是截屏软件,它往往需要一个热键来触发截屏。
|
||||
|
||||
到了移动时代,键盘不再是交互主体,但是,键盘作为输入文本的能力很难被替代(虽然有语音输入法),于是它便自然而然地保留下来。
|
||||
|
||||
不过在移动设备里,不太会有人会基于键盘来触发命令,只有常见的热键需求比如截屏、调大/调小音量、拍照等等,被设计为系统功能(对应的,这些功能的热键也被设计为系统按键)保留下来。
|
||||
|
||||
## 窗口内容绘制
|
||||
|
||||
在收到 onPaint 或 onDraw 消息时,我们就要绘制我们的窗口内容了,这时就需要操作系统的 GDI 子系统。
|
||||
|
||||
从大分类来说,我们首先要确定要绘制的内容是 2D 还是 3D 的。对于 2D 内容,操作系统 GDI 子系统往往有较好的支持,但是不同平台终究还是会有较大的差异。而对于 3D 内容来说,OpenGL 这样的跨平台方案占据了今天的主流市场,而 Vulkan 号称是 NextGL(下一代的 OpenGL),其潜力同样不容小觑。
|
||||
|
||||
从跨平台的难易程度来说,不同平台的 GDI 子系统往往概念上大同小异,相比整个桌面应用程序框架而言,更加容易抽象出跨平台的编程接口。
|
||||
|
||||
从另一个角度来说,GDI 是操作系统性能要求最高、最耗电的子系统。所以 GDI 优化往往通过硬件加速来完成,真正的关键角色是在硬件厂商这里。由此观之,由硬件厂商来推跨平台的 GDI 硬件加速方案可能会成为趋势。
|
||||
|
||||
## 通用控件
|
||||
|
||||
有了以上这些内容,窗口系统本身已经完备,我们就可以实现一个任意复杂的桌面应用程序了。
|
||||
|
||||
但是,为了进一步简化开发过程,操作系统往往还提供了一些通用的界面元素,通常我们称之为控件(Control)。常见的控件有如下这些:
|
||||
|
||||
- 静态文本 (Label);
|
||||
- 按钮 (Button);
|
||||
- 单选框 (RadioBox);
|
||||
- 复选框 (CheckBox);
|
||||
- 输入框 (Input,也叫EditBox/EditText);
|
||||
- 进度条 (ProgressBar);
|
||||
- 等等。
|
||||
|
||||
不同操作系统提供的基础控件大同小异。不过一些处理细节上的差异往往会成为跨平台开发的坑,如果你希望一份代码多平台使用,在这方面就需要谨慎处理。
|
||||
|
||||
## 结语
|
||||
|
||||
总结来说,桌面应用程序通常由用户交互所驱动。我们身处在由操作系统约定的编程框架中,这是桌面编程的特点。
|
||||
|
||||
在操作系统的所有子系统中,交互相关的子系统是毫无疑问的差异性最大的子系统。我们这里列了一个简单的对比表格:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/fd/124a93704283b082ecda38c1f0c3c9fd.jpg" alt="">
|
||||
|
||||
这还不是差异的全部。要做一个跨平台的桌面应用程序并不容易。我们需要面对的平台太多,简单罗列,如下所示。
|
||||
|
||||
- PC:Windows、MacOS、Linux 等;
|
||||
- PC 浏览器:Chrome、Safri、Firefox 等;
|
||||
- 手机/平板/手表:Android(不同手机厂商也会有细节差异)、iOS 等;
|
||||
- 小程序:微信、支付宝、快应用等。
|
||||
|
||||
怎么安排不同平台的优先级?怎么规划未来版本的迭代计划?选择什么样的跨平台方案?这些问题在业务架构之外,但极其考验架构师的决策能力。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊 “桌面程序的架构建议”。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
141
极客时间专栏/许式伟的架构课/桌面开发篇/22 | 桌面程序的架构建议.md
Normal file
141
极客时间专栏/许式伟的架构课/桌面开发篇/22 | 桌面程序的架构建议.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<audio id="audio" title="22 | 桌面程序的架构建议" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/b9/3044a5aa9f0704caeaefc3c6c040fdb9.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
上一讲我们介绍了图形界面程序的框架。站在操作系统交互子系统的角度来看,我们桌面应用程序的结构是下面这样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/af/ea2f8918fd742bba48ba2897267c1daf.png" alt="">
|
||||
|
||||
今天我们换一个角度,站在应用架构的角度,来聊聊如何设计一个桌面应用程序。
|
||||
|
||||
## 从 MVC 说起
|
||||
|
||||
关于桌面程序,我想你听得最多的莫过于 MVC 这个架构范式。MVC 全称是 “模型(Model)-视图(View)-控制器(Controller)”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/cb/32c7df68c3f5d11a0a32f80d7c3a42cb.png" alt=""><br>
|
||||
怎么理解 MVC 呢?一种理解是,Model 是 Input,View 是 Output,Controller 是 Process,认为 MVC 与计算机的 Input-Process-Ouput 这个基础模型暗合。
|
||||
|
||||
但更准确的解释是:Model 是数据,View 是数据的显示结果,同时也接受用户的交互动作,也就是事件。从这个意义来说,说 Model 是 Input 并不严谨,View 接受的用户交互,也是 Input 的一部分。
|
||||
|
||||
Controller 负责 Process(处理),它接受 “Model + 由 View 转发的事件” 作为 Input,处理的结果(Output)仍然是 Model,它更新了 Model 的数据。
|
||||
|
||||
View 之所以被理解为 Output,是因为 Model 的数据更新后,会发送 DataChanged(数据更新)事件,View 会在监听并收到 DataChanged 事件后,更新 View。所以把 View 理解为 Output 也并不算错,它从数据角度看其实是 Model 的镜像。
|
||||
|
||||
对 MVC 模式做些细微的调整,就会产生一些变种。比如,Model 的数据更新发出 DataChanged 事件后,由 Controller 负责监听并 Update View,这样就变成了 MVP 架构。MVP 全称是 “模型(Model)-视图(View)-表现(Presenter)”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/b3/017f2f7974febde6f4ddd917481ba1b3.png" alt="">
|
||||
|
||||
那么,我们究竟应该选择哪一种架构范式比较好?
|
||||
|
||||
要想判断我们写的程序架构是否优良,那么我们心中就要有架构优劣的评判标准。比较知名且重要的一些基本原则如下。
|
||||
|
||||
- 最低耦合原则:不同子系统(或模块)之间有最少的交互频率,最简洁且自然的接口。
|
||||
- 单一职责原则:不要让一个子系统(或模块)干多件事情,也不要让它不干事情。
|
||||
|
||||
如果在我们心中以遵循架构法则为导向,回过头再来看 MVC,又会有不同的理解。
|
||||
|
||||
## 理解 Model 层
|
||||
|
||||
我们先看 Model。如果你真正理解 Model 层的价值,那么可以认为你的架构水平已经达到了较高层次的水准。因为 Model 层太重要了。
|
||||
|
||||
我上面说 Model 层是数据,这其实还不是太准确。更准确来说,Model 层是承载业务逻辑的 DOM,即 “文档对象模型(Document Object Model)”。直白理解,DOM 是 “面向对象” 意义上的数据。它不只是有数据结构,也有访问接口。
|
||||
|
||||
为了便于理解,假设我们基于数据库来实现 Model 层。**这种情况下会有两种常见的架构误区。**
|
||||
|
||||
一种是直接让 Controller 层直接操作数据库,也就是拿数据库的读写接口作为 Model 层的接口。
|
||||
|
||||
另一种看起来高级一些,用所谓的 ORM 技术来实现 Model 层,让 Controller 直接操作 ORM。
|
||||
|
||||
为什么我们说这两种做法都有问题呢?原因就在于对 Model 层的价值不明。Model 层的使用接口最重要的是要自然体现业务的需求。
|
||||
|
||||
只有这样,Model 层的边界才是稳定的,与你基于的技术无关。是用了 MySQL,还是用了 NoSQL?是直接裸写 SQL 语句,还是基于 ORM?这都没关系,未来喜欢了还可以改。
|
||||
|
||||
另外,从界面编程角度看,Model 层越厚越好。为什么这么说?因为这是和操作系统的界面程序框架最为无关的部分,是最容易测试的部分,也同时是跨平台最容易的部分。
|
||||
|
||||
我们把逻辑更多向 Model 层倾斜,那么 Controller 层就简洁很多,这对跨平台开发将极其有利。
|
||||
|
||||
这样来看,直接让 Controller 层直接操作数据库,或者基于 ORM 操作数据库,都是让 Model 层啥事不干,这非常非常浪费,同样也违背了 “单一职责原则”。
|
||||
|
||||
我们需要强调,单一职责不只是要求不要让一个子系统(或模块)干多件事情,同时也要求不要让它不干事情。
|
||||
|
||||
如果我们用一句话来描述 Model 层的职责,那么应该是 “负责业务需求的内核逻辑”,我们以前经常叫它 “DataCore”。
|
||||
|
||||
那么 Model 层为何要发出 DataChanged 事件?
|
||||
|
||||
这是从 Model 层的独立性考虑。Model 层作为架构的最底层,它不需要知道其他层的存在,不需要知道到底是 MVC 还是 MVP,或者是其他的架构范式。
|
||||
|
||||
有了 DataChanged 事件,上层就能够感知到 Model 层的变化,从而作出自己的反应。
|
||||
|
||||
如果还记得第一章我们反复强调的稳定点与变化点,那么显然,DataChanged 事件就是 Model 层面对需求变化点的对策。大部分 Model 层的接口会自然体现业务需求,这是核心价值点,是稳定的。
|
||||
|
||||
但是业务的用户交互可能会变化多端,与 PC 还是手机,与屏幕尺寸,甚至可能与地区人文都有关系,是多变的。
|
||||
|
||||
用事件回调来解决需求的变化点,这一点 CPU 干过,操作系统也干过,今天你做业务架构也这么干,这就很赞。
|
||||
|
||||
## 理解 View 层
|
||||
|
||||
View 层首要的责任,是负责界面呈现。界面呈现只有两个选择,要么自己直接调用 GDI 接口自己画,要么创建子 View 让别人画。
|
||||
|
||||
View 层另一个责任是被自然带来的,那就是:它是响应用户交互事件的入口,这是操作系统的界面编程框架决定的。比较理想的情况下,View 应该把自己所有的事件都委托(delegate)出去,不要自己干。
|
||||
|
||||
但在 View 的设计细节中,也有很多问题需要考虑。
|
||||
|
||||
**其一,View 层不一定会负责生成所有用户看到的 View。**有的 View 是 Controller 在做某个逻辑的过程中临时生成的,那么这样的 View 就应该是 Controller 的一部分,而不应该是 MVC 里面的 View 层的一部分。
|
||||
|
||||
**其二,View 层可能需要非常友好的委托(delegate)机制的支持。**例如,支持一组界面元素的交互事件共同做委托(delegate)。
|
||||
|
||||
**其三,负责界面呈现,意味着 View 层和 Model 层的关系非常紧密,紧密到需要知道数据结构的细节,这可能会导致 Model 层要为 View 层提供一些专享的只读访问接口。**这合乎情理,只是要确保这些访问接口不要扩散使用。
|
||||
|
||||
**其四,负责界面呈现,看似只是根据数据绘制界面,似乎很简单,但实则不简单。**原因在于:为了效率,我们往往需要做局部更新的优化。如果我们收到 onPaint 消息,永远是不管三七二十一,直接重新绘制,那么事情就很好办。但是在大部分情况下,只要业务稍微复杂一点,这样的做法都会遇到性能挑战。
|
||||
|
||||
在局部更新这个优化足够复杂时,我们往往不得不在 Model 和 View 之间,再额外引入一层 ViewModel 层来做这个事情。
|
||||
|
||||
ViewModel 层顾名思义,是为 View 的界面呈现而设计的 Model 层,它的数据组织更接近于 View 的表达,和 View 自身的数据呈一一对应关系(Bidi-data-binding)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/37/43/37c573bc05f071fe7e4ac3a2c986c843.png" alt=""><br>
|
||||
一个极端但又很典型的例子是 Word。它是数据流式的文档,但是界面显示人们用得最多的却是页面视图,内容是分页显示的。
|
||||
|
||||
这种情况下就需要有一个 ViewModel 层是按分页显示的结构来组织数据。其中负责维持 Model 与 ViewModel 层的数据一致性的模块,我们叫排版引擎。
|
||||
|
||||
从理解上来讲,我个人会倾向于认为 ViewModel 是 View 层的一部分,只不过是 View 层太复杂而进行了再次拆分的结果。也就是说,我并不倾向于认为存在所谓的 “Model-View-ViewModel” 这样的模式。
|
||||
|
||||
## 理解 Controller 层
|
||||
|
||||
Controller 层是负责用户交互的。可以有很多个 Controller,分别负责不同的用户交互需求。
|
||||
|
||||
这和 Model 层、View 层不太一样。我们会倾向于认为 Model 层是一个整体。虽然这一个层会有很多类,但是它们共同构成了一个完整的逻辑:DOM。而 View 层也是如此,它是 DOM 的界面呈现,是 DOM 的镜像,同样是一个整体。
|
||||
|
||||
但负责用户交互的 Controller 层,是可以被正交分解的,而且应该作正交分解,彼此完全没有耦合关系。
|
||||
|
||||
一个 Controller 模块,可能包含一些属于自己的辅助 View,也会接受 View 层委托的一些事件,由事件驱动自己状态,并最终通过调用 Model 层的使用接口来完成一项业务。
|
||||
|
||||
Controller 模块的辅助 View 可能是持续可见的,比如菜单和工具条;也可能是一些临时性的,比如 Office 软件中旋转图形的控制点。
|
||||
|
||||
对于后者,如果存在 ViewModel 层的话,也有可能会被归到 ViewModel + View 来解决,因为 ViewModel 层可以有 Selection 这样的东西来表示 View 里面被选中的对象。
|
||||
|
||||
Controller 层最应该思考的问题是代码的内聚性。哪些代码是相关的,是应该放在一起的,需要一一理清。这也是我上面说的正交分解的含义。
|
||||
|
||||
如果我们做得恰当,Controller 之间应该是完全无关的。而且要干掉某一个交互特别容易,都不需要删除该 Controller 本身相关的代码,只需要把创建该 Controller 的一行代码注释掉就可以了。
|
||||
|
||||
从分层角度,我们会倾向于认为 **Model 层在最底层;View 层在中间,**它持有 Model 层的 DOM 指针;**Controller 层在最上方**,它知道 Model 和 View 层,它通过 DOM 接口操作 Model 层,但它并不操作 View 去改变数据,而只是监听自己感兴趣的事件。
|
||||
|
||||
如果 View 层提供了抽象得当的事件绑定接口,你会发现,其实 Controller 层大部分的逻辑都与操作系统提供的界面编程框架无关(除了少量辅助 View),是跨平台的。
|
||||
|
||||
**谁负责把 MVC 各个模块串起来呢?当然是应用程序(Application)了。**在应用开始的时候,它就把 Model 层、View 层,我们感兴趣的若干 Controller 模块都创建好,建立了彼此的关联,一切就如我们期望的那样工作起来了。
|
||||
|
||||
## 兼顾 API 与交互
|
||||
|
||||
MVC 是很好的模型来支持用户交互。但这不是桌面程序面临的全部。另一个很重要的需求是提供应用程序的二次开发接口(API,全称为 Application Programming Interface)。
|
||||
|
||||
提供了 API 的应用程序,意味着它身处一个应用生态之中,可以与其他应用程序完美协作。
|
||||
|
||||
通过哪一层提供 API 接口?我个人会倾向于认为最佳的选择是在 ViewModel 层。Model 层也很容易提供 API,但是它可能会缺少一些重要的东西,比如 Selection。
|
||||
|
||||
## 结语
|
||||
|
||||
这一讲我们探讨了一个桌面应用程序的业务架构设计。我们探讨了大家耳熟能详的 MVC 架构范式。一千个人眼中有一千个哈姆雷特,虽然都在谈 MVC,但是大家眼中的 MVC 各有不同。
|
||||
|
||||
我们站在什么样的架构是好架构的角度,剖析了 MVC 的每一层应该怎样去正确理解与设计,有哪些切实的问题需要去面对。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊基于浏览器的开发。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
153
极客时间专栏/许式伟的架构课/桌面开发篇/23 | Web开发:浏览器、小程序与PWA.md
Normal file
153
极客时间专栏/许式伟的架构课/桌面开发篇/23 | Web开发:浏览器、小程序与PWA.md
Normal file
@@ -0,0 +1,153 @@
|
||||
<audio id="audio" title="23 | Web开发:浏览器、小程序与PWA" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/bb/8cd0da8a78d24a2fa2d6af11e3316cbb.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
前面几讲我们聊到桌面软件开发,是从原生应用(Native App)角度来讲的,我们的讨论范围还只是单机软件,没有涉及网络相关的部分。
|
||||
|
||||
虽然介绍 Model 层的时候,我拿基于数据库实现 Model 层来谈常见的两个误区,但这只是因为这种问题经常能够见到,比较典型。实际纯单机软件很少会基于数据库来做,通常是自己设计的内存中的数据结构。
|
||||
|
||||
## 浏览器
|
||||
|
||||
今天开始我们聊聊浏览器。从商业价值看,浏览器带来的最为重大的进步是如下这三点。
|
||||
|
||||
**其一,软件服务化。**当产品交付从单机软件转向云服务后,社会分工就发生了巨大变化。
|
||||
|
||||
互联网让 “24 小时不间断服务”成为可能。任何一个环节的力量都得到百倍乃至千倍的放大,都有可能成长出一个超级节点,进而吞噬上下游,让服务链条更短。
|
||||
|
||||
**其二,随时发布。**这极大改进了软件迭代的效率。人们快速试验自己的想法,不必过度因为顾虑软件质量召回而束手束脚。
|
||||
|
||||
**其三,跨平台。**浏览器消除了不同操作系统平台的差异性,让一份代码到处运行成为可能。
|
||||
|
||||
不过我们今天把重心放到界面开发这个视角。**从作为界面开发框架的角度看,浏览器带来的最重大变化又是哪些?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/c5/b8063e7ac32e854676b640c86d4628c5.png" alt="">
|
||||
|
||||
**其一,操作系统的窗口系统被颠覆。**一个网页只是一个窗口,不再有父子窗口。所有网页中的界面元素,都是一个虚拟视图(Virtual View),无论是大家耳熟能详的通用控件(比如 input,image,div 等等),还是自绘窗口(canvas)都一样。
|
||||
|
||||
这一点非常关键。哪些元素是子 View,哪些元素是图形(Shape) 已经完全淡化了,更多的是通过一种统一机制来完成事件分派(Event Dispatch)。
|
||||
|
||||
**其二,窗口的绘制机制变了。**之前是调用操作系统的 GDI 生成界面,现在换成了 HTML+CSS。当然如果我们非要把 HTML+CSS 看作是另一种 GDI 语言,某种程度来看好像也可以。
|
||||
|
||||
但是实际上 GDI 与 HTML+CSS 有非常本质的差别。它们一个是在绘制界面,一个是在声明界面。这两者的本质差别,在视图更新(Update View)的时候一下子就显现出来。
|
||||
|
||||
上一讲我们在介绍 View 层的时候,介绍过 View 层的一大难点是做局部更新的优化。在 View 局部优化比较复杂的时候,我们甚至会引入 ViewModel 层来做视图局部更新的支持。
|
||||
|
||||
站在这个角度看 HTML+CSS,其实我们不能把它理解为 View 层,它其实是 ViewModel 层。View 层由谁干掉了?浏览器。在我们修改 HTML DOM 时,浏览器自动就更新了 View。怎么做到局部更新优化的?你不必关心,浏览器已经干完这件事情了。
|
||||
|
||||
这事的真正价值超过你的想象。它大幅提升了桌面应用开发的效率。
|
||||
|
||||
**其三,语言限制。**浏览器的确大幅改善了界面开发的效率,但是从语言支持的角度,大部分操作系统都支持各种语言作为开发工具,而浏览器长期以来只支持 JavaScript 一门语言。
|
||||
|
||||
这当然是一个不小的制约。所以有很多人在试图突破这个限制。Google 曾经想要把 Dart 语言打造为下一代的 JavaScript,但最终以失败告终。
|
||||
|
||||
今天主流的方案还是以代码转换器为主。也就是说,我可以用自己期望的语言(比如 Go 语言)来做开发。但是在发布前通过代码转换器转为 JavaScript。
|
||||
|
||||
今天还有一个重要的尝试是 WebAssembly。它的目标是打通各类语言与 Web 之间的桥梁。
|
||||
|
||||
**其四,B/S 架构。**无论是 B/S 还是 C/S,本质上还是软件服务化。这对软件架构产生了巨大影响。
|
||||
|
||||
一方面,从 Server 端的逻辑看,系统从单用户变成了多用户。另一方面,从 Browser 端(或 Client 端)看,仍然是单用户,但是没有了数据,数据都在 Server 端。这对应用架构提出了新的挑战。
|
||||
|
||||
应该怎么设计 Web 程序的架构?我们在下一讲中接着聊这个话题。
|
||||
|
||||
## 小程序
|
||||
|
||||
2016年9月,微信小程序(最初叫“应用号”)开始内测。下面是当天七牛云团队的一番内部对话。
|
||||
|
||||
>
|
||||
<p>**许式伟**:看下这篇,[微信应用号来了](https://mp.weixin.qq.com/s/OxgWEOlLPcB_3DMVw_GSFA)。<br><br>
|
||||
**Gina**:这个理念应该不是去构建一个Store,它的理念是用完即走,是场景通过扫码或者搜索触发的,并且应该打的是实体或者服务售卖群体,不会针对微信内消费,是订阅号的升级展现方式。<br><br>
|
||||
**许式伟**:[关于微信小程序(应用号),我能透露的几个细节](https://mp.weixin.qq.com/s/x94SDqUV1REfNQ67ihgYfw),这一篇更详细一些。<br><br>
|
||||
微信没有必要在微信App内放Appstore,可以只有Web版本的Appstore,App不需要安装,甚至可能以消息的方式发给别人,以服务号的形式存在,这是迭代式开发。<br><br>
|
||||
以后终极形态还可以变,当前重心应该在runtime的稳定。通过上面的介绍,微信实际上升级了浏览器内核的标准,符合我之前说的新一代浏览器的定义。<br><br>
|
||||
**Gina**:小程序是一种不需要下载安装即可使用的应用,它实现了应用“触手可及”的梦想,用户扫一扫或者搜一下即可打开应用。也体现了“用完即走”的理念,用户不用关心是否安装太多应用的问题。应用将无处不在,随时可用,但又无需安装卸载。<br><br>
|
||||
**徐倒立**:WebApp 这个技术和 idea最早来自 Google 浏览器 ,微信是商业化的最佳实践。Google浏览器在支持开发者开发App时就提出Intents,并且和Android是可以互动的。<br><br>
|
||||
**Gina**:没有好的土壤有好的功能也是没意义的。<br><br>
|
||||
**许式伟**:是,微信小程序在别的App不是做不到,是做了意义太小。苹果和腾讯不约而同在IM里面做App是有道理的。<br><br>
|
||||
**Gina**:IM比搜索和浏览器的封闭性更强。用户不容易跳转出去。封闭性强的土壤才能构建App生态。<br><br>
|
||||
**许式伟**:所以移动时代最佳的浏览器是IM,不再是以前传统浏览器。<br><br>
|
||||
**杜江华**:我们应该多讨论to B巨头们怎么玩、怎么思考的,对我们现阶段才更有意义,支付宝、微信等都是to C的。<br><br>
|
||||
**许式伟**:不是这样的。to C 的生态变化,会影响 to B。to C 是根源,我们是帮用户做App的,如果不知道以后App是怎么玩的,怎么可能做好。<br><br>
|
||||
**杜江华**:理解了,那应该是客户群之一互联网部分,还有不少大B 需要有其他不同的思考方式。<br><br>
|
||||
**Gina**:大B的趋势我挺想听的。这周聚会,阿杜能否把最近大项目和大传统客户的一些动作详细聊一下。你这边离业务最近。<br><br>
|
||||
**许式伟**:其实比你想象得还要恐怖,不管你是什么大B,你都得拥抱微信,只有微信和QQ让整个7亿中国网民在里面安家了,这就是一个虚拟的国家。所以我的判断是没有大B不开发微信小程序,这只是个眼光和时间问题。<br><br>
|
||||
**吕桂华**:这个微信应用号我们是应该关注的,相当于市场上多了一个操作系统。<br><br>
|
||||
**许式伟**:微信应用号不只是一个新OS,而且是下一代OS,苹果和谷歌不会坐视不理。当然还有一个痛点是跨平台。<br><br>
|
||||
**Gina**:这个东西可能对营销生态有大的影响。我们也要开发些营销工具。</p>
|
||||
|
||||
|
||||
在这段对话之后的一个月内,我们做出了七牛的第一笔对外投资:“[即速应用](http://m.jisuapp.cn)”,它致力于帮助企业开始快速构建自己的小程序。
|
||||
|
||||
**为什么微信小程序必然会成功?**
|
||||
|
||||
因为,有 7 亿人同时使用的操作系统,很少。如果我们把不同 Android 厂商归为不同的主体的话,微信小程序是当时世界上最大的单一来源的操作系统。
|
||||
|
||||
随后,支付宝发布了支付宝小程序,国内手机厂商联合发布了 “快应用”,今日头条也发布了自己的小程序。
|
||||
|
||||
一下子,小程序变成了一支巨大的新兴力量,成为真正意义上的国产操作系统,对抗着 Android 和 iOS 两大移动操作系统生态。
|
||||
|
||||
但是,目前来说,小程序生态仍然存在有诸多问题。
|
||||
|
||||
最为关键的,是标准不统一。虽然都叫小程序,但是它们的接口各自都在快速迭代,很难去建立统一的标准,更谈不上让开发者一次开发,到处可用。
|
||||
|
||||
这和 Android 不同。虽然 Android 厂商很多,但是不同 Android 的开发接口是一致的,开发工具链是一致的。
|
||||
|
||||
小程序的厂商们会好好坐下来谈一谈标准一致的事情吗?应该做,但可能他们现在没空管开发者们的体验,他们的关注点是怎么快速抢地盘。
|
||||
|
||||
聊了那么多,我们话题回到技术本身。小程序和传统的 Web 开发有何不同?
|
||||
|
||||
其实有很大不同。小程序更像是 Native 程序在线化,而不是 PC Web 移动化。
|
||||
|
||||
为什么我们这么说?因为小程序是一个应用,而不是由一个个 Web 页面构成。
|
||||
|
||||
我们需要提交应用给微信或支付宝,他们也会像苹果审核 AppStore 上的应用一样,掌控着 App 的生杀大权。
|
||||
|
||||
而且理论上可以比苹果更牛的是,他们可以下线一个已经有千万甚至上亿级别用户的 App,让他们一无所有。苹果可以掐掉一个 App 的新增,他们可以掐掉一个 App 的全部。
|
||||
|
||||
这会带来新的社会问题:操作系统厂商的权限边界究竟在哪里。这不是一个简单的技术问题,而是一个伦理与法律的问题。
|
||||
|
||||
正因为这个风险如此之高,所以所有的厂商在拥抱微信的同时,必然时时刻刻想着如何逃离微信。
|
||||
|
||||
**刀刃,永远是两面的。**
|
||||
|
||||
这也是我个人非常佩服Facebook扎克伯格的地方。他看到了终局,所以在发布 Libra 的时候,他选择的是让一步,放弃 Control。
|
||||
|
||||
我还是那句话,他会成功。
|
||||
|
||||
让一步,其实就是进一百步。
|
||||
|
||||
## PWA
|
||||
|
||||
国内大厂们纷纷布局小程序的时候,Google 也在发力自己的移动浏览器方案,叫 PWA,全称 “Progressive Web App”。
|
||||
|
||||
其实 Google 想要让浏览器获得 Native 应用同等体验之心是路人皆知的事实。
|
||||
|
||||
在 PC 时期,Google 就搞了 Google Native Client (NaCl),后来转向了 WebAssembly。移动应用的在线化,Google 也同样在探索。
|
||||
|
||||
PWA 开始于 2015 年,比微信小程序早很多,并得到了苹果和微软的支持。从这个角度来说,PWA 的潜力不容小觑。
|
||||
|
||||
怎么理解 PWA?你可以理解为海外版的小程序。
|
||||
|
||||
那么它和小程序的差别在哪?
|
||||
|
||||
其一,演进思路不同。PWA 基本上以兼容并对 Web 进行逐步改造升级为导向。而小程序和 Web 还是有较大程度的差异。
|
||||
|
||||
其二,关注焦点不同。PWA 更技术化,它很大的精力重心放在如何让 PWA 在断网情况下和本地应用有更一致的体验。而小程序关注点在如何撬动这么庞大的用户市场,小程序之后专门出现小游戏,更加能够证明这一点。
|
||||
|
||||
其三,PWA 并没有中心化的 AppStore,它更像是一项技术标准,而不是一个封闭的操作系统。支持 PWA 的厂商们不用担心被人掐脖子,怎么更新你的应用自己说了算。
|
||||
|
||||
虽然技术上相似,但是如果以操作系统角度看,两者有代差。PWA 如果我们看作操作系统的话,相比小程序来说太传统。
|
||||
|
||||
为什么这么讲?因为小程序符合我前面介绍现代操作系统的 “账号(Account)-支付(Pay)-应用市场(AppStore)” 的商业闭环,但是 PWA 并没有账号,也没有支付。
|
||||
|
||||
怎么看待 PWA 的未来?
|
||||
|
||||
最终把 PWA 发扬光大的,很可能是 Facebook(当然 Facebook 也非常大概率选择放弃包袱,和小程序一样重新出发)。加上 Libra,秒杀微信小程序。
|
||||
|
||||
## 结语
|
||||
|
||||
今天我们聊了浏览器,结合浏览器的发展趋势,谈了现在仍然在高速迭代中的移动浏览器之争。有中国特色的小程序,和海外版小程序 PWA。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊 “跨平台与 Web 开发的建议”。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
117
极客时间专栏/许式伟的架构课/桌面开发篇/24 | 跨平台与 Web 开发的建议.md
Normal file
117
极客时间专栏/许式伟的架构课/桌面开发篇/24 | 跨平台与 Web 开发的建议.md
Normal file
@@ -0,0 +1,117 @@
|
||||
<audio id="audio" title="24 | 跨平台与 Web 开发的建议" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/18/76c367e9cfcfc4256a656a08c244e718.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
上一讲我们聊了浏览器,以及移动浏览器之争:小程序与 PWA。
|
||||
|
||||
当我们思考浏览器从技术上带来了什么的时候,我们可以把它分为两点。
|
||||
|
||||
- 跨平台桌面程序开发;
|
||||
- Web 开发(B/S 架构的新型应用)。
|
||||
|
||||
今天我们分别就跨平台桌面程序和 Web 开发展开来聊一聊。
|
||||
|
||||
## 跨平台桌面程序开发
|
||||
|
||||
跨平台的桌面程序开发是一个超级难题。无数人前仆后继,各种方案层出不穷,但至今为止,仍然没有称得上真正深入人心的解决方案。
|
||||
|
||||
原因很简单,因为桌面程序本身的范畴在变。有两个关键的因素会导致桌面开发产生巨大的差异性。
|
||||
|
||||
一个因素自然是操作系统。不同的操作系统抽象的界面程序框架并不一致。这些不一致必然导致开发工作量的增加。
|
||||
|
||||
放弃某个操作系统,就意味着放弃某个流量入口,也就意味着放弃这些用户。所以虽然很麻烦,我们还是不得不支持着每一个主流的操作系统。
|
||||
|
||||
另一个因素是屏幕尺寸。就算相同的操作系统,在不同尺寸的屏幕上,交互的范式也会存在很大的差异性,这也会导致不低的跨平台工作量。
|
||||
|
||||
首先我们看下操作系统。
|
||||
|
||||
- PC 本地:Windows,macOS,Linux 等等;
|
||||
- PC Web:Chrome,Safari,FireFox 等等;
|
||||
- Mobile 本地:Android,iOS 等等;
|
||||
- Mobile Web:小程序,PWA 等等。
|
||||
|
||||
我们再看下屏幕尺寸。
|
||||
|
||||
- 大屏:PC、笔记本,Pad 等等;
|
||||
- 中屏:手机;
|
||||
- 小屏:手表。
|
||||
|
||||
如此繁复多样的终端类型,无怪跨平台如此之难。我们来总结一下当前都有哪些跨平台的解决方案。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/c7/daf115d3a745c302026b914ee760ccc7.jpg" alt="">
|
||||
|
||||
这个列表只是沧海一粟。之所以没有列那么多,也是因为大部分的跨平台框架都已经不怎么活跃,已经无疾而终了。
|
||||
|
||||
目前来说,还很难说哪个方案会胜出。
|
||||
|
||||
关于跨平台开发,我觉得有一句话特别深刻:“每一次统一的努力,都最终变成新的分裂”。当然,这样的事情在很多领域都会发生,只是跨平台开发更加如此。
|
||||
|
||||
但是无论如何,跨平台的梦还会继续。
|
||||
|
||||
## Web 开发
|
||||
|
||||
聊完了跨平台,我们来聊聊浏览器带来的另一面:Web 开发。
|
||||
|
||||
Web 的 B/S 架构意味着编写软件有了更高的复杂性。这主要表现在以下几个方面。
|
||||
|
||||
**其一,多用户。**有了 Server 端,意味着用户的数据不再是保存在 Client(Browser)端,而是存储在 Server 端。
|
||||
|
||||
**其二,更高的数据可靠性要求。**数据在 Client 端,客户自己对数据的可靠性负责。硬盘坏了,数据丢了,用户会后悔没有对数据进行备份。
|
||||
|
||||
但是一旦数据在 Server 端,数据可靠性的责任方就到了软件厂商这边。如果厂商不小心把数据搞丢了,用户就会跳起来。
|
||||
|
||||
**其三,更多可能的分工安排。**详细来说,Web 应用从流派来说,分为两大类:胖前端与胖后端。
|
||||
|
||||
所谓胖前端,是指把尽可能多的业务逻辑放在前端。极端情况下,整个网站就是一个单页的应用。胖前端无论开发体验还是用户体验,都更接近于本地应用(Native App)。
|
||||
|
||||
所谓胖后端,是指主要逻辑都在后端,包括界面交互的事件响应,也通过网络调用交给了后端来实现。
|
||||
|
||||
我们先看客户端(Client),也就是浏览器端(Browser)。上一讲我们提到,浏览器的界面框架并没有窗口系统,它通过 HTML+CSS 来描述界面。
|
||||
|
||||
HTML+CSS 与其理解为 View 层,不如理解为 ViewModel 层,因为 HTML DOM 从数据角度完整描述了界面的样子。而 View 层已经被浏览器自己实现了。
|
||||
|
||||
这极大简化了界面开发的复杂性,因为界面的局部更新是一个复杂的话题,今天浏览器通过引入 HTML+CSS 这样的 ViewModel 层把它解决了。
|
||||
|
||||
这个时候我们重新看 MVC 框架在浏览器下的样子,你会发现它变成了 MVMP 模式,全称为 “Model-ViewModel-Presenter”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/7f/94475e49c61f8dfbadb2448c7bc72b7f.png" alt="">
|
||||
|
||||
**首先,我们看事件响应过程。**浏览器的 View 收到了用户的交互事件,它把这些事件委托(delegate)给了 ViewModel 层,并且通过 HTML DOM 暴露出来。通过修改 HTML 元素的事件响应属性,一般名字叫 onXXX(比如 onclick),可以获得事件的响应机会。
|
||||
|
||||
**然后我们看 Model 层的数据变化(DataChanged)事件。**在标准的 MVC 模式中,Model 层的数据变化是通知到 View 层,但是在浏览器下 View 是由浏览器实现的,要想让它接受 DataChanged 事件并且去处理是不太可能了。
|
||||
|
||||
所以解决思路自然是让 Controlller 层来做,这样就变成了 MVP 模式。 但是我们又不是标准的 MVP,因为 Presenter 层更新界面(Update View)并不是操作 View,而是 ViewModel。
|
||||
|
||||
**综上,浏览器下的 MVC,最终实际上是 MVMP(Model-ViewModel-Presenter)。**
|
||||
|
||||
聊完了浏览器端,我们在来看下服务端(Server)。虽然这一章我们的重点不是聊服务端,但是为了有个完整的认识,我们还是要概要地梳理一下 Server 端的架构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/c2/7d4754709350d95b8afe0aa35e6e6dc2.jpg" alt="">
|
||||
|
||||
前面我们在 “[22 | 桌面程序的架构建议](https://time.geekbang.org/column/article/105356)” 中,曾提到桌面软件除了要支持用户交互外,另一个很重要的需求是提供应用程序的二次开发接口(API)。
|
||||
|
||||
到了 Web 开发,我们同样需要二次开发接口,只不过这个二次开发接口不再是在 Client 端完成的,而是在 Server 端完成。Server 端支持直接的 API 调用,以支持自动化(Automation)方面的需求。
|
||||
|
||||
所以,对 Server 端来说,最底层的是一个多租户的 Model 层(Multi-User Model),它实现了自动化(Automation)所需的 API。
|
||||
|
||||
在 Multi-User Model 层之上,有一个 Web 层。Web 层和 Model 层的假设不同,Web 层是基于会话的(Session-based),因为它负责用户的接入,每个用户登录后,会形成一个个会话(Session)。
|
||||
|
||||
如果我们对Web 层细究的话,又分为 Model 层和 ViewModel 层。为了区分,Web 这边的 Model 层我们叫它 Session-based Model。相应地,ViewModel 层我们叫它 Session-based ViewModel。
|
||||
|
||||
在服务端,Session-based Model 和 Session-based ViewModel 并不发生直接关联,它们通过自己网络遥控浏览器这一侧的 Model 和 ViewModel,从而响应用户的交互。
|
||||
|
||||
Session-based Model 是什么样的呢?它其实是 Multi-User Model 层的转译。把多租户的 API 转译成单租户的场景。所以这一层并不需要太多的代码,甚至理论上自动实现也是有可能的。
|
||||
|
||||
Session-based ViewModel 是一些 HTML+JavaScript+CSS 文件。它是真正的 Web 业务入口。它通过互联网把自己的数据返回给浏览器,浏览器基于 ViewModel 渲染出 View,这样整个系统就运转起来了。
|
||||
|
||||
## 结语
|
||||
|
||||
今天我们聊了 Web 带来的两个重要改变。一个是跨平台,一个是 Web 开发,即 B/S 架构下的新型应用到底应该怎么实现。
|
||||
|
||||
从跨平台来说,这个话题是桌面程序员(也叫“大前端”)永远的痛。计划赶不上变化,用来形容大前端程序员面临的窘境是一点都不过分的。一个玩意还没搞熟悉了,另一个东西又出来了,变化太快,要跟上实属不易。
|
||||
|
||||
从 Web 开发来说,MVC 变成了 MVMP(Model-ViewModel-Presenter)。我们和单机的桌面软件一样的建议,认真对待 Model 层,认真思考它的使用接口是什么样的,把 Model 层做厚。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将结合一个实际的案例,来讲解一下桌面开发(含单机软件和 Web)到底是什么样的。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
145
极客时间专栏/许式伟的架构课/桌面开发篇/25 | 桌面开发的未来.md
Normal file
145
极客时间专栏/许式伟的架构课/桌面开发篇/25 | 桌面开发的未来.md
Normal file
@@ -0,0 +1,145 @@
|
||||
<audio id="audio" title="25 | 桌面开发的未来" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/bc/be7b458df35c1130606cf6dfd3440bbc.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
今天,我原本想结合一个实战例子,来回顾一下 “桌面软件开发” 一章到今天为止的内容,但是由于要准备的内容比较多,会延后一下。
|
||||
|
||||
所以,今天我还是会按原来大纲的内容,谈谈我个人对桌面开发未来趋势的判断。
|
||||
|
||||
## 桌面平台的演进与未来
|
||||
|
||||
谈未来,我们要先看过去。
|
||||
|
||||
在 PC 时期,本地桌面操作系统主流的有 Windows、MacOS、Linux。为了消除不同平台的差异,于是就出现了 QT、wxWidgets 这样的跨平台解决方案。
|
||||
|
||||
但是它们都败了,败给了一个它们并没有意想得到的对手:**PC 浏览器**。
|
||||
|
||||
浏览器并不是为跨平台而来,但是除了干成了软件服务化外,也干成了跨平台这件事情。
|
||||
|
||||
虽然浏览器厂商很多,但是它们遵循相同的规范。**这意味着支持了浏览器,就支持了所有的 PC 用户。**
|
||||
|
||||
这太诱人了。
|
||||
|
||||
于是在软件服务化和跨平台开发的双重优势下,软件厂商们趋之若鹜,QT、wxWidgets 这些方案就变成小众需求。
|
||||
|
||||
QT 有机会反抗么?其实是有的。关于这一点我们后面再说。
|
||||
|
||||
然后,移动大潮来了。我记得 2006 年有一次我和雷军雷总吃饭,聊起对移动操作系统未来趋势的判断,我们俩各持己见。
|
||||
|
||||
雷总认为 WinCE 会赢,因为 Windows 已经培育了最大的开发者群体。而我认为 Symbian 会赢,因为它占据了最大的终端用户群。
|
||||
|
||||
**结局大家已经知道了,最后赢的是谁都没有预料到的玩家:Android 和 iOS。**
|
||||
|
||||
如果我们从事后看,实际上这个事情并不是完全没有迹象可循。iOS(诞生于2007年)固然当时还没有诞生,但是 Android 诞生于 2003 年,并于 2005 年被 Google 收购。作为搜索引擎厂商,Google 收购一个手机操作系统,显然不是随意为之的,而是公司发展战略上的考量。
|
||||
|
||||
Android 和 iOS 的诞生,一下子让操作系统的生态变得更为复杂。
|
||||
|
||||
操作系统不同,输入方式不同(鼠标 vs 触摸屏),屏幕大小不同,想要一套代码横跨 PC 和移动多个平台?太难。
|
||||
|
||||
这还不算。虽然还不像手机那么普遍,但是今天手表、电视机、汽车,以及各式各样的 IoT 传感设备,都需要操作系统的支持。
|
||||
|
||||
**从操作系统发展来说,我个人会倾向于按交互方式来分。**未来桌面操作系统和服务端操作系统会渐行渐远,差异越来越大。**从交互来说,服务端会维持简约,经典的命令行交互会长期占据主流。**
|
||||
|
||||
**而桌面操作系统,笔记本市场,鼠标+键盘仍然会占据主流**。虽然鼠标形态已经变了,变成了触控板,但是鼠标指针这种基于精确位置交互的方式会得到保留。多点触摸的交互,也会得到部分机型的支持。
|
||||
|
||||
**移动市场,多点触摸+键盘**会占据主流。但是语音助手也会得到一定程度的渗透。
|
||||
|
||||
**IoT 市场,语音助手会占据交互的主流。**但也会有一些设备引入多点触摸这种交互方式来补充。在这个市场,目前看技术上的 Amazon 和 Google 占据了领先地位。虽然苹果入场较早,但是 Siri 的表现还是和前两者有较大的差距。
|
||||
|
||||
IoT 设备会两极分化。**一类 IoT 设备是专用设备,它的应用场景非常固定,它对操作系统最大诉求是裁剪能力:最好不要给我太多的东西,匹配场景就好。**能不能提供AppStore?不是重点,有也只有很少的一些应用,其实直接找合作伙伴就好。
|
||||
|
||||
**一类 IoT 设备则有较大的通用性。**但受限于语音助手技术的限制,IoT 操作系统的开放性要比移动系统差很多。所以在有任何可能的时候,这些设备就会带上触摸屏变成一台由移动系统支持的设备。
|
||||
|
||||
长远来说,要看智能语音技术的发展。关于这一点,我个人抱谨慎乐观的态度。但显然,在很长一段时间里,我们面对的还是移动操作系统。
|
||||
|
||||
这么多操作系统怎么搞呢?
|
||||
|
||||
于是 React-Native 出现了。理论上,React-Native 可以横跨 PC 和移动。因为 React 本身基于 Web 技术,可以支持 PC 浏览器,而 React-Native 又支持 iOS 和 Android,从而做到 “Learn once,write anywhere”。
|
||||
|
||||
平台差异不能完全消除,只能尽可能地减少。
|
||||
|
||||
手机操作系统这场仗刚有了眉目,移动浏览器之争又起来了。
|
||||
|
||||
国内涌现了大量的小程序厂商,国外 Google 也在推 PWA。还有 Facebook 意见不明,不知道会去支持 PWA,还是基于自己的 React-Native 技术搞一套新的移动浏览器标准。
|
||||
|
||||
这下好了,统一的 Web 分裂成多个技术阵营。
|
||||
|
||||
移动浏览器,国内外不统一已经是既成事实。海外巨头们除了 Facebook,已经用明确的行动支持 PWA。小程序在海外要想有市场,要看头条腾讯阿里们的海外市场占有率。
|
||||
|
||||
移动 WebApp 技术的分裂是否会最终得到纠正?这仍然是未知之数。
|
||||
|
||||
但由此观之,终端操作系统的多元化已经是既成现实。这对开发者生态将产生重要的影响。
|
||||
|
||||
我们可能有人留意到,QT 今天基本上支持了所有的桌面操作系统,不管是 PC 还是移动。但是这还不太够,因为还差 Web、小程序和 PWA。
|
||||
|
||||
今天的跨平台,重点是要跨 Android、iOS、Web、小程序和 PWA。如果精力顾不上,PC 桌面操作系统的优先级反而可以缓一缓,毕竟 Web 也能够顶一下。
|
||||
|
||||
QT 的机会在这里。但是很明显它并没有意识到兼容 Web 开发对于一个跨平台工具的重要性。
|
||||
|
||||
就算在 PC 时期,一个同时支持 Web 和本地操作系统的跨平台工具也能够受到欢迎。今天随着桌面平台的多元化,跨平台工具的需求达到了历史最高点。
|
||||
|
||||
当然还有一种跨平台的思路,是垂直发展,比如专做游戏开发的跨平台。不过单就游戏开发这个领域而言,已经有强大的玩家,比如 Unity 在里面。
|
||||
|
||||
**那么,通用的跨平台怎么做到?**
|
||||
|
||||
**Google Flutter 给了一条路,它把对操作系统的要求最小化,整个界面系统完全自己在用户态构建。**
|
||||
|
||||
这个思路和 Go 语言有点像。Go 语言其实是在用户态完全重写了操作系统的进程管理和 IO 子系统。
|
||||
|
||||
那么 Flutter 会像 Go 语言一样成功么?
|
||||
|
||||
我个人持谨慎态度。不同操作系统的用户是有自己独特的交互范式的。比如 Android 和 iOS 用户的习惯就有一定的差异。而这可能恰恰是跨平台更难的一点。
|
||||
|
||||
另一个是软件体积问题。Android 是 Google 自己的,可以通过让 Android 预装基础库来减少体积。但是更多的系统有可能需要一个体积不小的跨平台层。
|
||||
|
||||
这会制约 Flutter 的发展。客户端软件的尺寸,对新用户的转化率有着至关重要的影响。何况像微信小程序这样的平台,还限制了小程序的尺寸,最早限制为 4M,后来放宽到 8M。
|
||||
|
||||
这和 Go 语言面临的环境不太一样。Go 语言因为面向的是服务端,用户对软件的尺寸不敏感,反倒是部署的便捷性更敏感。
|
||||
|
||||
我个人更倾向于尺寸更轻盈的跨平台工具。
|
||||
|
||||
其次是编程手法上的问题。大趋势是要用 Web 这种声明式的界面描述方式。至于是否需要在语法上进行一次重新梳理,我个人觉得是有必要的。React-Native 在这个方向的探索是个不错的尝试。
|
||||
|
||||
在这一点上,苹果的 SwiftUI 或许更值得关注。苹果以极简体验著称,SwiftUI 某种程度上来说代表了关于跨平台开发的可能方向。
|
||||
|
||||
## 儿童编程教育
|
||||
|
||||
在我们谈论桌面开发的时候,我认为其实还有一个重要但又很容易被忽视的趋势,是儿童编程教育的走向。
|
||||
|
||||
说到儿童编程教育,我们大多数人可能都知道 Scratch 语言。但是要说儿童编程的鼻祖,毫无疑问应该算 Logo 语言,海龟作图。
|
||||
|
||||
Scratch 语言由美国麻省理工大学(MIT)于 2007 年发布,到现在已经发展到了 3.0 版本,项目正变得越来越活跃。
|
||||
|
||||
在 Scratch 之后,Google 也曾经发布了 Blockly 语言进军儿童编程教育。但是由于缺乏社区基础,Blockly 语言一直不温不火。
|
||||
|
||||
但有两件有趣的事情。
|
||||
|
||||
**其一,Scratch 3.0 是基于 Blockly 的源代码改造而成的,为此据说 Google 也投入了大量的技术人员进行协助,双方协同开发。**
|
||||
|
||||
**其二,Google 基于 Blockly 语言搞出了一个 App Inventor,用于教育儿童学习 Android 开发。**
|
||||
|
||||
无独有偶的是,苹果推出的 Swift 语言启蒙教程也是针对儿童的,在 AppStore 上可以下载到,叫 “Swift Playgrounds”。
|
||||
|
||||
这意味着,我们原本以为两件风马牛不相及的事情,其实是密切相关的。
|
||||
|
||||
桌面开发的未来是什么?
|
||||
|
||||
从终局的视角来看,桌面开发的终极目标,是让儿童可以轻松编写出生产级的应用。
|
||||
|
||||
这不是痴人说梦。
|
||||
|
||||
在 iOS 出来之前,如果有人说他要开发一个让三岁小孩都会使用的电脑,可能会有很多人觉得绝无可能。
|
||||
|
||||
但是苹果的确做到了。虽然可能还不能完全识别电脑上常见的文字,但是一个三岁的儿童使用起 iPhone 或者 iPad 却毫不困难。
|
||||
|
||||
那么,让一个八岁刚刚上学没多久的小学生去做生产级的应用,这事也不是遥不可及的梦想。
|
||||
|
||||
桌面开发技术的演进,和儿童编程教育相向而行,有一天必然汇聚于一点上。
|
||||
|
||||
## 结语
|
||||
|
||||
今天我们结合桌面开发和儿童编程教育,聊了个人对桌面的未来演进趋势的判断。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将结合一个实际的案例,来讲解一下桌面开发(含单机软件和 Web)到底是什么样的。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
238
极客时间专栏/许式伟的架构课/桌面开发篇/26 | 实战(一):怎么设计一个“画图”程序?.md
Normal file
238
极客时间专栏/许式伟的架构课/桌面开发篇/26 | 实战(一):怎么设计一个“画图”程序?.md
Normal file
@@ -0,0 +1,238 @@
|
||||
<audio id="audio" title="26 | 实战(一):怎么设计一个“画图”程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bb/af/bbafd6ed8def45469c0a1c0f40021aaf.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
到上一讲为止,桌面程序架构设计的基本结构就讲完了。直到现在为止,我们没有讨论任何与具体的应用业务逻辑本身相关的内容。这是因为探讨的内容是普适有效的设计理念,整个讨论会显得很抽象。
|
||||
|
||||
今天我们结合一个实际的应用案例,来回顾一下前面我们介绍的内容。
|
||||
|
||||
我们选择了做一个 “画图” 程序。选它主要的原因是画图程序比较常见,需求上不需要花费过多的时间来陈述。
|
||||
|
||||
我们前面说过,一个 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 上下载,地址如下:
|
||||
|
||||
- [https://github.com/qiniu/qpaint](https://github.com/qiniu/qpaint)
|
||||
|
||||
今天我们讨论浏览器端的 Model,View 和 Controller。
|
||||
|
||||
## Model 层
|
||||
|
||||
我们先看 Model 层。浏览器端的 Model 层,代码就是一个 [dom.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/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](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/index.htm) 文件和一个 [view.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/view.js) 文件。index.htm 是总控文件,主要包含两个东西:
|
||||
|
||||
- 界面布局(Layout);
|
||||
- 应用初始化(InitApplication),比如加载哪些 Controllers。
|
||||
|
||||
而 [view.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/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](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/accel/menu.js)
|
||||
- Create Path:[creator/path.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/creator/path.js)
|
||||
- Create FreePath:[creator/freepath.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/creator/freepath.js)
|
||||
- Create Line, Rect, Ellipse, Circle: [creator/rect.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/creator/rect.js)
|
||||
|
||||
其中,[menu.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/accel/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 开发,但是我们当前给出的画图程序本质上还是单机版的。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
216
极客时间专栏/许式伟的架构课/桌面开发篇/27 | 实战(二):怎么设计一个“画图”程序?.md
Normal file
216
极客时间专栏/许式伟的架构课/桌面开发篇/27 | 实战(二):怎么设计一个“画图”程序?.md
Normal file
@@ -0,0 +1,216 @@
|
||||
<audio id="audio" title="27 | 实战(二):怎么设计一个“画图”程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/5d/1c1d5c2cyyb5d27fae4e3566fc4f855d.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
上一讲开始,我们进入了实战模式。从目前看到的反馈看,我的预期目标并没有达到。
|
||||
|
||||
我复盘了一下,虽然这个程序看起来比较简单,但是实际上仍然有很多需要交代而没有交代清楚的东西。
|
||||
|
||||
我个人对这个例子的期望是比较高的。因为我认为 “画图” 程序非常适合作为架构实战的第一课。“画图” 程序需求的可伸缩性非常大,完完全全是一个迷你小 Office 程序,很适合由浅及深去谈架构的演进。
|
||||
|
||||
所以我今天微调了一下计划,把服务端对接往后延后一讲,增加一篇 “实战(中)” 篇。这个“中”篇一方面把前面 “实战(上)” 篇没有交代清楚的补一下,另一方面对 “画图” 程序做一次需求的迭代。
|
||||
|
||||
## MVP 版画图程序
|
||||
|
||||
先回到 “实战(上)” 篇。这个版本对画图程序来说,基本上是一个 MVP 版本:只能增加新图形,没法删除,也没法修改。
|
||||
|
||||
怎么做?我们先看 Model 层,它的代码就是一个 [dom.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/dom.js) 文件。从数据结构来说,它是一棵以 QPaintDoc 为根的 DOM 树。这个 DOM 树只有三级:Document -> Shape -> LineStyle。具体细节可以参阅下表:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/55/5a2233a851ae2cb234d001725e437755.png" alt="">
|
||||
|
||||
这个表列出的是 Model 和 View、Controllers 的耦合关系:Model 都为它们提供了什么?可以看出,View 层当前对 Model 层除了绘制(onpaint),没有其他任何需求。而各个 Controller,对 Model 的需求看起来似乎方法数量不少,但是实质上目的也只有一个,那就是创建图形(addShape)。
|
||||
|
||||
我们再看 View 层。它的代码主要是一个 [index.htm](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/index.htm) 文件和一个 [view.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/view.js) 文件。View 层只依赖 Model 层,并且只依赖一个 doc.onpaint 函数。所以我们把关注点放在 View 自身的功能。
|
||||
|
||||
View 层只有一个 QPaintView 类。我们将其功能分为了三类:属于 Model 层职责相关的,属于 View 自身职责相关的,以及为 Controller 层服务的,得到下表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/4e/09297c130d51b3f6e502522367284b4e.png" alt="">
|
||||
|
||||
最后,我们来看 Controller 层。Controller 层的文件有很多,这还是一些 Controller 因为实现相近被合并到一个文件,如下所示。
|
||||
|
||||
- Menu, PropSelectors, MousePosTracker: [accel/menu.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/accel/menu.js)
|
||||
- Create Path:[creator/path.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/creator/path.js)
|
||||
- Create FreePath:[creator/freepath.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/creator/freepath.js)
|
||||
- Create Line, Rect, Ellipse, Circle: [creator/rect.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/creator/rect.js)
|
||||
|
||||
Controller 位于 MVC 的最上层,我们对它的关注点就不再是它的规格本身,也没人去调用它的方法。所以我们把关注点放在了每个 Controller 都怎么用 Model 和 View 的。
|
||||
|
||||
我们列了个表,如下。注意 Controller 对事件(Event)的使用从 View 中单独列出来了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/2a/769fa627d0cf556a9fb8fb494005e92a.png" alt="">
|
||||
|
||||
通过以上三张表对照着看,可以清晰看出 Model、View、Controllers 是怎么关联起来的。
|
||||
|
||||
## 改进版的画图程序
|
||||
|
||||
MVP 版本的画图程序,用着就会发现不好用,毕竟图形创建完就没法改了。所以我们打算做一个新版本出来,功能上有这样一些改进。
|
||||
|
||||
- 选择一个图形,允许删除、移动或者对其样式进行修改。
|
||||
- 图形样式增加 fillColor(填充色)。
|
||||
- 更加现代的交互范式:默认处于 ShapeSelector 状态,创建完图形后自动回到此状态。
|
||||
- 选择图形后,界面上的当前样式自动更新为被选图形的样式。
|
||||
|
||||
怎么改我们的程序?
|
||||
|
||||
完整的差异对比,请参见:
|
||||
|
||||
- [https://github.com/qiniu/qpaint/compare/v26...v27](https://github.com/qiniu/qpaint/compare/v26...v27)
|
||||
|
||||
下面,我们将详细讲解这些修改背后的思考。
|
||||
|
||||
我们先看 Model 层,新的规格见下表。
|
||||
|
||||
- [dom.js](https://github.com/qiniu/qpaint/blob/v27/paintweb/www/dom.js)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/6e/a1faf9a3a19124e7240b06341a7d356e.png" alt="">
|
||||
|
||||
为了方便大家理解,我们做了一个 Model 的 ChangeNotes 表格,如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/42/2a0dbe3fe4a13e555b13dd3b247d7042.png" alt="">
|
||||
|
||||
大部分是新功能的增加,不提。我们重点关注一个点:QLineStyle 改名为 QShapeStyle,且其属性 width、color 被改名为 lineWidth、lineColor。这些属于不兼容修改,相当于做了一次小重构。
|
||||
|
||||
重构关键是要及时处理,把控质量。尤其对 JavaScript 这种弱类型语言,重构的心智负担较大。为了保证质量仍然可控,最好辅以足够多的单元测试。
|
||||
|
||||
这也是我个人会更喜欢静态类型语言的原因,重构有任何遗漏,编译器会告诉你哪里漏改了。当然,这并不意味着单元测试可以省略,对每一门语言来说,自动化的测试永远是质量保障的重要手段。
|
||||
|
||||
话题回到图形样式。最初我们 new QLine、QRect、QEllipse、QPath 的时候,传入的最后一个参数是 QLineStyle,从设计上这是一次失误,这意味着后面这些构造还是都需要增加更多参数如 QFillStyle 之类。
|
||||
|
||||
把最后一个参数改为 QShapeStyle,这从设计上就完备了。后面图形样式就算有更多的演进,也会集中到 QShapeStyle 这一个类上。
|
||||
|
||||
当前 QShapeStyle 的数据结构是这样的:
|
||||
|
||||
```
|
||||
class QShapeStyle {
|
||||
lineWidth: number
|
||||
lineColor: string
|
||||
fillColor: string
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那么,这是合理的么?未来潜在的演进是什么?
|
||||
|
||||
对需求演进的推演,关键是眼光看多远。当前各类 GDI 对 LineStyle、FillStyle 支持都非常丰富。所以如果作为一个实实在在要去迭代的画图程序来说,上面这个 QShapeStyle 必然还会面临一次重构。变成如下这个样子:
|
||||
|
||||
```
|
||||
class QLineStyle {
|
||||
width: number
|
||||
color: string
|
||||
}
|
||||
|
||||
class QFillStyle {
|
||||
color: string
|
||||
}
|
||||
|
||||
class QShapeStyle {
|
||||
line: any
|
||||
fill: any
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
为什么 QShapeStyle 里面的 line 不是 QLineStyle,fill 不是 QFillStyle,而是 any 类型?因为它们都只是简单版本的线型样式和填充样式。
|
||||
|
||||
举个例子,在 GDI 系统中,FillStyle 往往还可以是一张图片平铺,也可以是多个颜色渐变填充,这些都无法用 QFillStyle 来表示。所以这里的 QFillStyle 更好的叫法也许是 QSimpleFillStyle。
|
||||
|
||||
聊完了 Model 层,我们再来看 View 层。
|
||||
|
||||
- [view.js](https://github.com/qiniu/qpaint/blob/v27/paintweb/www/view.js)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/0f/97573e6adacaccee0708b6d8937e650f.png" alt="">
|
||||
|
||||
View 层的变化不大。为了给大家更直观的感觉,我这里也列了一个 ChangeNotes 表格,如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/35/74ec3e1f23a052d57f677603b1e9c035.png" alt="">
|
||||
|
||||
其中,properties 改名为 style,以及删除了 get lineStyle(),和 properties 统一为 style。这个和我上面说的 Model 层的小重构相关,并不是本次新版本的功能引起的。
|
||||
|
||||
所以 View 层真正的变化是两个:
|
||||
|
||||
- 引入了 selection,当前只能单选一个 shape;在 selection 变化时会发出 onSelectionChanged 事件;
|
||||
- 引入了 onControllerReset 事件,它在 Controller 完成或放弃图形的创建时发出。
|
||||
|
||||
引入 selection 比较常规。View 变复杂了通常都会有 selection,唯一需要考虑的是 selection 会有什么样的变化,对于 Office 类程序,如果 selection 只允许是单 shape 这不太合理,但我们这里略过,不进行展开。
|
||||
|
||||
我们重点谈 onControllerReset 事件。
|
||||
|
||||
onControllerReset 事件是创建图形的 Controller(例如 QPathCreator、QRectCreator 等)发出,并由 Menu 这个 Controller 接收。
|
||||
|
||||
这就涉及了一个问题:类似情况还会有多少?以后是不是还会有更多的事件需要在 Controller 之间传递,需要 View 来中转的?
|
||||
|
||||
这个问题就涉及了 View 层事件机制的设计问题。和这个问题相关的有:
|
||||
|
||||
- 要不要支持任意的事件;
|
||||
- 监听事件是支持单播还是多播?
|
||||
|
||||
从最通用的角度,肯定是支持任意事件、支持多播。比如我们定义一个 QEventManager 类,规格如下。
|
||||
|
||||
```
|
||||
class QEventManager {
|
||||
fire(eventName: string, params: ...any): void
|
||||
addListener(eventName: string, handler: Handler): void
|
||||
removeListener(eventName: string, handler: Handler): void
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是,View 的事件机制设定,需要在通用性与架构的可控性之平衡。一旦 View 聚合了这个 QEventManager,通用是通用了,但是 Controller 之间会有什么样的事件飞来飞去,就比较难去从机制上把控了。
|
||||
|
||||
代码即文档。如果能够用代码约束的事情,最好不要在文档中来约束。
|
||||
|
||||
所以,就算是我们底层实现 QEventManager 类,我个人也不倾向于在 View 的接口中直接将它暴露出去,而是定义更具体的 fireControllerReset、 onControllerReset/offControllerReset 方法,让架构的依赖直观化。
|
||||
|
||||
具体代码看起来是这样的:
|
||||
|
||||
```
|
||||
class QPaintView {
|
||||
constructor() {
|
||||
this._eventManager = new QEventManager()
|
||||
}
|
||||
onControllerReset(handler) {
|
||||
this._eventManager.addListener("onControllerReset", handler)
|
||||
}
|
||||
offControllerReset(handler) {
|
||||
this._eventManager.removeListener("onControllerReset", handler)
|
||||
}
|
||||
fireControllerReset() {
|
||||
this._eventManager.fire("onControllerReset")
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
聊完了 View 层,我们接着聊 Controller 层。我们也把每个 Controller 怎么用 Model 和 View 列了个表,如下。
|
||||
|
||||
- Menu, PropSelectors, MousePosTracker: [accel/menu.js](https://github.com/qiniu/qpaint/blob/v27/paintweb/www/accel/menu.js)
|
||||
- ShapeSelector:[accel/select.js](https://github.com/qiniu/qpaint/blob/v27/paintweb/www/accel/select.js)
|
||||
- Create Path:[creator/path.js](https://github.com/qiniu/qpaint/blob/v27/paintweb/www/creator/path.js)
|
||||
- Create FreePath:[creator/freepath.js](https://github.com/qiniu/qpaint/blob/v27/paintweb/www/creator/freepath.js)
|
||||
- Create Line, Rect, Ellipse, Circle: [creator/rect.js](https://github.com/qiniu/qpaint/blob/v27/paintweb/www/creator/rect.js)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/95/5e3f87dc0a0695028362bc0fe28ea895.png" alt="">
|
||||
|
||||
内容有点多。为了更清楚地看到差异,我们做了 ChangeNotes 表格,如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/e9/6e97b0ccb1268fdcc2cea62dccd7e4e9.png" alt="">
|
||||
|
||||
首先,Menu、QPathCreator、QFreePathCreator、QRectCreator 的变更,主要因为引入了新的交互范式导致,我们为此引入了 onControllerReset 事件。还有一个变化是 QLineStyle 变 QShapeStyle,这一点前面已经详细讨论,不提。
|
||||
|
||||
所以 Controller 层的变化其实主要是两个。
|
||||
|
||||
其一,PropSelectors。这个 Controller 要比上一版本的复杂很多:之前只是修改 View 的 properties (现在是 style) 属性,以便于创建图形时引用。现在是改变它时还会作用于 selection (被选中的图形),改变它的样式;而且,在 selection 改变时,会自动更新界面以反映被选图形的样式。
|
||||
|
||||
其二,QShapSelector。这是新增加的 Controller,支持选择图形,支持删除、移动被选择的图形。
|
||||
|
||||
通过这次的需求迭代我们可以看出,目前 Model、View、Controller 的分工,可以使需求的分解非常正交。
|
||||
|
||||
Model 只需要考虑需求导致的数据结构演进,并抽象出足够自然的业务接口。View 层非常稳定,主要起到各类角色之间的桥接作用。Controller 层每个 Controller 各司其职,彼此之间不会受到对方需求的干扰。
|
||||
|
||||
## 结语
|
||||
|
||||
今天我们结合“画图” 程序重新梳理了一遍 MVC 架构。并且我们更进一步,通过对画图程序进行一次需求演进,来观察 MVC 架构各个角色对需求变更的敏感性。需要再次强调的是,虽然我们基于 Web 开发,但是我们当前给出的画图程序本质上还是单机版的。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
130
极客时间专栏/许式伟的架构课/桌面开发篇/28 | 实战(三):怎么设计一个“画图”程序?.md
Normal file
130
极客时间专栏/许式伟的架构课/桌面开发篇/28 | 实战(三):怎么设计一个“画图”程序?.md
Normal file
@@ -0,0 +1,130 @@
|
||||
<audio id="audio" title="28 | 实战(三):怎么设计一个“画图”程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/8d/71d320e07025f9e8e3178d311c23d68d.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
前面的两节课结束后,我们的画图程序已经基本实用。它有如下功能:
|
||||
|
||||
- 可以选择全局的图形样式(lineWidth、lineColor、fillColor);
|
||||
- 可以以全局的图形样式来创建各类图形(Path、FreePath、Line、Rect、Ellipse、Circle);
|
||||
- 可以选择已经创建的图形,并修改其图形样式;
|
||||
- 可以删除选择的图形;
|
||||
- 可以移动选择的图形。
|
||||
|
||||
前面有一些同学的反馈,我这里想回答一下。
|
||||
|
||||
有一个反馈是对 JavaScript 的使用,我为什么会用 class 关键字。
|
||||
|
||||
这是因为我不太希望这是一篇某个语言的教程,我选择的是如何用最接近大家思维的表达方式来表达程序逻辑,你就算没有系统学过 JavaScript,也应该能够理解这段程序想要做什么。
|
||||
|
||||
另外有一个反馈,是希望我不要一上来就从 MVC 这种模式讲起,而是如果没有 MVC,我们用最基础的裸写代码,会写出一个什么样的程序来,里面有哪些弊端,从而引入 MVC 来让程序架构变得更加清晰,功能之间解耦。
|
||||
|
||||
这个意见我觉得是比较中肯的,后面我们会补充一讲来裸写 MVP 版本的画图程序。
|
||||
|
||||
今天我们开始进入“实战:怎么设计一个‘画图’程序”的第三讲,怎么和服务端连接。
|
||||
|
||||
考虑到大家普遍反馈内容有点深,我们把服务端连接分为两节课去聊。今天这一讲我们谈的是在浏览器端进行持久化。
|
||||
|
||||
为什么需要在浏览器端进行持久化?
|
||||
|
||||
因为我们需要有更好的用户体验。在用户断网的情况下,这个画图程序还可以正常编辑,并且在恢复联网的情况下,需要能够把所有离线编辑的内容自动同步到服务端。
|
||||
|
||||
结合前面几讲的介绍,你可能立刻想到 Google 推的 PWA,它非常关注浏览器应用的离线体验。
|
||||
|
||||
但是当我们做一个技术选型的时候,显然首先要考虑的是这个技术的兼容性如何。我们今天并不基于 PWA 来干这件事情,而是基于更传统的 localStorage 技术来干。
|
||||
|
||||
具体我们改的代码如下:
|
||||
|
||||
- [https://github.com/qiniu/qpaint/compare/v27...v28](https://github.com/qiniu/qpaint/compare/v27...v28)
|
||||
|
||||
最核心的变化是 Model 层。完整的离线支持的 Model 层代码如下:
|
||||
|
||||
- [dom.js](https://github.com/qiniu/qpaint/blob/v28/paintweb/www/dom.js)
|
||||
|
||||
## 对象 ID
|
||||
|
||||
为了支持持久化,我们给每一个 Model 层 DOM 树的根 —— QPaintDoc 类引入了两个 ID,如下:
|
||||
|
||||
- localID: string
|
||||
- displayID: string
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/eb/56/eb45f019508b3c9f7a1f6bc868a5ac56.png" alt="">
|
||||
|
||||
其中 displayID 顾名思义,是用户可见的ID。我们的画图程序之前本地调试的行为是打开 [http://localhost:8888/](http://localhost:8888/) 来编辑一篇文档(QPaintDoc),但是现在会自动跳转到 [http://localhost:8888/#t10001](http://localhost:8888/#t10001) 或类似的 URL。这里 t10001 就是文档的 displayID。
|
||||
|
||||
其中,displayID 前面带 t 开头,表示这篇文档从它被创建开始,从未与服务器同步过,是一篇临时的文档。一旦它完成与服务端的同步后,就会改用服务端返回的文档 ID。
|
||||
|
||||
那么,localID 是什么?顾名思义,是这篇文档的本地 ID。在文档还没有和服务端同步时,它和 displayID 是有关系的,如果 displayID 是 t10001,那么 localID 就是 10001。但是文档第一次保存到服务端后,它的 displayID 会变化,而 localID 则并不改变。
|
||||
|
||||
**这有什么好处?**
|
||||
|
||||
**好处在于,我们在 localStorage 存储 DOM 树的时候,并不是把整篇文档 JSON 化后保存,而是分层的,QPaintDoc 里面的 shapes 数组保存的只是 shapeID。**
|
||||
|
||||
是的,每个 Shape(图形)也引入了一个 ID。这样,当 Shape 发生变化,比如修改图形样式、移动,我们修改 shapeID => shapeJsonData。
|
||||
|
||||
请注意,在浏览器的 localStorage 里面,shapeID 是要全局唯一的,我们实际存储的是 QPaintDoc.localID + ":" + shape.id。
|
||||
|
||||
看到这里我们回过头来看,为什么 QPaintDoc 有 displayID 和 localID 就可以理解了。如果只有一个 ID 并且这个 ID 是会发生变化的,那么在 ID 变化时,所有保存在 localStorage 中的这篇文档的图形对象 shapeID => shapeJsonData 数据都需要跟着变化。
|
||||
|
||||
引入 localID 就是让 QPaintDoc 一旦初始化(QPaintDoc.init 方法)后 ,ID 就固定下来了,只需要保证在同一个浏览器下是唯一就行。
|
||||
|
||||
所以,我们第一次访问 [http://localhost:8888/](http://localhost:8888/) 自动跳转的是 [http://localhost:8888/#t10001](http://localhost:8888/#t10001) ,第二次访问自动跳转的就是 [http://localhost:8888/#t10002](http://localhost:8888/#t10002) 了。这是因为在同一个浏览器下,我们不会让两个 QPaintDoc.localID 相同。
|
||||
|
||||
## 数据变更
|
||||
|
||||
我们把数据变更分为了两级:
|
||||
|
||||
- shapeChanged
|
||||
- documentChanged
|
||||
|
||||
什么情况下叫 shapeChanged?有这样三种:
|
||||
|
||||
- 增加一个图形(addShape),这个新增的 shape 发生了 shapeChanged;
|
||||
- 修改一个 shape 的图形样式(setProp),这个被修改的 shape 发生了 shapeChanged;
|
||||
- 移动一个 shape 的位置(move),这个位置改变的 shape 发生了 shapeChanged。
|
||||
|
||||
什么情况下发生 documentChanged?有这样两种:
|
||||
|
||||
- 增加一个图形(addShape),它会导致文档的图形数量增加一个,发生 documentChanged;
|
||||
- 删除一个图形(deleteShape),它会导致文档的图形数量减少一个,发生 documentChanged。
|
||||
|
||||
当然,可以预见的未来,我们支持不同 shape 交换次序(改变 Z-Order),这时文档虽然图形的数目不变,但是 shapes 数组的内容还是发生了改变,发生 documentChanged。
|
||||
|
||||
发生数据变更做什么?
|
||||
|
||||
在 shapeChanged 时,更新 localStorage 中的 shapeID => shapeJsonData 数据。在 documentChanged 时,更新 localID => documentJsonData 数据。
|
||||
|
||||
从未来的预期来说,数据变更不只是发生在用户交互。考虑多人同时编辑一篇文档的场景。数据变更消息,也会来自其他浏览器端的变更。具体的过程是:
|
||||
|
||||
- Client B 操作 => Client B 的 DOM 变更 => 服务端数据变更 => Client A 收到数据变更 => Client A 的 DOM 变更 => Client A 的 View 更新
|
||||
|
||||
在前面 26 讲、27 讲中,我们并没有引入数据变更事件,而是 Controller 变更完数据后,就自己主动调用 qview.invalidateRect 来通知 View 层重新绘制。这样做比较简单,虽然它并不符合标准的 MVC 架构。因为从 MVC 架构来说,界面更新并不是由 Controller 触发,而应该由 Model 层的数据变更(DataChanged)事件触发。
|
||||
|
||||
## 存储的容量限制与安全
|
||||
|
||||
localStorage 的存储容量是有限制的,不同的浏览器并不一样,大部分在 5-10M 这个级别。在同一个浏览器下,会有多个 QPaintDoc 的数据同时被保存在 localStorage 中。
|
||||
|
||||
这意味着,随着时间的推移,localStorage 的存储空间占用会越来越大,所以我们需要考虑数据清理的机制。
|
||||
|
||||
目前,我们通过 localStorage_setItem 函数来统一接管 localStorage.setItem 调用,一旦 setItem 发生 QuotaExceededError 异常,说明 localStorage 空间满,我们就淘汰掉最远创建的一篇文档。
|
||||
|
||||
这样,我们就不会因为 localStorage 太满而没法保存。只要我们及时联网同步文档,数据也就不会丢失了。
|
||||
|
||||
最后一个话题是安全。
|
||||
|
||||
既然我们把数据保存在了 localStorage 中,只要用户打开浏览器,就能够去通过特定手段来查看 localStorage 的数据。
|
||||
|
||||
这意味着如果文档中存在敏感数据的话,是可以被人感知的。尤其是我们画图程序如果未来支持多租户的话,在同一个浏览器下多个用户帐号登录登出时,就会发生多个用户的文档都在同一个 localStorage 中可见。
|
||||
|
||||
这意味着你登出帐号之后,其他人用这个浏览器,其实还是可以看到你的数据。这样就有隐私泄漏的风险。
|
||||
|
||||
解决这个问题最简单的方法是在用户帐号登出的时候,清空所有的 localStorage 中的文档。
|
||||
|
||||
## 结语
|
||||
|
||||
今天我们开始考虑 “画图” 程序的服务端连接。今天这一讲我们先做画图程序的本地浏览器存储的持久化,以便拥有更好的离线。
|
||||
|
||||
支持离线持久化存储的程序会很不一样。我们今天结合画图程序聊了 DOM 树在 JavaScript 内存和在 localStorage 存储上的差别。为了支持更新数据的粒度不是整个文档每次都保存一遍,存储分成 shape、document 两个级别。相应的,我们数据更新事件也分了 shapeChanged、documentChanged 两个级别。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
351
极客时间专栏/许式伟的架构课/桌面开发篇/29 | 实战(四):怎么设计一个“画图”程序?.md
Normal file
351
极客时间专栏/许式伟的架构课/桌面开发篇/29 | 实战(四):怎么设计一个“画图”程序?.md
Normal file
@@ -0,0 +1,351 @@
|
||||
<audio id="audio" title="29 | 实战(四):怎么设计一个“画图”程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a5/98/a54f243572d99c383ab5b12fe697b798.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
今天继续我们的画图程序。上一讲完成后,我们的画图程序不只是功能实用,并且还支持了离线编辑与存储。
|
||||
|
||||
今天我们开始考虑服务端。
|
||||
|
||||
我们从哪里开始?
|
||||
|
||||
第一步,我们要考虑的是网络协议。
|
||||
|
||||
## 网络协议
|
||||
|
||||
为了简化,我们暂时不考虑多租户带授权的场景。后面我们在下一章服务端开发篇会继续实战这个画图程序,将其改造为多租户。
|
||||
|
||||
在浏览器中,一个浏览器的页面编辑的是一个文档,不同页面编辑不同的文档。所以在我们的浏览器端的 dom.js 里面,大家可以看到,我们的 DOM 模型是单文档的设计。
|
||||
|
||||
但显然,服务端和浏览器端这一点是不同的,就算没有多租户,但是多文档是跑不了的。我们不妨把 QPaint 的文档叫drawing,如此服务端的功能基本上是以下这些:
|
||||
|
||||
- 创建新 drawing 文档;
|
||||
- 获取 drawing 文档;
|
||||
- 删除 drawing 文档;
|
||||
- 在 drawing 文档中创建一个新 shape;
|
||||
- 取 drawing 文档中的一个 shape;
|
||||
- 修改 drawing 文档中的一个 shape,包括移动位置、修改图形样式;
|
||||
- 修改 drawing 文档中的一个 shape 的 zorder 次序(浏览器端未实现);
|
||||
- 删除 drawing 文档的一个 shape。
|
||||
|
||||
完整的网络协议见下表:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/c3/1b174cea94808537e21c5328ad2b8bc3.png" alt="">
|
||||
|
||||
其中`<Shape>`是这样的:
|
||||
|
||||
```
|
||||
"path": {
|
||||
"points": [
|
||||
{"x": <X>, "y": <Y>},
|
||||
...
|
||||
],
|
||||
"close": <Boolean>,
|
||||
"style": <ShapeStyle>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```
|
||||
"line": {
|
||||
"pt1": {"x": <X>, "y": <Y>},
|
||||
"pt2": {"x": <X>, "y": <Y>},
|
||||
"style": <ShapeStyle>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```
|
||||
"rect": {
|
||||
"x": <X>,
|
||||
"y": <Y>,
|
||||
"width": <Width>,
|
||||
"height": <Height>,
|
||||
"style": <ShapeStyle>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```
|
||||
"ellipse": {
|
||||
"x": <X>,
|
||||
"y": <Y>,
|
||||
"radiusX": <RadiusX>,
|
||||
"radiusY": <RadiusY>,
|
||||
"style": <ShapeStyle>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中`<ShapeStyle>`是这样的:
|
||||
|
||||
```
|
||||
{
|
||||
"lineWidth": <Width>, // 线宽
|
||||
"lineColor": <Color>, // 线型颜色
|
||||
"fillColor": <Color> // 填充色
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中`<ZorderOperation>`可能的值为:
|
||||
|
||||
- "top": 到最顶
|
||||
- "bottom": 到最底
|
||||
- "front": 往前一层
|
||||
- "back": 往后一层
|
||||
|
||||
整体来说,这套网络协议比较直白体现了其对应的功能含义。我们遵循这样一套网络协议定义的范式:
|
||||
|
||||
- 创建对象:POST /objects
|
||||
- 修改对象:POST /objects/`<ObjectID>`
|
||||
- 删除对象:DELETE /objects/`<ObjectID>`
|
||||
- 查询对象:GET /objects/`<ObjectID>`
|
||||
|
||||
其实还有一个列出对象,只不过我们这里没有用到:
|
||||
|
||||
- 列出所有对象:GET /objects
|
||||
- 列出符合条件的对象:GET /objects?key=value
|
||||
|
||||
另外,有一个在网络设计时需要特别注意的点是:对重试的友好性。
|
||||
|
||||
为什么我们必须要充分考虑重试的友好性?因为网络是不稳定的。这意味着,在发生一次网络请求失败时,在一些场景下你不一定能确定请求的真实状态。
|
||||
|
||||
在小概率的情况下,有可能服务端已经执行了预期的操作,只不过返还给客户端的时候网络出现了问题。在重试时你以为只是重试,但实际上是同一个操作执行了两遍。
|
||||
|
||||
所谓重试的友好性,是指同一个操作执行两遍,其执行结果和只执行一遍一致。
|
||||
|
||||
只读操作,比如查询对象或列出对象,毫无疑问显然是重试友好的。
|
||||
|
||||
创建对象(POST /objects)往往容易被实现为重试不友好的,执行两遍会创建出两个对象来。我们对比一下这里创建新drawing和创建新shape的差别:
|
||||
|
||||
```
|
||||
POST /drawings
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
POST /drawings/<DrawingID>/shapes
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": <ShapeID>,
|
||||
<Shape>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,创建新 shape 时传入了 ShapeID,也就是说是由客户(浏览器端)分配 ShapeID。这样做的好处是如果上一次服务端已经执行过该对象的创建,可以返回对象已经存在的错误(我们用 status = 409 冲突来表示)。
|
||||
|
||||
而创建新 drawing 并没有传入什么参数,所以不会发生什么冲突,重复调用就会创建两个新 drawing 出来。
|
||||
|
||||
通过以上分析,我们可以认为:创建新 shape 是重试友好的,而创建 drawing 不是重试友好的。那么怎么解决这个问题?有这么几种可能:
|
||||
|
||||
- 客户端传 id(和上面创建新 shape 一样);
|
||||
- 客户端传 name;
|
||||
- 客户端传 uuid。
|
||||
|
||||
当然这三种方式本质上的差别并不大。比如客户端传 name,如果后面其他操作引用时用的也是 name,那么本质上这个 name 就是 id。
|
||||
|
||||
传 uuid 可以认为是一种常规重试友好的改造手法。这里 uuid 并没有实际含义,你可以理解为它是 drawing 的唯一序列号,也可以理解为网络请求的唯一序列号。当然这两种不同理解的网络协议表现上会略有不同,如下:
|
||||
|
||||
```
|
||||
POST /drawings
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"uuid": <DrawingUUID>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
POST /drawings
|
||||
Content-Type: application/json
|
||||
X-Req-Uuid: <RequestUUID>
|
||||
|
||||
```
|
||||
|
||||
修改对象和删除对象,往往是比较容易做到重试友好。但这并不绝对,比如我们这个例子中 “修改shape的顺序”,它的网络协议是这样的:
|
||||
|
||||
```
|
||||
POST /drawings/<DrawingID>/shapes/<ShapeID>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"zorder": <ZorderOperation>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中`<ZorderOperation>`可能的值为:
|
||||
|
||||
- "top": 到最顶
|
||||
- "bottom": 到最底
|
||||
- "front": 往前一层
|
||||
- "back": 往后一层
|
||||
|
||||
在 ZorderOperation 为 "front" 或 "back" 时,重复执行两遍就会导致 shape 往前(或往后)移动 2 层。
|
||||
|
||||
怎么调整?
|
||||
|
||||
有两个办法。一个方法是把修改操作用绝对值表示,而不是相对值。比如 ZorderOperation 为 "front" 或 "back" 是相对值,但是 Zorder = 5 是绝对值。
|
||||
|
||||
另一个方法是通用的,就是用请求的序列号(RequestUUID),这个方法在上面创建新 drawing 已经用过了,这里还可以用:
|
||||
|
||||
```
|
||||
POST /drawings/<DrawingID>/shapes/<ShapeID>
|
||||
Content-Type: application/json
|
||||
X-Req-Uuid: <RequestUUID>
|
||||
|
||||
{
|
||||
"zorder": <ZorderOperation>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当然用请求序列号是有额外代价的,因为这意味着服务端要把最近执行成功的所有的请求序列号(RequestUUID)记录下来,在收到带请求序列号的请求时,检查该序列号的请求是否已经成功执行,已经执行过就报冲突。
|
||||
|
||||
在网络协议的设计上,还有一个业务相关的细节值得一提。
|
||||
|
||||
细心的你可能留意到,我们 Shape 的 json 表示,在网络协议和 localStorage 存储的格式并不同。在网络协议中是:
|
||||
|
||||
```
|
||||
{
|
||||
"id": <ShapeID>,
|
||||
"path": {
|
||||
"points": [
|
||||
{"x": <X>, "y": <Y>},
|
||||
...
|
||||
],
|
||||
"close": <Boolean>,
|
||||
"style": <ShapeStyle>
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而在 localStorage 中的是:
|
||||
|
||||
```
|
||||
{
|
||||
"type": "path",
|
||||
"id": <ShapeID>,
|
||||
"points": [
|
||||
{"x": <X>, "y": <Y>},
|
||||
...
|
||||
],
|
||||
"close": <Boolean>,
|
||||
"style": <ShapeStyle>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从结构化数据的 Schema 设计角度,localStorage 中的实现是无 Schema 模式,过于随意。这是因为 localStorage 只是本地自己用的缓存,影响范围比较小,故而我们选择了怎么方便怎么来的模式。而网络协议未来有可能作为业务的开放 API ,需要严谨对待。
|
||||
|
||||
## 版本升级
|
||||
|
||||
另外,这个画图程序毕竟只是一个 DEMO 程序,所以还有一些常见网络协议的问题并没有在考虑范围之内。
|
||||
|
||||
比如从更长远的角度,网络协议往往还涉及协议的版本管理问题。网络协议是一组开放 API 接口,一旦放出去了就很难收回,需要考虑协议的兼容。
|
||||
|
||||
为了便于未来协议升级的边界,很多网络协议都会带上版本号。比如:
|
||||
|
||||
```
|
||||
POST /v1/objects
|
||||
POST /v1/objects/<ObjectID>
|
||||
DELETE /v1/objects/<ObjectID>
|
||||
GET /v1/objects/<ObjectID>
|
||||
GET /v1/objects?key=value
|
||||
|
||||
```
|
||||
|
||||
在协议发生了不兼容的变更时,我们会倾向于升级版本,比如升为 v2 版本:
|
||||
|
||||
```
|
||||
POST /v2/objects
|
||||
POST /v2/objects/<ObjectID>
|
||||
DELETE /v2/objects/<ObjectID>
|
||||
GET /v2/objects/<ObjectID>
|
||||
GET /v2/objects?key=value
|
||||
|
||||
```
|
||||
|
||||
这样做有这么一些好处:
|
||||
|
||||
- 可以逐步下线旧版本的流量,一段时间内让两个版本的协议并存;
|
||||
- 可以新老版本的业务服务器相互独立,前端由 nginx 或其他的应用网关来分派。
|
||||
|
||||
## 第一个实现版本
|
||||
|
||||
聊完了网络协议,我们就要开始考虑服务端的实现。在选择第一个实现版本怎么做时,有这样几种可能性。
|
||||
|
||||
第一种,当然是常规的憋大招模式。直接做业务架构设计、架构评审、编码、测试,并最后上线。
|
||||
|
||||
第二种,是做一个 Mock 版本的服务端程序。
|
||||
|
||||
两者有什么区别?
|
||||
|
||||
区别在于,服务端程序从架构设计角度,就算是非业务相关的通用型问题也是很多的,比如高可靠和高可用。
|
||||
|
||||
高可靠是指数据不能丢。就算服务器的硬盘坏了,数据也不能丢。这还没什么,很多服务甚至要求,在机房层面出现大面积事故比如地震,也不能出现数据丢失。
|
||||
|
||||
高可用是指服务不能存在单点故障。任何一台甚至几台服务器停机了,用户还要能够正常访问。一些服务比如支付宝,甚至要求做到跨机房的异地双活。在一个机房故障时,整个业务不能出现中断。
|
||||
|
||||
在没有好的基础设施下,要做好一个好的服务端程序并不那么容易。所以另一个选择是先做一个 Mock 版本的服务端程序。
|
||||
|
||||
这不是增加了工作量?有什么意义?
|
||||
|
||||
其一,是让团队工作并行。不同团队协作的基础就是网络协议。一个快速被打造的 Mock 的最小化版本服务端,可以让前端不用等待后端。而后端则可以非常便捷地自主针对网络协议进行单元测试,做很高的测试覆盖率以保证质量,进度不受前端影响。
|
||||
|
||||
其二 ,是让业务逻辑最快被串联,快速验证网络协议的有效性。中途如果发现网络协议不满足业务需求,可以及时调整过来。
|
||||
|
||||
所以我们第一版的服务端程序,是 Mock 的版本。Mock 版本不必考虑太多服务端领域的问题,它的核心价值就是串联业务。所以 Mock 版本的服务器甚至不需要依赖数据库,直接所有的业务逻辑基于内存中的数据结构就行。
|
||||
|
||||
代码如下:
|
||||
|
||||
- [https://github.com/qiniu/qpaint/tree/v29/paintdom](https://github.com/qiniu/qpaint/tree/v29/paintdom)
|
||||
|
||||
正式版画图程序的服务端,我们会在后面服务端开发一章的实战中继续去完成。
|
||||
|
||||
从架构角度来说,这个 paintdom 程序分为两层:Model 层和 Controller 层。
|
||||
|
||||
我们首先看一下 Model 层。它的源代码是:
|
||||
|
||||
- [paintdom/shape.go](https://github.com/qiniu/qpaint/blob/v29/paintdom/shape.go)
|
||||
- [paintdom/drawing.go](https://github.com/qiniu/qpaint/blob/v29/paintdom/drawing.go)
|
||||
|
||||
Model 层与网络无关,有的只是纯纯粹粹的业务核心逻辑。它实现了一个多文档版本的画图程序,逻辑结构也是一棵 DOM 树,只不过比浏览器端多了一层:
|
||||
|
||||
- Document => Drawing => Shape => ShapeStyle
|
||||
|
||||
浏览器端的 QPaintDoc,对应的是这里的 Drawing,而不是这里的 Document。
|
||||
|
||||
我们再来看一下 Controller 层。它的源代码是:
|
||||
|
||||
- [paintdom/service.go](https://github.com/qiniu/qpaint/blob/v29/paintdom/service.go)
|
||||
|
||||
Controller 层实现的是网络协议。你可能觉得奇怪,我为什么会把网络协议层看作 Controller 层,那么 MVC 中 View 层去了哪里。
|
||||
|
||||
首先服务端程序大部分情况下并不需要显示模块,所以不存在 View 层。网络协议层为什么可以看作 Controller 层,是因为它负责接受用户输入。只不过用户输入不是我们日常理解的用户交互,而是来自某个自动化控制(Automation)程序的 API 请求。
|
||||
|
||||
虽然这个 paintdom 程序的实现,有一些 Go 语言相关的知识点是挺值得讲的,尤其是网络协议实现相关的部分。不过我这里就不做展开了,感兴趣的同学可以自行学习一下 Go 语言。
|
||||
|
||||
总体来说,业务逻辑相关的部分理解起来相对容易,我们这里不再赘述。
|
||||
|
||||
## 结语
|
||||
|
||||
今天我们重点讨论了 “画图” 程序的网络协议,给出了常规网络协议设计上的一些考量点。网络协议的地位非常关键,它是一个 B/S 或 C/S 程序前后端耦合的使用界面,因而也是影响团队开发效率的关键点。
|
||||
|
||||
如何及早稳定网络协议?如何及早让前端程序员可以与服务端联调?这些都是我们应该重点关注的地方。
|
||||
|
||||
定义清楚网络协议后,我们给出了满足我们定义的网络协议的第一个服务端实现版本 paintdom 程序,用于串联业务逻辑。这个实现版本是 Mock 程序,它只关注业务逻辑,不关心服务端程序的固有的高可靠、高可用等需求。后续在下一章服务端开发中,我们会继续迭代它。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们会把这个 paintdom 服务端程序,和我们的 paintweb 画图程序串联起来。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
351
极客时间专栏/许式伟的架构课/桌面开发篇/30 | 实战(五):怎么设计一个“画图”程序?.md
Normal file
351
极客时间专栏/许式伟的架构课/桌面开发篇/30 | 实战(五):怎么设计一个“画图”程序?.md
Normal file
@@ -0,0 +1,351 @@
|
||||
<audio id="audio" title="30 | 实战(五):怎么设计一个“画图”程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/69/f8/6922e557d51b91fe245cyy6dcf469df8.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
我们继续聊我们的话题。这是画图程序的最后一讲了。当然我们后续还会结合这个实战程序展开讨论有关于架构的方方面面。
|
||||
|
||||
## 宏观的系统架构
|
||||
|
||||
上一讲开始,我们的画图程序有了跨团队协作:因为我们开始有了 paintdom 和 paintweb 两大软件。paintdom 监听的地址是 localhost:9999,而 paintweb 监听的地址是 localhost:8888。
|
||||
|
||||
应当注意,在实际业务中它们是不同的软件,事实上我们 paintweb 程序也完全是以进程间协作的方式,通过反向代理机制来调用 paintdom 的功能。但是在我们这个画图 DEMO 程序中,它们同属一个进程,paintdom 作为 paintweb 的一个 goroutine 在跑。这纯粹是因为我们想让这两个程序 “同生共死”,方便调试的时候起停进程。
|
||||
|
||||
paintdom 和 paintweb 之间相互协作的基础,是它们之间所采用的网络协议。
|
||||
|
||||
当我们说起网络协议,它其实通常包含两个层面的意思:其一是我们网络协议的载体,也就是协议栈(我们这里采纳的是 HTTP 协议,而 HTTP 协议又基于 TCP/IP 协议);其二是我们网络协议承载的业务逻辑。
|
||||
|
||||
当我们谈架构的时候,也会同时聊这两个层面,只是它们在不同的维度。我们会关心网络协议的协议栈选择什么,是基于 HTTP 还是基于自定义的二进制协议,这个是属于基础架构的维度。我们也会关心网络协议的业务逻辑,判断它是否自然体现业务需求,这是属于应用架构的维度。
|
||||
|
||||
明确了网络协议后,我们实现了 Mock 版本的服务端程序 paintdom。在实际项目中,Mock 程序往往会大幅提速团队的开发效率。这是因为它能够达到如下两个大的核心目标:
|
||||
|
||||
- 让团队的研发迭代并行,彼此可以独立演进。
|
||||
- 及早验证网络协议的合理性,在实战中达到用最短时间稳定协议的目的。
|
||||
|
||||
上一讲我们的 paintdom 和 paintweb 之间虽然定义了网络协议,并且实现了第一版,但是并没有去做两者的对接。
|
||||
|
||||
今天我们就来对接它们。
|
||||
|
||||
虽然 paintweb 没有对接服务端,但从文档编辑的角度来说,它的功能是非常完整的。我们对接 paintdom 和 paintweb 的目的不是加编辑功能,而是让文档可以存储到服务端,以便于人们在世界任何可以联网的角落都可以打开它。
|
||||
|
||||
当然严谨来说,说 paintweb 没有服务端是不正确的,paintweb 本身是一个 B/S 结构,它有它自己的服务端。如下:
|
||||
|
||||
```
|
||||
var wwwServer = http.FileServer(http.Dir("www"))
|
||||
|
||||
func handleDefault(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Path == "/" {
|
||||
http.ServeFile(w, req, "www/index.htm")
|
||||
return
|
||||
}
|
||||
req.URL.RawQuery = "" // skip "?params"
|
||||
wwwServer.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/", handleDefault)
|
||||
http.ListenAndServe(":8888", nil)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,paintweb 自己的服务端基本上没干什么事情,就是一个非常普通的静态文件下载服务器,提供给浏览器端下载 HTML + CSS + JavaScript 等内容。
|
||||
|
||||
所以 paintweb 的服务端完全是“平庸”的,与业务无关。具体的业务,都是通过 www 目录里面的文件来做到的。这些文件都是前端的浏览器端所依赖的,只不过被 “托管” 到 paintweb 服务端而已。
|
||||
|
||||
那么 paintweb 怎么对接 paintdom 呢?
|
||||
|
||||
物理上的对接比较简单,只是个反向代理服务器而已,代码如下:
|
||||
|
||||
```
|
||||
func newReverseProxy(baseURL string) *httputil.ReverseProxy {
|
||||
rpURL, _ := url.Parse(baseURL)
|
||||
return httputil.NewSingleHostReverseProxy(rpURL)
|
||||
}
|
||||
|
||||
var apiReverseProxy = newReverseProxy("http://localhost:9999")
|
||||
|
||||
func main() {
|
||||
http.Handle("/api/", http.StripPrefix("/api/", apiReverseProxy))
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,paintweb 的服务端干的事情仍然是 “平庸” 的,只是把发往 [http://localhost:8888/api/xxx](http://localhost:8888/api/xxx) 的请求,原封不动地发往 [http://localhost:9999/xxx](http://localhost:9999/xxx) 而已。
|
||||
|
||||
在现实中,paintweb 的服务端干的事情稍微复杂一些。它背后不只是有业务服务器 paintdom,还有必不可少的帐号服务器(Account Service),用来支持用户登录/登出。
|
||||
|
||||
帐号服务器是一个基础架构类的服务,与业务无关。公司很可能不只有 QPaint 这样一个业务,还会有别的,但这些业务可以共享相同的帐号服务。更准确地说,是必须共享相同的帐号服务,否则一个公司弄出好多套独立的帐号体系来,用户也会有所诟病。
|
||||
|
||||
在需要对接帐号服务器的情况下,实际上 paintweb 的服务端并不是原封不动地转发业务请求,而是会对协议进行转义。
|
||||
|
||||
在 “[24 | 跨平台与 Web 开发的建议](https://time.geekbang.org/column/article/107128)”这一讲中我们提到过:
|
||||
|
||||
>
|
||||
到了 Web 开发,我们同样需要二次开发接口,只不过这个二次开发接口不再是在 Client 端完成的,而是在 Server 端完成。Server 端支持直接的 API 调用,以支持自动化(Automation)方面的需求。
|
||||
<blockquote>
|
||||
|
||||
所以,对 Server 端来说,最底层的是一个多租户的 Model 层(Multi-User Model),它实现了自动化(Automation)所需的 API。
|
||||
|
||||
>
|
||||
|
||||
在 Multi-User Model 层之上,有一个 Web 层。Web 层和 Model 层的假设不同,Web 层是基于会话的(Session-based),因为它负责用户的接入,每个用户登录后,会形成一个个会话(Session)。
|
||||
|
||||
>
|
||||
|
||||
如果我们对Web 层细究的话,又分为 Model 层和 ViewModel 层。为了区分,Web 这边的 Model 层我们叫它 Session-based Model。相应地,ViewModel 层我们叫它 Session-based ViewModel。
|
||||
|
||||
>
|
||||
|
||||
在服务端,Session-based Model 和 Session-based ViewModel 并不发生直接关联,它们通过自己网络遥控浏览器这一侧的 Model 和 ViewModel,从而响应用户的交互。
|
||||
|
||||
>
|
||||
|
||||
Session-based Model 是什么样的呢?它其实是 Multi-User Model 层的转译。把多租户的 API 转译成单租户的场景。所以这一层并不需要太多的代码,甚至理论上自动实现也是有可能的。
|
||||
|
||||
>
|
||||
|
||||
Session-based ViewModel 是一些 HTML+JavaScript+CSS 文件。它是真正的 Web 业务入口。它通过互联网把自己的数据返回给浏览器,浏览器基于 ViewModel 渲染出View,这样整个系统就运转起来了。
|
||||
|
||||
这段话说的比较抽象,但结合 QPaint 这个实际的例子,就非常明朗了:
|
||||
|
||||
- paintdom 就是这里说的 Multi-User Model 层,负责多租户的业务服务器。
|
||||
- paintweb 服务端实现 Session-based Model 层,负责 Session-based 到 Multi-User 的转译。由于我们当前这个例子还不支持多租户,转译就变成了简单的转发。后面我们在 “服务端开发” 一节中会给大家看实际的转译层是怎么做的。
|
||||
|
||||
所以你可以看到,其实 paintweb 自身的服务端是业务无关的。它做这样一些事情:
|
||||
|
||||
- Web 前端文件的托管(作为静态文件下载服务器);
|
||||
- 支持帐号服务,实现 Web 的用户登录;
|
||||
- 做业务协议的转译,将 Session-based 的 API 请求转为 Multi-User 的 API 请求。
|
||||
|
||||
当然,我们这里假设 Web 自身的业务逻辑都是通过 JavaScript 来实现的。这意味着我们是基于 “胖前端” 模式的。
|
||||
|
||||
但这并不一定符合事实,有些公司会基于 “胖后端” 模式。这意味着大部分的前端用户行为,都是由后端支持的,比如我们用 PHP 来实现 Web 后端的业务代码。
|
||||
|
||||
胖后端模式的好处是 Web 代码比较安全。这里的 “安全” 是指 IT 资产保全方面的安全,不是指业务存在安全问题,因为别人看不到完整的 Web 业务逻辑代码。
|
||||
|
||||
但是胖后端模式的缺点是没办法支持离线。大部分的用户交互都需要 Web 后端来响应,一旦断了网就什么都干不了了。
|
||||
|
||||
在 “胖后端” 模式下,我个人会倾向于基于类似 PHP 这种胶水语言来实现 Web 后端的业务代码。而一旦我们这么做,paintweb 的业务逻辑就被剥离了,paintweb 自身的后端仍然是业务无关的,只是多了一个职责:支持 PHP 脚本语言。
|
||||
|
||||
真正 Web 后端业务逻辑,还是放在了 www 目录中,以 PHP 文件存在,这些文件就不是简单的静态资源,而是 “胖后端” 的业务代码。
|
||||
|
||||
既然 paintweb 后端是 “平庸” 的,与业务无关,那么整个业务逻辑的串联,靠的就是 www 里面的 js 文件,和 paintdom 提供的 API 接口。
|
||||
|
||||
上面我们说过,在连接 paintdom 之前,paintweb 程序独立看是完整的,它支持离线创建、编辑以及存储文档到浏览器本地的 localStorage 上。
|
||||
|
||||
对接 paintdom 与 paintweb 后我们并不会放弃离线编辑的能力,而是要能够做到:
|
||||
|
||||
- 在断网情况下,表现为上一讲我们达到的效果,可以继续离线编辑和保存;
|
||||
- 一旦联网,所有离线编辑的内容可以自动保存到 paintdom 服务器。
|
||||
|
||||
## 计算变更
|
||||
|
||||
听起来挺简单一件事情?
|
||||
|
||||
其实很复杂。第一件要做的事情是:怎么知道断网后离线编辑过的内容有哪些?
|
||||
|
||||
思路一是不管三七二十一,每次都完整保存整篇文档。这很浪费,因为不单单刚恢复联网的时候我们需要保存文档,平常每一次编辑操作我们也都会自动保存修改的内容。
|
||||
|
||||
思路二是记录完整的编辑操作历史,每做一个编辑操作都将其记录到 localStorage。这个思路看似会更节约,但是实际上在很多情况下会更浪费。原因在于:
|
||||
|
||||
- 一个对象编辑多次,会有很多条编辑操作的指令要保存;
|
||||
- 断网久了,编辑操作累计下来,其存储空间甚至可能超过文档大小。
|
||||
|
||||
所以这种方案缺乏很好的鲁棒性,在 badcase 情况下让人难以接受。
|
||||
|
||||
思路三是给对象增加版本号。通过对比整个文档的基版本(baseVer,即上一次同步完成时的版本),与某个对象的版本 ver。如果 ver > baseVer,说明上一次同步完成后,该对象发生了变更。完整的变更信息的计算逻辑如下:
|
||||
|
||||
```
|
||||
prepareSync(baseVer) {
|
||||
let shapeIDs = []
|
||||
let changes = []
|
||||
let shapes = this._shapes
|
||||
for (let i in shapes) {
|
||||
let shape = shapes[i]
|
||||
if (shape.ver > baseVer) {
|
||||
changes.push(shape)
|
||||
}
|
||||
shapeIDs.push(shape.id)
|
||||
}
|
||||
let result = {
|
||||
shapes: shapeIDs,
|
||||
changes: changes,
|
||||
ver: this.ver
|
||||
}
|
||||
this.ver++
|
||||
return result
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 同步变更
|
||||
|
||||
有了变更的信息,怎么同步给服务端?
|
||||
|
||||
一个可能的思路是把变更还原为一条条编辑操作发给服务端。但是,这样做问题会很复杂,因为这些编辑操作一部分发送成功,一部分发送失败怎么办?
|
||||
|
||||
这种部分成功的中间态是最挑战我们程序员的编程水平的,很烧脑。
|
||||
|
||||
我个人一贯坚持的架构准则是不要烧脑。尤其对大部分非性能敏感的业务代码,简单易于实施为第一原则。
|
||||
|
||||
所以我们选择了修改网络协议。增加了同步接口:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/13/15b65c55fae904ca16ec6192ed81b613.png" alt="">
|
||||
|
||||
这很有趣。在我们讨论相互配合的接口时,我们非常尊重业务逻辑,按照我们对业务的理解,定义了一系列的编辑操作。但是,到最后我们却发现,它们统统不管用,我们要的是一个同步协议。
|
||||
|
||||
是最初我们错了吗?
|
||||
|
||||
也不能这么说。最初我们定义协议的逻辑并没有错,只是没有考虑到支持离线编辑这样的需求而已。
|
||||
|
||||
复盘这件事情,我们可以这么说:
|
||||
|
||||
- 需求的预见性非常重要。如果我们没有充分预见到需求,大部分情况下就会因为我们缺乏市场洞察而买单;
|
||||
- 进一步说明,及早推出 Mock,让前端可以快速迭代,进而及早去发现原先定义网络协议的不足是很有必要的。越晚做出协议调整,事情就越难,也越低效。
|
||||
|
||||
有了同步协议,我们就可以把变更信息同步给服务端了。这个事情我们交给了 QSynchronizer 类来完成(详细请看 [dom.js#L204](https://github.com/qiniu/qpaint/blob/v30/paintweb/www/dom.js#L204))。
|
||||
|
||||
## 加载文档
|
||||
|
||||
把变更详细推送给服务端后,理论上我们就可以在世界各地看到这篇文档。
|
||||
|
||||
怎么做到?
|
||||
|
||||
我们接下来就谈谈怎么来加载文档。这个过程的难点在于怎么根据服务端返回的 json 数据重建整个文档。
|
||||
|
||||
上一讲我们已经说过,我们图形(Shape)的网络协议中的数据格式,和 localStorage 中是不同的。这意味着我们需要做两套图形数据的加载工作。
|
||||
|
||||
这挺没有必要。
|
||||
|
||||
而且,从预测变更的角度,我们很容易预期的一个变化,就是画图程序支持的图形(Shape)的种类会越来越多。
|
||||
|
||||
这两个事情我们一起看。为此我们做了一次重构。重构目标是:
|
||||
|
||||
- 统一 localStorage 和网络协议中的图形表示;
|
||||
- 增加新的图形种类要很容易,代码非常内聚,不必到处修改代码。
|
||||
|
||||
为此我们增加 qshapes: QSerializer 全局变量,允许各种图形类型注册自己的创建方法(creator)进去。示意代码如下:
|
||||
|
||||
```
|
||||
qshapes.register("rect", function(json) {
|
||||
return new QRect(json)
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
为了支持 QSerializer 类(代码参见 [dom.js#L89](https://github.com/qiniu/qpaint/blob/v30/paintweb/www/dom.js#L89)),每个图形需要增加两个方法:
|
||||
|
||||
```
|
||||
interface Shape {
|
||||
constructor(json: Object)
|
||||
toJSON(): Object
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样我们就可以调用 qshapes.create(json) 来创建一个图形实例。
|
||||
|
||||
有了这个能力,我们加载文档就水到渠成了,具体代码请参考 QPaintDoc 类的 _loadRemote(displayID) 方法(参见 [dom.js#L690](https://github.com/qiniu/qpaint/blob/v30/paintweb/www/dom.js#L690))。
|
||||
|
||||
完整来说,加载文档的场景分为这样三类:
|
||||
|
||||
- _loadBlank,即加载新文档。在联网情况下,会在服务端创建一个新 drawing。在非联网情况下,会本地创建一个临时文档(displayID 以 t 开头)。
|
||||
- _loadTempDoc,即加载一个临时文档。即该文档从创建之初到目前,一直都处于离线编辑的状态。同样它也分两个情况,如果当前处于联网状态下,会在服务端创建一个新 drawing,并把当前的离线编辑的数据同步过去。如果在非联网的情况下,会加载离线编辑的数据,并可继续离线编辑。
|
||||
- _loadRemote,即加载一个远程文档。该文档在本地有可能编辑过,那么会先加载本地缓存的离线编辑的数据。如果当前处于联网状态,会异步加载远程文档,成功后本地离线编辑的内容会被放弃。
|
||||
|
||||
另外,加载文档结束后,QPaintDoc 会发出 onload 消息。这个消息当前会被 QPaintView 响应,用来刷新界面,代码如下:
|
||||
|
||||
```
|
||||
class QPaintView {
|
||||
constructor() {
|
||||
...
|
||||
let view = this
|
||||
this.doc.onload = function() {
|
||||
view.invalidateRect(null)
|
||||
}
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
之所以会有 onload 消息,是因为向服务器的 ajax 请求,什么时候完成是比较难预期的,我们加载文档是在异步 ajax 完成之后。这样来看,完成文档加载后发出 onload 事件,就可以避免 Model 层需要去理解 View 层的业务逻辑。
|
||||
|
||||
## Model 层的厚度
|
||||
|
||||
介绍到这里,我们基本上把本次迭代的主体内容介绍清楚了。其他有些小细节的变动,我们不再赘述。详细的代码变更请参阅:
|
||||
|
||||
- [https://github.com/qiniu/qpaint/compare/v29...v30](https://github.com/qiniu/qpaint/compare/v29...v30)
|
||||
|
||||
下面我想聊的话题是关于 Model 层的厚度问题。我们在 “[22 | 桌面程序的架构建议](https://time.geekbang.org/column/article/105356)” 中提到:
|
||||
|
||||
>
|
||||
从界面编程角度看,Model 层越厚越好。为什么这么说?因为这是和操作系统的界面程序框架最为无关的部分,是最容易测试的部分,也同时是跨平台最容易的部分。我们把逻辑更多向 Model 层倾斜,那么 Controller 层就简洁很多,这对跨平台开发将极其有利。
|
||||
|
||||
|
||||
我们秉承的理念是 Model 层越厚越好。事实上在这次 “画图” 程序实战中,我们在一直坚持这一点。让我们来观测两组数据。
|
||||
|
||||
其一,不同版本(v26..v30)的 Model 层(dom.js)对比:
|
||||
|
||||
- MVP 版本(v26 版)的 [dom.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/dom.js) ,约 120 行。
|
||||
- 最新版本(v30 版)的 [dom.js](https://github.com/qiniu/qpaint/blob/v30/paintweb/www/dom.js) ,约 860 行。
|
||||
|
||||
Model 层的代码行翻了多少倍?7.x 倍。
|
||||
|
||||
其二,不同版本(v26..v30)的变更历史:
|
||||
|
||||
v27:[https://github.com/qiniu/qpaint/compare/v26...v27](https://github.com/qiniu/qpaint/compare/v26...v27)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/6f/23a102e16f26d278ef10a4938066fd6f.png" alt="">
|
||||
|
||||
v28:[https://github.com/qiniu/qpaint/compare/v27...v28](https://github.com/qiniu/qpaint/compare/v27...v28)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/4d/1ab61e11c153a2b3b38bc90bf1006c4d.png" alt="">
|
||||
|
||||
v29:[https://github.com/qiniu/qpaint/compare/v28...v29](https://github.com/qiniu/qpaint/compare/v28...v29)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/ab/74032dc7ab390189f3be591c13dad2ab.png" alt="">
|
||||
|
||||
v30:[https://github.com/qiniu/qpaint/compare/v29...v30](https://github.com/qiniu/qpaint/compare/v29...v30)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/b7/f4715e1055c75bbdf1b9d21bb89496b7.png" alt="">
|
||||
|
||||
不知道你看出什么来了吗?
|
||||
|
||||
一个有趣的事实是,多个版本的迭代,基本上都是以变更 Model 层为多。v29 版本的变更看似比较例外,没有修改 dom.js。但是实际上 v29 整个变更都是 Model 层的变更,因为是增加了服务端的 Model(我们前面把它叫做 Multi-User Model)。
|
||||
|
||||
我们深刻思考这个问题的话,我们会有这样一个推论:
|
||||
|
||||
- 如果我们不是让 Model 层代码以内聚的方式放在一起,而是让它自由的散落于各处,那么我们的代码变更质量会非常不受控。
|
||||
|
||||
为什么?Model 层总体来说是最容易测试的,因为它的环境依赖最小。如果这些代码被分散到 View、Controller 层中,代码的阅读难度、维护难度、测试的难度都会大幅增加。
|
||||
|
||||
通过几轮的功能迭代,我们对 Model 层的认知在不断的加深。我们总结一下它的职责,如下:
|
||||
|
||||
- 业务逻辑,对外暴露业务接口。它也是 Model 的最本职的工作。
|
||||
- 实现 View 层委托的 onpaint 事件,完成绘制功能。
|
||||
- 实现 Controller 层的 hitTest 接口,用来实现 selection 支持。
|
||||
- 实现与服务端 Multi-User Model 层的通讯,View、Controllers 组件都不需要感知服务端。
|
||||
- 实现离线编辑 localStorage 的存取。
|
||||
|
||||
除了少量 View(onpaint)、Controllers(hitTest)的需求,大部分都是 Model 层的正常业务范畴。
|
||||
|
||||
这些职责已经很多,所以 Model 层自然会胖。
|
||||
|
||||
## 结语
|
||||
|
||||
今天我们完成了画图程序前后端 paintdom、paintweb 的对接。由于考虑支持离线编辑,对接工作有较大的复杂性,你如果不能理解,建议仔细对代码进行研读。当然后面我们还会掰开来细谈这个案例。
|
||||
|
||||
这是最新版本的源代码:
|
||||
|
||||
- [https://github.com/qiniu/qpaint/tree/v30](https://github.com/qiniu/qpaint/tree/v30)
|
||||
|
||||
到这里我们的实战过程就先告一段落了。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。到现在为止,我们探讨的都是一个完整的桌面应用程序(可能是单机的,也可能是 B/S 结构的)的业务架构。
|
||||
|
||||
下一讲我们会谈谈辅助界面元素(自定义控件)的架构设计,它和应用程序的业务架构考虑的问题颇有不同。
|
||||
|
||||
话外:按照大纲,当前进度还只有 1/3 的内容。看起来我们最终会比原计划的 58 讲超出不少,可能要往 90 讲去了。关于这一点,我总体还是以说清楚事情为目标,在聊的过程会根据反馈作出适当的调整。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
372
极客时间专栏/许式伟的架构课/桌面开发篇/31 | 辅助界面元素的架构设计.md
Normal file
372
极客时间专栏/许式伟的架构课/桌面开发篇/31 | 辅助界面元素的架构设计.md
Normal file
@@ -0,0 +1,372 @@
|
||||
<audio id="audio" title="31 | 辅助界面元素的架构设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/ba/f6587d7fbe86023ee25c9c28af17d6ba.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
我们第二章 “桌面软件开发” 今天开始进入尾声。前面我们主要围绕一个完整的桌面应用程序,从单机到 B/S 结构,我们的系统架构应该如何考虑。并且,我们通过五讲的 “画图” 程序实战,来验证我们的架构设计思路。
|
||||
|
||||
这个实战有点复杂。对于编码量不多的初学者,理解起来还是有点复杂性的。为了减轻理解的难度,我们从原计划的上下两讲,扩大到了五讲。尽管如此,理解上的难度仍然还是有的,后面我们做总结时,会给出一个不基于 MVC 架构的实现代码。
|
||||
|
||||
今天我们不谈桌面应用的架构,而是来谈谈辅助界面元素的架构设计。
|
||||
|
||||
辅助界面元素非常常见,它其实就是通用控件,或者我们自定义的控件。例如在我们画图程序中使用了线型选择控件([menu.js#L105](https://github.com/qiniu/qpaint/blob/v30/paintweb/www/accel/menu.js#L105)),如下:
|
||||
|
||||
```
|
||||
<select id="lineWidth" onchange="onIntPropChanged('lineWidth')">
|
||||
<option value="1">1</option>
|
||||
<option value="3">3</option>
|
||||
<option value="5">5</option>
|
||||
<option value="7">7</option>
|
||||
<option value="9">9</option>
|
||||
<option value="11">11</option>
|
||||
</select>
|
||||
|
||||
```
|
||||
|
||||
还有颜色选择控件([menu.js#L115](https://github.com/qiniu/qpaint/blob/v30/paintweb/www/accel/menu.js#L115)),如下:
|
||||
|
||||
```
|
||||
<select id="lineColor" onchange="onPropChanged('lineColor')">
|
||||
<option value="black">black</option>
|
||||
<option value="red">red</option>
|
||||
<option value="blue">blue</option>
|
||||
<option value="green">green</option>
|
||||
<option value="yellow">yellow</option>
|
||||
<option value="gray">gray</option>
|
||||
</select>
|
||||
|
||||
<select id="fillColor" onchange="onPropChanged('fillColor')">
|
||||
<option value="white">white</option>
|
||||
<option value="null">transparent</option>
|
||||
<option value="black">black</option>
|
||||
<option value="red">red</option>
|
||||
<option value="blue">blue</option>
|
||||
<option value="green">green</option>
|
||||
<option value="yellow">yellow</option>
|
||||
<option value="gray">gray</option>
|
||||
</select>
|
||||
|
||||
```
|
||||
|
||||
我们统一用通用的 select 控件实现了一个线型选择器、两个颜色选择器的实例。虽然这种方式实现的颜色选择器不够美观,但是它们的确可以正常工作。
|
||||
|
||||
不过,产品经理很快就提出反对意见,说我们需要更加用户友好的界面。赶紧换一个更加可视化的颜色选择器吧?比如像下图这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/49/65ca44b08788bd03776bcd86ea3d0749.png" alt="">
|
||||
|
||||
## 辅助界面元素的框架
|
||||
|
||||
怎么做到?
|
||||
|
||||
我们不妨把上面基础版本的线型选择器、颜色选择器叫做 BaseLineWidthPicker、BaseColorPicker,我们总结它们在画图程序中的使用接口如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/2c/4c660159e3d632130c25614f0b0eb02c.png" alt="">
|
||||
|
||||
我们解释一下这个表格中的各项内容。
|
||||
|
||||
id 是控件的 id,通过它可以获取到辅助界面元素的顶层结点。
|
||||
|
||||
value 是界面元素的值,其实也就是辅助界面元素的 Model 层的数据。从 MVC 架构角度来说,Model 层的数据一般是一棵 DOM 树。但是对很多辅助界面元素来说,它的 DOM 树比较简单,只是一个数值。比如线型选择器是一个 number,颜色选择器是一个 Color 值。
|
||||
|
||||
palette 是颜色选择器的调色板,用来指示颜色选择器可以选择哪些颜色。
|
||||
|
||||
blur() 方法是主动让一个界面元素失去焦点。
|
||||
|
||||
onchange 事件是在该界面元素的值(value)通过用户界面交互进行改变时发送的事件。需要注意的是,这个事件只在用户交互时发送。直接调用 element.value = xxx 这样的方式来修改界面元素的值是不会触发 onchange 事件的。
|
||||
|
||||
为了便于修改辅助界面元素,我们计划引入统一的辅助界面元素的框架。
|
||||
|
||||
这个框架长什么样?
|
||||
|
||||
首先,每个界面元素使用的时候,统一以 `<div type="xxx">`来表示。比如上面的一个线型选择器、两个颜色选择器的实例可以这样来表示:
|
||||
|
||||
```
|
||||
<div type="BaseLineWidthPicker" id="lineWidth" onchange="onIntPropChanged('lineWidth')"></div>
|
||||
|
||||
<div type="BaseColorPicker" id="lineColor" onchange="onPropChanged('lineColor')" palette="black,red,blue,green,yellow,gray"></div>
|
||||
|
||||
<div type="BaseColorPicker" id="fillColor" onchange="onPropChanged('fillColor')" palette="white,null(transparent),black,red,blue,green,yellow,gray"></div>
|
||||
|
||||
```
|
||||
|
||||
那么它是怎么被替换成前面的界面元素的?
|
||||
|
||||
我们引入一个全局的 qcontrols: QControls 实例,所有我们定义的控件都向它注册(register)自己。注册的代码如下:
|
||||
|
||||
```
|
||||
class QControls {
|
||||
constructor() {
|
||||
this.data = {}
|
||||
}
|
||||
register(type, control) {
|
||||
this.data[type] = control
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,注册的逻辑基本上没做什么,只是建立了类型(type)和控件的构建函数(control)的关联。有了这个关联表,我们就可以在适当的时候,把所有的 `<div type="xxx">`的div 替换为实际的控件。替换过程如下:
|
||||
|
||||
```
|
||||
class QControls {
|
||||
init() {
|
||||
let divs = document.getElementsByTagName("div")
|
||||
let n = divs.length
|
||||
for (let i = n-1; i >= 0; i--) {
|
||||
let div = divs[i]
|
||||
let type = div.getAttribute("type")
|
||||
if (type != null) {
|
||||
let control = this.data[type]
|
||||
if (control) {
|
||||
control(div)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码逻辑很简单,遍历文档中所有的 div,如果带 type 属性,就去查这个 type 有没有注册过,注册过就用注册时指定的构建函数去构建控件实例。
|
||||
|
||||
完整的辅助界面元素框架代码如下:
|
||||
|
||||
- [controls/base.js](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/controls/base.js)
|
||||
|
||||
具体构建控件的代码是怎么样的?源代码请参考这两个文件:
|
||||
|
||||
- [controls/BaseLineWidthPicker.js](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/controls/BaseLineWidthPicker.js)
|
||||
- [controls/BaseColorPicker.js](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/controls/BaseColorPicker.js)
|
||||
|
||||
我们拿 BaseColorPicker 作为例子看下吧:
|
||||
|
||||
```
|
||||
function BaseColorPicker(div) {
|
||||
let id = div.id
|
||||
let onchange = div.onchange
|
||||
let palette = div.getAttribute("palette")
|
||||
let colors = palette.split(",")
|
||||
let options = []
|
||||
for (let i in colors) {
|
||||
let color = colors[i]
|
||||
let n = color.length
|
||||
if (color.charAt(n-1) == ")") {
|
||||
let offset = color.indexOf("(")
|
||||
options.push(`<option value="` + color.substring(0, offset) + `">` + color.substring(offset+1, n-1) + `</option>`)
|
||||
} else {
|
||||
options.push(`<option value="` + color + `">` + color + `</option>`)
|
||||
}
|
||||
}
|
||||
div.outerHTML = `<select id="` + id + `">` + options.join("") + `</select>`
|
||||
let elem = document.getElementById(id)
|
||||
if (onchange) {
|
||||
elem.onchange = onchange
|
||||
}
|
||||
}
|
||||
|
||||
qcontrols.register("BaseColorPicker", BaseColorPicker)
|
||||
|
||||
```
|
||||
|
||||
可以看到,构建函数的代码大体分为如下三步。
|
||||
|
||||
第一步,从占位的 div 元素中读入所有的输入参数。这里是 id, onchange, palette。
|
||||
|
||||
第二步,把占位的 div 元素替换为实际的界面。也就是 div.outerHTML = `xxx` 这段代码。
|
||||
|
||||
第三步,如果用户对 onchange 事件感兴趣,把 onchange 响应函数安装到实际界面的 onchange 事件中。
|
||||
|
||||
## jQuery 颜色选择器
|
||||
|
||||
接下来我们就开始考虑替换颜色选择器的实现了。新版本的颜色选择器,我们不妨命名为 ColorPicker。这个新版本的使用姿势必须和 BaseColorPicker 一样,也就是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/f8/fc3856e8ab9aaf35c7af1611e57a47f8.png" alt="">
|
||||
|
||||
从使用的角度来说,我们只需要把之前的 BaseColorPicker 换成 ColorPicker。如下:
|
||||
|
||||
```
|
||||
<div type="BaseLineWidthPicker" id="lineWidth" onchange="onIntPropChanged('lineWidth')"></div>
|
||||
|
||||
<div type="ColorPicker" id="lineColor" onchange="onPropChanged('lineColor')" palette="black,red,blue,green,yellow,gray"></div>
|
||||
|
||||
<div type="ColorPicker" id="fillColor" onchange="onPropChanged('fillColor')" palette="white,null(transparent),black,red,blue,green,yellow,gray"></div>
|
||||
|
||||
```
|
||||
|
||||
那么实现方面呢?
|
||||
|
||||
我们决定基于 jQuery 社区的 [spectrum](https://github.com/bgrins/spectrum) 颜色选择器。
|
||||
|
||||
我们的画图程序的主体并没有引用任何现成的框架代码。jQuery 是第一个被引入的。
|
||||
|
||||
对待 jQuery,我们可以有两种态度。一种是认为 jQuery 设计非常优良,我们很喜欢,决定将其作为团队的编程用的基础框架。
|
||||
|
||||
在这种态度下,我们允许 jQuery 风格的代码蔓延得到处都是,典型表现就是满屏皆是 $ 符号。
|
||||
|
||||
当然这种选择的风险是不低的。有一天我们不想再基于 jQuery 开发了,这意味着大量的模块需要进行调整,尤其是那些活跃的项目。
|
||||
|
||||
另一种态度是,认为 jQuery 并不是我们的主体框架,只是因为我们有些模块用了社区的成果,比如 [spectrum](https://github.com/bgrins/spectrum) 颜色选择器,它是基于 jQuery 实现的。这意味着我们要用 [spectrum](https://github.com/bgrins/spectrum),就需要引入 jQuery。
|
||||
|
||||
这种团队下,我们会尽可能限制 jQuery 的使用范围,尽量不要让它的代码蔓延,而只是限制在颜色选择器等少量场景中。
|
||||
|
||||
我们这一讲假设我们的态度是后者。我们有自己的基础开发框架(虽然我们其实基本上接近裸写 JavaScript 的状态),所以不会大面积使用 jQuery。
|
||||
|
||||
这样我们需要包装 jQuery 组件。代码如下(参阅 [controls/ColorPicker.js](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/controls/ColorPicker.js)):
|
||||
|
||||
```
|
||||
function ColorPicker(div) {
|
||||
let id = div.id
|
||||
let onchange = div.onchange
|
||||
let palette = div.getAttribute("palette")
|
||||
let colors = palette.split(",")
|
||||
let value = colors[0]
|
||||
div.outerHTML = `<input type="button" id="` + id + `" value="` + value + `">`
|
||||
let elem = $("#" + id)
|
||||
elem.spectrum({
|
||||
showInitial: true,
|
||||
showInput: true,
|
||||
showButtons: true,
|
||||
preferredFormat: "hex6"
|
||||
})
|
||||
if (onchange) {
|
||||
elem.change(onchange)
|
||||
}
|
||||
Object.defineProperty(document.getElementById(id), "value", {
|
||||
get() {
|
||||
return value
|
||||
},
|
||||
set(x) {
|
||||
if (this.busy) {
|
||||
return
|
||||
}
|
||||
value = x
|
||||
this.busy = true
|
||||
elem.spectrum("set", value)
|
||||
this.busy = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
qcontrols.register("ColorPicker", ColorPicker)
|
||||
|
||||
```
|
||||
|
||||
这里大部分代码比较常规,只有 Object.defineProperty 这一段看起来比较古怪一些。这段代码是在改写 document.getElementById(id) 这个界面元素的 value 属性的读写(get/set)函数。
|
||||
|
||||
为什么需要改写?
|
||||
|
||||
因为我们希望感知到使用者对 value 的改写。正常我们可能认为接管 onchange 就可以了,但是实际上 element.value = xxx 这样的属性改写是不会触发 onchange 事件的。所以我们只能从改写 value 属性的 set 函数来做。
|
||||
|
||||
set 函数收到 value 被改写后,会调用 elem.spectrum("set", value) 来改变 spectrum 颜色控件的当前值。
|
||||
|
||||
但这里又有个细节问题:elem.spectrum("set", value) 内部又会调用 element.value = value 来修改 document.getElementById(id) 这个界面元素的 value 属性,这样就出现了死循环。怎么办?我们通过引入一个 busy 标志来解决:如果当前已经处于 value 属性的 set 函数,就直接返回。
|
||||
|
||||
## 辅助界面元素的架构设计
|
||||
|
||||
到目前为止,我们实现了三个符合我们定义的控件规范的辅助界面元素。如下:
|
||||
|
||||
- [controls/BaseLineWidthPicker.js](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/controls/BaseLineWidthPicker.js)
|
||||
- [controls/BaseColorPicker.js](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/controls/BaseColorPicker.js)
|
||||
- [controls/ColorPicker.js](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/controls/ColorPicker.js)
|
||||
|
||||
观察这些辅助界面元素的代码,你会发现它们都没有基于 MVC 架构。
|
||||
|
||||
是因为辅助界面元素不适合用 MVC 架构来编写么?
|
||||
|
||||
当然不是。
|
||||
|
||||
更本质的原因是因为它们规模太小了。这些界面元素的特点是 DOM 都是一个 value,并不是一棵树,这样 Model 层就没什么代码了。同样的逻辑,View 层、Control 层代码量都过于短小,就没必要有那么清楚的模块划分。View 负责界面呈现,Control 负责事件响应,只是在心里有谱就好了。
|
||||
|
||||
但并不是所有辅助界面元素都这么简单。
|
||||
|
||||
举一个简单的例子。让我们给自己设定一个新目标:把我们前面实战的 “画图” 程序,改造成一个标准的辅助界面元素,这可行么?
|
||||
|
||||
答案当然是肯定的。
|
||||
|
||||
但是这意味着我们有一些假设需要修正。这些假设通常都和唯一性有关。
|
||||
|
||||
比如,全局有唯一的 View 对象实例 qview: QPaintView。如果我们是辅助界面元素,意味着我们可能在同一个界面出现多个实例。在多实例的情况下,View 对象显然就应该有多个。
|
||||
|
||||
再比如,我们画图程序的辅助界面元素(参见 [accel/menu.js](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/accel/menu.js))都是单例,具体表现为这些界面元素的 id 都是固定的。
|
||||
|
||||
当然,辅助界面元素的改造方案有多种可能性。一种方案是将辅助界面元素也改造为多例,使得每个 QPaint 实例都有自己的辅助界面元素。
|
||||
|
||||
另一种方案是继续保持单例,这意味着多个 QPaint 实例会有一个当前实例的概念。辅助界面元素根据场景,可以是操作全部实例,也可以是操作当前实例。
|
||||
|
||||
我们选择继续保持单例。这意味着 qview: QPaintView 这个全局变量可以继续存在,但是和之前的含义有了很大不同。之前 qview 代表的是单例,现在 qview 代表的是当前实例。
|
||||
|
||||
有了当前实例当然就有切换。这样就需要增加焦点相关的事件响应。
|
||||
|
||||
在画图程序中,很多 Controller 都是 View 实例相关的。比如:PathCreator、ShapeSelector 等。在 View 存在多例的情况下,这些 Controller 之前的 registerController 动作就需要重新考虑。
|
||||
|
||||
为了支持多例,我们引入了 onViewAdded、onCurrentViewChanged 事件。当一个新的 View 实例被创建时,会发送 onViewAdded 事件。Controller 可以响应该事件去完成 registerController 动作。如下:
|
||||
|
||||
```
|
||||
onViewAdded(function(view) {
|
||||
view.registerController("PathCreator", function() {
|
||||
return new QPathCreator(view, false)
|
||||
})
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
原先,当前图形样式是放在 View 中的,通过 qview.style 可以访问到。这会导致多个 View 实例的当前图形样式不一样,但是我们辅助界面元素又是单例的,这就非常让人混淆。最后我们决定把 qview.style 挪到全局,改名叫 defaultStyle(参阅 [accel/menu.js#L42](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/accel/menu.js#L42))。
|
||||
|
||||
做完这些改造,我们的画图程序就有了成为一个标准控件的基础。具体代码如下(参阅 [PaintView.js](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/PaintView.js)):
|
||||
|
||||
```
|
||||
function newPaintView(drawingID) {
|
||||
let view = new QPaintView(drawingID)
|
||||
fireViewAdded(view)
|
||||
return view
|
||||
}
|
||||
|
||||
function initPaintView(drawingID) {
|
||||
let view = newPaintView(drawingID)
|
||||
setCurrentView(view)
|
||||
}
|
||||
|
||||
function PaintView(div) {
|
||||
let id = div.id
|
||||
let width = div.getAttribute("width")
|
||||
let height = div.getAttribute("height")
|
||||
div.outerHTML = `<canvas id="` + id + `" width="` + width + `" height="` + height + `">你的浏览器不支持Canvas!</canvas>`
|
||||
initPaintView(id)
|
||||
}
|
||||
|
||||
qcontrols.register("PaintView", PaintView)
|
||||
|
||||
```
|
||||
|
||||
有了这个 PaintView 控件,我们就可以到处引用它了。我们做了一个 PaintView 控件的 DEMO 程序,它效果看起来是这样的(代码参阅 [PaintDemo.htm](https://github.com/qiniu/qpaint/blob/v31/paintweb/www/PaintDemo.htm)):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/52/295e17f40fa63b929a4a5175da39ae52.png" alt="">
|
||||
|
||||
从这个截图看,细心的你可能会留意到,还有一个问题是没有被修改的,那就是 URL 地址。我们的 QPaintView 在 load 文档后会修改 URL,这作为应用程序并没有问题。但是如果是一个控件,整个界面有好多个 PaintView,URL 中应该显示哪个文档的 ID?
|
||||
|
||||
显然谁都不合适。如果非要显示,可能要在 PaintView 实例附近放一个辅助界面元素来显示它。
|
||||
|
||||
怎么修改?
|
||||
|
||||
这个问题暂且留给大家。
|
||||
|
||||
## 结语
|
||||
|
||||
今天探讨了辅助界面元素,或者叫控件的架构设计。从大的实现逻辑来说,它和应用程序不应该有本质的不同。但控件总是要考虑支持多实例,这会带来一些细节上的差异。
|
||||
|
||||
支持多实例听起来是一项简单的工作,但是从我的观察看,对很多工程师来说实际上并不简单。不少初级工程师写代码往往容易全局变量满天飞,模块之间相互传递信息不假思索地基于全局变量来完成。这些不良习惯会导致代码极难控件化。
|
||||
|
||||
当然我们不见得什么桌面应用程序都要考虑把它控件化。但是我们花一些精力去思考控件化的话,会有助于你对架构设计中的一些决策提供帮助。
|
||||
|
||||
当然更重要的,其实是让你有机会形成更好的架构设计规范。
|
||||
|
||||
这一讲我们作出的修改如下:
|
||||
|
||||
- [https://github.com/qiniu/qpaint/compare/v30...v31](https://github.com/qiniu/qpaint/compare/v30...v31)
|
||||
|
||||
这是最新版本的源代码:
|
||||
|
||||
- [https://github.com/qiniu/qpaint/tree/v31](https://github.com/qiniu/qpaint/tree/v31)
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们会谈谈架构设计的第二步:如何做好系统架构。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
321
极客时间专栏/许式伟的架构课/桌面开发篇/32 | 架构:系统的概要设计.md
Normal file
321
极客时间专栏/许式伟的架构课/桌面开发篇/32 | 架构:系统的概要设计.md
Normal file
@@ -0,0 +1,321 @@
|
||||
<audio id="audio" title="32 | 架构:系统的概要设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/a8/0780f239787e47eb7e0510b60e3b28a8.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
我们第二章 “桌面开发篇” 就快要结束了。今天我们把话题重新回到架构上。
|
||||
|
||||
## 基础架构与业务架构
|
||||
|
||||
桌面开发篇我们主要涉及的内容如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/50/553d5dd6b9e774585514a05674066550.png" alt="">
|
||||
|
||||
对于一位架构师而言,其架构工作的内容可以大体分为两块,一块是基础架构,一块是业务架构。
|
||||
|
||||
基础架构,简单来说就是做技术选型。选择要支持的操作系统、选择编程语言、选择技术框架、选择第三方库,这些都可以归结为基础架构方面的工作。
|
||||
|
||||
基础架构的能力,考验的是选择能力。背后靠的是技术前瞻性和判断力。这并不简单。大部分架构师往往更容易把关注点放到业务架构上,但实际上基础架构的影响面更广,选错产生的代价更高。
|
||||
|
||||
架构师之间的差距,更大的是体现在其对待基础架构的态度和能力构建上。真正牛的架构师,一定会无比重视团队的技术选型,无比重视基础平台的建设。阿里提倡的 “大中台、小前台”,本质上也是在提倡基础平台建设,以此不断降低业务开发的成本,提升企业的创新能力。
|
||||
|
||||
业务架构,简单来说就是业务系统的分解能力。基础架构其实也是对业务系统的分解,只不过分解出了与业务属性几乎无关的部分,形成领域无关的基础设施。而业务架构更多的是分解领域问题 。
|
||||
|
||||
一旦我们谈业务架构,就避不开领域问题的理解。所谓领域问题,谈的是这个领域的用户群面临的普遍需求。所以我们需要对用户的需求进行分析。
|
||||
|
||||
在第一章,我们已经聊了需求分析:
|
||||
|
||||
- [17 | 架构:需求分析(上)](https://time.geekbang.org/column/article/100140)
|
||||
- [18 | 架构:需求分析(下)- 实战案例](https://time.geekbang.org/column/article/100930)
|
||||
|
||||
这是我们开始业务架构的第一步。没有需求分析,就没有业务架构。在业务架构过程中,需求分析至少应该花费三分之一以上的精力。
|
||||
|
||||
今天,我们聊一聊架构的第二步:系统的概要设计,简称系统设计。
|
||||
|
||||
系统设计,简单来说就是 “对系统进行分解” 的能力。这个阶段核心要干的事情,就是明确子系统的职责边界和接口协议,把整个系统的大框架搭起来。
|
||||
|
||||
那么怎么分解系统?
|
||||
|
||||
首先我们需要明确的是分解系统优劣的评判标准。也就是说,我们需要知道什么样的系统分解方式是好的,什么样的分解方式是糟糕的。
|
||||
|
||||
最朴素的评判依据,是这样两个核心的点:
|
||||
|
||||
- 功能的使用界面(或者叫接口),应尽可能符合业务需求对它的自然预期;
|
||||
- 功能的实现要高内聚,功能与功能之间的耦合尽可能低。
|
||||
|
||||
在软件系统中有多个层次的组织单元:子系统、模块、类、方法/函数。子系统如何分解模块?模块如何分解到更具体的类或函数?每一层的分解方式,都遵循相同的套路。也就是分解系统的方法论。
|
||||
|
||||
## 接口要自然体现业务需求
|
||||
|
||||
我们先看功能的使用界面(或者叫接口)。
|
||||
|
||||
什么是使用界面?
|
||||
|
||||
对于函数,它的使用界面就是函数原型。
|
||||
|
||||
```
|
||||
package packageName
|
||||
|
||||
func FuncName(
|
||||
arg1 ArgType1, ..., argN ArgTypeN
|
||||
) (ret1 RetType1, ..., retM RetTypeM)
|
||||
|
||||
```
|
||||
|
||||
它包含三部分信息。
|
||||
|
||||
- 函数名。严谨来说是包含该函数所在的名字空间的函数名全称,比如上例是 packageName.FuncName。
|
||||
- 输入参数列表。每个参数包含参数名和参数类型。
|
||||
- 输出结果列表。每个输出结果包含结果名和结果类型。当然,很多语言的函数是单返回值的,也就是输出结果只有一个。这种情况下输出结果没有名称,只有一个结果类型,也叫返回值类型。
|
||||
|
||||
对于类,它的使用界面是类的公开属性和方法。
|
||||
|
||||
```
|
||||
package packageName
|
||||
|
||||
type ClassName struct {
|
||||
Prop1 PropType1
|
||||
...
|
||||
PropK PropTypeK
|
||||
}
|
||||
|
||||
func (receiver *ClassName) MethodName1(
|
||||
arg11 ArgType11, ..., arg1N1 ArgType1N1
|
||||
) (ret11 RetType11, ..., ret1M1 RetType1M1)
|
||||
|
||||
...
|
||||
|
||||
func (receiver *ClassName) MethodNameL(
|
||||
argL1 ArgTypeL1, ..., argLNL ArgTypeLNL
|
||||
) (retL1 RetTypeL1, ..., retLML RetTypeLML)
|
||||
|
||||
|
||||
```
|
||||
|
||||
它包含以下内容。
|
||||
|
||||
- 类型名。严谨来说是包含该类型所在的名字空间的类型名全称,比如上例是 packageName.ClassName。
|
||||
- 公开属性列表。每个属性包含属性名和属性类型。Go 语言对属性的支持比较有限,直接基于类型的成员变量来表达。而一些语言,比如 JavaScript,对属性的支持比较高级,允许给某个属性设定 get/set 方法。这样就能够做到只读、只写、可读写三种属性。
|
||||
- 公开方法列表。
|
||||
|
||||
方法和函数本质上是一样的,有的只是细节不同。这表现在下面几点。
|
||||
|
||||
<li>
|
||||
名字空间不同。普通函数的函数名全称是 packageName.FuncName,而方法的方法名全称是 packageName.(*ClassName).MethodName 这种形式。
|
||||
</li>
|
||||
<li>
|
||||
方法相比函数多了一个概念叫 receiver(接受者),也就是方法所作用的对象。在 Go 语言中 receiver 是显式表达的。但大部分语言中 receiver 是隐藏的,通常名字叫 this 或 self。
|
||||
</li>
|
||||
|
||||
对于模块,它的使用界面比较多样,需要看模块类型。典型的模块类型有这样一些:
|
||||
|
||||
- 包(package)。一些语言中也叫静态库(static library)。
|
||||
- 动态库(dynamic library)。在 Go 语言中有个特殊的名称叫插件(plugin)。
|
||||
- 可执行程序(application)。
|
||||
|
||||
对于包(package)和动态库(dynamic library),这两者都是代码的一种发布形态,只是标准的制定方不同。包(package)一般是由编程语言定义的,对开发者比较友好。而动态库(dynamic library)一般是操作系统定义的,可以做到跨语言,但是对开发者往往不太友好。为什么不友好?因为它要定义跨语言的符号定义和类型定义的标准。这意味着它只能取多个编程语言之间的共性部分。
|
||||
|
||||
对于可执行程序(application),又要分多种情况。最常见的可执行程序有这么几类:
|
||||
|
||||
- 网络服务程序(service);
|
||||
- 命令行程序(command line application);
|
||||
- 桌面程序(GUI application)
|
||||
|
||||
对于网络服务程序(service),它的使用界面是网络协议。前面我们在 [“画图” 程序实战(四)](https://time.geekbang.org/column/article/111289)这一讲中也有定义过画图服务端的网络协议。如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/c3/1b174cea94808537e21c5328ad2b8bc3.png" alt="">
|
||||
|
||||
对于命令行程序(command line application),它的使用界面包括:
|
||||
|
||||
- 命令行,包括:命令名称、开关列表、参数列表。例如:CommandName -Switch1 ... -SwitchN Arg1 ... ArgM。
|
||||
- 标准输入(stdin)。
|
||||
- 标准输出(stdout)。
|
||||
|
||||
对于桌面程序(GUI application),它的使用界面就是用户的操作方式。桌面程序的界面外观当然是重要的,但不是最重要的。最重要的是交互范式,即用户如何完成功能的业务流程的定义。为什么我们需要专门引入产品经理这样的角色来定义产品,正是因为使用界面的重要性。
|
||||
|
||||
以上这些组织单元都物理上存在,最后我们还剩一个概念:子系统。在实际开发中,并不存在物理的实体与子系统这个概念对应,它只存在于架构设计的文档中。
|
||||
|
||||
那么怎么理解子系统?
|
||||
|
||||
子系统是一个逻辑的概念,物理上可能对应一个模块(Module),也可能是多个模块。你可以把子系统理解为一个逻辑上的大模块(Big Module),这个大模块我们同样会去定义它的使用接口。
|
||||
|
||||
子系统与模块的对应方式有两种常见的情况。
|
||||
|
||||
一种情况,也是最常见的情况,子系统由一个根模块(总控模块)和若干子模块构成。子系统的使用接口,就是根模块的使用接口。
|
||||
|
||||
另一种情况,是子系统由多个相似的模块构成。例如对于 Office 程序来说,IO 子系统由很多相似模块构成,例如 Word 文档读写、HTML 文档读写、TXT 文档读写、PDF 文档读写等等,这些模块往往有统一的使用界面。
|
||||
|
||||
通过上面对子系统、模块、类、函数的使用界面的解释,你会发现其实它们是有共性的。它们都是在定义完成业务需求的方法,只不过需求满足方式的层次不一样。类和函数是从语言级的函数调用来完成业务,网络服务程序是通过网络 RPC 请求来完成业务,桌面程序是通过用户交互来完成业务。
|
||||
|
||||
理解了这一点,你就很容易明白,“功能的使用界面应尽可能符合业务需求对它的自然预期” 这句话背后的含义。
|
||||
|
||||
一个程序员的系统分解能力强不强,其实一眼就可以看出来。你都不需要看实现细节,只需要看他定义的模块、类和函数的使用接口。如果存在大量说不清业务意图的函数,或者存在大量职责不清的模块和类,就知道他基本上还处在搬砖阶段。
|
||||
|
||||
无论是子系统、模块、类还是函数,都有自己的业务边界。它的职责是否足够单一足够清晰,使用接口是否足够简单明了,是否自然体现业务需求(甚至无需配备额外的说明文档),这些都体现了架构功力。
|
||||
|
||||
## 功能实现准则:高内聚低耦合
|
||||
|
||||
系统分解的套路中,除了功能自身的使用界面之外,我们还关注功能与功能之间是如何被连接起来的。当然这就涉及了功能的实现。
|
||||
|
||||
功能实现的基本准则是:功能自身代码要高内聚,功能与功能之间要低耦合。
|
||||
|
||||
什么叫高内聚?简单来说,就是一个功能的代码应该尽可能写在一起,而不是散落在各处。我个人在高内聚这个方向上养成的习惯是:
|
||||
|
||||
- 一个功能的代码尽可能单独一个文件,不要和其他功能混在一起;
|
||||
- 一些小功能的代码可能放在一起放在同一个文件中,但是中间也会用“// ------------------ ”这样的注释行分割成很多逻辑上的 “小文件”,代表这是一段独立的小功能。
|
||||
|
||||
代码高内聚的好处是,多大的团队协作都会很顺畅,代码提交基本上不怎么发生冲突。
|
||||
|
||||
那么什么叫低耦合?简单来说就是实现某个功能所依赖的外部环境少,易于构建。
|
||||
|
||||
功能实现的外部依赖分两种。一种是对业务无关的基础组件依赖,一种是对底层业务模块的依赖。
|
||||
|
||||
基础组件可能是开源项目,当然也可能来自公司的基础平台部。关于基础组件的依赖,我们核心的关注点是稳定。稳定体现在如下两个方面。
|
||||
|
||||
一方面是组件的成熟度。这个组件已经诞生多久了,使用接口是不是已经不怎么会调整了,功能缺陷(issue)是不是已经比较少了。
|
||||
|
||||
另一方面是组件的持久性。组件的维护者是谁,是不是有足够良好的社区信用(credit),这个项目是不是还很活跃,有多少人在参与其中,为其贡献代码。
|
||||
|
||||
当然从架构角度,我们关注的重点不是基础组件的依赖,而是对其他业务模块的依赖。它更符合业务系统分解的本来含义。
|
||||
|
||||
对底层业务模块的依赖少、耦合低的表现为:
|
||||
|
||||
- 对底层业务的依赖是 “通用” 的,尽量不要出现让底层业务模块专门为我定制接口;
|
||||
- 依赖的业务接口的个数少,调用频次低。
|
||||
|
||||
## 怎么做系统分解?
|
||||
|
||||
有了系统分解的优劣评判标准,那么我们具体怎么去做呢?
|
||||
|
||||
总体来说,系统分解是一个领域性的问题,它依赖你对用户需求的理解,并不存在放之四海皆可用的办法。
|
||||
|
||||
系统分解首先要从需求归纳出发。用户需求分析清楚很重要。把需求功能点涉及的数据(对象)、操作接口理清楚,并归纳整理,把每个功能都归于某一类。然后把类与类的关系理清楚,做到逻辑上自洽,那么一个基本的系统框架就形成了。
|
||||
|
||||
在系统的概要设计阶段,我们一般以子系统为维度来阐述系统各个角色之间的关系。
|
||||
|
||||
对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。但这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不作进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。
|
||||
|
||||
为了降低风险,系统的概要设计阶段也应该有代码产出。
|
||||
|
||||
这个阶段的代码用意是什么?
|
||||
|
||||
有两个方面的目的。**其一,系统的初始框架代码。也就是说,系统的大体架子已经搭建起来了。其二,原型性的代码来验证。一些核心子系统在这个阶段提供了 mock 的系统。**
|
||||
|
||||
这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。
|
||||
|
||||
代码即文档。代码是理解一致性更强的文档。
|
||||
|
||||
##
|
||||
|
||||
## 再谈 MVC
|
||||
|
||||
本章我们主要探讨的是桌面程序开发。虽然不同桌面应用的业务千差万别,但是桌面本身是一个很确定性的领域,因此会形成自己固有的系统分解的套路。
|
||||
|
||||
大家已经知道了,桌面程序系统分解的套路就是 MVC 架构。
|
||||
|
||||
虽然不同历史时期的桌面程序的交互方式不太一样,有基于键盘+鼠标的、有基于触摸屏的,但是它们的框架结构是非常一致的,都是基于事件分派做输入,GDI 做界面呈现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/c5/b8063e7ac32e854676b640c86d4628c5.png" alt="">
|
||||
|
||||
那么为什么会形成 Model-View-Controller(简称 MVC)架构?
|
||||
|
||||
我们第一章探讨需求分析时,我们反复强调一点:要分清需求的稳定点和变化点。稳定点是系统的核心能力,而变化点则需要做好开放性设计。
|
||||
|
||||
从这个角度来看,我们可以认为,业务的核心逻辑是稳定的,除非出现了新的技术革命导致产品的内在逻辑发生了质的变化。所以我们最底层一般以类和函数的形态来组织业务的核心逻辑,这就是 Model 层。
|
||||
|
||||
但用户交互是一个变化点。大家都是一个 “画图” 程序,无论是在 PC 桌面和手机上,Model 层是一样的,但是用户交互方式并不一样,View、Controllers 就有不小的差别。
|
||||
|
||||
当然 Model 层也有自己的变化点。它的变化点在于存储和网络。Model 层要考虑持久化,就会和存储打交道,就有自己的 IO 子系统。Model 层要考虑互联网化,就要考虑 B/S 架构,考虑网络协议。
|
||||
|
||||
不过无论是存储还是网络,从架构视角来说变化都是可预期的。存储介质会变,网络技术会变,但是变的只是实现,它们的使用接口并没变化。这意味着 Model 层不只是核心逻辑稳定,IO 和网络子系统也都很稳定。当然这也是把它们归于 Model 层的原因。如果它们是易变的,可能就被从 Model 层独立出去了。
|
||||
|
||||
用户交互这个变化点,主要体现在两个方面。一方面是屏幕尺寸导致的变化。更小的屏幕意味着界面上的信息需要被更高效地组织起来。另一方面则是交互的变化,鼠标交互和触摸屏的多点触摸交互是完全不同的。
|
||||
|
||||
View 层主要承担了界面呈现的工作。当然这也意味着它也承担了屏幕尺寸这个变化点。
|
||||
|
||||
Controller 层主要承担的是交互。具体来说就是响应用户的输入事件,把用户的操作转化为对 Model 层的业务请求。
|
||||
|
||||
Controller 层有很多 Controller。这些 Controller 通常各自负责不同的业务功能点。
|
||||
|
||||
也就是说,Model 层是一个整体,负责的是业务的核心逻辑。View 层也是一个整体,但在不同的屏幕尺寸和平台可能有不同的实现,但数量不会太多。而且现在流行所谓的响应式布局,也是鼓励尽可能在不同屏幕尺寸不同平台下共享同一个 View 的实现。Controller 层并不是一个整体,它是以插件化的形式存在,不同 Controlller 非常独立。
|
||||
|
||||
这样做的好处是可以快速适应交互的变化。比如以创建矩形这样一个功能为例,在 PC 鼠标+键盘的交互方式下有一个 RectCreator Controller,在触摸屏的交互方式可以是一个全新的 RectCreator Controller。在不同平台下,我们可以初始化不同的 Controller 实例来适应该平台的交互方式。
|
||||
|
||||
当然前面在 “[22 | 桌面程序的架构建议](https://time.geekbang.org/column/article/105356)” 一讲中,我们也介绍过 MVC 结构的一些变种,比如 MVP(Model-View-Presenter),主要是 Model 的数据更新发出 DataChanged 事件后,由 Controller 负责监听并 Update View,而不是由 View 层响应 DataChanged 事件并 Update View。
|
||||
|
||||
这些不同模型的差异其实只是细节的权衡、取舍,并不改变实质。
|
||||
|
||||
## 怎么看待实战?
|
||||
|
||||
第一章 “基础平台篇”,从架构的角度,我们主要是在学习基础架构。我们总体是从学历史的角度在聊,大家也是以听故事的方式为主。
|
||||
|
||||
但是第二章开始,我们话题逐步过渡到业务架构,同时也开始引入实战案例:“画图” 程序。
|
||||
|
||||
为什么实战是很重要的?
|
||||
|
||||
**学架构,我个人强调的理念是 “做中学”。**
|
||||
|
||||
**首先还是要勤动手。然后配合本专栏去思考和梳理背后的道理,如此方能快速进步。**
|
||||
|
||||
我们不能把架构课学成理论课。计算机科学本身是一门实践科学,架构经验更是一线实战经验的积累和总结。
|
||||
|
||||
为了方便大家进一步看清楚架构演变过程,我给画图程序实现了一个所有代码都揉在一起的非 MVC 版本(分支 v01):
|
||||
|
||||
- [www/index.htm](https://github.com/qiniu/qpaint/blob/v01/paintweb/www/index.htm)
|
||||
|
||||
它的功能对应我们 “[26 | 实战(一):怎么设计一个“画图”程序?](https://time.geekbang.org/column/article/108887)” 这一讲中的最小化的画图程序。这是当时给出的源代码(分支 v26):
|
||||
|
||||
- [www/*](https://github.com/qiniu/qpaint/tree/v26/paintweb/www)
|
||||
|
||||
可以看到,v01 版本所有代码,包括 HTML+JavaScript,总共也就 470 行左右。所以这是一个非常小的架构实战案例。如果我们进一步减少案例的代码规模的话,可能就不太需要架构思想了。
|
||||
|
||||
我们不妨对比一下两个版本的差异。
|
||||
|
||||
一个最基础的对比是代码规模。v26 版本我们分拆了多个文件:
|
||||
|
||||
<li>
|
||||
Model:[dom.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/dom.js)(100 行)
|
||||
</li>
|
||||
<li>
|
||||
View:[view.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/view.js)(112 行)
|
||||
</li>
|
||||
<li>
|
||||
Controllers:
|
||||
<ul>
|
||||
- [accel/menu.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/accel/menu.js)(86 行)
|
||||
- [creator/path.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/creator/path.js)(90 行)
|
||||
- [creator/freepath.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/creator/freepath.js)(71 行)
|
||||
- [creator/rect.js](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/creator/rect.js)(108 行)
|
||||
|
||||
总控:[index.htm](https://github.com/qiniu/qpaint/blob/v26/paintweb/www/index.htm)(18 行)
|
||||
|
||||
这些文件加起来的代码量大概在 580 行,比 v01 版本多了 110 行。
|
||||
|
||||
这说明 MVC 架构的价值并不是给我们降低总代码行数。实际上,它关注的重点是如何让我们团队协同作战,让工作并行。
|
||||
|
||||
怎么让工作并行?这就要求我们实现功能的时候,做到功能自身代码要高内聚,功能间的依赖要低耦合。v26 版本我们把功能分拆为 6 个文件(除了总控 index.htm 不算),可以交给 6 个团队成员来做,平均每个人写 100 行左右的代码。
|
||||
|
||||
当然,对于总体代码量 500 行不到的一个程序来说,这多多少少显得有点小题大做。但我们在此之后演进迭代了多个版本,功能越来越复杂,分工的必要性也就越来越大。
|
||||
|
||||
除了代码规模外,对比 v01 和 v26 版本,我们不妨从这样一些点来看。
|
||||
|
||||
- 功能的高内聚。某个功能代码被分散在多少地方。
|
||||
- 功能间的低耦合。当然 v01 版本所有代码都揉在了一起,我们不妨从如何做系统分解的视角来推演 v26 版本用 MVC 架构的意义。
|
||||
- 怎么减少全局变量,为控件化做好准备。
|
||||
|
||||
## 结语
|
||||
|
||||
在我们介绍完第二章 “桌面开发” 篇的所有内容后,今天我们介绍了架构的第二步:系统的概要设计。
|
||||
|
||||
在概要设计阶段,我们一般以子系统为维度来阐述系统各个角色之间的关系。对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。
|
||||
|
||||
这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不作进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。
|
||||
|
||||
为了降低风险,概要设计阶段也应该有代码产出。
|
||||
|
||||
这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。
|
||||
|
||||
代码即文档。代码是理解一致性更强的文档。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们会回顾和总结第二章的内容。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
113
极客时间专栏/许式伟的架构课/桌面开发篇/33 | 桌面开发篇:回顾与总结.md
Normal file
113
极客时间专栏/许式伟的架构课/桌面开发篇/33 | 桌面开发篇:回顾与总结.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="33 | 桌面开发篇:回顾与总结" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/5c/e76054e8c9b70663589fc82d3bb0185c.mp3"></audio>
|
||||
|
||||
你好,我是七牛云许式伟。
|
||||
|
||||
到今天为止,我们第二章 “桌面开发篇” 就要结束了。今天,让我们对整章的内容做一个回顾与总结。本章我们主要涉及的内容如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/50/553d5dd6b9e774585514a05674066550.png" alt="">
|
||||
|
||||
这一章的内容主要分为三类。
|
||||
|
||||
**一类是基础平台**,也就是上图中的浅绿色背景部分,谈的是 Native 桌面操作系统和浏览器的演变过程。
|
||||
|
||||
**一类是业务架构**,也就是上图中的浅棕色背景部分,谈得是如何开发一个桌面软件。
|
||||
|
||||
**最后一类是实战**,也就是上图浅黄色背景部分,我们以画图程序作为例子谈业务架构,并对需求进行了多次的迭代。
|
||||
|
||||
通过本章的内容,我们总结一下桌面开发的特点。
|
||||
|
||||
**首先从基础平台看。它的特点是:种类多、迭代快、知识有效期短。**让桌面开发工程师(大前端)痛苦的是,时不时就有各种新平台、新语言、新框架冒出来,让人应接不暇。
|
||||
|
||||
**其次从要开发的产品本身看。它的特点是:需求多、迭代快。**桌面开发(大前端)负责的是和活生生的个体打交道,我们的开发人员需要为了功能丰富,体验便捷做各种努力。
|
||||
|
||||
为了让产品有竞争力,很多团队的发布周期都是至少一个月迭代一个版本,有的甚至是一周发布一个版本。而Web 前端就更夸张了,一些公司甚至没有统一的发版概念,只要某个功能产品经理验收了,测试验收了,就可以发。
|
||||
|
||||
**最后我们从对程序员的技能要求看。它的特点是门槛极低,但天花板又极高。**
|
||||
|
||||
桌面开发(大前端)的代码量大,代码变更又很频繁,所以它对程序员的第一要求,不是质量,而是数量上的需求极大。为什么 GitHub 的语言排行榜总是 JavaScript 排名第一?这不是别的原因,是市场需求所致。
|
||||
|
||||
与之相对的,服务端开发则非常不同。服务端开发并不是一上来就有的,是互联网出现后产生的新分工。它并不负责用户交互,所以在需求提炼时可以做到极强的可预测性。因而服务端的第一挑战往往不是快速响应,而是性能和稳定性等质量需求。
|
||||
|
||||
桌面开发的客观需求量大,这决定了它的门槛要求必须极低。我在描述桌面开发的未来也提到过,桌面开发技术的演进方向,是 7-8 岁的儿童也可以开发生产级的应用。这是门槛低的极致状态。
|
||||
|
||||
但是为什么我又说桌面开发的天花板又极高呢?因为桌面开发的团队人数多、人员质量参差不齐、代码量大、迭代变更频繁,这意味着桌面软件工程项目的管理难度极高。所以桌面开发对架构师能力、软件工程的水平要求之高,要远高于服务端开发。
|
||||
|
||||
当然,从国内的现状来说,凡是堆人和加班可以解决的,最终都是用堆人和加班解决。架构师能力培养和软件工程能力提升?对大部分公司来说,他们的想法可能是:这太慢了,等不起。
|
||||
|
||||
## 桌面开发篇的内容回顾
|
||||
|
||||
这一章前面我们讲了些什么?为了让你对第二章内容有个宏观的了解,我画了一幅图,如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/14/5a083512c16a9ff8d661149eae283c14.png" alt="">
|
||||
|
||||
我们首先从单机软件开发讲起。我们开篇第一讲首先回顾了桌面开发关于交互方式的变更。从最早命令行程序,到 2D/3D GUI 图形界面程序,到智能交互程序的萌芽。
|
||||
|
||||
为什么我们从交互变更谈起?因为这是桌面系统迭代的根源。每一次桌面系统大的变更周期,都是由一场新的交互革命所驱动。
|
||||
|
||||
随后,我们介绍了今天仍然处于主流地位的图形界面操作系统提供的编程框架。尽管使用接口各不相同,但是今天主流桌面操作系统的框架本质大同小异,都是基于事件分派做输入,GDI 做界面呈现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/c5/b8063e7ac32e854676b640c86d4628c5.png" alt="">
|
||||
|
||||
互联网的出现,衍生出了浏览器,它支持了一种新的应用形态:Web 应用。这意味着在操作系统之上,产生了一个新操作系统。Web 应用也在演变,从静态页,到以 Gmail 为代表的 AJAX 应用,到 PWA,到小程序。
|
||||
|
||||
PC 浏览器之争已经结束,但移动浏览器的竞争才刚开始。
|
||||
|
||||
怎么做一个桌面程序?标准的套路是 MVC 架构。无论是单机还是 Web 应用,它都是适用的,只是 Web 程序需要考虑客户端与服务端的分工,需要引入网络协议。
|
||||
|
||||
跨平台开发,是桌面程序开发绕不过去的问题。几年前也许不明显,这得益于 Android 和 iOS 的垄断。但是现在又回到了群雄逐鹿的时期。Native 手机操作系统、传统 Web、众多的小程序种类、国际市场的 PWA 等等,需要综合考虑进行取舍。
|
||||
|
||||
聊完单机软件和 Web 应用,我们也探讨了桌面开发的未来趋势。桌面开发技术的演进,目标是越来越低的门槛,它和儿童编程教育相向而行,有一天必然汇聚于一点上。
|
||||
|
||||
为了让你更好地理解桌面开发的架构逻辑,我们引入了一个长达 5 讲的实战案例。这个案例建议深度消化。
|
||||
|
||||
为什么实战是很重要的?
|
||||
|
||||
**学架构,我个人强调的理念是 “做中学”。**
|
||||
|
||||
首先还是要勤动手。然后配合本专栏去思考和梳理背后的道理,如此方能快速进步。
|
||||
|
||||
我们不能把架构课学成理论课。计算机科学本身是一门实践科学,架构经验更是一线实战经验的积累和总结。
|
||||
|
||||
通过这个实战案例,我们也探讨了辅助界面元素,也就是控件的架构。控件架构没有什么特别的地方,唯一需要注意的是支持多实例。用多实例去思考你的应用程序架构的合理性,会有助于你对架构设计中的一些决策提供帮助。
|
||||
|
||||
当然更重要的,其实是让你有机会形成更好的架构设计规范。
|
||||
|
||||
作为最后收官,我们聊了架构第二步:系统的概要设计,简称系统设计。我们这个阶段关注的是全局性的风险,怎么保证项目可以按时、按质、高度并行化地被执行。
|
||||
|
||||
**系统架构打的是地基。**
|
||||
|
||||
这个阶段需要选择操作系统、选择语言、选择主框架,选择项目所依赖的最核心的基础设施。这就是我说的有关于基础架构的工作。
|
||||
|
||||
这个阶段也需要分解业务系统。我们一般以子系统为维度来阐述系统各个角色之间的关系。对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。
|
||||
|
||||
这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不作进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。
|
||||
|
||||
为了降低风险,概要设计阶段也应该有代码产出。
|
||||
|
||||
这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。
|
||||
|
||||
代码即文档。代码是理解一致性更强的文档。
|
||||
|
||||
## 桌面开发篇的参考资料
|
||||
|
||||
桌面开发的知识迭代更新非常快,所以很难去列经典书籍。
|
||||
|
||||
这里我列一下我认为值得重点关注的技术:
|
||||
|
||||
- JavaScript。毫无疑问,这是当前桌面开发的第一大语言,务必要精通。这方面我推荐程劭非(winter)的极客时间专栏“[重学前端](http://gk.link/a/106jG)”。
|
||||
- 微信小程序。这方面资料比较少,我推荐高磊的极客时间视频课“[9小时搞定微信小程序开发](http://gk.link/a/106jH)”。
|
||||
- React 和 Vue。这应该当前比较知名的两大前端框架,可以学习一下。前者可以看下王沛的“[React实战进阶45讲](http://gk.link/a/106jM)”,后者可以看下唐金州的“[Vue开发实战](http://gk.link/a/106jN)”。
|
||||
- Flutter 和 SwiftUI。这两个技术很新,其中 Flutter 已经有一些资料,比如陈航的“[Flutter核心技术与实战](http://gk.link/a/106jO)”。SwiftUI 与 Swift 语言关联很紧,在张杰的“[Swift核心技术与实战](https://time.geekbang.org/course/intro/218)”中有所涉略。
|
||||
- PWA 和 WebAssembly。这方面图书还比较少,不妨看官方材料结合实战来学习。
|
||||
|
||||
当然,经典的 Android、iOS 方面的开发资料,也值得看看。这方面资料非常多,我就不再去提名了。
|
||||
|
||||
## 结语
|
||||
|
||||
今天我们对本章内容做了概要的回顾,并借此对整个桌面开发的骨架进行了一次梳理。
|
||||
|
||||
这一章我们开始聊业务架构。学业务架构最好的方式是:“做中学”。做是最重要的,然后要有做后的反思,去思考并完善自己的理论体系。
|
||||
|
||||
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们开始进入第三章:服务端开发篇。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。
|
||||
Reference in New Issue
Block a user