去年有个项目需要在 Windows 上做一块实时数据看板要求 CPU 占用不能太高刷新帧率还要稳定在 30fps 以上。当时团队里出现了明显的分歧一边是长期做 Qt 的坚持用 QPainter 一套代码搞定另一边是从 MFC 转过来的老将认为 GDI 在 Windows 上更贴近系统底层、性能更有保障。谁都说服不了谁最后我干脆把两种方案都写了一遍用实际数据说话。这个“Qt自带绘图与GDI绘图方式比较”的实测结果就是这篇文章的由来。文章面向的是正在纠结实时绘图方案的桌面端开发者尤其是用 Qt 做 Windows 客户端的场景。我会先从两套体系的基础架构差异说起然后给出实时绘制场景下的性能实测和瓶颈定位过程最后聊聊踩过的坑以及我个人的选型结论。不管你最后选哪条路至少能少走几趟弯路。1. 两套绘图体系的家底各有各的“底层逻辑”1.1 QPainter 是什么一个跨平台的绘制抽象层QPainter 是 Qt 自带的绘图引擎入口它的设计思路很明确——向上对开发者提供统一的绘制接口向下通过 QPaintEngine 适配不同的底层渲染设备。在 Windows 平台上Qt 5 默认走的是 raster 引擎所有绘制操作先在内存里的 QImage 上完成属于软件渲染也可以切换成 OpenGL 或 Direct3D 后端但默认路径就是 CPU 渲染。QPainter 的绘制对象不一定是控件它可以画在 QWidget、QImage、QPixmap、QPicture 等 QPaintDevice 上。这意味着你可以把绘制结果输出到不同类型的设备灵活性非常高。void DashboardWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.setPen(QPen(QColor(0x33, 0xCC, 0xFF), 2)); painter.drawLine(QPointF(0, height() / 2), QPointF(width(), height() / 2)); }1.2 GDI 是什么Windows 平台上的图形设备接口增强版GDI 是 Windows 提供的 2D 图形绘制接口是传统 GDI 的扩展补充。它的编程模型是Graphics 对象 笔 画刷 绘制目标。Graphics 可以绑定到窗口的 HDC也可以绑定到内存位图和 QPainter 的 QPaintDevice 概念有异曲同工之处。GDI 的最大特点是完全依赖 Windows 原生 API所有绘制由 GDI32.dll 和 gdiplus.dll 协同完成。在 Windows 下它确实有“近水楼台先得月”的优势但代价是平台锁定——写出来的绘制代码基本没法迁移到 Linux 或 macOS。// GDI 需要在程序启动时初始化 GdiplusStartupInput input; ULONG_PTR token; GdiplusStartup(token, input, NULL); // 窗口绘制回调 void OnPaint(HDC hdc) { Graphics graphics(hdc); Pen pen(Color(255, 0x33, 0xCC, 0xFF), 2); graphics.SetSmoothingMode(SmoothingModeAntiAlias); graphics.DrawLine(pen, 0, height / 2, width, height / 2); }1.3 为什么这两套东西经常被拿来对比桌面端项目做实时绘制时最终都会回到这两个问题上绘制吞吐量够不够会不会在某个时候卡一下QPainter 的优势是跨平台、和 Qt 事件循环深度绑定GDI 的优势是系统集成度高、Windows 上没有中间层。很多人以为 GDI 更快这是最大的误解来源。两套绘图面对的是同一块屏幕、同一个 CPU真正的性能差距更多取决于绘制路径上的额外开销——GDI 是 COM 风格接口每次调用需要进入 GDIP 的封装层QPainter 的 raster 引擎则经过了一整条 Qt 渲染管线。到底谁更高效不能拍脑袋必须实测。2. 实测实时绘制场景下的性能表现与瓶颈定位2.1 我的测试环境和压测方案为了尽量贴近真实业务我没有用简单的“画几万条线”这种抽象基准测试而是搭了一个模拟示波器界面的场景画布尺寸 1000x600每秒刷新 60 次每一帧绘制 500 个数据点组成的波形曲线同时叠加网格线、坐标轴标签和一个动态游标。测试机器是一台 Intel i7-10750H、16GB 内存、Windows 10 22H2 的笔记本编译环境是 MSVC2019 64 位Qt 版本 5.15.2。为了公平两种方案都用双缓冲QPainter 画在 QPixmap 上再一次性 BitBlt 到窗口GDI 画在内存 Bitmap 上再利用 GDI 的 BitBlt 上屏。压测指标主要有三个单帧绘制耗时单位 ms越低越好60fps 下的 CPU 占用率连续运行 10 分钟后的绘制稳定性是否出现掉帧或毛刺2.2 不开抗锯齿时两者打平但 QPainter 的波动更小第一组测试关闭抗锯齿。结果和我预想的差不多QPainter 单帧绘制平均耗时 4.2msGDI 平均 4.6ms差距在 10% 以内对实时应用来说基本无感。值得注意的不是平均值而是耗时的分布。QPainter 的 P50 是 4.0ms、P95 是 5.1ms表现非常稳定GDI 的 P50 是 4.3ms、P95 是 7.8ms尾部延迟明显偏大。在实时绘制场景里P95 比平均值更重要——偶尔一次超过 frame budget 就会表现为肉眼可见的卡顿。GDI 在某些绘制调用上会触发内部状态检查比如笔的缩放变化、坐标变换矩阵的重新计算这些都会带来不确定的延迟。2.3 打开抗锯齿后事情开始变得不一样第二组测试开启抗锯齿QPainter::Antialiasing / SmoothingModeAntiAlias。这一轮 GDI 明显掉队了。QPainter 单帧平均 7.3msP95 8.6ms仍有稳定跑满 60fps 的能力GDI 单帧平均 12.8msP95 18.4ms在数据点较多的局部区域比如游标附近密集绘制时出现了可感知的掉帧GDI 的抗锯齿走的是软件走样消除算法对每条线段的端点都要做额外的像素覆盖计算而 QPainter 的 raster 引擎内部用了更激进的分块扫描策略在处理连续线段时能复用相邻像素的计算结果。两者在“直线”和“折线”上的性能差异特别明显——QPainter 对 QPainterPath 或折线做了批处理优化GDI 的 DrawLines 虽然也走批量路径但内部仍会对每条线独立做几何裁剪。2.4 坐标变换和缩放GDI 的隐藏短板实时看板经常要做坐标缩放和视图平移。我用TranslateTransform和ScaleTransform对两组接口做了同样的变换处理结果更有意思QPainter 在平移/缩放场景下耗时增加约 20%GDI 开启缩放变换后耗时增加约 90%问题出在 GDI 的变换管线设计上。GDI 的Graphics::ScaleTransform()会触发内部世界变换矩阵的重新计算并且影响到后续所有绘制对象的笔宽度和画刷平铺模式而 QPainter 的变换只修改 QTransform绘制参数在光栅化时才统一应用到像素坐标少了一层对象级遍历。如果你的实时绘制不涉及缩放GDI 完全够用但只要涉及时不时拉近拉远、钻取详情GDI 的短板就会暴露。3. 双缓冲与刷新机制实时绘制真正的胜负手3.1 QPainter 的自动后台缓冲与事件驱动刷新Qt 的 QWidget 默认双缓冲是开着的paintEvent()里所有的绘制操作都会先作用在一个后台像素映射上等事件返回后再统一上屏。这意味着只要不自己往 HDC 上乱画基本不会出现闪烁。配合QTimer或QElapsedTimer你可以控制刷新频率QTimer *timer new QTimer(this); connect(timer, QTimer::timeout, this, [this]() { update(); // 请求重绘Qt 会合并多次请求 }); timer-start(16); // 约 60fpsupdate()有个很实用的特性如果在同一个事件循环迭代里被多次调用Qt 会自动合并成一个重绘事件不会重复执行 paintEvent。这在高频数据更新时特别友好属于一种天然的节流机制。3.2 GDI 的经典双缓冲写法手工管理 Bitmap 和 BitBltGDI 本身不带双缓冲需要自己实现。标准做法是// 创建内存画布 Bitmap backBuffer(width, height, PixelFormat32bppARGB); Graphics memGraphics(backBuffer); memGraphics.SetSmoothingMode(SmoothingModeAntiAlias); // 在内存画布上绘制 memGraphics.Clear(Color(255, 15, 15, 15)); memGraphics.DrawLine(pen, x1, y1, x2, y2); // 一次性上屏 Graphics screenGraphics(hdc); screenGraphics.DrawImage(backBuffer, 0, 0);这里有个性能陷阱Graphics::DrawImage()在默认情况下会对目标矩形做颜色修正和 alpha 混合处理。如果只是做整幅画面的拷贝可以用DrawImage(backBuffer, 0, 0, width, height)并确认源位图和目标尺寸一致否则会触发昂贵的缩放路径。更高效的做法是直接 GetDC 拿到句柄后用 GDI 的BitBlt做块拷贝避免经过 GDI 的 DrawImageHDC memDC CreateCompatibleDC(hdc); HBITMAP hBitmap nullptr; backBuffer.GetHBITMAP(Color(0, 0, 0, 0), hBitmap); HBITMAP oldBmp (HBITMAP)SelectObject(memDC, hBitmap); BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(hBitmap); DeleteDC(memDC);GetHBITMAP()是一次开销不小的转换不应在每帧都调用。正确的做法是只在尺寸变化时创建一次 Bitmap 和 HBITMAP每帧直接在一块持久化的内存 DC 上绘制然后 BitBlt。我在第一版代码里每帧都调用 GetHBITMAP结果 60fps 直接掉到 30fps后来才意识到问题。3.3 局部更新机制谁更能减少无效绘制实时看板不是整屏都在变化往往只有图表区域需要刷新周围静态控件一动不动。两种方案对局部更新的支持完全不同。QPainter 可以调用update(QRect)指定需要重绘的区域Qt 会根据 region 自动裁剪绘制范围减少无效的光栅化操作。实测把整屏刷新改成只刷新图表区域后QPainter 的 CPU 占用率下降了 40% 以上。GDI 对应的做法是InvalidateRect(hwnd, rect, TRUE)Windows 会把需要重绘的区域合并到 WM_PAINT 消息里。但是 GDI 在创建 Graphics 对象时绑定的是整个窗口的 HDC虽然系统会裁剪绘制区域内部仍然要为整个目标建立绘制上下文。也就是说局部刷新的裁剪收益在 GDI 里不如 Qt 里来得明显。如果你的界面 70% 以上的区域每帧都在变化这个差异可以忽略但如果只有一小块示波器在动QPainter 的局部更新优势会让你的 CPU 占用率拉开一个明显的差距。4. 抗锯齿、坐标系与文本绘制几个容易被忽视的细节差异4.1 抗锯齿的细节差异GDI 的偏色与 QPainter 的亚像素渲染抗锯齿打开之后两者都会对图形边缘做平滑。但有一个容易被忽视的区别GDI 的抗锯齿使用近似 alpha 混合在深色背景上绘制浅色图形时边缘偶尔会出现轻微的颜色偏移尤其是细线交叉区域会有一圈淡淡的灰色光晕。QPainter 的抗锯齿在软件光栅化层面做的是真正的多像素采样边缘透明度更准确。在浅色背景下两者几乎看不出差别但在深色科技风界面这种风格在实时看板里非常常见下QPainter 的观感会明显更干净。如果你选择 GDI 并且界面上有大量极细线条建议测试一下深色背景下的线条质量同时可以考虑用SmoothingModeHighQuality代替SmoothingModeAntiAlias前者内部使用更高精度的抗锯齿算法线条质量会好一点代价是性能进一步下降。4.2 坐标系统y 轴方向、像素中心对齐和 DPI 缩放QPainter 和 GDI 的坐标系都是 y 轴向下为正这一点是一致的。真正容易踩坑的是像素中心对齐问题。GDI 在绘制 1px 直线时默认把线画在整数坐标上实际覆盖的是相邻两行像素导致 1px 直线看起来像 2px 的模糊线。解决办法是在绘制前把坐标平移 0.5graphics.TranslateTransform(0.5f, 0.5f);QPainter 没有这个问题因为它的 raster 引擎会把坐标映射到物理像素格点。如果你从 GDI 迁移到 Qt看到别人的代码里多了一个TranslateTransform(0.5, 0.5)千万别直接删掉那是在纠正坐标对齐。DPI 缩放也是一个坑。Qt 5 在 Windows 上默认开启高 DPI 缩放模式QPainter 绘制时坐标系自动适配逻辑像素而 GDI 是独立于系统 DPI 感知体系之外的如果你的应用声明了 DPI 感知GDI 绘制坐标需要手动查GetDpiForWindow来做换算否则在高分屏上会出现绘制内容偏小的经典问题。4.3 文本绘制GDI 的排版质量更接近 Windows 原生文本绘制这块情况反过来了。GDI 的DrawString走的是 Windows 自带的字体渲染引擎和系统文本渲染结果完全一致QPainter 的文本绘制则依赖 Qt 的字体引擎在某些字体上尤其是 CJK 字体和特殊符号字体的渲染效果和系统原生渲染有细微差异。如果你的实时看板需要频繁刷新数字标签我的建议是用 Qt 的QStaticText替代普通drawText。普通drawText每次都要做文本布局分析而QStaticText在文本内容不变时只做一次布局绘制阶段直接走图形基元输出。实测在 60fps 刷新 20 个动态数字标签的场景下改用QStaticText后单帧耗时减少 1.8ms 左右。GDI 这边没有静态文本优化方案每次DrawString都要走完整的文本布局管线效率偏低。这是 GDI 在实时文本刷新场景里的明显短板。5. 混用 GDI 与 Qt一段真实的踩坑排查记录5.1 问题一GDI 在当前线程未初始化有些项目在 Qt 窗口里嵌入 GDI 绘图比如老代码改造最普遍的问题就是GDI 初始化跟线程绑定。GdiplusStartup是按线程初始化的如果在一个线程里调用了GdiplusStartup然后在另一个线程创建 Graphics 对象会直接抛异常或导致绘制缺失。我当时在一个 worker 线程里离屏渲染波形图主线程只负责显示结果结果 GDI 一路黑屏。排查了很久才意识到必须在 worker 线程内也调用GdiplusStartup。由于我的 worker 线程是常驻的初始化一次即可不需要每次绘制都启动和关闭。5.2 问题二QImage 和 HBITMAP 互转导致的一连串诡异闪烁做两套方案对照时我尝试过用 GDI 绘制到 HBITMAP再转成 QImage 塞进 QLabel 显示。这个流程里的坑很深// 方式一QImage 转 HBITMAP每帧都做性能极差 HBITMAP hbitmap image.toHBITMAP(); // 需要注意 alpha 通道和内存拷贝 // 方式二GDI 绘制到 QImage 的 bits() 指针 uchar *data image.bits(); Bitmap gdiBitmap(image.width(), image.height(), image.bytesPerLine(), PixelFormat32bppARGB, data); Graphics g(gdiBitmap);方式二看似高效直接共享内存但有两个致命问题。一是 QImage 的 bits() 按行对齐方式可能和 GDI 的 stride 不一致表面上 bytesPerLine 相同但像素格式的 alpha 顺序不同——Qt 默认是 ARGB32 或 RGB32GDI 的 PixelFormat32bppARGB 则按 Windows 内存布局颜色分量顺序会有差异。二是 QImage 的数据如果被 Qt 的隐式共享机制在某个时刻拷贝了比如 detach之前传给 GDI 的指针就成了悬空指针轻则花屏重则崩溃。解决思路是始终以 GDI 的 Bitmap 为数据源在显示阶段用系统 GDI 的 BitBlt 上屏绕开 QImage 的介入或者以 QImage 为数据源在创建 GDI Bitmap 时锁住 QImage确保在绘制期间不会被 detach。5.3 问题三消息风暴和绘制事件竞争在 Qt 中嵌入原生 GDI 绘制还需要注意 Windows 消息循环和 Qt 事件循环的协调。Qt 在 Windows 上使用自己的事件分发机制通过QAbstractNativeEventFilter可以收到 WM_PAINT 等原生消息但处理时机和 Qt 的重绘调度并不同步。如果你在原生消息里调用了InvalidateRect请求重绘而 Qt 同时也在派发 QPaintEvent就可能出现重绘竞争GDI 绘制的内容被 Qt 的空白背景覆盖或者反过来画面闪烁。我最终的规避方案是在自定义 QWidget 的paintEvent()里完成所有绘制不在原生消息层做回调。Qt 控件的winId()窗口句柄在 paintEvent 中通过HDC hdc GetDC((HWND)winId())拿到然后把 HDC 临时包装成 GDI Graphics。这样能保证 GDI 的绘制节奏和 Qt 的重绘节奏一致虽然绕了一圈但稳定性最好。6. 选型建议什么项目走哪条路更省心6.1 优先选 QPainter 的场景项目本身基于 Qt基本不考虑迁移到其他框架界面需要跨平台同一套渲染代码要在 Windows/macOS/Linux 上跑涉及大量抗锯齿、坐标变换、局部刷新的复杂图形交互后续可能用到 Qt 生态里的图表库、OpenGL 集成、视频渲染等QPainter 的最直观优势还在于开发效率。它和 Qt 的信号槽、事件系统是同一个生态不需要在两种渲染模型之间做心智切换。当你把 QPainter 的掌控力提升上来之后很多以前你以为只有游戏引擎才能做的事它都能做。6.2 优先选 GDI 的场景项目已经是 MFC / Win32 架构引入 Qt 不现实只是偶尔在 Windows 原生窗口上绘制简单图形不需要复杂的变换和局部更新与该系统的其他 GDI 图形模块深度集成比如直接操作 HBITMAP、ICON 等资源不在乎跨平台代码只跑 Windows如果你走 GDI我建议精心设计好“绘制分层”一份逻辑坐标系的数据模型一份负责把逻辑坐标映射到像素的设备层。方便后续切换到 Direct2D——GDI 的性能上限在复杂场景确实偏低微软自己也建议 GDI 只作为兼容层使用。6.3 我最终的结论和选择那次实测到最后我选的是 QPainter。核心原因不是某一场测试的胜负而是实时绘制项目永恒的问题不是“能画出来”而是“在叠加了缩放、抗锯齿、文本刷新、局部更新之后还能不能保持稳定”。GDI 在简单场景下性能并不差但一旦进入现代界面的复杂度它的稳定性明显跟不上。当然这不代表 QPainter 能适用所有场景。如果你的绘图代码需要被非 Qt 的模块复用或者你已有一个成熟的 GDI 绘制引擎没必要为了“统一框架”而重写。技术选型永远是围绕团队积累和项目约束来权衡的。最后分享一个我后来一直在用的实测模式不要只测单帧平均耗时重点记录 P95 和 P99不要只做静态曲线测试要模拟 10 分钟以上的持续刷新不要忽略抗锯齿、缩放这些“看似简单”的组合条件它们往往才是实时绘制卡顿的真凶。这个经验不管用 QPainter 还是 GDI 都适用希望对正在纠结的你有点帮助。
阅读完成 · 觉得有帮助?