Skip to content

Stage 3 — 让窗口学会画画:Cairo 绘制原语


这一阶段的目标

前两个阶段我们的窗口一直是一块白板——能接收事件,但什么也画不了。这个阶段的目标是把 Cairo 绘图库接进来,然后用 RAII 封装出一套干净的 Painter 接口,让框架具备了"画东西"的能力。做完这一步,我们就拥有了 GUI 框架三大支柱中的第二根——第一根是事件循环,第三根是控件树(下个阶段的事)。

为什么不直接用 XCB 原生绘图

XCB 本身确实提供了一些绘图函数——矩形、线条、多边形,都是纯色填充,没有抗锯齿。这些能让你画个彩色方格纸,但如果你想画一个带圆角的按钮、渐变色的背景、或者稍微像样一点的文字,XCB 原生绘图就力不从心了。这就是为什么所有基于 X11 的 GUI 框架(包括 GTK)都在底层接了一层更高级的绘图库。Cairo 是 GTK 的选择,也是我们的选择——它提供了矢量图形路径、贝塞尔曲线、文字渲染(通过 FreeType 后端)、Alpha 混合和渐变这些现代 2D 绘图必备的能力。

需要想清楚的问题

第一个问题:Cairo 的绘图上下文跟 Win32 的 HDC 在概念上是对标的——它都是一个"画笔状态机",保存着当前的颜色、线宽、变换矩阵、裁剪区域等状态。那封装的时候,你的 Painter 类应该"拥有"这个绘图上下文,还是仅仅"持有"一个引用?想一想两种方案的生命周期管理有什么区别,以及哪种方案更适合我们后续的使用场景(多个控件共享同一个绘图上下文依次绘制)。

第二个问题:我们需要定义一些公共的几何类型——位置、尺寸、矩形、颜色。这些类型的设计看起来很简单,但有一个决策需要做:坐标用整数还是浮点数?Cairo 的坐标系统是浮点的,但窗口尺寸和像素位置在 XCB 层面是整数的。想一想哪些地方需要浮点精度(比如渐变、变换、文字排版),哪些地方用整数就够了,然后决定你的类型定义怎么统一这个问题。

第三个问题:状态保存和恢复。Cairo 提供了一个状态栈——cairo_save 把当前状态压栈,cairo_restore 从栈顶弹出恢复。这个机制在 Win32 里对应的是 SelectObject 的"保存旧对象 → 使用新对象 → 恢复旧对象"三步模式(交叉引用 Phase 1 的 GDI 章节)。你的 Painter 接口需要暴露这个能力吗?想一想如果父控件和子控件共享同一个绘图上下文依次绘制,父控件设置的裁剪区域和变换状态会不会影响子控件的绘制?怎么避免这种状态污染?

设计方向提示

Painter 类的核心思路是"轻量封装,不拥有资源"。它的职责是把 Cairo 的 C API 包装成类型安全的 C++ 接口,同时隐藏一些容易用错的细节(比如路径的 move_to / line_to / close_path 序列)。对于基本图形——矩形、圆、线条——你可以在内部处理 Cairo 的路径构建逻辑,让调用方只需要指定几何参数和填充/描边模式。

颜色类型值得单独想一下。Cairo 的颜色通道是 0.0 到 1.0 的浮点值,而我们在应用层面更习惯用 0-255 的整数值。你的接口可以选择只暴露浮点形式(跟 Cairo 对齐),也可以提供两种构造方式。考虑到教学目的,跟 Cairo 保持一致可能更清晰。

文字绘制是 Cairo 相比 XCB 原生绘图的一个重大升级。你需要提供一个绘制文字的接口,至少要支持指定位置、内容、字体族和字号。Cairo 的文字绘制涉及字体选择、字形布局和光栅化三个步骤,但大部分细节被 Cairo 抽象掉了——你只需要选字体、设字号、调用绘制函数。

验证方法

写一个测试程序:在 XCB_EXPOSE 事件的处理回调中,用 Painter 画一个蓝色填充矩形、一个红色描边圆、一条绿色粗线、以及一段文字"Hello MiniUI"。正确情况下窗口应该显示这些图形和文字,调整窗口大小后图形能正确重绘。

如果画出来的东西位置不对,检查坐标系——XCB/Cairo 的坐标原点在窗口左上角,y 轴向下。如果你在 Win32 里习惯了 GetClientRect 确定绘制区域,在 XCB 里对应的信息可以通过 XCB_CONFIGURE_NOTIFY 事件获取。

如果文字没有显示或者显示为方块,检查字体族名称是否正确——在 Linux 上 "sans""sans-serif" 通常是可用的,但如果你指定了一个不存在的字体名,Cairo 会静默回退,不一定会报错。

可能踩的坑

Cairo 的绘图表面(surface)和绘图上下文(context)是两个独立的对象。Surface 代表绘制目标(可以是窗口、内存位图、PNG 文件等),Context 代表绘图状态。创建 Context 的时候需要传入 Surface,但 Surface 的生命周期必须覆盖 Context 的整个使用期。如果 Surface 先被销毁了,Context 的绘制操作就会访问无效内存。这个生命周期依赖关系在封装的时候要特别注意。

⚠️ 另一个容易翻车的地方是 Cairo 的错误处理。Cairo 的设计哲学是"静默失败"——大多数错误不会被直接报告,而是记录在一个内部状态里,你需要主动调用 cairo_status 来检查。如果你的绘制结果跟预期不符但没有报错,查一下 cairo_status 的返回值。

还有一个关于 XCB Surface 创建的细节:你需要在收到 XCB_EXPOSE 事件的时候才创建或更新 Cairo 的 XCB surface,而不是在窗口刚创建的时候就建好。因为窗口映射完成之前,X Server 可能还没有分配好窗口的绘制缓冲区。

如果卡住了

如果你不确定 Cairo 的绘图 API 怎么用,推荐直接看 Cairo 的官方 Tutorial——它的 "Cairo绘图指南" 从画线开始一步步教,非常清晰。重点关注路径(Path)的概念:Cairo 的所有图形都是通过构建路径然后填充或描边来绘制的。

如果你在"怎么把 Cairo 跟 XCB 窗口关联起来"这个点上卡住了,搜索 cairo_xcb_surface_create 这个函数——它是 Cairo 针对 XCB 后端的 surface 创建函数,接收 XCB 连接和窗口 ID 作为参数。想一想这个 surface 应该在哪里创建、在哪里销毁——它跟窗口的生命周期应该是什么关系。

关于几何类型的定义,如果你还在犹豫用整数还是浮点,一个务实的做法是:位置和尺寸用整数(跟窗口系统的坐标系对齐),颜色用浮点(跟 Cairo 的 API 对齐)。后面如果发现精度不够再调整,不要在设计阶段就过度纠结。


下一步

画笔有了,下一阶段是整个框架最核心的架构决策——设计 Widget 基类,建立控件树,让我们的程序从"一个窗口画一堆东西"升级为"一棵控件树,每个控件自己负责自己的绘制和事件处理"。

基于 VitePress 构建