加载中...

本篇要回答的问题:抛开各操作系统接口的巨大差异,图形界面程序有一套怎样统一的大逻辑?事件如何从产生、入队、分派,最终到达正确的窗口并完成绘制?

怎么读这份笔记(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 重写说明:原版笔记是按原文结构做的术语摘要(对照表 + 关键词加粗)。事后发现读不懂——因为术语是结论,脱离了逼出它的那个问题就没法理解。
现在改成两部分:通俗版从一个加法器把整套框架长出来(每个概念只在上一步不好使时才出现),术语版保留原文骨架并逐节指回通俗版
教训记一笔:没写过某类程序时,任何对比表都是空的。要先造出那个问题,名词才有地方落。

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关