Skip to content

Stage 2 — 事件循环:GUI 的心跳


这一阶段的目标

上一个阶段我们用裸的 XCB API 打开了一个窗口,在 while 循环里手动取事件、手动 switch 分发。这段代码能跑,但它有个根本问题:如果我们后续的每个阶段都这么写,代码会很快变成一团面条。这个阶段的目标是把事件循环抽象成一个 Application 类——它负责管理 XCB 连接、封装主循环、提供事件回调注册机制。做完这一步之后,"写一个 GUI 程序"就变成了"创建 Application → 注册回调 → 调用 run",底层的 XCB 细节被藏起来了。

为什么这一步要单独拿出来

因为事件循环是所有 GUI 程序的心跳。不管你用的是 Win32 的 GetMessage + DispatchMessage,还是 GTK 的 gtk_main(),还是 Qt 的 app.exec(),核心逻辑都是同一个东西:while(running) { event = wait(); dispatch(event); }。把这个抽象搞清楚了,后面所有框架的事件模型你都能一眼看穿。

需要想清楚的问题

先想一个问题:上一阶段的代码里,事件分发是硬编码的 switch-case。如果我们要让用户(也就是使用框架的程序员)自定义事件处理逻辑,而不是把所有处理都塞进一个巨大的 switch 里,应该怎么设计?有几种常见的思路值得比较:一种是继承加虚函数的方式——用户继承 Application 并重写某个处理方法;另一种是回调注册的方式——用户把函数对象注册到特定的事件类型上。这两种方式各有什么优缺点?如果同一个事件类型有多个处理者,哪种方式更自然?

再想一个问题:连接管理。XCB 的 xcb_connection_t* 是一个 C 风格的资源句柄,需要手动连接、手动断开。如果 Application 类负责管理它,那连接的生命周期应该怎么跟对象绑定?构造函数里连接、析构函数里断开——这个 RAII 模式我们在 Win32 里封装 GDI 对象的时候已经见过了(交叉引用 Phase 1 的资源管理章节)。析构的时候要考虑一个边界情况:如果用户忘记显式退出事件循环,析构函数被调用时事件循环可能还在跑,这要怎么处理?

还有一个关于"退出"的问题。当用户调用退出函数时,事件循环应该停下来。但如果是阻塞等待事件的方式取事件,那循环可能卡在等待里出不来。想一想,你需要一个什么机制来让阻塞的等待能够被打断?能不能用一个布尔标志位?如果标志位的读取和设置可能在不同线程里发生,需要考虑什么?

设计方向提示

Application 类的核心职责只有两个:管理 XCB 连接,驱动事件循环。连接管理是纯 RAII——构造即连接,析构即断开。事件循环这块,关键是把"取事件"和"分发事件"这两个步骤封装起来,同时给用户留出注册自定义处理器的入口。

关于事件回调的数据结构,你需要一个能按事件类型索引的容器。每种事件类型可能对应多个回调,因为用户可能对同一个事件注册多个处理器。想想 std::unordered_map 加上 std::vector 的组合能不能满足需求?这里不需要追求性能极致,教学级框架的可读性比性能更重要。

框架的使用接口应该尽量简洁。想一想,一个最简的用法大概是:创建 Application 实例,注册几个事件回调,调用 run() 让程序跑起来。run() 应该返回一个 int 作为退出码,跟 main 函数的惯例保持一致。

验证方法

写一个简单的测试程序:创建 Application,注册鼠标按下事件的回调(打印鼠标坐标),注册键盘按下事件的回调(打印按键码,按 Escape 退出),然后调用 run()。正确情况下,点击窗口会在终端打印坐标,按键会打印按键码,按 Escape 后程序正常退出,终端没有任何崩溃或内存错误。

如果回调没有被触发,检查事件掩码是否正确设置——XCB 需要在创建窗口时声明你关心哪些事件,如果你在 Application 里重新封装了窗口创建,别忘了把事件掩码传进去。

如果程序退出时崩溃或者卡死,检查 xcb_disconnect 是否被正确调用了,以及是否还有未释放的 XCB 事件对象——每个 xcb_wait_for_event 返回的事件都需要手动 free

可能踩的坑

XCB 事件的 response_type 字段有一个容易忽略的细节:最高位可能被用作标志位,所以取事件类型的时候一定要做掩码操作 response_type & ~0x80,否则某些事件类型你永远匹配不上。这个坑在 Stage 1 里提过,但在这里封装的时候尤其重要,因为你的分发逻辑如果没做掩码处理,所有基于事件类型的回调注册都会失效。

还有一个关于 xcb_wait_for_event 返回值的问题:它可能返回 nullptr,这通常意味着连接出错了(比如 X Server 崩了)。如果你的循环里没做 nullptr 检查,直接解引用就会崩。

⚠️ XCB 事件对象是从堆上分配的,每次 xcb_wait_for_event 返回的指针需要你来 free。如果你的回调里做了 return 提前退出循环,别忘了在退出前 free 最后那个事件。用 RAII 封装一下是个好习惯。

如果卡住了

如果你不确定回调注册的接口应该长什么样,去翻一翻 std::function 的文档——std::function<void(某个事件指针类型)> 是一个很自然的回调类型。至于怎么把回调跟事件类型关联起来,想一想 std::unordered_map 以事件类型为 key、以回调列表为 value 的用法。

如果你在"怎么让事件循环退出"这个点上纠结,试一个最简单的方案:用一个布尔变量控制循环是否继续。先跑通再说,后面遇到多线程问题的时候再升级为原子变量或者事件通知。

另一个有帮助的思考方式:把你写的 Application 类想象成 Win32 里的 GetMessage → TranslateMessage → DispatchMessage 三件套的封装。你的 run() 方法对应了整个消息循环,你的回调注册对应了 WndProc 里的 switch-case 分支——只不过分支的内容不再是硬编码的,而是由用户动态注入的。


下一步

事件循环封装好了,下一阶段我们要让窗口不只是白板一块——把 Cairo 绘图库接进来,画出真正的图形和文字。

基于 VitePress 构建