如果你是一个做 Linux 桌面客户端的开发者,最近两年应该能明显感觉到风向变了:新的 Linux 发行版默认会话大量转向 Wayland,X11 的话题已经从“要不要迁”变成“什么时候迁完”。我最初是从一块嵌入式触摸屏项目被拖进 Wayland 客户端开发这个坑的,当时发现网上的资料零散到令人崩溃——要么是 weston 的示例代码直接糊脸,要么是 GTK/Qt 封装之后完全看不清底层机制。这篇“持续更新的 Wayland 客户端开发指南(附源码)”就是为了解决这个问题出现的:它不追求当一个面面俱到的协议百科全书,而是把我自己从零写 Wayland 客户端时的关键路径、协议细节和源码组织方式整理出来,并且随着 Wayland 生态的演进持续更新。如果你正打算做原生 Wayland 客户端、或者想把旧的 X11 客户端迁过来,这份指南和配套源码能帮你少走不少弯路。
1. 为什么这个时间点值得把 Wayland 客户端开发这件事重新认真对待
1.1 从 X11 迁移过来的人,第一堂课是“忘掉 X11 思维”
我和很多人一样,最早写 GUI 客户端是在 X11 环境下:X server 管所有窗口的堆叠、输入、绘制,客户端只要连上 X server,用 Xlib 或者 XCB 往窗口里画东西就行。那时候开发者的脑回路是“我开一个窗口,绘制区域是我的,剩下的交给服务端”。但 Wayland 不是这么玩的,它把“显示服务器”拆成了 compositor(合成器)和 client 两个角色,而且 compositor 不再是服务端下发绘制指令,而是由客户端自己把 buffer 提交给 compositor,由 compositor 决定怎么合成、什么时候上屏。
这一下子就把很多习惯性操作颠覆了:窗口不是“开出来的”,而是“协商出来的”;绘制不是“画在窗口上”,而是“绘制到 buffer 再提交”;合成器之间的互操作性靠协议,不再是全局坐标加子窗口那一套。如果你带着 X11 的惯性直接写 Wayland 客户端,很容易在第一轮就卡在“为什么我的窗口没有内容”这种问题上。
我这份指南的源码仓库里专门给了一个x11-to-wayland的迁移对照文件,把两种模型下最常见的操作逐一拆开对比。最开始我本来只想给自己留个备忘,后来发现很多同事和网友都需要这个东西——尤其是“Expose 事件”和“frame callback”这两套逻辑的差异,几乎每个人都会栽一次。
1.2 选哪个 compositor 做开发基准:不能只看 weston
Wayland 客户端开发的尴尬之处在于,协议是统一的,但客户端要面对的 compositor 是多样的。GNOME 的 Mutter、KDE 的 KWin、wlroots 系的各种合成器(sway、river、wayfire),还有我们嵌入式领域常用的 Weston,它们对协议的支持程度和细节行为并不完全一致。我刚开始写指南时只用 Weston 测试,结果拿到 KWin 上一跑,发现有些窗口状态的处理存在偏差。
所以这份指南从第一版开始就坚持“至少在三类 compositor 上验证”:以 Weston 为最小基准,围着 wlroots 的生态做兼容验证,再在 Mutter 和 KWin 上做人工验收。这样带来的直接好处是,源码里不会再出现“在 Weston 上能跑、但在别人的桌面上黑屏”这种问题。
给新手的建议也很简单:本地装一个weston作为快速迭代环境,再留一个跑主流桌面发行版的虚拟机做交叉验证。不要只盯着一个 compositor 调,因为你根本不知道用户最终用的是哪个。
1.3 三层技术路线:裸协议、封装库、工具集
刚接触 Wayland 客户端开发时,最容易迷路的是“我到底该直接操作 wayland-client 库,还是用 GTK/Qt,还是用 SDL/GLFW”。这三条路线并不冲突,它们解决的是不同量级的问题。
- 裸协议层:稳定可靠,能看清所有机制,代码量大。
- 封装库(GTK/Qt):开发效率高,但你得接受框架帮你屏蔽掉细节。
- 工具集(SDL/GLFW):适合游戏和简单图形程序,窗口和缓冲区管理已封装好。
我这份指南选择的是第一条路线:基于libwayland-client直接写客户端。原因很简单——这份指南的核心目标是让你理解 Wayland 客户端的工作原理,而不是训练你调用框架 API。源码仓库里所有示例代码都控制在尽可能小的依赖范围内,编译和运行只需要wayland-client、wayland-protocols、wayland-scanner,再配合一个 xdg-shell 协议文件就够了。
2. 从零打通第一个 Wayland 客户端:一个能显示窗口的骨架
2.1 最小依赖与编译环境
先交代一下我使用的参考环境,这决定了后面所有命令和代码的上下文。我个人的开发机是 Arch Linux,装了wayland、wayland-protocols、meson、ninja、pkg-config,编译器用的是 clang。如果是在 Debian/Ubuntu 系,对应的包名大概是libwayland-dev、wayland-protocols、meson、ninja-build。
有个必须注意的细节:wayland-client库本身只是协议对象和连接管理,真正让窗口出现在屏幕上的协议(比如xdg-shell)在wayland-protocols包里以 XML 文件形式提供。我们要用wayland-scanner把这些 XML 生成对应的 C 头文件和 C 源码,再自己编译进去。
以 xdg-shell 为例,生成命令是这样的:
wayland-scanner client-header /usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml xdg-shell-client-protocol.h wayland-scanner private-code /usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml xdg-shell-protocol.c你当然也可以手写这两百多行的协议结构体,但没必要,也容易出错。用wayland-scanner生成后,直接#include就能用。
2.2 连接 display 和获取全局对象
Wayland 客户端的起手式和 X11 非常像——先连接“服务器”。X11 是XOpenDisplay(),Wayland 是wl_display_connect(NULL)。这里的NULL表示从环境变量WAYLAND_DISPLAY里读取 socket 路径,如果环境变量没设置,连接就会失败。
连上之后不能直接创建窗口,而是要先通过wl_registry拿到 compositor 暴露的全局对象。这个机制我在指南里花了很大篇幅解释,因为它相当于 Wayland 的服务发现机制:
struct wl_display *display = wl_display_connect(NULL); if (!display) { // 报错退出,多半是 WAYLAND_DISPLAY 没设置,或者没在 Wayland 会话里 } struct wl_registry *registry = wl_display_get_registry(display); wl_registry_add_listener(registry, ®istry_listener, NULL); wl_display_roundtrip(display); // 阻塞等到 compositor 把全局对象广播完在registry_listener里,你要根据interface的名字做匹配。比如看到wl_compositor就wl_registry_bind,看到xdg_wm_base就绑定到 xdg-shell 上。这里有个非常容易踩的坑:wl_display_roundtrip()这一句缺不得。如果你只wl_display_dispatch()一次,很可能连 global 都还没收到,后面的wl_compositor_create_surface就会因为传了 NULL 指针而 segfault。
2.3 创建 surface、xdg_toplevel,并且真正让像素上屏
有了wl_compositor之后,创建窗口就变成了两步:先创建一个wl_surface(本质上是 compositor 里的一个绘图画布),再在上面创建一个xdg_toplevel(负责窗口装饰、标题、最大化/最小化这些交互语义)。
struct wl_surface *surface = wl_compositor_create_surface(compositor); struct xdg_surface *xdg_surface = xdg_wm_base_get_xdg_surface(xdg_wm_base, surface); struct xdg_toplevel *toplevel = xdg_surface_get_toplevel(xdg_surface);但注意,光有这两个对象是不够的,屏幕上不会出现任何有颜色的像素。Wayland 的模型里,内容是通过wl_surface.attach+wl_surface.commit提交的。你可以把wl_surface想象成一个画框,你得先把画布(buffer)放进去,再告诉合成器“画框内容变了”。
我第一次做这一步时用的是wl_shm共享内存:申请一块 buffer,把像素数据填进去,然后把它提交给 surface。完整的最小流程是:
// 1. 创建共享内存 pool,并以 mmap 方式映射到客户端地址空间 struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, size); struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0, w, h, stride, WL_SHM_FORMAT_XRGB8888); // 2. 把像素画进 mmap 得到的内存指针里 memset(pixels, 0xff, size); // 这里可以换成任意绘制逻辑 // 3. attach + damage + commit wl_surface_attach(surface, buffer, 0, 0); wl_surface_damage_buffer(surface, 0, 0, w, h); wl_surface_commit(surface);注意第 3 步必须三步连用,缺一个都会出问题。damage是告诉合成器哪块区域需要更新,不调用的话,合成器可能认为 buffer 没有变化,直接不重绘。这里就是 X11 开发者最不适应的地方:X11 是“改了共享区域,X server 自动重绘”,Wayland 是“你必须声明脏区域,合成器才考虑刷新”。
2.4 事件循环:别用 sleep,用 wl_display_get_fd 来驱动
写终端程序时,很多人习惯用sleep/usleep做定时刷新。但在 Wayland 客户端里,这条路走不通——compositor 和客户端之间是事件驱动的,如果你不处理事件,窗口就没法被正常管理,甚至会被 compositor 判定为失联。
正确做法是拿到wl_display_get_fd(display),然后把它交给poll()或者epoll,等 fd 可读时再去wl_display_dispatch(display)。值得注意的还有wl_display_flush,它在事件循环里经常被遗忘:如果你向 compositor 提交了大量请求,缓冲区满了,flush会返回EAGAIN,你需要等待EPOLLOUT再继续 flush。
一个可用的最小事件循环长这样:
int fd = wl_display_get_fd(display); while (running) { wl_display_flush(display); struct pollfd fds = { .fd = fd, .events = POLLIN, }; poll(&fds, 1, -1); if (fds.revents & POLLIN) { wl_display_dispatch(display); } }这一步是新手分水岭:能写出自己的事件循环,说明你开始真正理解 Wayland 客户端不是“画完就结束”,而是一整套和 compositor 的长期对话。
3. 折腾输入法协议那几天:text-input-v3 和输入法弹窗
3.1 输入法在 Wayland 客户端里是从天而降的复杂性
如果你只是做一个全屏游戏或者简单的绘图程序,输入法可能不是你最优先考虑的事情。但一旦涉及文本输入框,Wayland 客户端开发的难度会瞬间上升一个台阶。Wayland 标准输入法相关协议包括text-input系列和input-method系列,前者是普通客户端用来接收输入法文本的接口,后者是输入法进程读写键盘和 preedit 状态的接口。
早期推广 Wayland 时,大家用 GTK/Qt 开发,输入法这层感知不强,因为框架帮你处理了和输入法的通信。但到了裸写协议这一层,你很快会发现网上大量讨论都集中在gtk_im_module和qt_im_module相关的环境变量上,甚至还会看到“检测到设置了 gtk_im_module 和 qt_im_module,而且 wayland 输入法前端正在正常工作”这类日志。这背后其实是旧时代的 X11 输入法模块机制被带进了 Wayland 生态,不少应用还在用环境变量来强制指定输入法模块,而 Wayland 原生输入法走的是协议层面的事件。
如果你用裸 wayland-client 写客户端,没法和 GTK/Qt 的输入法模块自动衔接,你要么自己实现zwp_text_input_v3协议,要么在你的窗口管理器里嵌套一个支持输入法的子窗口。没有第三条路。
3.2 手动接入 zwp_text_input_v3 的过程
在我这份指南的源码里,text-input相关的示例是我整理笔记时最费事的一部分,因为text-input-v3对比 v1/v2 做了一次大的精简。v3 最大的变化是增加了“串行化状态更新”的设计:客户端需要给 compositor 发送zwp_text_input_v3_set_*系列方法,然后用zwp_text_input_v3_commit提交这次输入状态的快照;compositor 处理完之后返回enter、leave、preedit、commit_string等事件。
要实现一个基本的输入框,核心事件处理是:
enter/leave:输入焦点进入/离开文本输入区域;preedit_started/preedit:输入法正在组合拼音/双拼时的预编辑串;commit_string:用户确认后的最终文本。
我在测试时踩过一个特别隐蔽的坑:v3 的preedit事件里,如果preedit字符串为空,不代表预编辑结束,你需要等commit_string事件。把“空 preedit”当作“取消”处理,会导致输入法翻天覆地地错乱。代码里一定不要把preedit的时序和commit的语义搞混。
3.3 输入法候选弹窗:layer-shell 与 xdg-popup 的抉择
输入法不只是输入框,还有一个很头疼的部分是候选词弹窗。在 X11 下,输入法直接在全局坐标上开一个窗口覆盖上去就行。但在 Wayland 下,普通客户端根本没有办法创建任意位置的全局窗口——你必须让 compositor 同意你在这个位置显示内容。
标准的做法是用xdg_popup,它挂在xdg_surface上,位置相对于某个父窗口定位。但由于输入法自身往往不是某一个窗口的所有者,它可能同时服务多个应用,所以更适合的是zwlr_layer_shell_v1协议——它允许客户端在屏幕的某个边缘或指定位置创建一个层来显示内容。
layer-shell至今还不是 stable 协议,而是 wlroots 系推动的扩展协议,这也是输入法弹窗在各个桌面环境下表现不一致的根源。Mutter 对 layer-shell 的支持一直比较保守,KWin 也是最近几个版本才跟得比较紧。我自己最后用的是一套 fallback 方案:优先用layer-shell创建候选窗口,拿不到协议就退回xdg_popup,尽量保证在主流 compositor 上都能显示。
4. 从“能出窗口”到“能稳住”:生命周期、双缓冲和事件循环坑
4.1 frame callback 不是 Expose 事件的替身
很多从 X11 转过来的人会犯一个错误:看到frame callback就以为它是 “Expose 事件”,觉得窗口需要重绘的时候就会回调。这个理解是错的。
wl_surface.frame回调的含义是“这一帧内容已经被 compositor 收到并参与合成”,它并不表示“你需要重绘”,而更像是“你可以准备下一帧了”。我第一次把绘制逻辑挂在frame回调上时,发现窗口动起来特别卡,因为我每次等frame回来才画下一帧,但某些合成器上frame回调的节流策略会让你的动画丢帧。
正确的做法是把动画状态机拆成requested -> drawn -> presented三个阶段:你在时间轴上请求画下一帧,画完提交 buffer,收到frame回调之后才重置请求标志。这样既不会攒一堆绘制任务,也不会因为等待回调而错失刷新窗口。
4.2 事件分发模型的坑:wl_display_dispatch 不是万能的
wl_display_dispatch会阻塞等待 compositor 的消息,而wl_display_dispatch_pending只处理已经接收但尚未处理的事件。这两者的区别在事件循环里非常关键。
如果你在主线程里既做 UI 绘制,又处理文件 I/O,不能直接wl_display_dispatch,因为一旦开始阻塞,你的绘制循环就断了。推荐做法是wl_display_prepare_read+poll+wl_display_read_events+wl_display_dispatch_pending这套组合拳。它允许你先把 fd 交给poll等事件,等有事件可读时再真正读入并分发。
源码仓库里我在event-loop.c里实现了一个参考版本,核心逻辑如下:
int ret = wl_display_prepare_read(display); if (ret == 0) { poll(&fds, 1, timeout); wl_display_read_events(display); wl_display_dispatch_pending(display); } else { wl_display_dispatch_pending(display); }这样既能避免主线程被一个不相关的 fd 事件卡死,也能保证 Wayland 事件不积压在 socket 缓冲区里。
另外还有一个必须掌握的:wl_display_roundtrip和wl_display_flush的关系。roundtrip会在发送请求之后等待 compositor 的同步回调,这在做初始化查询时非常有用。但如果你在事件循环里频繁调用roundtrip,很容易造成每帧都额外多一次同步往返,性能会明显下降。这个问题在低功耗的嵌入式设备上尤其明显,我一个跑在 ARM 板子上的客户端因此 CPU 占用率涨了 30%。
4.3 多线程访问:所有 Wayland 对象都有线程亲和性
libwayland 的对象并不是线程安全的,这是让不少客户端开发者掉进并发大坑的地方。默认情况下,一个wl_display连接创建的所有 proxy 对象都绑定在创建它的线程上,其他线程调用这些 proxy 的方法,轻则事件丢失,重则直接段错误。
我一开始天真地把界面线程和业务线程分开,业务线程直接把数据往wl_surface的 buffer 里写,结果产生了一个只在特定时序下出现的诡异崩溃。查了很久才意识到,Wayland 客户端里跨线程访问共享对象之前,要么加锁、要么通过wl_proxy_marshal_flags做线程迁移,要么干脆用一个独立的事件线程,所有 UI 操作都通过事件队列投递。
源码里我提供了一种相对轻量的方案:只把wl_display的读事件放在一个线程里,而绘制和窗口操作都通过wl_display_dispatch_queue投递到指定的事件队列。这样既避免了对象竞争,也不至于引入太重度的锁机制。
5. 这份指南为什么能持续更新:源码结构与协议追踪方法
5.1 代码仓库的模块划分
指南附带源码的结构,是特意为“持续更新”设计的。如果所有代码都堆在一个main.c里,过两周我自己都不想看。我按职责拆成了几块:
protocol/ # 存放从 wayland-protocols 同步来的 XML 文件 generated/ # wayland-scanner 生成的头文件和协议代码 core/ # display 连接、registry 绑定、事件循环、surface 生命周期 protocol-handler # 具体协议的 listener 和 proxy 封装,如 xdg-shell、text-input ui/ # 基于裸协议写的最小控件:窗口、按钮、文本输入框 examples/ # 每个协议点的独立示例,比如 shm-background、popup、layer-shell tests/ # 基本的 smoke test 和脚本这样的划分带来的直接好处是:当协议更新时,我只需要更新protocol/下的 XML 和protocol-handler/里的实现,其他模块不受影响。
5.2 追踪上游协议变更的方式
Wayland 生态里,协议并不是一成不变的。wayland-protocols仓库里的协议有三个阶段:unstable、staging、stable。staging 这个阶段是最近几年才引入的,目的是让那些已经比较成熟但还没转正的协议有一个更正式的过渡期。比如xdg-shell目前是 stable,但早年在 staging 和 unstable 里折腾了很久;text-input-v3至今还在 unstable 里。如果你项目里的某个协议刚好处于 unstable 阶段,就要有“协议细节可能会变”的心理准备。
我自己的做法是每周跑一次git submodule update,把wayland-protocols拉到最新,然后检查涉及到的 XML 文件有没有 diff。有 diff 就去对照wayland-protocols的 CHANGELOG,判断是不是破坏性变更。比如 xdg-shell 从 v1 升到 v2 时,xdg_toplevel的很多 setter 都变了,如果你不跟上版本,客户端在新合成器上可能直接报“version mismatch”错误。
5.3 自动化冒烟测试和人工验收清单
“持续更新”要成立,必须有一套能快速验证当前代码没被改坏的流程。我在这份指南的源码仓库里放了两个层次的验证脚本。
第一层是自动化冒烟测试:用weston --backend=headless-backend.so跑一个 headless Weston 实例,设置好XDG_RUNTIME_DIR和WAYLAND_DISPLAY,然后启动客户端示例,检查它是否能在几秒内创建出 surface 并收到 frame callback。这一步可以在 CI 里跑,能在协议变更后第一时间暴露编译错误和基本逻辑错误。
第二层是人工验收清单,因为我始终认为自动化测试覆盖不了真实的桌面交互感受。验收清单基本是这样:
- Weston(x11 backend 和 headless backend 各跑一遍);
- wlroots 系:sway 或 river 上跑,看窗口能否正常弹层;
- Mutter(GNOME 默认):看 xdg-dialog / popup 的表现,重点查输入法弹窗是否能正常跟随;
- KWin(KDE 默认):看窗口状态和 frame callback 的时序是否正常。
实测下来,最容易出现差异的就是输入法协议和 layer-shell,KWin 上layer-shell的支持程度和 Mutter 有明显不同。这也是我在指南里专门写“图层协议兼容性”一节的原因——不同桌面环境对扩展协议的支持步调是真的不一致,你不能假设“协议存在,就一定所有 compositor 都支持”。
另外补一个很多人容易忽略的地方:XDG_RUNTIME_DIR的权限。Wayland socket 通常在这个目录下,如果目录权限是 0755 而不是 0700,部分 compositor 会拒绝连接。这个坑在自动化测试环境里特别容易踩,因为很多 CI 容器默认的 umask 不是 077。
最后再分享一个小技巧
做 Wayland 客户端开发时,如果遇到“窗口没反应”或者“画面不刷新”,别急着查代码逻辑,先设置环境变量WAYLAND_DEBUG=1跑一遍客户端。这个调试开关会打印出所有发出和收到的 Wayland 协议消息,你能直接看到wl_surface.commit有没有发出、compositor 有没有回 frame 事件。很多时候问题根本不是你的绘制代码错了,而是你没有把 buffer attach 到正确的位置,或者damage区域声明得不对。WAYLAND_DEBUG=1是我在开发过程中用得最多的排错工具,没有之一。
我自己的代码仓库会保持每周或每两周更新一次。如果你在用这份指南时踩到新的坑,非常欢迎把场景和复现步骤丢给我,这本身也是这份指南“持续更新”的动力来源。