最后一讲。 29 讲协议定了、服务端能跑了,但两边还没接上。这一讲把它们接起来。
而且这一讲有一段全系列的收口:用五个版本的真实行数,回答"到底什么该放进 Model"。
这一讲的代码:
| 你要的东西 | 路径 |
|---|---|
最终版浏览器端(dom.js 856 行) |
代码/qpaint源码/v30/ |
| 29→30 的 Model diff(746 行,最大的一次) | diff-dom-v29-v30.txt |
| 29→30 的 ViewModel diff(只有 22 行) | diff-view-v29-v30.txt |
服务端加了什么(就多一条 /sync) |
diff-service-v29-v30.txt |
| 最终版服务端 | 代码/qpaint源码/v30-服务端/ |
| 每个文件的结构地图 | 带读-源码导航 |
本篇讲的东西在 v30/dom.js(856 行)里的位置:
89 class QSerializer ★ 注册式的反序列化(第四节)
179 callAsync ★ 异步请求(带读-06 · 1.4 拆过)
204 class QSynchronizer ★ 同步器:算变更、发、失败重试(第三节)
└─ fireChanged _isTempDoc 就 return / 指数退避
292 shapeChanged ★ 那个 type hack 消失了(4.3)
387 qshapes.register("line") ┐
458 qshapes.register("rect") │ 四种图形各自登记(第四节)
538 qshapes.register("ellipse")│
648 qshapes.register("path") ┘
782 QPaintDoc.prepareSync ★ 用 ver > baseVer 算变更(第二节)
建议读法:先看
782的prepareSync(14 行)和89的QSerializer(20 行)——
30 讲的两个核心就这 34 行,其余是围绕它们的配套。
一 · 宏观架构:paintweb 是「平庸」的
1.1 两个程序的分工
浏览器
│ http://localhost:8888/…
▼
paintweb(Go,监听 8888)
├─ / → 发 www/index.htm 和那堆 js ← 静态文件服务器
└─ /api/… → 【原封不动转发】给 localhost:9999 ← 反向代理
│
▼
paintdom(Go,监听 9999)
真正的业务:drawings、shapes、sync
paintweb 的服务端代码,去掉注释就这么点:
var apiReverseProxy = newReverseProxy("http://localhost:9999")
var wwwServer = http.FileServer(http.Dir("www"))
func main() {
go paintdom.Main() // 起 paintdom
http.Handle("/api/", http.StripPrefix("/api/", apiReverseProxy)) // 转发
http.HandleFunc("/", handleDefault) // 发文件
http.ListenAndServe(":8888", nil)
}
原文反复用了"平庸"这个词形容它,而且是褒义:
“paintweb 的服务端完全是’平庸’的,与业务无关。具体的业务,都是通过 www 目录里面的文件来做到的。”
(DEMO 里 go paintdom.Main() 让两个程序同进程跑,只是为了调试时一起起停。
实际业务中它们是两个独立部署的软件。)
1.2 为什么要这么设计——中间那层转发不多余吗
这个链路看着确实奇怪:paintweb 收到 /api/… 什么都不干就转走了。
那为什么不让浏览器直接访问 9999?
五个理由,从最硬的说起。
① 浏览器不许跨域
页面是从 localhost:8888 下载的,那它发的请求默认只能发回 8888。
发给 9999 属于跨域,浏览器直接拦掉。
两个解法:服务端配 CORS 放行,或者让请求走同一个地址、由服务端转发。qpaint 选了后者。
最直接的理由:让浏览器只跟一个地址打交道。
② paintdom 不该直接暴露
paintdom 是业务服务器。它凭什么信任来的请求?
真实系统里,登录和鉴权是 paintweb 这层做的,paintdom 只处理"已经确认过身份"的请求——
代码里那个 Authorization: QPaintStub 1 就是个占位。
中间这层是一道门。 你不会把数据库直接暴露给公网,同理不会把业务服务器直接暴露。
③ 这一层现在是空的,但位置留出来了
现在(DEMO):
浏览器 ──▶ paintweb ─── 原封不动转发 ───▶ paintdom
将来(真实系统):
浏览器 ──▶ paintweb ─┬─ 查登录状态 ─────▶ 帐号服务
│
└─ 转译后再转发 ────▶ paintdom
"存我的这张图"
↓ 转译
"存用户 42 的这张图"
加登录、加多租户时,改这一处,两边都不用动。
这是你已经见过三次的手法——口子先开在正确的位置,实现先给最笨的:
| 口子 | 哪一讲留的 | 当时的实现 |
|---|---|---|
invalidateRect(rect) |
26 | 参数收了不用,全屏重画 |
hitCode |
27 | 只有 0 和 1 |
paintweb 的转译层 |
30 | 原封不动转发 |
④ 两个程序可以独立演进、独立部署
paintweb |
paintdom |
|
|---|---|---|
| 干什么 | 发静态文件 + 转发 | 真正的业务 |
| 多久改一次 | 几乎不改 | 天天改 |
| 挂了会怎样 | 整站打不开 | 页面还能开,只是 API 报错 |
| 要几台 | 1 台够了 | 可能要横向扩到 10 台 |
而且 29 讲说的 /v1/ /v2/ 版本分流,就在这一层做——老流量走老服务器,新流量走新服务器。
⑤ DEMO 里同进程,是妥协不是设计
func main() {
go paintdom.Main() // ← 把 paintdom 当 goroutine 起在同一个进程里
...
}
原文自己说了,这纯粹是为了调试时起停一个进程就行,让两个程序"同生共死"。
实际业务中它们是两个独立部署的软件。
收在一条你已经很熟的形状上
浏览器 ──▶ paintweb ──▶ paintdom
✗ 浏览器不认识 paintdom(它只知道 /api/…)
✗ paintdom 不认识浏览器(它只处理 HTTP 请求,不管谁发的)
又一次"中间加一层,两头互相不认识"——跟 View 在两个 Controller 之间中转事件
(27 讲onControllerReset)是同一个形状,只是这次隔的是两个进程。这个中间层在业界有名字:网关 / BFF(Backend For Frontend)。真实系统里几乎都有。
1.3 paintweb 服务端的三件事——全都与业务无关
| # | 干什么 | 现在做了吗 |
|---|---|---|
| 1 | 托管前端文件(静态下载服务器) | ✓ |
| 2 | 支持帐号服务,实现 Web 用户登录 | 还没有 |
| 3 | 业务协议的转译:把 Session-based 的请求转成 Multi-User 的请求 | 现在退化成了原封不动转发 |
第 2 条为什么值得单独说:帐号服务是基础架构,不是业务。
一个公司会有很多业务,它们必须共享同一套帐号体系——
否则用户在你家一个公司要记好几套账号,肯定要骂人。
1.4 把 24 讲那段抽象的话落地
24 讲讲过一段很抽象的分层,30 讲用 QPaint 把它对上号了:
| 24 讲说的 | QPaint 里是谁 | 干什么 |
|---|---|---|
| Multi-User Model | paintdom |
多租户的业务服务器,实现自动化所需的 API |
| Session-based Model | paintweb 的服务端 |
把多租户 API 转译成单租户场景(现在退化成转发) |
| Session-based ViewModel | www/ 里那堆 js |
真正的 Web 业务入口 |
| View | 浏览器 | 渲染 |
“Session-based Model 是 Multi-User Model 层的转译”——
服务端那头管的是"所有人的所有图",浏览器这头只关心"我的这一张"。
中间那层就是把’所有人’收窄成’我’。 因为现在还不支持多租户,这层就变成了空转发。
1.5 胖前端 vs 胖后端
原文顺带聊了一个真实的技术选型分歧:
| 胖前端(QPaint 选的) | 胖后端(比如 PHP) | |
|---|---|---|
| 业务逻辑写在 | 浏览器的 js 里 | 服务端 |
| 代码安全(IT 资产) | 别人能看到你全部业务逻辑 | 看不到 |
| 能不能离线 | 能 | 不能——断网就什么都干不了 |
QPaint 选胖前端,就是为了离线。 这条从 28 讲一路贯穿到这里。
有意思的是原文的态度:即便选胖后端,他也倾向于"用 PHP 这种胶水语言写 Web 后端",
这样paintweb自身仍然是业务无关的,只是多了个"支持 PHP"的职责。
他一直在守"承载业务的东西和托管它的东西要分开"这条线。
二 · 计算变更:三个思路
问题:断网编辑了半天,恢复联网时——怎么知道哪些内容变过?
2.1 思路一 · 每次都完整保存整篇文档
浪费。 而且不只是恢复联网那一次——平常每次编辑都要自动保存。
一篇 500 个图形的文档,你改一个矩形的颜色就重传 500 个。
2.2 思路二 · 记录完整的编辑操作历史
“每做一个操作就记一条到 localStorage”。听起来更省,实际上常常更浪费:
| 为什么 | |
|---|---|
| 一个对象编辑多次 | 拖一个矩形拖了 200 下,就是 200 条操作记录——而它最终只是"坐标变了" |
| 断网久了 | 操作累计下来,存储空间甚至可能超过文档本身 |
原文的评价:“这种方案缺乏很好的鲁棒性,在 badcase 情况下让人难以接受。”
值得记的是这个判断方式:不看平均情况,看最坏情况。
思路二在"改几下就同步"时确实省,但它的最坏情况没有上界——这才是它被否掉的理由。
2.3 思路三 · 给对象加版本号 ← 选这个
文档有一个基版本 baseVer = 上一次同步完成时的版本
每个图形有自己的 shape.ver = 它最后一次被改时的版本
shape.ver > baseVer ⇒ 上次同步之后,这个图形变过
代价:每个图形多存一个整数。收益:算变更是一次 O(n) 的遍历,而且最坏情况有上界
(最多就是全部图形,等于思路一,不会更差)。
prepareSync(baseVer) {
let shapeIDs = [], changes = []
for (let i in this._shapes) {
let shape = this._shapes[i]
if (shape.ver > baseVer) { // ★ 就这一句判断
changes.push(shape)
}
shapeIDs.push(shape.id) // ID 列表是全的(很小)
}
this.ver++
return { shapes: shapeIDs, changes: changes, ver: this.ver }
}
三个思路的取舍,是一道很典型的题:
存什么 最好情况 最坏情况 一 · 全量 只存当前状态 差 和最好一样(有上界) 二 · 操作历史 存过程 很好 无上界(可能超过文档本身) 三 · 版本号 当前状态 + 一个整数 很好 不会比思路一差 思路三赢在"最好情况接近思路二,最坏情况不差于思路一"。
三 · ★ 同步变更:协议被推翻了
这是 30 讲最值得读的一段。
3.1 有了变更信息,怎么发给服务端?
自然的想法:把变更还原成一条条编辑操作发上去——正好 29 讲那七条接口就是干这个的:
POST /drawings/507f/shapes 加一个图形
POST /drawings/507f/shapes/2 改一个图形
DELETE /drawings/507f/shapes/3 删一个图形
但作者否掉了这条路。理由是:
“这些编辑操作一部分发送成功,一部分发送失败怎么办?
这种部分成功的中间态是最挑战我们程序员的编程水平的,很烧脑。”
具体想一下就知道有多麻烦:你发了 5 条,第 3 条超时了。现在服务端处于什么状态?
前两条生效了、后两条呢?要不要回滚?回滚也可能失败。要不要重发第 3 条?
重发会不会重复执行?……每一个分支都要写代码、都要测。
3.2 一条准则
“我个人一贯坚持的架构准则是不要烧脑。
尤其对大部分非性能敏感的业务代码,简单易于实施为第一原则。”
这句值得单独抄下来。 它不是在说"别写复杂的代码",而是在说——
当一个方案会逼出大量的中间态和边界情况时,先回头看看能不能换个方案让这些情况根本不存在。
3.3 于是加了一条 /sync
把"一堆操作"换成"一次整体同步":
POST /drawings/507f1f77/sync
{
"shapes": ["1","2","3"], ← 现在这篇图有哪些图形、什么顺序
"changes": [ …变过的图形… ], ← 这些图形的完整内容
"ver": 7
}
服务端拿到就整体覆盖。要么整个成功,要么整个失败——没有中间态。
我 diff 了 v29 → v30 的服务端路由表,只多了一行:
"POST/drawings": p.PostDrawings,
"GET/drawings/*": p.GetDrawing,
+ "POST/drawings/*/sync": p.PostDrawingSync, ← 新增的就这一条
"POST/drawings/*/shapes": p.PostShapes,
...
一个诚实的观察:旧的七条接口并没有被删掉,只是浏览器端不再用它们了。
它们仍然是"给自动化程序用的 API"(29 讲说的那个用途)。
所以准确说不是"推翻",是**“给同一个业务加了第二套、粒度完全不同的接口”**。
3.4 复盘:最初错了吗?
原文的自问自答很坦率:
"这很有趣。在我们讨论相互配合的接口时,我们非常尊重业务逻辑,按照我们对业务的理解,
定义了一系列的编辑操作。但是,到最后我们却发现,它们统统不管用,我们要的是一个同步协议。是最初我们错了吗?也不能这么说。
最初我们定义协议的逻辑并没有错,只是没有考虑到支持离线编辑这样的需求而已。"
两条复盘结论:
| # | |
|---|---|
| 1 | 需求的预见性非常重要。 没预见到,大部分情况下要为"缺乏市场洞察"买单 |
| 2 | 及早推出 Mock,让前端快速迭代,进而及早发现协议的不足,是很有必要的。 越晚做出协议调整,事情就越难,也越低效 |
这一段是 29 讲那个 Mock 决策的直接回报。
29 讲说 Mock 的价值是"快速验证网络协议的有效性,发现不满足可以及时调整"——
30 讲就真的调整了。 如果当时憋大招做正式版(接数据库、做高可靠高可用),
等做完才发现协议粒度不对,那些工作要重来一大半。
四 · 加载文档:一次重构
4.1 问题:两套格式,两套加载代码
到 v29 为止,同一个图形有两种 JSON 写法:
localStorage 里: {"type":"QRect", "x":10, "y":10, "width":50, ...}
网络协议里: {"id":"2", "rect": {"x":10, "y":10, "width":50, ...}}
于是要写两套"从 JSON 造回对象"的代码。而且——图形种类只会越来越多,
每加一种就要在两个地方各改一次。
4.2 QSerializer:让图形自己登记
重构目标(原文):① 统一两种表示;② 加新图形要容易、代码内聚、不必到处改。
class QSerializer {
constructor() { this.creators = {} }
register(name, creator) { this.creators[name] = creator }
create(json) {
for (let key in json) {
if (key != "id") { // ★ 除了 id,剩下那个 key 就是类型
let creator = this.creators[key]
if (creator) return creator(json)
break
}
}
alert("unsupport shape: " + JSON.stringify(json))
}
}
var qshapes = new QSerializer()
各个图形在自己的文件位置登记自己:
qshapes.register("line", function(json) { return new QLine(json) })
qshapes.register("rect", function(json) { return new QRect(json) })
qshapes.register("ellipse", function(json) { return new QEllipse(json) })
qshapes.register("path", function(json) { return new QPath(json) })
每个图形加两个方法就够了:
class QRect {
constructor(r, style) {
if (style) { …正常新建… } // 有 style → 新建
else { …从 json 读… } // 没 style → 从 JSON 反序列化
}
toJSON() {
return { id: this.id, rect: { x:…, y:…, width:…, height:…, style:…, ver:… } }
}
}
这个你早就见过——就是带读-00 · 2.3 讲的注册表 + 工厂,
跟registerController一模一样的套路,只不过这次注册的是"怎么从 JSON 造回对象"。收益:加一种新图形 = 写一个类 + 一行
register,loadShape那个 switch 消失了。
4.3 顺带:那个 hack 消失了
带读-05 · 5.3 吐槽过 v28 里 shape.type 被当两个东西用的 hack。v30 里它没了:
// v28(hack)
function shapeChanged(shape) {
let parent = shape.type // type 被当父指针用
shape.type = shape.constructor.name // 临时换成类名
let val = JSON.stringify(shape)
shape.type = parent // 换回去
...
}
// v30(干净)
function shapeChanged(parent, shape, noSync) {
shape.ver = parent.ver
let val = JSON.stringify(shape) // toJSON() 自己产出正确格式,不用手脚
localStorage_setItem(parent.localID + ":" + shape.id, val)
if (!noSync) parent.syncer.fireChanged(parent)
}
parent 变成了参数,格式由 toJSON() 负责。 那个将就没了。
重构的常见形态就是这样:不是"把烂代码改漂亮",而是"引入一个新概念(
toJSON/QSerializer),
原来的将就自然就没地方待了"。
4.4 三种加载场景
| 方法 | 什么时候走 | 联网时 | 断网时 |
|---|---|---|---|
_loadBlank |
URL 没 hash(全新文档) | 在服务端建一个新 drawing | 本地建临时文档(t 开头) |
_loadTempDoc |
URL 是 #t10001(一直没同步过) |
在服务端建新 drawing,并把离线编辑的数据同步过去 | 加载离线数据,继续离线编辑 |
_loadRemote |
URL 是 #507f1f77 |
先加载本地缓存,再异步拉远程;成功后本地离线内容被放弃 | 只用本地缓存 |
注意
_loadRemote那句"本地离线编辑的内容会被放弃"——这是一个很粗暴的冲突策略:
远程赢。真正的协同编辑(多人同时改同一个图形)要复杂得多,qpaint 没走到那一步。
4.5 onload 事件:避免 Model 去理解 View
class QPaintView {
constructor() {
let view = this
this.doc.onload = function() { // ← View 事先挂一个函数进去
view.invalidateRect(null)
}
}
}
为什么要有这个事件? 原文:
“向服务器的 ajax 请求,什么时候完成是比较难预期的,我们加载文档是在异步 ajax 完成之后。
这样来看,完成文档加载后发出 onload 事件,就可以避免 Model 层需要去理解 View 层的业务逻辑。”
这就是带读-05 第四部分讲的
DataChanged那件事,第一次在代码里真的落地了。
Model 加载完了只管喊一声"我加载完了",它不知道听众是谁;View 事先挂了一个"那我重画"。又一次:事件是唯一一种「我通知你,但我不认识你」的通信方式。
五 · ★ Model 层的厚度(全系列收口)
22 讲说"Model 层越厚越好",26~30 讲一路在实践它。现在用真实行数验一遍。
5.1 五个版本的真实行数(我数的,不是估的)
| 文件 | 层 | v26 | v27 | v28 | v29 | v30 | 倍数 |
|---|---|---|---|---|---|---|---|
dom.js |
Model | 109 | 306 | 497 | 497 | 856 | ×7.9 |
view.js |
ViewModel | 112 | 125 | 131 | 131 | 133 | ×1.2 |
creator/rect.js |
Controller | 108 | 93 | 93 | 93 | 93 | ×0.9 |
creator/path.js |
Controller | 90 | 91 | 91 | 91 | 91 | ×1.0 |
creator/freepath.js |
Controller | 71 | 72 | 72 | 72 | 72 | ×1.0 |
accel/select.js |
Controller | — | 91 | 91 | 91 | 93 | 新增 |
accel/menu.js |
Controller | 86 | 156 | 156 | 156 | 156 | ×1.8 |
index.htm |
组装 | 18 | 19 | 19 | 19 | 19 | ×1.1 |
服务端 paintdom/ |
Model+Ctrl | — | — | — | 657 | 725 | 新增 |
(原文说"v26 约 120 行 → v30 约 860 行,7.x 倍"——我数出来是 109 → 856,×7.9,对得上。)
5.2 三个数字
| # | 数字 | 说明什么 |
|---|---|---|
| 1 | Model:×7.9 | 五轮需求,重量全压在这一层 |
| 2 | ViewModel:×1.2 | 五个版本累计只涨了 21 行(112 → 133) |
| 3 | 原有的三个 Creator:269 → 256,反而少了 13 行 | 五轮需求迭代下来,三个画图工具的代码不增反减 |
第 3 条是最强的证据。
27 讲加选择工具、28 讲加离线存储、29 讲加服务端、30 讲加同步——
rect.js、path.js、freepath.js从头到尾几乎没被打扰过。
(rect.js反而变少,是因为normalizeRect被搬去了 Model——它是 Model 的活。)
v29 看起来是个例外(dom.js 一行没改),原文解释得很好:
“实际上 v29 整个变更都是 Model 层的变更,因为是增加了服务端的 Model
(我们前面把它叫做 Multi-User Model)。”
5.3 Model 层的五项职责
原文总结的(这是全系列最该抄下来的一张表):
| # | 职责 | 哪一讲长出来的 |
|---|---|---|
| 1 | 业务逻辑,对外暴露业务接口(最本职的工作) | 26 |
| 2 | 实现 View 层委托的 onpaint,完成绘制 |
26 |
| 3 | 实现 Controller 层的 hitTest,支持 selection |
27 |
| 4 | 与服务端 Multi-User Model 通讯——View、Controller 都不需要感知服务端 | 29 / 30 |
| 5 | 离线编辑 localStorage 的存取 | 28 |
“除了少量 View(onpaint)、Controller(hitTest)的需求,大部分都是 Model 层的正常业务范畴。
这些职责已经很多,所以 Model 层自然会胖。”注意第 4 条:整个联网、同步、离线重试,View 和 Controller 一无所知。
这就是为什么 28 讲 Controller 零改动、29 讲浏览器端零改动。
5.4 推论:为什么必须内聚
"如果我们不是让 Model 层代码以内聚的方式放在一起,而是让它自由地散落于各处,
那么我们的代码变更质量会非常不受控。为什么?Model 层总体来说是最容易测试的,因为它的环境依赖最小。
如果这些代码被分散到 View、Controller 层中,代码的阅读难度、维护难度、测试的难度都会大幅增加。"
把 856 行放在一个文件里,看着"很胖";散到八个文件里,看着"很均衡"——
但后者每一处都变得难测、难改。
"胖"不是缺点,"散"才是。
六 · 全系列回顾
| 讲 | 新需求 | 被逼出来的 | 代价落在哪 |
|---|---|---|---|
| 26 | 能画图 | 三层分工、多态自绘、事件委托 | — |
| 27 | 能选中/拖动/改样式/删除 | hitTest bound move setProp deleteShape、Selection、事件中转 |
Model +197,View +13,Creator 各 +1 行 |
| 28 | 关掉再打开还在、断网能编辑 | 对象 ID(两个)、分层存储、变更分级 | Model +191,View +6,Controller 零改动 |
| 29 | 有个服务端 | 网络协议、Mock 版服务端 | 浏览器端零改动,新增 657 行 Go |
| 30 | 两边真的接起来 | 版本号算变更、/sync 协议、QSerializer |
Model +359,View +2 |
贯穿五讲的几条:
| # | |
|---|---|
| 1 | 架构设计 = 先问"这个需求要求程序回答什么它现在答不上来的问题",再把问题变成接口装到该装的层 |
| 2 | 好的设计决定会反复付利息——26 讲"让 Model 自己干",被复用成 hitTest/bound/move/setProp/toJSON |
| 3 | 事件是唯一一种"我通知你但我不认识你"的通信方式——onControllerReset、onSelectionChanged、onload、DataChanged |
| 4 | 接口的严谨程度,取决于"一旦定错了,收回来有多贵"——localStorage 随意,网络协议严谨 |
| 5 | 不要烧脑——一个方案会逼出大量中间态时,先看能不能换个方案让这些情况根本不存在 |
| 6 | Model 层越厚越好,而且必须内聚——胖不是缺点,散才是 |
七 · 验收
| # | 问题 | 答案在 |
|---|---|---|
| 1 | paintweb 的服务端为什么被称作"平庸"的?它干哪三件事?中间那层转发不多余吗? |
1.1 ~ 1.3 |
| 2 | 算"哪些内容变过"的三个思路是什么?为什么选版本号? | 二 |
| 3 | 29 讲精心设计的那七条编辑接口,为什么到 30 讲不用了? | 三 |
| 4 | QSerializer 解决了什么问题?它跟 registerController 是什么关系? |
4.2 |
| 5 | 五个版本下来,Model / ViewModel / Controller 各涨了多少?说明什么? | 5.1 · 5.2 |
答案
1. 因为它与业务无关——业务全在 www/ 的 js 和 paintdom 里。它只干三件事:
托管前端文件、支持帐号服务(还没做)、业务协议转译(现在退化成原封不动转发)。
"平庸"在这里是褒义:它没有把业务逻辑吸进来。
2. ① 全量保存——浪费;② 记操作历史——听着省,最坏情况没有上界(一个对象改 200 次就 200 条,
断网久了历史可能超过文档本身);③ 给对象加版本号,ver > baseVer 就是变过的 ← 选这个。
它赢在"最好情况接近思路二,最坏情况不差于思路一"。
3. 因为一条条发编辑操作会产生部分成功的中间态——发了 5 条第 3 条超时,服务端是什么状态?
要不要回滚?重发会不会重复执行?每个分支都要写要测。
换成一次整体 /sync,要么整个成功要么整个失败,没有中间态。
这就是那条准则:不要烧脑。
(旧的七条没删,仍是给自动化程序用的 API。)
4. 解决了**“同一个图形有两套 JSON 格式,要写两套加载代码”,而且加新图形要到处改**。
QSerializer 让每个图形自己登记怎么从 JSON 造回自己,加新图形 = 写一个类 + 一行 register。
它跟 registerController 是同一个套路(注册表 + 工厂),只是注册的东西不同。
5. Model ×7.9(109→856)、ViewModel ×1.2(只涨 21 行)、
原有三个 Creator 269→256,反而少了 13 行。
说明需求的重量几乎全压在 Model 上,上面两层几乎没被打扰——
这正是"Model 越厚越好"的实际收益。而 Model 必须内聚:散出去就变得难测、难改。
全系列到此结束
收尾总结在这里:带读-08 · 收尾:五讲带走了什么
——六个反复出现的手法、16 条值得背下来的判断、作者自己没做到的地方,以及"如果只记三条"。
带读-地图 / 源码导航 索引,随时回来看
带读-00 前端底子(26~30 共用)
带读-01 ~ 03 26 讲:Model / 一次画矩形 / 收口
带读-04 27 讲:加选择工具
带读-05 28 讲:离线持久化与对象 ID
带读-06 29 讲:网络协议与服务端
带读-07 30 讲:对接、同步、Model 层的厚度 ← 你在这
这门课后面还会拿这个程序继续讲(31 讲辅助界面元素、32 讲概要设计、41~44 讲服务端实战),
到那时你手上已经有一份完整读过的代码了。
