Skip to content

Stage 1 — 在 X11 世界里打开一扇窗


任务速览

这是一个关于系统级 GUI 编程的实践任务——我们要在 Linux 上,用 XCB 和 Cairo 从零手搓一个教学级的 GUI 框架,代号 MiniUI。核心训练的不是"会用某个框架",而是让你真正理解所有 GUI 框架在底层到底在干什么。整个任务拆成 8 个阶段,如果每天能投入 2-3 小时专注编码,大概需要一到两周完成,前提是你对 C++ 基础语法、RAII 资源管理、以及我们在 Phase 1 里积累的 Win32 编程经验有基本的认知——如果你还没有碰过 Win32,别慌,我们会在关键路口用 Win32 做横向对比,帮你建立参照系。

我们的路径大概是这样的:先在 X11 上打开第一个窗口并跑通事件循环,建立对 X Window System 客户端-服务器架构的整体认知;然后把 Cairo 绘图库接进来,让窗口能画出有模有样的图形和文字: 接下来是最核心的部分——我们会一步步搭建控件树、布局引擎、信号系统,每一层都是在解决一个具体的 GUI 设计问题;最后两阶段处理渲染性能优化,并用整个框架拼出一个迷你的文本编辑器作为收官。每个阶段我会告诉你需要想清楚哪些问题、往哪个方向走,具体怎么实现完全是你的事。


前言

说实话,如果你只是想快速搞出一个带界面的桌面应用,那直接上 Qt 或者 GTK 写几行代码就能搞定,完全没必要折腾到 XCB 这一层的。但如果你真的想搞懂"一个 GUI 框架到底在底层干了什么"——为什么窗口要注册窗口类、为什么消息循环不能卡住、为什么控件树是几乎所有框架的共同选择——那我们就得最薄的一层开始拆。我们在 Phase 1 里已经用 Win32 写了不少窗口程序,也踩过消息循环、控件管理、GDI 绘图这些坑了。那些经验不会白费,因为 Win32 和 X11 虽然是两套完全不同的系统,但在 GUI 核心概念上惊人地同构:都需要连接窗口系统、创建窗口、跑事件循环、处理绘制请求。搞懂了一边的原理,另一边就是 API 映射的问题。

我们这次选择的工具链是 XCB 而不是更常见的 Xlib。Xlib 是对 X Protocol 的高层封装,帮你在幕后做了很多隐式缓冲和同步的事情,这些东西在入门的时候确实方便,但在你试图理解"到底发生了什么"的时候反而是障眼物。XCB 更薄、更接近 X Protocol 的原始语义,每个请求都是显式的,每个回复都需要你明确等待——用 XCB 写代码会让你对 X11 的客户端-服务器架构有更直观的感受。绘图方面我们选择 Cairo,它是 GTK 的底层渲染库,提供了矢量图形、文字渲染、Alpha 混合这些 XCB 原生绘图做不到的事情。

⚠️ 整个系列的环境前提是 WSL2 + XLaunch(或 WSLg)。如果你用的是原生 Linux 桌面,那 X Server 已经在跑了,不需要额外配置;如果你在 WSL2 里,请确保 DISPLAY 环境变量正确设置了,后面环境搭建阶段我们会详细说。


环境与约束说明

先说清楚我们要在什么环境下折腾。我们的目标平台是 Linux,具体来说是 WSL2 上的 Linux 发行版(Ubuntu 22.04 或更新版本)。你需要安装 XCB 和 Cairo 的开发头文件和库文件,还需要 CMake 和一个支持 C++20 的编译器(GCC 12+ 或 Clang 15+)。显示服务这块,WSLg 在 Windows 11 上开箱即用,如果你还在用 Windows 10 或者 WSLg 没配置好,就需要装一个 XLaunch 作为 X Server。

环境安装没什么花哨的,一行 apt install 就能搞定所有依赖。安装完之后,你需要确认 X11 连通性——最简单的方式就是跑一下 xeyes,如果一双眼睛跟着你的鼠标转,说明 X Client 到 X Server 的通路已经打通了。

⚠️ 有一个特别容易踩的坑:MSYS2 用户的 MINGW64 / UCRT64 / CLANG64 子系统不能混用,跟我们现在做的事情没关系,但如果你同时也在折腾 Windows 端的东西,别搞混了。

还有一个关于 DISPLAY 环境变量的事情:WSLg 用户只需要设 export DISPLAY=:0,而 XLaunch 用户需要把 DISPLAY 指向 Windows 宿主机的 IP 地址加上 :0.0。如果你发现程序跑起来了但窗口没出来,先查这个变量。


完整路线图

我们把整个 MiniUI 框架的构建过程拆成了 8 个阶段,这里一次性把所有阶段的目标串起来,让你心里有数,后面就按顺序走,不再剧透。

第一个阶段(就是现在)我们要在 X11 上打开第一个窗口,理解 XCB 的基本工作流程,建立 Win32 和 X11 的横向对比。第二个阶段聚焦事件循环——这是所有 GUI 程序的心跳,我们会把裸的 XCB 事件处理封装成一个 Application 类。第三个阶段把 Cairo 绘图库接进来,用 RAII 封装出一套干净的绘图接口。第四个阶段是最关键的架构决策:我们会设计 Widget 基类,建立控件树,实现命中测试和事件传播。第五个阶段在控件树之上搭建布局引擎,实现 Measure → Arrange → Render 的三遍渲染管线。第六个阶段实现信号系统,用观察者模式解耦控件间通信,并引入 Button 等交互控件。第七个阶段做渲染性能优化,引入双缓冲和脏区管理。最后第八个阶段是一个收官项目——用我们手搓的整个框架拼出一个迷你文本编辑器,同时做一轮框架 vs Qt/GTK 的差距分析。

每一层都只解决一个问题,每一层都建立在前一层的基础上。整个过程就像搭积木,但这里的每一块积木你都得自己削。


这一阶段的目标:第一个 XCB 窗口

这个阶段的目标很简单:用 XCB 打开一个空白窗口,让它出现在屏幕上,然后等一个按键事件后退出。听起来 trivial,但这一步会强迫你面对 X11 客户端-服务器架构的所有基础问题——怎么连接 X Server、怎么获取屏幕信息、怎么创建窗口、怎么让窗口可见、怎么从事件队列里取事件。

为什么从 XCB 开始而不是直接写框架

因为 GUI 框架本质上是对底层窗口系统的封装,如果你不理解底层在干什么,封装就变成了空中楼阁。Win32 程序员对 RegisterClassEx → CreateWindowEx → ShowWindow → GetMessage → DispatchMessage 这条路径已经烂熟于心了,XCB 做的事情完全一样,只是 API 形态不同——而且更裸露,因为 XCB 几乎不做任何隐式处理,每一步都要你显式调用。

需要想清楚的问题

X11 是一个客户端-服务器架构,你的程序是 Client,屏幕上的图形输出和用户输入由 Server 管理。这意味着你打开一个窗口的过程,本质上是向 Server 发送一系列请求——连接请求、创建窗口请求、映射窗口请求。想一想,既然是网络式架构,那请求发出之后,你能确定 Server 一定处理好了吗?XCB 的很多函数只是把请求塞进发送缓冲区,并不会等 Server 确认。那窗口真正"出现"的时机是什么?有什么函数可以强制刷新发送缓冲区?

创建窗口的时候,你需要告诉 Server 很多信息:窗口的父窗口是谁、位置和尺寸、窗口类、视觉类型、以及事件掩码(event mask)。事件掩码是一个位掩码,用来告诉 Server "我对哪些事件感兴趣"。这里有个设计上的问题值得思考:为什么 X11 要求你事先声明关心哪些事件,而不是默认把所有事件都发给你?如果所有事件都发,会造成什么问题?

获取屏幕信息这一步需要遍历一个迭代器——XCB 的 setup 数据结构里存着所有可用的屏幕,你需要根据连接时返回的屏幕编号定位到正确的那个。这个接口设计看起来比 Win32 的 GetDC 之类的函数繁琐很多,但想一想,X11 的设计是支持多屏幕的,而且是在网络层面支持——Server 上可能有多块显卡多个显示器,Client 需要能指定在哪块屏幕上创建窗口。

设计方向提示

一个最简的 XCB 窗口程序大概有四个步骤:建立连接、获取屏幕、创建并映射窗口、进入事件循环。连接管理这块,想一想谁来负责释放连接——如果你用 C 风格的裸调用,那函数末尾要手动断开连接;如果你用 RAII,连接的生命周期就和对象绑定在一起了。我们后面会封装一个 Application 类来管理连接,但现在先用裸的 XCB 调用把流程跑通。

事件循环的结构是所有 GUI 程序的共同骨架:while(running) { event = wait(); dispatch(event); }。XCB 提供了两种取事件的方式,一种是阻塞等待,一种是立即返回——想一想这两种方式分别适合什么场景?在我们的第一个程序里,哪种更合适?

验证方法

编译并运行你的程序。正确情况下你应该看到一个白色的空白窗口出现在屏幕上,终端没有任何输出(或者只有你主动打印的调试信息)。按下任意键后窗口关闭,程序退出。

如果你编译时找不到 xcb/xcb.h 头文件,说明 XCB 开发库没装好,回去检查 apt install 那一步。如果程序运行了但窗口没出现,先检查 DISPLAY 环境变量,再检查 xcb_connect 的返回值是否为 nullptr——连接失败的时候 XCB 不会主动崩溃,你需要自己检查。

如果窗口一闪而过,很可能是事件循环有问题——你可能没有正确处理 XCB_KEY_PRESS 事件,或者在某个分支里提前退出了循环。

可能踩的坑

XCB 的错误处理跟 Win32 很不一样。Win32 的 API 失败了通常返回 FALSE 或者 NULL,你可以用 GetLastError 查原因。XCB 的很多请求是异步的,错误不会在调用时立即返回,而是以一个错误事件的形式出现在事件队列里。这意味着你的事件循环里除了正常事件,还要能处理错误事件。错误事件的 response_type 的最高位会被置为 0——你可以用 event->response_type & ~0x80 来区分正常事件和错误。

还有一个细节:xcb_flush 这个调用很多人会忘。XCB 为了性能,不会在每个请求后自动刷新发送缓冲区。如果你发现窗口没出来,或者在等一个永远不会来的事件,可能就是忘了 flush。

如果卡住了

如果你在"怎么创建窗口"这一步就卡住了,推荐去读 XCB 的官方教程,从 XCB documentation 的 "Tutorial" 部分开始。XCB 的 API 命名非常规律:xcb_*_window 形式的函数通常跟窗口操作相关,xcb_*_event 跟事件相关。

如果你能创建窗口但事件循环不工作,试一下在事件循环里打印每个事件的 response_type,看看你收到了什么类型的事件,跟你注册的事件掩码是否匹配。

一个有帮助的思考角度:把你在 Win32 里写的 WinMain → RegisterClassEx → CreateWindowEx → ShowWindow → 消息循环 这条路径的每一步列出来,然后问自己"在 XCB 里对应的是什么"。你会发现大部分步骤都有对应的 XCB 函数,只是名字不同、参数不同,但概念完全同构。唯一缺失的是"注册窗口类"这一步——X11 没有窗口类的概念,创建窗口时直接指定所有属性。


下一步

窗口能打开、能接收事件了,下一阶段我们要把这块裸的样板代码封装成一个 Application 类,把事件循环抽象成框架的一部分,这样后面的阶段就不用再跟 XCB 的底层 API 直接打交道了。

基于 VitePress 构建