
在Windows上做Qt界面开发凡是做到实时曲线、大屏看板、自定义控件这一类需求一定会撞上同一个问题绘图到底该用Qt自带的QPainter还是干脆调Windows原生的GDI这个选择题我纠结过很多次也在不同项目里踩过坑。网上能搜到的资料大多是单讲某一种方案怎么用像标题这种“Qt自带绘图与GDI绘图方式比较”的对比内容要么浅尝辄止要么直接给结论但不说为什么。这篇博文我想用实际项目里验证过的经验把两套机制从底层原理、触发模型、性能表现到具体实现完整过一遍重点放在实时绘制场景比如波形显示、自定义进度条、画线工具这类需要高频刷新的需求。如果你是刚接触Qt或者在用Qt做Windows桌面工具想搞清楚“绘图方案到底怎么选”这篇文章应该能帮你省掉不少弯路。1. 两种方案到底是什么1.1 Qt绘图体系QPainter背后那套抽象Qt自带的绘图体系核心是三个类QPainter、QPaintDevice、QPaintEngine。QPainter是绘图指令的入口你在代码里调用的drawLine、drawRect、drawPath、drawText都由它负责解释QPaintDevice是绘制目标也就是“画到哪儿去”常见的实现有QWidget、QImage、QPixmap、QOpenGLFramebufferObjectQPaintEngine则躲在最底层把QPainter的指令翻译成具体后端的操作。很多初学者只跟QPainter打交道不知道后面两个类的存在这很正常。但真正理解这套抽象以后你会发现一个巨大的好处同样的绘制代码可以在完全不同的目标上复用。你在QImage上画完一张图可以转手保存成PNG也可以把它贴到QWidget上在QWidget上写的绘图代码也能轻松改成输出到打印机。这种“写一次到处画”的能力是Qt绘图系统区别于平台原生API的核心优势。从渲染后端看Qt 5/6在widget体系里默认走的是光栅引擎software raster engine纯CPU计算这一点和GDI其实是同一条路线。只有在使用QOpenGLWidget或者自己配置了OpenGL渲染路径时才会走硬件加速。也就是说针对“Qt自带绘图vs GDI”这个标题的默认比较场景两边都是CPU软渲染剩下的差异更多来自各自的实现细节和API设计。1.2 GDI在Windows生态里的定位GDI是微软在Windows XP时代推出的图形设备接口用来取代早期的GDI。它的核心设计思路比老GDI强了不少所有绘图操作统一由Graphics对象发起可以在构造时绑定窗口HDC也可以直接绑定窗口句柄HWND。另外它内置了抗锯齿、渐变画刷、路径、图像处理等功能这在当时算非常“现代”的绘图接口。使用GDI有一个绕不开的前置步骤初始化。必须在程序启动时调用GdiplusStartup结束时调用GdiplusShutdown。很多初学者第一次跑GDI代码还没画任何东西就崩溃十有八九是漏了这一步。用起来以后它的模型也非常“Windows原生”你写的是OnPaint、处理的是WM_PAINT消息、依赖的是HDC。这种风格的优点是跟操作系统贴合紧密缺点也很明显——一旦脱离Windows这些代码就彻底不能用了。要理解GDI在实时绘制场景里的定位可以把它看成是“Windows Console程序升级到GUI时代后给出的官方绘制答案”。它的性能上限取决于你如何使用它简单的线段、矩形绘制速度可观但复杂路径、高分辨率抗锯齿、批量小图元绘制时性能开销会明显增加。具体数字后面实测部分会给出。1.3 为什么实时绘制场景需要单独拿出来比“实时绘制”这四个字是区分绘图方案优劣的关键分水岭。如果只是画一张静态界面或者程序启动时生成一张图那用哪个方案都差别不大选自己熟悉的即可。但实时绘制意味着每秒有大量帧需要刷新每帧都要重新执行绘图代码而且用户对流畅度的感知非常直接——画面卡一下、闪一下体感都会非常差。典型的实时绘制需求包括实时波形显示示波器、心电信号、传感器采集曲线大屏数据看板里的动态图表自定义进度条、加载动画画布编辑工具里的鼠标跟随预览画线、画矩形选框视频帧叠加标注框这些场景里方案的触发模型、重绘合并策略、双缓冲机制、坐标变换效率每一项都会影响最终表现。所以这篇文章接下来的所有对比我都会把“实时绘制”作为默认测试环境而不是拿一段静态绘制代码随便说两句就完事。2. 实时绘制机制对比从触发到渲染的路径2.1 Qt的paintEvent与事件合并机制Qt里所有自绘控件的绘制逻辑都写在paintEvent里系统认为窗口需要重绘的时候会自动调用它。你在外部代码里触发重绘有两种方式update()和repaint()。这两者的区别是实时绘制场景首先必须搞明白的概念。update()是异步的。它只把“需要重绘”这个事件标记进事件队列然后立即返回真正执行paintEvent要等到程序控制权回到事件循环之后。如果在一个时间片里连续调用了多次update()Qt会把它们合并成一次重绘paintEvent只会执行一次。这个设计非常关键因为它天然天然限制了无意义的重复绘制保护了CPU和GPU不被高频刷新拖垮。repaint()则是同步的。它直接调用paintEvent调用完才返回中间不会等待事件循环。看起来“更听话”好像刷新更及时但代价是如果在定时器或循环里高频调用repaint()窗口会每帧都立刻全量重画闪烁、卡顿几乎是必然的。我在项目里见过不少新手喜欢用repaint()理由是“update不生效”实际上多半是自己把事件循环阻塞了或者对异步刷新机制理解不到位。实时绘制时我的经验只有一句话优先用update()需要局部刷新时用update(rect)传入脏矩形区域能大幅减少重绘面积。保护性编程思维放在这里就是你只管“告诉系统有东西需要重绘”剩下的合并与调度交给Qt收益通常比你手动“强迫”系统高效得多。2.2 GDI的WM_PAINT与消息驱动模型GDI的实时绘制路径绕不开Windows的消息机制。当你调用InvalidateRect时系统会给窗口发送WM_PAINT消息收到WM_PAINT后你调用BeginPaint拿到HDC再把这个HDC传给GDI的Graphics对象去执行绘制。对应关系其实很明显InvalidateRect对应Qt的update()WM_PAINT对应paintEventBeginPaint/EndPaint则相当于Qt在调用paintEvent之前帮你准备好的绘制上下文。但有一个容易踩坑的差异点GDI的Graphics对象构造是有成本的尤其是设置各种SmoothingMode、CompositingMode之后。在WM_PAINT里每次重新构造一个Graphics再次设置一遍参数高频刷新时会造成不少无谓开销。比较合理的做法是提前准备好绘图参数或者在WM_PAINT里减少不必要的状态切换。另外还要注意GDI本身并不会帮你合并重绘请求消息循环的合并能力取决于系统对WM_PAINT的处理机制。Windows本身会在队列未处理完成时合并多个无效区域但如果你写入的代码路径对每个InvalidateRect都强制UpdateWindow同步刷新就会绕过这种合并机制导致和Qt里疯狂调repaint()同样的后果。所以GDI场景下实时刷新也是同样的原则异步无效化为主同步刷新只用于必须立刻反馈的交互反馈比如鼠标拖拽框选这类。2.3 双缓冲闪烁问题为什么存在又怎么解决双缓冲是实时绘图里绕不开的话题闪烁的本质是画面在屏幕上的update旧画面清除和新画面绘制分两步进行且中间有一段时间屏幕显示的是“半成品”内容人眼就把它识别为闪烁。Qt支持双缓冲的方式比较“隐形”。从Qt 4.1开始QWidget默认就是双缓冲的系统会在内存位图里完成整个paintEvent的绘制再把整块结果一次性呈现到屏幕上。这也是为什么你直接用QPainter在QWidget上画动态图形不大会出现明显闪烁感。要说注意事项就是别在自己的paintEvent里再用setAutoFillBackground(true)之类的方式叠加无意义的背景清除容易把双缓冲的优势抵消掉。GDI本身不默认提供双缓冲你需要自己实现。常见的做法是创建一个与窗口DC兼容的位图内存画布把一个Gdiplus::Graphics绑定到这个位图上所有绘制操作都执行在该Graphics上绘画完成后一次性BitBlt到窗口DC。如果你的绘制内容是实时波形这种整体可变的内容这几乎是GDI方案下消除闪烁的唯一正路如果只是局部小区域更新也可以只对脏矩形做双缓冲性能会更好。2.4 坐标转换setWindow/setViewport的用武之地坐标系统是绘图方案对比里容易被忽视、但在实时绘制中非常核心的差异点。Qt的QPainter支持两套坐标概念逻辑坐标窗口坐标系和设备坐标视口坐标系。默认情况下逻辑坐标原点在绘制区域左上角X轴向右Y轴向下单位是像素。通过setWindow可以重新定义逻辑坐标范围通过setViewport可以定义对应的物理像素范围。举个例子我要在一个宽度可变的控件里固定显示一条0到100的波形纵轴线用setWindow(0, -10, 100, 20)以后不管控件拉大缩小还是DPI变化坐标系都自动按比例映射波形不会因为窗口尺寸变化而变形。这对实时波形显示简直是刚需因为窗口缩放是经常发生的交互操作。GDI对应的坐标变换机制是Graphics对象的变换矩阵有TranslateTransform、ScaleTransform、RotationAngle等接口。用起来比Qt的更底层也更灵活但也要自己负责更多细节。比如你要实现类似“窗口尺寸变化后波形曲线自动缩放”的效果GDI里要么处理WM_SIZE消息后调整变换矩阵要么每次绘制前重新计算所有点的像素坐标。前者效率更高后者更直观但容易出错。这里我的经验是如果项目主要是Qt那么直接用setWindow/setViewport解决逻辑坐标到像素坐标的映射省心且性能足够好如果团队对Windows GDI/GDI更熟用变换矩阵也不难只是每个概念都要多一层理解。3. 性能实测不同负载下谁更稳3.1 测试环境与测试方法为了让对比数据有参考价值我选择了一个普通办公机环境Windows 10专业版CPU是i5-850016GB内存集成显卡Qt版本5.15.2 MSVC2019 64位GDI使用系统自带的gdiplus.dll。测试窗口尺寸固定为1280x720绘制逻辑尽可能等价。测试分三档简单图元绘制5000条直线、中等复杂度绘制2000个点连成的折线路径、高复杂度绘制5000个矩形5000条直线混合场景。每档分别测量两种方案的帧率FPS以及绘制一帧的平均CPU耗时。测试方式是通过定时器驱动重绘连续运行100帧后统计平均数据。这里需要说明一点GDI在测试时开启抗锯齿Qt绘图也开启QPainter::Antialiasing保持两边都承受同级别的质量开销。这样比出来的数据才是“同质量前提下谁更快”而不是“关掉特效裸奔赛跑”。3.2 不同场景的耗时与CPU占用对比第一档5000条直线QPainter抗锯齿平均帧耗时约3.5ms折合约285FPSCPU占用率约12%GDI抗锯齿平均帧耗时约5.2ms折合约190FPSCPU占用率约18%第二档2000点折线路径QPainter抗锯齿平均帧耗时约2.8ms约350FPSCPU占用率约10%GDI抗锯齿平均帧耗时约4.1ms约240FPSCPU占用率约15%第三档5000矩形5000直线混合QPainter抗锯齿平均帧耗时约12ms约80FPSCPU占用率约35%GDI抗锯齿平均帧耗时约18ms约55FPSCPU占用率约48%需要强调这些数据来自我手头这台老机器绝对数值不值得照搬到你的环境里但相对趋势是有参考价值的在软渲染的前提下Qt自家光栅引擎在大量图元场景下比GDI有可感知的性能优势差距大约在30到40%。这个优势主要来自Qt光栅引擎对一些基础图元绘制的流水线做了更好的优化以及paintEvent合并机制减少了无效重绘。但在“点位数量中等、绘制路径复杂”的场景里比如画一条由几万个点组成的平滑曲线两边差距会缩小因为决定帧耗时的瓶颈已经变成了坐标变换和路径生成而不是图元光栅化本身。这类场景的性能优化重点应该放在字节点集处理和路径生成上而非纠结选Qt还是GDI。3.3 高频实时绘制的调参经验实测之后有几个高频实时绘制的调参经验比方案选型本身更影响最终体验。第一能用局部刷新就别全局刷新。如果你的波形只是末尾新来了几个点旧点没有变化完全可以用update(rect)只刷新新数据区域而不是每次把整个控件擦掉重画。这个“脏矩形”思路对Qt和GDI都适用。第二关闭不必要的计算重放。很多实时绘制卡顿不是因为绘图API慢而是因为每次paintEvent里都在重复计算坐标、创建临时对象、甚至做字符串拼接。把这些计算移到数据更新阶段paintEvent只做“根据已算好的数据画出来”帧率会有质的提升。第三合理控制刷新频率。实时绘制的场景并不都需要60FPS比如自定义进度条5到10帧就足够顺滑实时波形15到20帧人眼就感觉不到明显卡顿。为其付出60FPS的CPU代价往往不值得。用定时器驱动刷新时把间隔设置得合理一些既是性能优化也是散热优化。第四抗锯齿不是必须全局开启的。高性能场景里可以在绘制背景、网格、批量辅助线时关闭抗锯齿只对前景关键曲线、文字开启抗锯齿。视觉上几乎没差别性能上能砍掉三分之一到二分之一的绘制耗时。4. 两个实战案例从零实现波形控件4.1 用QPainter实现实时波形控件波形控件是最典型的实时绘制场景。我用Qt重写一遍完整的实现路径逻辑分三步走数据维护、坐标计算、绘制。第一步数据维护。用一个QVector或std::vector保存最新N个采样点比如1000个点。新数据到来时把旧数据往前挪、新数据追加到尾部。注意这里不能每次重绘都重新拷贝整个数组。第二步坐标计算。在paintEvent里根据控件的当前尺寸调用setWindow/setViewport设置逻辑坐标让波形能在不同窗口大小下自动缩放。这一步做完之后后续的绘制坐标就可以直接用“真实数据值”来表达了。第三步绘制。先用QPainter画网格背景再用drawPolyline画波形折线。关键点是设置好QPen的宽度、颜色以及抗锯齿选项。我给出一个精简但可直接运行的代码骨架class WaveWidget : public QWidget { Q_OBJECT public: explicit WaveWidget(QWidget *parent nullptr) : QWidget(parent), m_points(1000) { setAttribute(Qt::WA_OpaquePaintEvent, true); m_points.fill(0.0f); } void appendValue(float v) { // 数据左移新值放入尾部 for (int i 0; i m_points.size() - 1; i) m_points[i] m_points[i 1]; m_points.back() v; update(); // 关键用update()异步触发重绘 } protected: void paintEvent(QPaintEvent *) override { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.fillRect(rect(), QColor(28, 28, 32)); // 设定逻辑坐标系范围0~999Y轴动态 int w width(); int h height(); painter.setViewport(0, 0, w, h); painter.setWindow(0, -2.0f, m_points.size() - 1, 4.0f); // 画网格线这里可关抗锯齿 painter.setRenderHint(QPainter::Antialiasing, false); QPen gridPen(QColor(255, 255, 255, 40)); gridPen.setWidth(0); painter.setPen(gridPen); for (int x 0; x m_points.size(); x 100) painter.drawLine(x, -2.0f, x, 2.0f); // 画波形主曲线 painter.setRenderHint(QPainter::Antialiasing, true); QPen curvePen(QColor(0, 255, 170)); curvePen.setWidthF(1.2f); painter.setPen(curvePen); QPointF polyline[1000]; for (int i 0; i m_points.size(); i) { polyline[i] QPointF(i, m_points[i]); } painter.drawPolyline(polyline, m_points.size()); } private: QVectorfloat m_points; };这个实现有两点很关键。一个是setViewport/setWindow配合使用让逻辑坐标和窗口像素解耦波形不会因为窗口拉大而变形或者留边。另一个是WA_OpaquePaintEvent属性它告诉Qt不需要再用背景色预先填充窗口能少一次无用绘制。配合fillRect(rect(), 背景色)自行清屏视觉上不会闪。4.2 用GDI实现同等效果的波形控件GDI版本要在Win32消息循环和HDC环境下工作。我这里用最接近真实项目的写法核心是WM_PAINT响应、双缓冲内存画布、以及GDI绘图API。最开始要记得初始化GDIGdiplus::GdiplusStartupInput gdiplusStartupInput; ULONG_PTR gdiplusToken; Gdiplus::GdiplusStartup(gdiplusToken, gdiplusStartupInput, nullptr);然后在窗口类里维护数据数组在WM_PAINT消息里执行绘制。注意WM_PAINT里必须用双缓冲先创建一个内存位图把GDI的Graphics绑定上去绘制结束后再一次性呈现到屏幕。我把核心绘制函数简化如下void DrawWaveForm(HWND hWnd, HDC hdc, const std::vectorfloat points) { RECT rc; GetClientRect(hWnd, rc); int nW rc.right - rc.left; int nH rc.bottom - rc.top; // 1. 创建内存缓冲位图 Gdiplus::Bitmap memBitmap(nW, nH, PixelFormat32bppRGB); Gdiplus::Graphics memG(memBitmap); memG.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); // 2. 清空背景 Gdiplus::SolidBrush bgBrush(Gdiplus::Color(28, 28, 32)); memG.FillRectangle(bgBrush, 0, 0, nW, nH); // 3. 画网格可选关闭抗锯齿节省性能 Gdiplus::Pen gridPen(Gdiplus::Color(255, 255, 255, 40)); for (int x 0; x nW; x 100) { memG.DrawLine(gridPen, x, 0, x, nH); } // 4. 坐标映射数据范围0~999Y方向映射到窗口中央±50% float midY nH / 2.0f; float yScale nH / 4.0f; Gdiplus::PointF* polyline new Gdiplus::PointF[points.size()]; for (size_t i 0; i points.size(); i) { float px (float)i / (float)(points.size() - 1) * nW; float py midY - points[i] * yScale; polyline[i] Gdiplus::PointF(px, py); } // 5. 画曲线 Gdiplus::Pen curvePen(Gdiplus::Color(0, 255, 170), 1.2f); memG.DrawLine(curvePen, polyline, points.size()); delete[] polyline; // 6. 一次性BitBlt到窗口 Gdiplus::Graphics screenG(hdc); screenG.DrawImage(memBitmap, 0, 0, nW, nH); }这个版本没有直接使用GDI里更高级的Transform功能来做窗口比例映射而是自己换算像素坐标原因是Win32消息循环下WM_SIZE之类的窗口尺寸逻辑容易和GDI变换互相干扰手写坐标换算最可控。另外步奏里有个很容易被忽略的坑Gdiplus::PointF数组的坐标值必须是用float表示的像素坐标如果直接拿“数据值”画曲线会跑出控件范围这和Qt里setWindow后的逻辑坐标概念完全不同。4.3 两种实现的选型复盘把上面两个实现放在一起看选型结论其实非常清晰如果你已经是Qt项目或者项目将来可能有跨平台需求选QPainter是更合理的路线。代码便宜、逻辑简洁、自动双缓冲、坐标系统完善光“不用自己处理WM_PAINT和HDC”这一点就能省掉大量样板代码。如果你的项目是纯Windows环境团队对Win32消息循环和GDI更熟悉或者需要跟已有的C Win32代码库做更底层的集成那么GDI完全可以只要自己做好双缓冲和坐标换算。两个方案在实时绘制能力上Qt自带的光栅引擎在纯软渲染下有性能优势我实测大概领先30%到40%但如果你能接受GDI并做好局部刷新和内存复用实际体验的差别并不像纸面数字那么大。真正的瓶颈往往在绘制逻辑的数据结构和坐标变换设计不在API本身。5. 常见问题与排查速查表5.1 闪烁与重影先检查双缓冲再检查刷新策略画动态图形时出现闪烁Qt环境里先确认是不是在paintEvent里做了额外的背景清除比如调用QPainter的eraseRect或者自己用背景色fill整个区域。正确做法是设置WA_OpaquePaintEvent属性然后在paintEvent里用fillRect完整覆盖绘制区域Qt的默认双缓冲机制会保证显示结果不闪。GDI环境里闪烁的根源通常是没有双缓冲。如果你发现自己直接在WM_PAINT的HDC上调用GDI绘制那闪烁几乎无法避免。解决办法是按4.2节的套路先画到内存位图再一次性BitBlt。如果用了双缓冲还闪检查你是不是在定时器回调里调用了UpdateWindow强制同步刷新要改成InvalidateRect。这里给一个排查经验表症状原因解决方向整体闪烁无双缓冲GDI常见内存位图 BitBlt局部残影背景清除不彻底确保fillRect覆盖整个绘制区域画面撕裂刷新频率过高且为全量重绘改用脏矩形局部刷新降低帧率边缘锯齿闪烁抗锯齿状态下频繁缩放绘制绘制到固定分辨率缓冲区再缩放显示5.2 DPI缩放与坐标系错位Windows下高分屏DPI缩放是绕不开的坑。Qt 5.6之后可以在main函数里设置AA_EnableHighDpiScaling和AA_UseHighDpiPixmaps让Qt自动处理DPI。但如果你在自定义paintEvent里用了setViewport或setWindow要注意计算的基准是设备独立像素还是物理像素混用时会出现波形显示区域偏移、缩放变形这类问题。排查思路是打印rect()和实际物理size()做对比确认坐标系基准。GDI的DPI问题更麻烦。默认情况下GDI的坐标系单位是像素但DPI缩放后系统会把逻辑坐标先做缩放变换如果你直接用GetClientRect拿到的尺寸去换算波形坐标画出来会偏小或者错位。解决办法有两个一个是调用SetProcessDPIAware让程序自己感知DPI变化然后所有坐标都基于物理像素计算另一个是GDI层面用Graphics::SetPageUnit配合DPI参数转换。前者更直接也更适合实时绘制我推荐优先用。5.3 文本与抗锯齿的肉眼差异很多人没注意Qt和GDI在文本渲染上的观感差异其实很大。GDI默认的文本渲染依赖系统ClearType字体边缘在相同字号下会显得更锐利而Qt在没有启用字体平滑策略时默认字体渲染可能偏“软”。如果你做的是大屏显示或高精度数据标注这种差异会在细节上影响观感。解决办法是在两套方案里都显式开启文本抗锯齿。Qt里设置QFont的HintingPreference和StyleStrategyGDI里用TextRenderingHintClearTypeGridFit。如果还需要更精确的文本位置两者都有文字测量API但要注意Qt的fontMetrics和GDI的MeasureString在字体指标上并非完全一致跨方案复用时别直接拿同一个宽高度硬编码。5.4 线程安全问题实时绘制经常涉及后台线程采集数据、前台UI线程刷新。这个场景下QPainter和GDI在安全性上要求不同。QPainter不能在非GUI线程直接绘制QWidget这是Qt的铁律。正确做法是后台线程写入数据缓冲区通过信号通知UI线程在UI线程的paintEvent里读取并绘制。如果数据量很大可以用双缓冲数组后台线程写当前数组UI线程读上一帧数组配合原子变量或简单锁做交换。QPainter本身在QImage上绘制是线程安全的同一时刻只能一个线程操作同一个QImage所以你也可以在后台线程先把波形画到QImage里再把QImage丢给UI线程显示这种方法在高负载实时绘制里很实用。GDI的Graphics对象不是线程安全的。多个线程同时创建多个Graphics分别绘制还可以如果你把同一个Graphics传给多个线程使用很容易崩溃。Win32下绘制窗口内容本身也只能在拥有该窗口的线程里进行所以GDI方案的线程模型必须遵循“后台采集、UI线程绘制”的架构。想用后台线程预先渲染的也得先渲染到独立的Bitmap对象再将结果提交到UI线程进行屏幕呈现。6. 一些自己的体会这两套绘图方案我前前后后用了好几年个人实践下来的结论可以总结成一句话如果你的项目已经是Qt就老老实实用QPainter别因为一时好奇去换GDI。Qt自带绘图在跨平台、开发效率、双缓冲处理、坐标系统完善度上综合优势非常明显实时绘制性能还更好。GDI适合的场景更多是在纯Windows的C项目里你本身就在写Win32代码或者需要跟老系统集成再考虑引入它。最后分享一个小技巧无论选哪套方案实时绘制的性能优化顺序都应该是“减少绘制面积 减少绘制次数 优化绘制状态切换 优化绘制算法本身”这个顺序我踩过好几次坑才总结出来。很多人一开始就纠结绘图API快不快、硬件加速开没开但真正让画面掉帧的往往是每帧都在做无畏的全屏重绘或者每次绘制都在重复创建对象。把这些基本问题解决掉你会发现哪怕不开硬件加速Qt和GDI也都能很轻松地撑起常见的实时绘制需求。