本篇要回答的问题:抛开各操作系统接口的巨大差异,图形界面程序有一套怎样统一的大逻辑?事件如何从产生、入队、分派,最终到达正确的窗口并完成绘制?
怎么读这份笔记(2026-08-29 重写,原因见文末)
- 第一部分「通俗版」:从一个三行的加法器脚本出发,一步步把整套框架长出来。每个概念只在"上一步不好使了"的时候才出现。第一次读、或者读原文没看懂时,看这部分。
- 第二部分「术语版」:按原文章节顺序逐节整理,每节开头挂一行白话对照指回第一部分。以后查名词看这部分。
- 第三部分:总表、速记、可跑脚本。
两部分讲的是同一件事,只是入口不同。术语难懂不是因为它深,是因为它是结论——而结论脱离了逼出它的那个问题就没法理解。
术语 ↔ 白话 总对照表
先放这张。原文所有名词,加上"它为什么非存在不可":
| 原文术语 | 白话 | 为什么非有不可 |
|---|---|---|
| 事件 Event | 一条"用户干了什么"的消息 | 只有操作系统看得见键盘鼠标 |
| 事件队列 Event Queue | 一个收件箱 | 系统没法直接调你的函数;且用户手快,不能丢 |
| 事件分派循环 RunLoop | while 收件箱: 取一件,查表,执行 |
顺序不由你定了,你只能等 |
| 窗口 Window / View | 一块矩形 + 挂在上面的规则 | 事件只给坐标,你得查出这归谁 |
| 事件处理类(继承) | 写个子类,把"该干嘛"填进空方法 | 框架得有办法找到你写的代码 |
| 委托 delegate | 把一个函数交给框架 | 同上,另一条路,且能运行期换 |
| 窗口过程 WindowProc | 一个函数收所有事件,靠 switch 分 |
因为 Win32 API 是 C 的,没有类和继承 |
| 焦点窗口 | 一个变量,记着"谁在听键盘" | 键盘事件没有坐标,查不了表 |
| Caret 输入光标 | 那根闪烁的竖线 | 让用户看见焦点在谁身上;焦点唯一,所以它唯一 |
| onPaint / onDraw | 系统主动塞进收件箱的"你那块脏了" | 别人能弄脏你的画面,而只有系统知道 |
| 事件处理链 | 焦点不管就往父窗口递 | 不然"按 ESC 关窗口"得在每个控件里写一遍 |
| 鼠标捕获 Mouse Capture | 捕获期间无视坐标,全给捕获者 | 拖动时手会拖出窗口,坐标会骗人 |
| 热键 HotKey | 分派前先查一张全局表,命中直接送走 | 截屏软件根本不在前台,走焦点永远轮不到 |
| TranslateMessage | 分派前那步"这个键能不能变成字符" | 键盘有两个用途:打字要字符,命令要键 |
| GDI | 坐标换算 + 裁剪 | 屏幕只有一块,你不知道自己在哪、被谁挡 |
| 通用控件 Control | 系统替你写好的那几千行输入框 | 一个能用的输入框不能每个 App 重写一遍 |
| 应用程序级事件 | 没有坐标的事,问不出归谁,归程序本身 | 进程要被杀、低电量,跟"哪个窗口"无关 |
第一部分 · 通俗版:把这套东西从零长出来
地基:为什么图形界面程序非得长成那个怪样子
第 1 步:一个你完全懂的程序
a = input("第一个数:")
b = input("第二个数:")
print("结果 =", int(a) + int(b))
第一个数:3
第二个数:4
结果 = 7
注意一件从来没留意过的事:顺序是你定的。 你规定先问 a、再问 b、最后算。用户没得选——不能先填第二个数,不能填完回头改,不能不填就要结果。
input()把他锁死在你的流程里。
第 2 步:把同一个加法器做成界面
屏幕上两个输入框、一个"计算"按钮。就这么点东西。但用户自由了:可以先填右边、可以一个都没填就点计算、可以填完 a 又回去改。
关键判断:这是全部困难的源头——顺序不再由你定。
第 3 步:顺序不归你了,代码就没法那样写
你不能再写 a = input(),因为你不知道用户下一步要干什么。唯一还能写的,是一堆"如果……就……":
def 框A被改了(内容): 数据["a"] = 内容
def 框B被改了(内容): 数据["b"] = 内容
def 按钮被点了(): 算一下(数据["a"], 数据["b"])
这三个函数之间没有先后。 谁先谁后由用户当场决定。
你的代码从「一串步骤」变成了「一堆规则」——这是第一个变化。
第 4 步:谁来调这三个函数?
你自己调不了,你不知道用户干了什么。只有操作系统知道,因为键盘鼠标接在它身上。
所以只能是:系统告诉你"用户干了什么",你查这对应哪条规则,然后执行。系统发给你的这条消息,就叫「事件」:
{"x": 18, "y": 3, "内容": "4"} # 在屏幕 (18,3) 这个位置输入了 "4"
第 5 步:为什么中间要有个队列,不能直接调?
| 理由 | 说明 |
|---|---|
| 系统跟你不是一路人 | 它没法直接 call 你的函数,只能把消息放到一个地方,你自己来取 |
| 用户手快 | 你还在处理上一件事,他又点了三下。这三下不能丢,得排着 |
这个队列就是个收件箱,没有别的含义。
第 6 步:于是程序的形状变了
while 收件箱:
事 = 收件箱.pop(0) # 取一件
w = 这个坐标归谁(事["x"], 事["y"]) # 查这归哪条规则
w["规则"](事.get("内容")) # 执行
你的
main里只剩这个循环。你写的那三个规则函数,一个都不是你自己调的。
第 7 步:跑一遍,看用户怎么打乱顺序
用户的操作顺序:先填右边 → 没填全就点计算 → 回头填左边 → 点计算 → 又改左边 → 再算。
收件箱取出一件:{'x': 18, 'y': 3, '内容': '4'}
坐标 (18,3) 落在「框B」的矩形里
规则:框B被改了 -> 记下 b="4"
收件箱取出一件:{'x': 28, 'y': 3}
坐标 (28,3) 落在「计算键」的矩形里
规则:按钮被点了 -> 还没填全(a="" b="4"),不算 ← 用户乱来,程序没崩
收件箱取出一件:{'x': 5, 'y': 3, '内容': '3'}
规则:框A被改了 -> 记下 a="3"
收件箱取出一件:{'x': 28, 'y': 3}
规则:按钮被点了 -> 3 + 4 = 7
收件箱取出一件:{'x': 5, 'y': 3, '内容': '10'}
规则:框A被改了 -> 记下 a="10" ← 回头改,也没问题
收件箱取出一件:{'x': 28, 'y': 3}
规则:按钮被点了 -> 10 + 4 = 14
第 1 步那个
input()版本,上面任何一件事都做不到。
第 8 步:「窗口」自己浮出来了
事件里只有 {"x": 18, "y": 3}。你怎么知道这是"框B被改了"而不是"按钮被点了"?只能靠坐标。 你得事先记一张表:
窗口表 = [
{"名字": "框A", "x": 2, "y": 2, "宽": 10, "高": 3, "规则": 框A被改了},
{"名字": "框B", "x": 14, "y": 2, "宽": 10, "高": 3, "规则": 框B被改了},
{"名字": "计算键", "x": 26, "y": 2, "宽": 8, "高": 3, "规则": 按钮被点了},
]
这张表里的一行,就是一个「窗口」。窗口 = 一块矩形 + 这块矩形上挂的规则。 没有别的含义。
原文那句"窗口是一个独立可复用的界面元素",翻译过来就是这句话。
地基这一层的账
| 原文的说法 | 其实是 | 为什么非有不可 |
|---|---|---|
| 事件 | 一条"用户干了什么"的消息 | 只有操作系统看得见键盘鼠标 |
| 事件队列 | 一个收件箱 | 系统没法直接调你的函数,且用户手快 |
| 事件分派循环 | while 收件箱: 取一件,查表,执行 |
顺序不归你定了,你只能等 |
| 窗口 | 一块矩形 + 挂在上面的规则 | 事件只给坐标,你得查出这归谁 |
这四条全部来自同一件事:顺序不再由你定。
第二层:两个新问题
问题一:键盘事件没有坐标
地基那层所有事件都带 x, y,能查表。但真实的键盘事件长这样:
{"种类": "按键", "键": "4"} # 没有 x,没有 y
你查不了表。 这个 “4” 该进框A还是框B?事件本身没告诉你。唯一的办法是事先记住"现在轮到谁在听":
焦点 = None # 就一个变量
用户上一次点了哪个框,焦点就是谁:
取出:{'种类': '点击', 'x': 18, 'y': 3}
有坐标 -> 查表 -> 归「框B」
焦点交给「框B」(以后的键盘都归它)
取出:{'种类': '按键', '键': '4'}
【没有坐标】查不了表 -> 只能给焦点:框B
│ | | | 4_ | | CALC |
取出:{'种类': '点击', 'x': 5, 'y': 3}
焦点交给「框A」(以后的键盘都归它)
取出:{'种类': '按键', '键': '1'}
【没有坐标】查不了表 -> 只能给焦点:框A
│ | 1_ | | 4 | | CALC |
关键判断:焦点存在的唯一理由,是键盘事件没有坐标。
顺带解释一个做过一万次、从没想过为什么的动作:你必须先点一下输入框才能打字。 那一下不是在"打开"这个框——它是在把焦点交给它。
那根闪烁的竖线(原文叫 Caret,上面输出里的 _)作用是让用户看见焦点在谁身上。所以它同一时刻只能有一个——因为焦点只有一个。
问题二:别人也能弄脏你的画面
到这里为止,重画都发生在你自己改了数据之后——这个想得到。但看这件事:
取出:{'种类': '重画', '原因': '别的窗口盖住你又挪开了,你那块花了'}
这不是用户干的,是系统说:别的窗口盖住你又挪开了,你那块花了
我的数据一个字没变 -> 照着数据从头画一遍就行
用户把另一个程序的窗口拖过来盖住你又挪开。你的数据一个字都没变,但你的画面没了。
你的代码永远发现不了这件事——你只认识"用户在我的地盘上干了什么",而这件事发生在别人的窗口上。只有系统知道。 所以系统得能主动往你的收件箱里塞一件事。
关键判断:这就是 onPaint 跟其他所有事件的区别——
别的事件是「用户干了什么」,onPaint 是「系统要你把画面补上」。
由此逼出一条硬规矩
既然系统可能在任何时刻要求你从头画一遍——
你的画面必须随时能从数据重新生成。
你不能想着"我上次画了个 1,这次在旁边加个 0 变成 10"。你得永远保存**「该显示什么」**:
数据 = {"框A": "10", "框B": "4", "结果": "14"} # 存的是这个
# 不是"我在第 5 列画过一个 1"
重画屏幕() 每次都从 数据 里现算,从不参考"我上次画成什么样"。所以不管系统什么时候喊它,画出来都是对的。
一句话:数据是真的,画面只是数据的影子,随时可以重投一次。
第二层的账
| 原文的说法 | 其实是 | 为什么非有不可 |
|---|---|---|
| 焦点窗口 | 一个变量,记着"谁在听键盘" | 键盘事件没有坐标,查不了表 |
| Caret 光标 | 那根闪烁的竖线 | 让用户看见焦点在谁;焦点唯一所以它唯一 |
| onPaint / onDraw | 系统主动塞进收件箱的一件事 | 别人能弄脏你的画面,而只有系统知道 |
第三层:四个补丁
套路都一样——主干规则在某种情况下不好使了,打个补丁。
补丁一:拖动的时候,坐标会骗人
主干规则:鼠标事件按坐标查表。
不好使的情况:用户在框A里按住鼠标拖选文字,手一抖拖到框B上面去了。按主干规则这个移动事件归框B——可用户明明还在框A里拖。框A会以为用户松手了,框B会莫名收到一堆不属于它的移动。
补丁:谁按下的,谁就「捕获」鼠标。捕获期间无视坐标,全给他。松开才解除。
同一个坐标 (18,3),捕获前后归属完全不同:
取出:{'种类': '移动', 'x': 18, 'y': 3}
坐标是 (18,3),但鼠标已被「框A」捕获 -> 不查表,直接给它
取出:{'种类': '松开', 'x': 18, 'y': 3}
松开 -> 解除捕获,坐标重新生效
取出:{'种类': '移动', 'x': 18, 'y': 3}
查表 -> 框B ← 同一个坐标,现在归框B了
天天在用:拖滚动条、拖选文字、拖窗口标题栏、拖画布——手拖出去了也不会断,全靠这个补丁。
补丁二:有些命令根本不该走焦点
主干规则:键盘事件给焦点窗口。
不好使的情况:截屏软件。你按 F12 截屏时,它根本不在前台,连焦点都没有——走焦点这条路永远轮不到它。
补丁:在分派之前先查一张全局热键表。命中就直接送走,焦点看都看不见。
取出:{'种类': '按键', '键': 'F12'}
热键表命中 -> 直接送给 截屏软件(根本不在前台),焦点「框A」压根没看见
注意补丁的位置:在"给焦点"这一步之前,等于插了个队。
到了手机上,键盘不再是主角,但这几个"不走焦点的命令"太刚需,于是直接做成物理按键:截屏、音量、拍照。
补丁三:你根本不知道自己在屏幕哪儿
主干规则:窗口自己重画。
不好使的情况:屏幕只有一块,窗口有好多个。 框A以为自己从 (0,0) 开始画,可它实际在屏幕的 (2,2),而且可能被别的窗口盖住了一半。要是每个窗口都直接往屏幕上写——画错位置,还会把别人的内容盖掉。
补丁:不许你碰屏幕,只能调系统的画图函数(这就是 GDI)。 它替你干两件事:
def GDI画字(w, 内x, 内y, 文字):
if not (0 <= 内x+i < w["宽"]-2): continue # ① 裁剪:超出你地盘的,剪掉
屏幕[w["y"]+内y][w["x"]+1+内x+i] = c # ② 换算:窗口内坐标 -> 屏幕坐标
用户一直打字,超出框宽的部分自动没了:
取出:{'种类': '按键', '键': '9'}
GDI:「框A」想画 "123456789_",只画得下 "12345678",剪掉 2 个
│ |12345678| | |
框A的代码里一个字都没提这事——它照样从 (0,0) 开始画,以为自己独占一块屏幕。
关键洞见:这就是「窗口独立可复用」的技术底座。 有了换算和裁剪,每个窗口才敢装作屏幕上只有自己。原文强调"GDI 画到指定窗口而不是屏幕",说的就是这个。
补丁四:真正的输入框,是几千行
主干规则:窗口 = 矩形 + 规则。我们的"框A"接受按键往里塞字符,就 1 行。
不好使的情况:用户按了个 Backspace。
取出:{'种类': '按键', '键': 'Backspace'}
✗ 我们这个"框"只会往后加字符,不认识 Backspace —— 真输入框还得处理
光标移动/选中反白/双击选词/Ctrl+A/复制粘贴/撤销/输入法…… 几千行
而且这几千行每个 App 都得重写一遍,还会写得不一样——用户在 A 软件里双击能选词,到 B 软件里不行。
补丁:系统直接把写好的给你——Label、Button、CheckBox、Input、ProgressBar。这就是原文的「通用控件」。
代价(原文说的那个坑):每家细节不一样。Windows 的 CheckBox 有三态,Mac 的没有;长按输入框 Android 弹菜单、iOS 弹放大镜。跨平台框架只有两条路——抹平(哪个平台都不像原生)或暴露(代码开始分叉)。
整套东西,一张表收尾
| 层 | 规则 | 被什么逼出来的 |
|---|---|---|
| 地基 | 收件箱 + 循环 + 查表 + 窗口 | 顺序不由你定了 |
| 第二层 | 焦点 | 键盘事件没有坐标 |
| onPaint | 别人能弄脏你的画面,只有系统知道 | |
| 补丁 1 | 鼠标捕获 | 拖动时坐标会骗人 |
| 补丁 2 | 热键 | 有些命令的接收方根本不在前台 |
| 补丁 3 | GDI | 屏幕只有一块,你不知道自己在哪、被谁挡 |
| 补丁 4 | 通用控件 | 一个能用的输入框是几千行,不能每人写一遍 |
原文那两讲,全部内容就是这张表。每一行都是被上一行的某个漏洞逼出来的。
第二部分 · 术语版:原文逐节 + 白话对照
本讲把关注点收敛到当今仍占主流的图形界面程序。最大复杂性在于各操作系统接口差异巨大、跨平台困难;但基本大逻辑相通。原文顺序:事件 → 窗口与响应 → 事件分派 → 内容绘制 → 通用控件。
事件:从硬件到事件队列
白话对照:这一节讲的就是第一部分 · 地基 · 第 4~5 步——事件是"用户干了什么"这条消息,队列是收件箱。
每个进程都有一个全局的事件队列(Event Queue)。事件产生到入队的全过程:
| 步骤 | 动作 |
|---|---|
| 1 | 键盘/鼠标/触摸屏硬件产生硬件中断 |
| 2 | 操作系统的硬件中断处理程序收到事件 |
| 3 | 确定该事件的目标进程 |
| 4 | 将事件放入目标进程的事件队列 |
展开:这四步里被省略的东西
① 中断处理程序为什么不能直接调你的 onClick?
中断发生时 CPU 处在中断上下文:不能睡眠、不能等锁、通常还关着中断。这时候去调用户代码,等于让整台机器停下来等你。所以中断处理被拆成两半:
| 半 | 干什么 | 术语 |
|---|---|---|
| 上半部 | 只做最急的:把扫描码从硬件寄存器读走存进内核缓冲,立刻返回开中断 | top half |
| 下半部 | 慢慢做剩下的:翻译、定位目标进程、投递 | bottom half / softirq / DPC |
关键洞见:队列不是为了"排队"才存在的,它是中断上下文与进程上下文之间的解耦缓冲。硬件的时间尺度是微秒,你的
onClick是毫秒——中间必须垫一层。
(白话版第 5 步说的"系统跟你不是一路人",底层就是这件事。)
② 一次按键要翻译三层:扫描码 → 键码 → 字符
| 层 | 是什么 | 跟什么绑定 | 谁负责 |
|---|---|---|---|
| 扫描码 scan code | 键盘发出的原始编号 | 键的物理位置 | 键盘硬件 |
| 键码 key code / virtual key | 逻辑上"哪个键" | 键盘布局 | 操作系统驱动层 |
| 字符 char | 最终的文字 | 布局 + 修饰键 + 输入法 | 操作系统 / 输入法 |
这就解开了后文那个最费解的
TranslateMessage——它干的正是第 ②→③ 层这一步:把 KeyDown 翻成 Char。翻不出字符的键就没有 Char 事件。
本机实测(tkinter event_generate 合成按键;keycode 是 Tk 的合成值、非真实扫描码):
| 我按的 | keysym(逻辑键) | char(翻译出的字符) | state(修饰键) |
|---|---|---|---|
| a | a |
'a' |
0 |
| Shift+A | A |
'A' |
1 |
| 方向键 ↑ | Up |
'' 空 |
0 |
| Ctrl+A | a |
'a' |
4 |
| F1 | F1 |
'' 空 |
0 |
- 方向键和 F1 翻不出字符 → 所以 KeyDown 和 Char 必须是两种事件,不能合并。
- Ctrl+A 的字符仍是
'a',只有 state 不同 → 同一个字符靠修饰键状态区分是"输入文本"还是"触发命令",正对上第 20 讲的键盘功用二分。
顺带解释一个常见困惑:游戏用 keycode,文本框用 char。WASD 在法语 AZERTY 键盘上是 ZQSD——游戏要的是位置,输入框要的是字符。
③ "确定目标进程"这一步是一条安全边界
| 事件类型 | 怎么定目标进程 |
|---|---|
| 鼠标 / 触摸 | 按坐标找最上层窗口 → 它属于哪个进程 |
| 键盘 | 送给前台活跃窗口所属进程(系统级焦点) |
| 全局热键 | 系统先截一道,注册过的优先 |
这一步只有操作系统能做,用户程序绕不过去——所以它就是一条安全边界:
- 键盘记录器必须拿到特权(全局钩子 / 辅助功能授权)才能旁路它
- 密码框的 secure input,本质是请求系统"别把这些事件分给别人"
- 点击劫持 / Tapjacking 的原理,就是骗过"按坐标定目标"这一步
④ 队列不是朴素的 FIFO
| 机制 | 干什么 | 不这么做会怎样 |
|---|---|---|
| 合并 coalescing | 鼠标移动一秒上千个,只留最新的 | 处理不过来,越积越多 |
| 优先级分档 | 重绘、定时器排低档,队列空了才轮到 | 光顾着重画,来不及响应输入 |
| 标脏 invalidate | 你不直接画,只说"这块脏了",系统攒着一起画 | 改十次属性画十遍 |
本机实测:
=== 重绘是「标脏 + 攒着一起画」,不是「改一次画一次」 ===
我改了 8 次尺寸,实际触发 onPaint 只有 1 次
收到的尺寸:[(460, 200)] ← 中间 7 个尺寸压根没画
=== 重绘是「最低优先级」:队列空了才轮到它 ===
普通事件 1
普通事件 2
普通事件 3
★ 空闲任务(重绘就排在这一档) ← 排在最后
实践铁律:永远不要自己调
draw()/paint(),要调invalidate让系统安排。setNeedsDisplay/invalidate()/setState/requestAnimationFrame全是同一件事。
也顺带解释了:界面卡顿时最先卡的一定是重绘——因为它优先级最低,输入事件一多就轮不上它。
窗口与事件响应
白话对照:窗口就是第一部分 · 地基 · 第 8 步那张表里的一行;"响应机制的两种"回答的是同一个问题——框架怎么找到你写的那段代码。
**窗口(Window / View)**是独立可复用的界面元素。它响应事件 → 修改内部状态 → 调用 GDI 更新显示。响应机制有两种:
| 机制 | 含义 | 平台示例 |
|---|---|---|
| 事件处理类 | 自定义窗口类继承事件处理类(EventHandler,iOS 叫 Responder) | Windows 做成回调"窗口过程 WindowProc",本质相同 |
| 委托 delegate | 事件处理委托给别人 | Web 中给元素实现 onclick |
关键洞见:有个特殊事件 onPaint/onDraw——操作系统不会帮你保存被遮挡的窗口内容,当遮挡解除时,它发 onPaint 事件让窗口自己重绘。
另外,应用程序(Application)也能响应事件(如进程将被杀、低电量告警);若约定存在主窗口,也可理解为发给主窗口。
展开:窗口的本质、两种机制的真实差异
① 窗口的本质是三件套,不是"一块画面"
| 组成 | 内容 |
|---|---|
| 几何 | 相对父窗口的位置和大小 |
| 状态 | 自己那份数据 |
| 响应表 | 收到什么事件、调哪个函数 |
关键洞见:坐标是相对父窗口的——这就是"独立可复用"四个字的技术含义。一个按钮不需要知道自己在屏幕哪个位置、有没有被别的窗口挡住、外层窗口有没有被拖动,塞进任何父窗口都能工作。
② 继承 vs 委托:原文说"并无本质差异",但差异不小
两种做法回答的是同一个问题——框架怎么找到你写的那段代码:
| 继承(EventHandler / Responder) | 委托(delegate / onclick) | |
|---|---|---|
| 框架靠什么找到你 | 靠"你是我的子类",它知道你一定有那个方法 | 靠"你塞给我的那个函数" |
| 关系 | is-a:我就是个按钮,我自己知道被点了该干嘛 | has-a:按钮不知道,它去问外面的人 |
| 换行为 | 得写个子类 | 运行期换个函数就行 |
| 复用代价 | 类会爆炸:每种行为一个子类 | 无 |
| 语言要求 | 需要语言级继承 | 能传个函数就行 |
| 代表 | MFC CWnd、Cocoa NSView、Android View |
JS onclick、iOS delegate、Qt signal/slot |
两种写法,框架那一行调用完全一样(本机实测,OK 用继承、NO 用委托):
w.被点了() # ← 框架只写得出这一行,它压根不知道你用了哪种
取出:在 (5,2) 点了一下 -> 归「OK」
-> 「OK」:我自己处理(方法写在我这个类里) ← 继承
取出:在 (20,2) 点了一下 -> 归「NO」
-> 「NO」:我不干,交给外面那个函数(+10) ← 委托
Windows 的 WindowProc 是第三条路:函数指针 + switch(消息号),所有事件进同一个函数。
def 我的窗口过程(w, 事的种类, 参数):
if 事的种类 == "点击": ...
elif 事的种类 == "重画": ...
elif 事的种类 == "关闭": ...
为什么这么难看?因为 Win32 API 是 C 写的。 C 里没有类、没有继承、没有方法,只有函数指针。要让 C、Pascal、VB、Delphi 都能调同一套 API,就只能是"给我一个函数指针"。原文那句"为了让窗口类可复用,且与语言无关",说的就是这个。
演化方向很清楚:从继承走向回调。 继承要求你在编译期定死行为,回调可以运行期替换。React 走到了终点——连对象都没有了,只剩函数 + props。
③ onPaint 深挖:立即模式 vs 保留模式(原文完全没提,但这是整个 GUI 最重要的一组概念)
| 立即模式 immediate mode | 保留模式 retained mode | |
|---|---|---|
| 系统记什么 | 什么都不记,需要时喊你重画 | 记住一棵对象树 |
| 你干什么 | 每次把整个画面重新描述一遍 | 改树上的属性,系统决定怎么画 |
| 代表 | onPaint、Canvas、游戏、Dear ImGui | DOM、场景图、UIView 树 |
| 好处 | 状态只有一份(在你的数据里),屏幕永远是数据的函数 | 只改变化的部分,省 |
| 坏处 | 可能整个重画,慢 | 状态有两份,容易不同步 |
原文说"操作系统不帮你保存被遮挡内容",那是表层原因(当年内存贵)。深层原因是:立即模式让 屏幕 = f(数据),从根上消灭了"界面和数据不一致"这类 bug。这个好处大到让它 40 年没被淘汰——也就是白话版那条"数据是真的,画面只是数据的影子"。
React 就是把两者缝在一起:写起来像立即模式(每次 render 都描述整个 UI),跑起来是保留模式(virtual DOM diff 完只改变化的那一点)。
这句话是理解 React 的钥匙——它是 onPaint 的直系血脉。
④ 原文这段今天还成立吗?
| 年代 | 情况 |
|---|---|
| 原文的年代 | 所有窗口直接画在同一块屏幕内存上,被挡住 = 内容真没了 → 必须靠 onPaint 补 |
| 今天 | macOS 的 Quartz Compositor / Windows 的 DWM 给每个窗口一块独立离屏缓冲,遮挡再移开不再需要你重画(本机实验里遮挡确实不触发,改尺寸才触发) |
但模型一点没变:系统只记得"你上次画出来的那张图",不记得"内容应该长什么样"。所以尺寸变了、数据变了、滚动了,还是得你自己重画一遍。
⑤ Application 级事件:为什么它们不发给窗口
因为它们跟"哪个窗口"无关,是整个进程的处境:即将被杀、进入后台、内存告急、电量告急。问"这归谁的地盘"?问不出来——它不属于任何一块矩形。
这在桌面时代是边角料,到移动时代变成生死攸关——移动操作系统会随时杀掉后台进程。Android 的
onPause/onStop/onDestroy、iOS 的didEnterBackground,都是在给你"最后存档"的机会。这是桌面→移动最实质的架构变化之一:你的进程随时会死,所以状态必须能随时序列化、随时恢复。 这条今天直接套在 Agent 上——长跑的 Agent 同样得假设自己随时被掐断,中间状态必须落盘。
原文最后那句"如果约定存在主窗口,把应用程序级事件理解为发给主窗口也可以"——意思是:嫌多个收件人麻烦,就指定某块地盘当老大,这些没坐标的事全丢给它。纯粹是个约定,不改变本质。
事件分派
白话对照:这一节 = 第一部分 · 第二层 · 问题一(焦点) + 第三层 · 补丁一(鼠标捕获) + 补丁二(热键)。
一句话贯穿全节:鼠标事件带坐标,键盘事件不带坐标。整节的复杂度全是这一条逼出来的。
事件从全局队列到窗口,靠事件分派循环(Event Dispatch Loop)。有的平台隐藏为 RunLoop,有的要自己写。Windows 把事件叫消息(Message):
for {
msg, ok := winapi.GetMessage() // 取一个消息
if !ok { break }
winapi.TranslateMessage(msg) // 把按键事件转为字符事件 onChar
winapi.DispatchMessage(msg) // 分派
}
中间那行
TranslateMessage为什么要单独一步? 因为键盘有两个用途:打字要的是"字符 h",命令要的是"哪个键被按了"。方向键、F1 翻不出字符,所以 KeyDown 和 Char 必须是两种事件,不能合并。详见上一节 展开 · ② 一次按键要翻译三层(含本机实测:Up/F1的 char 为空,Ctrl+A的 char 仍是'a'只有 state 不同)。
哪个窗口该响应?这就是事件处理链(EventHandler Chain),不同事件分派规则不同:
| 事件类型 | 分派规则 | 例外 |
|---|---|---|
| 鼠标/触摸 | 发生处所在窗口响应 | 拖放:**鼠标捕获(Mouse Capture)**后即使移出仍发往该窗口 |
| 键盘(KeyDown/KeyUp/onChar) | 焦点窗口先响应,不感兴趣再逐层上升到顶层 | —— |
键盘的两个能力对应不同分派:
| 能力 | 目标 | 说明 |
|---|---|---|
| 输入文本 | 焦点窗口 | 需输入光标 Caret 指示目的窗口 |
| 触发命令 | 不一定在焦点/活跃窗口 | Windows 的热键 HotKey 让非活跃窗口也能响应(如截屏软件) |
关键判断:移动时代键盘不再是交互主体,但输入文本的能力难被替代(语音输入法也补不上),故自然保留;而键盘触发命令的需求在移动端退化为系统级热键(截屏、音量、拍照)。
展开:一次点击的完整旅程 —— 下沉 hit test + 上浮响应链
原文讲了"鼠标按位置、键盘按焦点",但没讲这个"找"的过程分两个方向。
一句话:先从外往里找到"该谁管",再从里往外问"谁真的管"。
① 下沉 hit test :从根窗口开始,逐层问"这个坐标落在你哪个子窗口里",一直钻到最深的那个
② 上浮 响应链 :从命中的窗口开始,自己不处理就往父级递,一直递到顶
关键洞见:DOM 事件的 capture / bubble 两个阶段,就是这两步的直接后代(capture = 下沉,bubble = 上浮)。iOS 的
hitTest:+nextResponder链、Android 的dispatchTouchEvent+onTouchEvent,全是同一套骨架换了名字。
本机实测(tkinter):
=== 1. hit test:给个坐标,系统回答「这块地盘归谁」 ===
坐标(140, 95) 命中 -> Button .!frame.!button
坐标( 20, 20) 命中 -> Frame .!frame
=== 2. 事件处理链:一个事件依次经过 4 个处理者 ===
Tk 给按钮排的处理链 bindtags = ('.!frame.!button', 'Button', '.', 'all')
① 控件自己 (widget)
② 控件类 (class)
③ 顶层窗口 (toplevel)
④ 全局 (all)
=== 3. 任何一环都能掐断后续 ===
① 控件自己:我处理了,break ← ③ 顶层窗口再也没被叫到
| 实验现象 | 在别的框架里叫什么 |
|---|---|
| 给坐标 → 系统答出归谁 | hitTest: / document.elementFromPoint |
| 事件依次流经 4 个处理者 | 响应链 / 冒泡 |
return "break" 掐断后续 |
stopPropagation() / return true / 不调 super |
为什么要设计成两个方向? 下沉是为了精确(找到最具体的那个控件);上浮是为了兜底(具体控件不管,让更外层的容器有机会统一处理,比如"点面板任意处都关闭弹窗")。少任何一个方向,要么写不出通用容器,要么每个控件都得重复处理。
窗口内容绘制
白话对照:见第一部分 · 第三层 · 补丁三——GDI 干的是坐标换算 + 裁剪,让每个窗口敢装作自己独占屏幕。
收到 onPaint/onDraw 时用 GDI 子系统绘制:
| 维度 | 现状 |
|---|---|
| 2D | GDI 子系统支持较好,但各平台仍有较大差异 |
| 3D | OpenGL 跨平台占主流,Vulkan(NextGL)潜力不容小觑 |
| 跨平台难度 | GDI 概念大同小异,比整个应用框架更易抽象出跨平台接口 |
关键洞见:GDI 是操作系统性能要求最高、最耗电的子系统,优化往往靠硬件加速完成,真正关键角色在硬件厂商——故由硬件厂商推跨平台 GDI 硬件加速方案可能成趋势。
补两句原文没展开的:
- 为什么 2D 各平台差异大、3D 反而统一:2D 每家自己发明(GDI / Quartz / Cairo);3D 因为要直接跟显卡打交道,早早统一到 OpenGL / Vulkan 这种跨厂商标准上。
- 为什么最耗电:一块 2K 屏差不多 400 万像素,每秒重画 60 次——每秒两亿多次像素运算。这个量级只能交给 GPU。原文的判断今天基本应验:Skia(Chrome、Android、Flutter 都在用)就是架在各家 GPU API 之上的那一层。
通用控件
白话对照:见第一部分 · 第三层 · 补丁四——一个能用的输入框是几千行,不能每个 App 重写一遍。
窗口系统本身已完备,但为简化开发,操作系统还提供控件(Control):静态文本 Label、按钮 Button、单选框 RadioBox、复选框 CheckBox、输入框 Input、进度条 ProgressBar 等。
注意:不同操作系统的基础控件大同小异,但处理细节差异常成为跨平台开发的坑。具体是这类:Windows 的 CheckBox 有三态(选中/未选/半选),Mac 的行为不一样;长按输入框,Android 弹菜单、iOS 弹放大镜。
跨平台方案的分歧点其实只有一个:你选择在哪一层统一。
| 方案 | 在哪一层统一 | 代价 |
|---|---|---|
| Qt / Flutter | 自带 GDI,所有控件自己画 | 长得不像原生 |
| React Native | 统一在控件层,映射到各平台原生控件 | 控件细节差异成坑(原文点名的那个坑) |
| Electron / Web | 统一在浏览器这一层 | 扛一整个浏览器 |
| 小程序 | 平台自己定框架 | 每家一套,全得适配 |
第三部分 · 贯穿全篇的两条,与收尾
贯穿一:老大换人了(控制反转)
命令行程序 图形界面程序
main() { main() {
读输入 ← 你是主人 建窗口
算 RunLoop() ← 这行永不返回,主人是操作系统
写输出 }
} ← 程序会结束 onClick() { ... } ← 你的代码碎成一堆回调
onPaint() { ... } 被那个循环喊起来干活
一句话:命令行是你调用操作系统,图形界面是操作系统调用你。
这条解开第 20 讲结尾最费解的那句——“框架侵入性强、彼此难融合,所以语音交互自成体系”:
一个进程只能有一个主循环。 图形界面框架要占着这个位置,语音框架也要占着,谁都不肯当被调用的那一方 → 融不了。不是技术不够,是主人的位置只有一个。
(现代的解法是:谁都别当主人,大家一起往同一个 event loop 上挂回调——这正是 JS 和 asyncio 走的路。)
贯穿二:循环只有一个,谁占住它界面就死
这个死循环是单线程的。 你见过的所有"UI 怪脾气",全是这一条推出来的:
| 你见过的现象 | 其实是同一件事 |
|---|---|
| 点了按钮界面卡住转圈 | 你在回调里没返回,循环取不了下一件事 |
| Android 的 ANR「无响应」弹框 | 系统在监控循环是不是被占死了 |
| iOS/Android/Web 都规定「UI 只能在主线程改」 | 界面状态归这个循环独占,别的线程插手会撕裂 |
| JS 是单线程、非要用 async/await | 浏览器的 event loop 就是这个循环的直系后代 |
Python asyncio 里绝不能写 time.sleep |
同一个道理,loop 就是那个循环 |
| Electron 应用一卡卡整个窗口 | renderer 进程的循环被 JS 占死了 |
本机实测(tkinter,在回调里 sleep(3)):
[ 0.15s | 线程 8514380160] mainloop() 之前:这行是我自己的代码在跑
[ 0.17s | 线程 8514380160] onPaint 第 1 次:宽 420px,我自己把内容重画了一遍
[ 0.45s | 线程 8514380160] after 回调也是排进同一个队列的
[ 0.75s | 线程 8514380160] 按钮 onClick:我在主循环里,循环正等我返回
[ 1.07s | 线程 8514380160] onPaint 第 2 次:宽 560px,我自己把内容重画了一遍
[ 1.56s | 线程 8514380160] !! sleep(3) 开始,这 3 秒主循环取不了事件 —— 窗口会冻住
[ 4.57s | 线程 8514380160] !! sleep 结束,积压的事件现在才开始处理
[ 4.59s | 线程 8514380160] 我本该 1.5s 被叫到,但排在阻塞后面 ← 迟到整整 3 秒
[ 5.37s | 线程 8514380160] mainloop() 返回了 —— 只有窗口关闭,程序才继续往下走
| 现象 | 证明了什么 |
|---|---|
| 所有回调的线程 id 完全相同 | 循环只有一个 |
| 本该 1.5s 的回调被压到 4.59s | 阻塞回调 = 冻住整个界面,一点不玄学 |
mainloop() 后面那行最后才打印 |
控制反转:主人是循环,不是你 |
这一讲唯一的实践结论:永远不要在回调里干慢活。慢活扔给别的线程/进程,干完往队列里塞一个事件回来。这条规则从 1985 年的 Windows 到今天的 React,一个字没变——也是第 11 讲协程/async 存在的理由。
总结
图形界面程序由用户交互所驱动,统一大逻辑是:硬件中断产生事件 → 入进程全局事件队列 → 事件分派循环取出 → 经事件处理链送达正确窗口(鼠标按位置、键盘按焦点)→ 窗口在 onPaint 时调 GDI 绘制。控件进一步简化开发。我们身处操作系统约定的编程框架中,这是桌面编程的特点。
而这一整套的根,是一句话:顺序不再由你定。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| 事件队列 | 每进程一个的收件箱,承接操作系统投递的事件 |
| onPaint/onDraw | 系统不保存被遮挡内容,让窗口自行重绘 |
| 事件分派循环 | GetMessage→Translate→Dispatch 的取-分派过程 |
| 事件处理链 | 鼠标按位置下沉、键盘按焦点上浮的响应链 |
| 焦点 | 一个变量,因为键盘事件没有坐标 |
| 鼠标捕获/热键 | 拖放与非活跃窗口响应命令的两种例外机制 |
| GDI | 坐标换算 + 裁剪;最耗性能/最耗电、靠硬件加速 |
一句话速记
硬件中断造事件入队,分派循环取出沿事件处理链送达——鼠标按位置、键盘按焦点,窗口在 onPaint 时调 GDI 自重绘。
白话版:顺序不由你定了,所以只能守着一个收件箱,来一件查一件表,谁的地盘归谁。
几条值得记住的判断
- 一整套结构的根是"顺序不由你定",其余全是被逼出来的补丁。
- 系统不替你保存被遮挡内容,必须靠 onPaint 重绘;数据是真的,画面只是数据的影子。
- 焦点存在的唯一理由是键盘事件没有坐标。
- 鼠标按位置、键盘按焦点分派;拖放靠鼠标捕获、命令靠热键破例。
- 循环只有一个——永远不要在回调里干慢活。
- 交互子系统差异性最大,跨平台的分歧点只有一个:你选择在哪一层统一。
思考题
在操作系统所有子系统中,交互相关子系统差异性最大,跨平台桌面应用要面对 PC(Win/Mac/Linux)、浏览器(Chrome/Safari/Firefox)、手机平板手表(Android/iOS)、小程序(微信/支付宝/快应用)等众多平台。回到你的项目:怎么安排这些平台的优先级、规划迭代计划、选择跨平台方案?这些问题在业务架构之外,却极其考验架构师的决策能力。
附:可跑的脚本
四个脚本在同目录 代码/ 下,从简到繁,改改末尾的收件箱列表就能自己试:
| 脚本 | 演示什么 | 对应本笔记 |
|---|---|---|
step1.py |
一个普通脚本,顺序由你定 | 第 1 步 |
step2.py |
收件箱 + 循环 + 查表 + 窗口,顺序由用户定 | 第 2~8 步 |
step3.py |
焦点(键盘没坐标)+ onPaint(系统主动喊你重画) | 第二层 |
step4.py |
鼠标捕获 + 热键 + GDI 裁剪 + 控件的动机 | 第三层四个补丁 |
最值得手动改一次的两个:
step3.py—— 把中间某个点击去掉,看键盘的字进了谁的框(焦点这事儿手动试一次就彻底记住)step4.py—— 把捕获者那个分支注释掉,看拖出窗口时事件跑去了谁那儿
2026-08-29 重写说明:原版笔记是按原文结构做的术语摘要(对照表 + 关键词加粗)。事后发现读不懂——因为术语是结论,脱离了逼出它的那个问题就没法理解。
现在改成两部分:通俗版从一个加法器把整套框架长出来(每个概念只在上一步不好使时才出现),术语版保留原文骨架并逐节指回通俗版。
教训记一笔:没写过某类程序时,任何对比表都是空的。要先造出那个问题,名词才有地方落。


