手写 87,094 行 C++ GUI 库:四个真正花时间的坑

0 阅读5分钟

我写了一个跨平台界面库,纯 C++20,Windows 和 Linux 都能跑。不用 Qt,不用 SDL,也不用 GLFW——窗口层直连 Win32 和 X11,渲染层手写了 D3D12、Vulkan、OpenGL 三个后端。

写完去数了一遍代码,87,094 行。

这个数字本身不说明难度。所以我把它拆开,看看时间到底花在哪了。

仓库:github.com/Z-ze489/Zek… (MIT,一个静态库)

先说结论

学习目的,可以做。要出货,别做。

真正贵的地方,不在你以为的那三个。

87,094 行花在哪

部分行数
Win32 窗口层2,146
X11 窗口层3,370
D3D12 / Vulkan / OpenGL4,823
软件光栅化兜底792
FreeType 文字 + 输入法1,582
控件、布局、主题、事件约 74,000
合计87,094

真正跟显卡打交道的,不到 7%。

剩下七万多行全是控件、布局、主题、事件。这些代码没有一行是难的,但每一行都得写。

坑一:同一个概念,三套 API,三套资源生命周期

这是第一个没人提前告诉我的坑。

在 OpenGL 里,你可以随时删掉一张纹理,驱动会替你同步——这是个很贴心的设计。

Vulkan 和 D3D12 不这样。如果那一帧还在 GPU 里飞,你删了就是野指针,崩在你看不见的地方。

// OpenGL — 随时可删,驱动替你同步
glDeleteTextures(1, &tex);

// Vulkan — 还在飞的不能删
vkWaitForFences(dev, 1, &fence, VK_TRUE, UINT64_MAX);
deferred.push({ frameIdx, tex });

所以你得自己维护 fence、帧槽、延迟回收队列,等它确实不在飞了才释放。

所谓"学三个 API",本质是维护三套不一样的资源生命周期语义,还要让它们看起来像同一个 DrawFrame 调用。这一层的抽象如果做错了,后面每加一个控件都要改三个文件。

反过来,如果这层做对了,加一个后端只要 800 到 1500 行,控件层一个字都不用动。

坑二:视觉一致性是像素级的,不是 API 级的

第二个坑更反直觉。

三个后端都能画出圆角之后,你以为就完了。没有。同一个按钮,在 D3D12 和 OpenGL 上,边角的软硬不一样。

我用的是 SDF:顶点带圆角参数,像素着色器算距离场,不做几何细分。听起来很干净。

但抗锯齿的带宽必须正好是一个像素,要和硬件 MSAA 的过渡宽度对齐。

我一度写成三个像素,结果所有形状一起变糊。而且带宽、梯度数学、几何 padding 是一组联立的不变量,改一个另外两个必须跟着改。

这件事没有任何文档写过。只能自己踩一次,盯着屏幕,再改回来。

一套圆角矩形是这样,文字抗锯齿、阴影衰减、描边对齐、高 DPI 下的半像素偏移,全是同一个模式。

前 20% 的时间在学 API,后 80% 在交一致性税。

坑三:平台成本是差集,不是加法

直觉是 Win32 更啰嗦,X11 更干净。

实际情况反过来:X11 的窗口层比 Win32 多写了 55%。

平台窗口层行数
Win322,146
X113,370

在 Win32 上,CreateWindowEx 加 WndProc 就是一套现成的规则。输入法一个 ImmSetCompositionWindow 就把候选框挪到光标旁边了,DPI 也有现成的 API。

在 X11 上,你得自己把窗口接进现代桌面:_NET_WM_NAME、_NET_WM_ICON、_NET_WM_PID、_NET_WM_WINDOW_TYPE、_NET_WM_STATE,二十来个 atom,少一个,窗口管理器就行为异常。

而你自己机器上看不出来,因为你的窗口管理器刚好宽容。

所以加一个平台的成本,不是"平台数量乘以常数",而是"和第一个平台的差集"。

坑四:真正劝退的不是 D3D12,是输入法

大部分人以为难的是图形 API。不是。

组字画在哪、候选框跟谁走、什么时候算一次提交、退格退到哪。一个状态机,跟 GPU 一点关系都没有,但它吃掉一半调试时间。

因为它的 bug 是"有时候"——在这一个输入法下复现,换一个就没了。

这也是我唯一不推荐任何人硬啃的部分:它不难,它只是耗时,而且没有尽头。

现在的真实状态

Windows 10/11 x64 是主平台,D3D12 / OpenGL 4.6 / Vulkan 1.0+ 三个后端都能跑。

Linux x64 是支持平台,OpenGL(走 EGL)和 Vulkan,只支持 X11,Wayland 还没做。

macOS、iOS、Android、鸿蒙的源码在仓库里,但我不把它们算作可用平台。分层做对了,让它们写起来便宜,不等于写完了。

怎么跑起来

cmake --preset windows-msvc
cmake --build --preset windows-msvc-release

产物是 x64\Release\Zekit.lib。Linux 用 cmake --preset linux-release。

最小程序十三行:

#include <Zekit.h>
#include <ZeButton.h>

int main() {
    Kit::Window win;
    Kit::Button btn("OK");
    btn.SetSize(220, 60);
    btn.SetOnClick([] { /* ... */ });
    win.AddWidget(btn);
    win.Show();
    return win.exec();
}

窗口和按钮都是栈上的局部变量,AddWidget 之后就不用管了。没有 new,也没有 delete。

最后

为什么还是做了?因为我想知道底下到底怎么转的。

这一条,不构成建议。

如果你只想要一个能点的按钮,用 Qt 或者 Dear ImGui。这个拆解是写给想弄明白底层到底怎么转的人的。

仓库:github.com/Z-ze489/Zek… 镜像:gitee.com/Z-ze489/Zek…