从零搭建一套属于自己的多平台 UI 框架,听起来像是个“闲着没事找罪受”的活。但我最近确实把这件事完整跑了一遍,从底层窗口抽象、事件系统,到组件绘制、字体渲染,再到 Windows 和 Linux 两个平台的行为差异逐一踩平。这篇文章不是泛泛而谈的架构科普,而是我在开发过程中真实遇到的坑、反复调整后的设计取舍,以及可以直接拿走的代码骨架。如果你正准备用 C++ 自研 UI 框架,或者想在现有项目里引入一套轻量级跨平台界面层,这篇文章应该能帮你省下不少弯路。
C++ 生态里其实不缺 UI 方案,Qt、Dear ImGui、wxWidgets 都是成熟选项。那为什么我还要自己做一套?一句话:因为现有的都不完全契合我手头那个项目的硬性要求。我需要一套能在性能和可控性之间取得平衡的框架,同时又不希望被第三方库的“全家桶”风格绑架。这套框架最终跑在 Windows 和 Linux 上,以固定窗口模式为主,偶尔也会内嵌到其他程序的宿主窗口中,这种需求用 Qt 当然能做,但铺设成本明显偏重。
所以我决定从底层开始自己造轮子。本文会尽量把整个过程讲透,包括每一处设计背后的“为什么”,以及我在摸索中总结出的实操技巧。如果你是 UI 框架的初学者,可以从环境搭建和基础架构开始看;如果你已经有了一些基础,可以直接跳到组件实现和问题排查章节,那里有更具体的代码和踩坑记录。
1. 为什么选择自研 C++ 框架
1.1 自研和现成框架的取舍
先做个粗略对比,方便你判断自己的场景到底适不适合自研:
| 对比维度 | 自研框架 | Qt / wxWidgets | Dear ImGui |
|---|---|---|---|
| 开发成本 | 高,需要自己实现窗口、事件、绘制 | 低,开箱即用 | 极低,即时模式方便调试 |
| 可定制深度 | 完全可控,每个像素都由自己决定 | 受限,部分细节需要 hack | 受限,风格定制需额外工作 |
| 内存占用 | 只保留真正需要的代码 | 较大 | 相对轻量 |
| 性能优化空间 | 高,可针对性优化 | 受框架限制 | 中等,适合工具类应用 |
| 长期维护成本 | 自己承担,更新迭代靠个人 | 跟随上游版本 | 跟随上游版本 |
从表格可以清楚看到,自研的核心优势不是“少写代码”,而是“完全掌握每个细节”。我选择自研,一是因为需要把 UI 嵌入另一个进程的交换链里实现离屏渲染,二是因为对不同平台的窗口行为有非常细的定制需求。
1.2 哪些场景适合自研
如果你手头的是以下类型项目,自研 UI 框架很可能是一个值得考虑的路线:
- 游戏引擎或嵌入式应用的 Debug UI,希望摆脱 Dear ImGui 默认的即时模式风格;
- 需要将渲染层导出为动态库给 C# 或脚本层调用的桌面应用;
- 需要同时兼容 Windows 窗口和 Linux 原生窗口,又不想引入庞大的依赖;
- 对启动速度和内存占用有比较严格要求的工具类程序。
对我个人来说,触发自研的另一个重要原因是,我想让框架最终适配“嵌入宿主窗口”的场景。这个场景下,窗口句柄由外部程序提供,UI 框架自己不创建顶层窗口,只负责在给定区域内完成消息循环和绘制。这套逻辑用 Qt 很难优雅地做,因为 QWidget 的设计默认是“窗口拥有制”,强行嵌入会产生一系列焦点、坐标系、输入法问题。
提醒一下,如果你只是想快速完成一个工具界面,不要自研,直接用现成框架效率更高。自研适合的是“有长期调优计划”的项目。
2. 环境搭建与工具链选择
2.1 编译器与构建系统
自研 UI 框架对构建系统的要求比普通业务项目严格得多。我的选择是:
- Windows: MSVC x64 + CMake + Ninja
- Linux: GCC/G++ 11+ + CMake
- 代码编辑器: VS Code + C/C++ 扩展
CMake 几乎是跨平台 C++ 项目的默认选择了,理由很朴素——它能一套代码同时产出 Visual Studio 工程和 Ninja 工程,还能在切换平台时自动处理不同编译器带来的差异标记。我在框架根目录维护了一个CMakeLists.txt,用一堆option()来控制开关,比如是否启用离屏渲染、是否编译为动态库、是否开启测试等。
Vcpkg或Conan在后期需要依赖第三方库时很顺手,但自研核心阶段我尽量保持“零第三方依赖”。因为 UI 框架的指针原理很底层,每引入一个依赖,都会多一层不确定性。等基础框架跑通后,再集成字体解析库、纹理压缩库等也用得心安。
2.2 三大平台各自的暗坑
虽然我只维护 Windows 和 Linux 两个平台,但积累下的差异点足够让人头疼。这里有几个关键差异:
- 窗口样式与像素格式:Windows 需要处理
HDC和垂直同步,Linux 则依赖 X11 的Visual类型和交换链缓冲; - 输入法与字符输入:Windows 的
WM_IME_*消息和 Linux 的XIM机制完全不同,需要分别适配光标跟随和候选框语境; - DPI 缩放:Windows 上要处理
SetProcessDpiAwarenessContext,Linux 上则受桌面环境主导,不同显示服务器(X11/Wayland)行为也有差异; - 字体回退:Windows 上中文字符可以回退到微软雅黑,Linux 上则可能需要指定多个字体路径,否则中文直接显示成方块。
这些差异不是“挂个#ifdef”就能糊弄过去的。每个平台背后的窗口系统都有自己的坐标原点、消息循环、键盘焦点约定,必须在接口层做一次统一抽象。
2.3 快速验证最小工程
环境搭好后,我建议你先做一个最小工程验证窗口能不能弹出来。这是自研框架的第一个里程碑,比你想象中更有用——它能帮你验证编译器、构建系统、链接器和运行库策略是否一切正常。
我当时的做法是写一个window_test.cpp,在两个平台上分别创建原生窗口、填充背景色、处理关闭事件。这个工程约 200 行,但覆盖了窗口创建、消息循环、垂直同步和最小化的行为。跑通这一步,后续加组件、加绘制才会更有底。
建议把运行库策略在框架早期就定下来。Windows 上直接静态链接运行时(
/MT),还是动态链接(/MD),会影响后续用 C# 调用导出层的互操作。Linux 上则要决定-fPIC手动打开,因为后续必然要编.so给宿主程序加载。
3. 框架架构设计与核心模块规划
3.1 分层设计,而不是“一个大文件”
架构上,我最终把框架分成三层:
- 平台抽象层(Platform):负责窗口创建、事件传递、坐标变换,每个平台单独实现,但对外暴露一致接口;
- 内核层(Core):包括事件调度、组件树、绘制上下文、定时器、消息循环;
- 组件层(Widgets):按钮、复选框、输入框、滚动条等具体控件,由内核层提供基础能力。
分层带来的直接好处是,组件层代码几乎不感知操作系统差异。我写组件时只需要使用Event和Rect,不需要知道Windows的消息循环长什么样,也不需要在每个按钮里都塞一大堆平台相关代码。
3.2 事件系统:先想清楚数据流再动手
事件系统是自研框架的灵魂。很多自研框架死在事件系统上,因为没用模型直接写业务,最后全乱了。
我的方案参考了游戏引擎常见的“事件冒泡 + 事件捕获”模式,同时保持极简。一个 UI 事件由以下部分组成:
- 事件类型(鼠标移动、鼠标按下、鼠标释放、键盘按下、字符输入等);
- 事件目标(最底层组件);
- 当前派发路径;
- 处理阶段(捕获阶段或冒泡阶段)。
事件从顶层窗口注入,沿组件树向下捕获,到达目标组件后再向上冒泡。这个过程中,每个节点可以选择拦截事件,也可以选择继续传递。这样设计的好处是在实现“模态对话框”和“全局快捷键”时非常省事,我只需要在对话框实例上拦截所有事件即可。
3.3 绘制上下文与渲染后端
绘制部分我采用现成的图形库作为后端,例如 Irrlicht 或自写的 RHI。如果你没有现成的渲染器,推荐先接一个简单的 2D 图形库,不要一开始就触碰复杂的光栅化算法。
绘制上下文抽象出几个核心接口:
class DrawContext { public: virtual void drawRect(const Rect& rect, const Color& color) = 0; virtual void drawText(const std::string& text, const Rect& rect) = 0; virtual void setClipRect(const Rect& rect) = 0; virtual void resetClipRect() = 0; };这些接口用Rect来沟通坐标,底层再转换成像素坐标。这么设计的好处是,组件层不关心后端用的是哪个图形 API,后期换渲染管线时组件代码几乎不用改。
在设计接口时,不要为了“面向未来”而过度抽象。接口应该能描述当前的真实需求,而不是凭空想象需求。
4. 双应用模式设计与编译开关
4.1 native 模式和宿主机模式
这套 UI 框架支持两种运行模式:
- native 模式:框架自己创建顶层窗口,处理消息循环,适合独立运行的桌面工具;
- 宿主机模式:不创建窗口,而是在外部进程提供的内存区域或窗口句柄上完成渲染,适合被 C# 或脚本层调用。
两种模式看起来相差不大,实际实现时却要处理非常多的分支。例如鼠标事件注入:native 模式下鼠标消息来自操作系统,宿主机模式下则需要外部程序周期性地把鼠标位置和按键状态“推送”进来。如果框架只支持一种模式,可以直接简化很多代码,但同时支持两种模式,才能覆盖我的实际需求。
4.2 在 CMake 中控制模式
我在构建时用编译期定义来区分这两种模式:
#if defined(UI_HOST_MODE) void injectMouseEvent(const MouseEvent& event); #else void onNativeEvent(const NativeEvent& event); #endifCMake 中对应的选项:
option(UI_ENABLE_HOST_MODE "Enable host mode support" ON) if(UI_ENABLE_HOST_MODE) target_compile_definitions(ui_framework PRIVATE UI_HOST_MODE=1) endif()编译期开关的好处在于,宿主机模式的代码可以完全不参与 native 模式的发布包,避免二进制膨胀,也让编译期可以发现很多连接错误。
4.3 双模式的平台差异处理
native 模式下,窗口消息传递依赖PeekMessage或GetMessage之类的逻辑;宿主机模式下,事件注入是外部调用方主动发起的。这俩不能混在一起,否则外部调用方和原生消息循环会相互竞争,导致界面卡顿。
我最终的方案是:框架内部始终保留一个统一的事件入口,native 模式做的只是“从操作系统拉取事件后调用这个统一入口”,宿主机模式则直接暴露这个统一入口给外部调用方。这样就把平台差异缩小到最小范围。
宿主机模式还有一个注意点:外部调用方可能期望在渲染线程和 UI 线程分离的模型下工作。如果框架本身设计成了单线程,就一定不要在外部强行加锁,否则很容易进入死锁。早期我踩过一次这样的坑,最后靠引入“事件缓冲队列”解决的。
5. UI 组件实现与生命周期管理
5.1 通用组件库怎么规划
组件是 UI 框架最外层的表现。初期我不建议一上来就做几十种组件,那会让内核设计朝令夕改。我从最常用的几个组件开始:
- 按钮:用于响应点击;
- 复选框:用于表示布尔状态;
- 输入框:用于处理文本输入和光标闪烁;
- 滑块:用于表示连续取值。
每个组件继承同一个基类Widget,基类保存位置、尺寸、可见性、父组件指针和事件回调。
组件的核心难点在于绘制状态管理。以按钮为例,它至少有四种状态:普通、悬停、按下、禁用。不同状态下可能对应不同贴图或颜色,状态变更时要主动触发重绘。这里我用setDirty()标记来告诉渲染器“这一块需要重新绘制”,而不是每次消息循环都重绘整个窗口,性能上会好很多。
5.2 组件生命周期与父容器管理
UI 框架最容易出现内存问题的地方就是组件生命周期。我在基类里统一使用std::shared_ptr管理组件,父容器持有孩子的强引用,孩子的parent字段用普通指针存储。这样确保父容器销毁时,子组件会自然释放,同时能避免循环引用导致的内存泄漏。
组件创建后先进入visible状态,组件树以深度优先顺序遍历绘制。每个组件维护一个visible标志,事件注入前先检查可见性,防止隐藏在界面外的组件仍然接收鼠标点击。
class Widget : public std::enable_shared_from_this<Widget> { public: void setParent(std::shared_ptr<Widget> parent); void setVisible(bool visible); bool hitTest(const Point& point) const; virtual void onPaint(DrawContext& ctx) = 0; void setDirty(bool dirty); protected: std::weak_ptr<Widget> m_parent; std::vector<std::shared_ptr<Widget>> m_children; Rect m_rect; bool m_visible = true; bool m_dirty = false; };一个实用技巧:不要在组件析构函数里调用虚函数决定触发什么清理逻辑。组件被销毁时,很可能
vptr已经切回基类,调用虚函数并不会做你想做的事。需要清理状态时,在onDestroyed()这样的钩子里写,而不是析构函数里。
5.3 组件交互:鼠标点击的命中测试
命中测试是所有组件交互的基础。我的命中测试算法很简单:先在窗口层面做矩形裁剪,再按层级顺序遍历组件树查找最上层且包含该坐标的那个组件。
这个算法有一个小坑:裁剪到窗口矩形后,还需要考虑translate变换。如果你的框架支持组件平移或弹窗效果,hitTest时必须逆变换坐标,否则点击位置会偏移。
调试时我通常会在窗口上输出一个“调试绘制模式”,把每个组件的包围盒画出来。有一次点击按钮没反应,排查了很久才发现是窗口被老板拖到了副屏上,DPI 缩放比例不一致导致坐标计算错位。这类问题很难靠纯逻辑排查,可视化调试信息帮了大忙。
6. 常见问题与排查技巧实录
6.1 窗口显示空白或初始位置异常
自研框架刚跑通时,最常遇到的两个问题是窗口白屏和位置异常。
白屏大多数情况下是绘制上下文没有正确获取到后端的像素缓冲。Windows 上常见的是WM_PAINT事件里没获取PAINTSTRUCT,或者垂直同步设置不合理;Linux 上则多半是 X11 的GLX视觉类型和XCreateWindow时的色彩深度不一致。
位置异常和坐标系有关。Windows 的屏幕坐标通常以左上角为原点,但多显示器环境下有负坐标存在;Linux 上不同窗口管理器对窗口初始位置的解释也有差异。处理这些问题时,我的建议是给框架加一个“平台信息输出”命令,启动时打印当前屏幕信息、窗口矩形和 DPI 值,避免靠猜。
6.2 C# 调用 C++ 时的参数传递错误与访问违规
如果你打算把框架导出给 C# 这样的宿主语言调用,记得关注AccessViolation这类问题。我最早遇到C# 调用 C++ 出现 access violation c0000005时,检查了分配的内存、指针引用,都正常,最后发现是调用约定和参数类型不匹配。
C++ 侧需要明确导出接口:
extern "C" { UI_API void ui_show_window(void* host_handle); }C# 侧:
[DllImport("ui_framework.dll", CallingConvention = CallingConvention.Cdecl)] public static extern void ui_show_window(IntPtr hostHandle);这里最常见的问题是:
- C++ 侧没有
extern "C",函数名被 C++ 编译器添加了修饰符号; - C# 侧的
CallingConvention和 C++ 编译选项不匹配; - 跨语言传
struct时有内存对齐差异,导致字段偏移错位。
经验:跨语言调用时,传
void*或IntPtr永远比传结构体安全。如果必须传结构体,建议用[StructLayout(LayoutKind.Sequential)]明确定义布局,并对比 C++ 侧的#pragma pack值。
6.3 字体与中文渲染问题
中文字体渲染在不同平台上的表现差异非常大。Windows 上我直接调用字符映射 API 来获取字形位图,然后上传到纹理;Linux 上则需要自己维护字体配置文件,指定字体扫描目录和回退顺序。
一个相对稳妥的跨平台方案是:
- 不依赖系统字体渲染,改用 FreeType 或类似库对字体文件进行栅格化;
- 将字形缓存成纹理图集,绘制文本时只做纹理采样;
- 遇到不支持的字符时,按“中文字体 -> 英文字体 -> 默认字体”的回退链依次尝试。
纹理图集实现时,注意字形的基线偏移和填充区域。不同字体模板下基线高度差异明显,直接按固定行高绘制,会出现中文和英文对不齐的情况。这个问题没有短路方法,只能一个字符一个字符地做测量,计算出正确的坐标。
6.4 构建失败问题与运行库策略
构建失败集中在两种情况:
- 编译失败:分平台 API 名称不一致,或者某个头文件在不同系统上包含顺序不一样;
- 链接失败:平台库没链接上,比如 Windows 的
user32.lib、gdi32.lib,Linux 的X11、Xext等。
我在 CMake 里统一做掉这些依赖处理,而不是在代码里到处#pragma comment(lib, ...)。这样更方便维护,在 Linux 上也不会产生一大段平台相关的编译警告。
关于运行库,Windows 上如果目标是纯 C++ 应用,我习惯用动态运行库(/MD);如果目标是 C# 互操作,则动态和静态都可能遇到问题,需要根据实际宿主环境测试。最终你会发现,运行时策略这个问题,没有万能解,只能面对自己的目标环境去验证。
把自己框架的依赖项列一个清单,每次换平台升级编译器后重新跑一遍完整构建,能很快发现接口变动和弃用项。很多自研框架后期没法维护,就是因为依赖管理混乱,升级个依赖库就崩。
7. 性能优化与渲染细节
7.1 绘制的目标是减少绘制指令
UI 框架性能的衡量标准不是那一帧的DrawCall次数就行,关键是单位时间内处理用户操作的能力。另一个性能指标是“脏矩形区域数量”——如果每一帧都重绘整个窗口,自研框架还不如直接用现成引擎来的省心。
我做的性能方案:
- 维护脏矩形列表,每一帧只提交需要重绘的区域;
- 窗口内容没有变化时,不触发重绘;
- 利用纹理图集合并字形贴图,减少状态切换次数。
最终效果是,在一个 300 个组件的界面上做局部刷新,帧开销能控制在 1ms 量级。这个数据在调试模式下也稳定。
DrawCall 本身不是万恶之源,状态切换才是。比如换了纹理、换了混合模式、换了裁剪矩形,这些状态切换会让 GPU 流水线停顿。框架设计初期应当把“保存状态、切换后再恢复”这种机制做进绘制上下文中,否则后续优化会非常被动。
7.2 纹理子区域与脏矩形刷新
组件绘制高频操作是“局部重绘”,我习惯把纹理上传和内容更改分离。初始时上传一次完整的背景贴图到 GPU 纹理,组件状态变化时只需要在 CPU 侧修改颜色值或子区域,然后再把这个小矩形传给渲染上下文。
这样做能有效避开整张贴图反复上传的带宽压力。对于 UI 这类高频更新场景,带宽往往比缓冲区可用容量更珍贵。
字体缓存也是同样的套路。第一次遇到某个字形时生成并上传位图,后续绘制直接命中缓存。拼音这类高频重用字和生僻字高频射抽时都能快速响应。
在 Linux 上做字体缓存时,曾经遇到过字形位图本身带有抗锯齿边缘,但混合模式没设置为预乘 Alpha,导致文字带白边,半天排查不出原因。后来统一把所有纹理上传前的
RGBA数据转为预乘 Alpha,才从根本上解决。
7.3 像素格式与颜色空间
不同图形后端对像素格式的偏好差别很大。Windows 原生GDI和 OpenGL 的RGBA处理方式不同,有些 Linux 窗口合成器还强制转换成BGRA。最好在渲染后端入口处做一次统一的像素格式转换,而不是在各业务组件里到处判断位置。
我最终决定框架内部统一使用RGBA8,后端输出时再按需转换为目标格式。这样组件生态中杜绝了颜色分量错位的问题。
如果目标平台需要 P3 广色域或 HDR 展示,这套基于
RGBA8的管线是不行的。不过对一般桌面工具类应用来说,RGBA8完全够用,也不高攀硬件。
7.4 消息循环与空闲刷新策略
native 模式下,消息循环驱动 UI 刷新,这个大家都知道。但有一个细节容易被忽略:当窗口频繁被系统消息触发时,要不要立即刷新?
我的方案是:采用“惰性刷新”策略。收到WM_PAINT或Expose事件时,只标记需要重绘,不立即同步渲染。等到本次事件队列全部处理完后,再统一做一次重绘。这样能确保一批联动操作(比如按钮按下时同时改变按钮状态和触发音效播放)只在同一个帧内被观察到,避免画面闪烁。
宿主模式下的刷新策略又不一样:外部程序推动了一整帧的事件数据后,应该由外部程序来决定何时后处理重绘。如果每隔事件都立刻刷屏,外部程序这个三角会变得很脆弱,反而拖垮整个宿主进程。
这里有一个很实用的排查技巧:打开你使用的图形驱动的调试工具,查看绘制时的同步状态。大多数闪烁问题不是绘制逻辑错了,而是同步和缓冲机制没有配对。
8. 多平台更新与发布策略
多平台项目到了一定阶段后,最消耗精力的往往不是写代码,而是维护各平台构建产物和测试环境。我的做法是每完成一个小版本,就在三个平台上各跑一次烟雾测试:
- 启动进程,窗口显示正常;
- 创建 500 个组件,执行一次全量布局;
- 触发全部组件状态变更,连续跑 5 分钟无崩溃;
- 卸载编辑器场景,重新加载,内存占用保持稳定不超过增长阈值。
前三项覆盖功能正确性,最后一项覆盖生命周期和内存泄漏。多平台项目最容易出现的问题不是功能缺失,而是资源在某个平台没有按预期释放。
发布配置上,Windows 我用 VS 项目自动生成安装包,Linux 则使用 CMake 的install规则把动态库复制到合适位置。还要注意各平台自身的安全检查机制,比如 Windows 上如果没有正确的清单文件,某些系统组件可能拒绝加载。
个人经验:多平台发布最重要的不是自动化到极致,而是“人工可以快速重现用户的环境”。我保留了一套每套虚拟机装有不同系统的集成测试环境,出了兼容性问题,就先在不同的虚拟机上复现,再定位根因。
结语(个人经验与后续扩展)
这段自研 C++ 多平台 UI 框架的经历,带给我最大的收获是,对“系统级工程”有了更真实的掌控感。过去用 Qt 搭界面时,很多行为细节都是“黑盒”;现在从平台抽象到组件交互都自己设计,出了问题能直接定位到某一段代码逻辑。这种掌控感,是自研框架最吸引人的地方。
如果你也有自己框架的打算,我建议不要一开始就追求功能数量,先把核心架构和事件流跑顺,再逐步扩展组件。框架的核心价值永远在于稳定性和可维护性,而不是“我今天又加了一个华丽控件”。
后续我计划做两个方向的扩展:一是把字体渲染接入更成熟的 FreeType 方案,二是在宿主模式下加入多窗口分离渲染。这些都属于“底层不推倒重来就能加进去”的功能。经过这一轮开发,这套框架的扩展性我总算能拍胸脯保证了。