ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Qt QPainter与GDI+实时绘制对比:选型与优化实战

Qt QPainter与GDI+实时绘制对比:选型与优化实战 Qt自带绘图与GDI绘图方式比较实时绘制场景下的选型与踩坑实录最近在做一个工业上位机项目需要实时绘制动态曲线和自定义控件界面框架用的Qt但绘制层遇到了一个很现实的问题用Qt自带的QPainter画还是继续用Windows下的GDI这俩能混着用吗性能差多少为什么同样的绘制逻辑一个流畅一个卡顿这篇文章就把我这两周实测对比的结果、踩过的坑、以及最终沉淀下来的绘制优化方案一次性说清楚给正在纠结同样问题的朋友一个可复用的参考。先说结论免得后面看晕如果你的绘制逻辑已经脱离Qt的QPainter体系比如从第三方模块拿到的是GDI的HDC句柄或者你需要在Qt窗口上叠画一段已有GDI代码那就必须搞清楚两者的坐标系、状态机制和刷新策略差异如果是纯Qt项目建议优先用QPainter但要用对离屏缓存、脏矩形和增量刷新这三板斧否则照样卡成PPT。这篇文章适合用Qt做上位机、数据采集显示、自定义控件的开发者也适合从MFC/GDI转过来的老Windows程序员。1. 为什么会在Qt项目里纠结GDI还是自带绘图1.1 真实场景实时绘制到底在绘制什么先说我的项目背景设备通过串口和网口每秒上报几千个数据点界面需要把这些点实时画成波形同时还要绘制温度表盘、液位指示、报警状态等自定义控件整体帧率要求不低于30fps。听起来不复杂但一旦把坐标变换、抗锯齿、文字绘制、背景刷新全算进去性能就开始告急。这种场景下画什么往往决定了你选什么方案。如果你只是画几条静态折线、几个矩形QPainter和GDI差别不大但如果涉及高频动态刷新、大量图元叠加、透明混合两者的渲染管线和底层加速方式会产生肉眼可见的差距。GDI在Windows下是系统级绘图接口QPainter是跨平台绘图抽象层在Windows平台上QPainter的底层光栅引擎既可以走GDI也可以走自身的FP16格式光栅化这两条路径在实时绘制下的表现完全不一样。1.2 QPainter与GDI的本质差异很多人以为QPainter和GDI只是API不同底层差不多这个认知在低频率绘制下没什么问题但到了实时绘制场景就会翻车。它们至少有三个本质差异第一坐标系模型不同。GDI默认用的是物理设备像素坐标原点在窗口客户区左上角Y轴向下QPainter默认的坐标系统是逻辑坐标通过setWindow和setViewport可以随时做映射和缩放。热词里专门有“qt 逻辑坐标系 设备坐标系”这个点在实际开发中非常重要因为GDI代码里的坐标值到Qt里直接画会歪必须在两者之间建映射关系。第二状态管理模式不同。GDI的Graphics对象是一个“大管家”所有画笔、画刷、变换、混合模式都挂在它身上绘制顺序变了要手动保存和恢复状态Save/Restore。QPainter也有类似的save()/restore()但QPainter的状态栈更轻量而且每个绘制函数都是一个独立操作不容易出现“上一个图形的画刷忘了还原导致后面全都变红”这种问题。第三文本渲染引擎不同。这是很多人忽略的坑。GDI的文本渲染走的是TextRenderingHint控制Qt则使用自己的字体引擎Windows上可能走DirectWrite或GDI同一个字体、同一字号、同一个坐标两者画出来的文字宽度可能差1到2个像素。如果你的界面里有文字和图形拼接的控件比如表盘刻度值从GDI迁移到Qt后会出现文字偏移、遮挡刻度的现象。1.3 选型前先问自己三个问题我的建议是动手写代码之前先做三个判断可以省下大量返工时间你的绘制代码是新的还是现有的如果是从零开始直接用QPainter因为它在Qt的事件循环、窗口系统、绘图设备集成方面有天然优势如果有一大坨稳定的GDI代码比如成熟的报表打印模块或复杂图表算法迁移成本高可以考虑在Qt窗口内混用GDI但必须按第4节的方式处理。目标平台只有Windows还是需要跨平台如果未来要部署到Linux或嵌入式设备GDI这条路直接堵死QPainter是唯一选择而且Qt的跨平台光栅引擎在嵌入式环境下的表现比桌面端更可控。你的性能瓶颈到底在绘制本身还是刷新机制大部分实时绘制卡顿都不是“画得慢”而是“画了太多次”或“整个窗口都在重绘”。如果是后者换GDI还是QPainter都救不了你必须从刷新策略解决这事本文第4节细说。2. 两种绘图方式的底层机制逐个拆解2.1 QPainter渲染管线逻辑坐标映射到像素的完整过程QPainter绘制一个图形理论上要经历坐标变换、路径光栅化、像素填充三个阶段。坐标变换是它最灵活的地方——setWindow定义了逻辑坐标系的范围setViewport定义了屏幕上对应的物理区域两者配合可以实现自动缩放。举个例子我想让波形数据的横轴始终是0到1000对应1秒内的采样点纵轴是0到5V无论窗口怎么拉伸都不变形只需// 逻辑坐标0~1000x0~5y painter.setWindow(0, 0, 1000, 5); // 视口当前窗口的整个绘图区域 painter.setViewport(rect());这样画数据点的时候直接用真实数值即可Qt自动帮你做线性映射。GDI里要实现同样的效果需要手动计算ScaleTransform和TranslateTransform而且单位是像素数据和像素混在一起代码可读性会差很多。QPainter的光栅化引擎在Windows上有两个后端默认的QPainter::Raster后端是所有平台一致的软件光栅化绘制质量稳定但受CPU限制QPainter::OpenGL后端则可以将某些绘制操作转发给GPU。不过实测下来大量小图元的实时绘制走OpenGL后端不一定更快因为状态切换和纹理上传的开销会抵消GPU的并行优势。我自己的项目里1万个点以内的折线绘制Raster后端加离屏缓存反而是最稳的方案。QPainter还有一个“隐藏”特性它可以绑定不同的QPaintDevice除了窗口QWidget还能绘制到QImage、QPixmap、QPicture甚至QPrinter。这个特性对实时绘制至关重要因为你可以先在内存QImage上把静态背景画好然后每次刷新只把动态曲线合并上去避免每帧都重绘复杂背景。2.2 GDI渲染管线依赖HDC的Windows专属绘制方案GDI对比QPainter最别扭的地方在于它强依赖设备上下文HDC。绘制之前要拿Graphics对象从窗口HDC构造绘制结束后通常还要Release设备上下文。在Qt的paintEvent里混用GDI第一件事就是解决“如何安全地把QPainter还没用完的HDC拿走”的问题。GDI的绘制质量选项由SmoothingMode控制抗锯齿模式对性能影响巨大。SmoothingModeAntiAlias开启后折线、椭圆等图形会获得更好的边缘平滑效果但绘制速度会下降数倍。我实测过绘制1万个连续数据点的折线关闭抗锯齿约3毫秒开启后约29毫秒接近十倍差距。而QPainter的Antialiasing开关对性能的影响没有这么夸张大概在1.5到2倍之间原因是它用光栅引擎统一处理了抗锯齿逻辑管线更高效。GDI还有几个和实时绘制相关的底层特性它的合成模式CompositingMode决定了源像素和背景像素的混合方式CompositingModeSourceCopy可以关闭Alpha混合直接覆盖像素适合绘制频繁变化的动态图层CompositingModeSourceOver则是标准透明混合性能开销更大。这个细节在两种绘制方式混用时非常关键如果两个图层之间出现残留阴影或叠影多数是混合模式没设对。2.3 双缓冲与刷新策略性能差距的核心来源实时绘制场景下双缓冲和刷新策略的重要性远远超过绘图API本身。GDI在MFC时代的标准做法是用内存位图做双缓冲绘制完一次性BitBlt到窗口Qt的QWidget默认开启了Qt::WA_OpaquePaintEvent和自动双缓冲窗口的绘制在内部会先生成到后台缓冲再统一刷新从机制上规避了闪烁。但这里有一个很多人没注意的差异Qt的双缓冲是“整个窗口级别”的不是“控件级别”的。如果你在一个大型主窗口里放了一个实时刷新的小控件并且这个控件每帧都调用update()那么Qt会重绘整个窗口区域即使你没动其他部分。解决方式是给实时绘制控件设置setAttribute(Qt::WA_StaticContents)并且在paintEvent里手动利用QRegion限制重绘区域也就是常说的“脏矩形”刷新。GDI虽然也可以手动控制重绘区域InvalidateRectGetUpdateRgn但它在Qt框架里的控制粒度更粗因为你拿到的HDC往往对应的是整个Qt窗口的物理区域处理不当会把Qt自身的控件也重绘一遍。2.4 颜色模型与Alpha混合的实际差异颜色的表示方式也会在实际开发中制造麻烦。GDI的Color是ARGB四个字节Alpha在前Qt的QColor有红绿蓝和Alpha通道但它的数值范围和颜色空间在不同平台上有细微差异。尤其是做深色主题界面时GDI画的半透明遮罩和Qt画的半透明遮罩叠加在同一个画布上颜色衔接处会出现肉眼可见的色带。我在测试中遇到过一个具体问题用GDI画了一个半透明红色矩形Alpha128再用QPainter在它上面画一个半透明蓝色矩形Alpha128交界处呈现的颜色和两个API内部分别画的一半一半的结果有明显偏差。后来查下去发现是因为GDI的默认伽马校正和QPainter的颜色合成算法不一致。解决办法有两种要么统一用一种API完成所有Alpha混合要么把中间图层导出为QImage再交给对方处理。3. 实时绘制场景的实测性能与效果逐项对比3.1 测试环境与方法如何量化对比才靠谱这一节是纯经验分享先说明测试方法否则数据没有参考价值。硬件环境i7-10750H CPU16GB内存Windows 10 1909屏幕分辨率2560x1440缩放125%。软件环境Qt 5.15.2 MSVC2019 64位系统自带GDI 1.1。所有测试用QElapsedTimer计时每种场景连续绘制100帧取平均值并且关闭调试器干扰。测试分三个维度静态资源绘制一次性画大量图元、动态实时刷新每帧更新数据重绘波形、交互式变换拖拽平移和缩放时重绘每个维度分别对比QPainter、GDI、QPainter离屏缓存三种模式。性能以“单帧绘制耗时毫秒”和“帧率稳定性”两个指标衡量。3.2 静态资源绘制QPainter与GDI谁更占优静态资源绘制的测试内容是在一个1280x720的绘图区里绘制5000个随机点、500条折线、200个矩形、100段文字模拟一个复杂的监控仪表盘界面。实测数据QPainter默认模式抗锯齿关闭单帧约21.6毫秒GDI默认模式抗锯齿关闭单帧约27.3毫秒QPainter把静态背景预先绘制到QImage再把QImage贴到窗口单帧约8.2毫秒背景是缓存只算贴图时间。QPainter胜出的主要原因在绘制调用开销小它的drawPoint/drawLine每次调用的CPU指令比GDI少大量图元时差距会被放大。而GDI的DrawLine每次都要做状态检查、画笔参数解析、走GDI内部的对象管理调用次数一多开销就上来了。如果追求极致的静态绘制性能QPainter还有一招QPainter::drawPoints一次传入几千个QPoint比循环调用drawPoint快得多。这个特性和GDI的DrawLines类似但Qt的批量接口做得更全面支持点线、多边形、路径等常见类型。3.3 动态实时刷新波形绘制的终极考验动态实时刷新是最贴近我项目实际的场景每帧数据更新2000个点以30fps为目标绘制一条滚动波形同时显示网格和游标。测试结果如下表所示方案单帧耗时说明QPainter直接重绘全部区域12.4ms包含网格、背景、曲线全部重画QPainter 离屏缓存背景6.1ms背景网格只画一次曲线每帧更新QPainter 离屏缓存 脏矩形3.8ms只重绘波形发生变化的小区域GDI直接重绘全部区域19.7ms抗锯齿关闭其余条件相同GDI 内存位图双缓冲11.2ms手动管理内存DC和BitBltQPainter 离屏缓存 脏矩形在我这个场景下表现最好单帧3.8毫秒意味着可以把帧率推到60fps甚至更高。GDI的19.7毫秒虽然勉强够30fps但一旦系统负载升高帧率就掉到20以下波形会明显卡顿。这里要特别说明一点GDI绘制波形卡顿的根源不在绘图本身而在状态管理。GDI的Graphics对象在每次绘制时都要重新设置抗锯齿、缩放、平移等状态这些操作在底层会触发多次GDI状态机的重新计算。QPainter的状态栈设计更高效同类操作的开销只有GDI的十分之一到五分之一。3.4 交互式缩放和平移两种方式的体验差距波形类应用免不了缩放、平移、十字光标跟随这些交互。这类场景的难点在于每次交互都需要重新计算坐标映射并且重绘频率可能达到每秒几十次鼠标移动触发。我模拟了按住左键拖拽平移波形同时鼠标光标位置画一条竖线的场景持续5秒统计平均帧时间方案平均帧时间主观感受QPainter update()局部刷新7.2ms流畅光标跟随无延迟感QPainter 全窗口刷新13.8ms可感知到刷新但可接受GDI InvalidateRect局部区域15.5ms偶发卡顿光标略滞后GDI Invalidate()全窗口24.1ms明显卡顿帧率约40fpsQPainter的优势不仅体现在绘制速度还体现在它和Qt事件循环的集成深度。update()触发的是异步重绘请求Qt会在合适的时机批量处理不会因为鼠标事件频率过高而导致重绘堆积。而GDI混在Qt里时InvalidateRect的行为需要自己管理事件循环里稍微处理不当就会造成重绘风暴。4. GDI与Qt混用的关键细节与性能杀手4.1 为什么会有人选择混用GDI和QtQt自己的QPainter已经很能打了为什么还有人混用GDI我总结了自己和周围同事的实际案例主要有三类合理需求第一类历史代码资产。很多团队在引入Qt之前用MFC或Win32写了大量GDI绘制代码比如专业的波形分析控件、图片标注工具、报表打印模块。这些代码经过长期打磨算法成熟、边界处理完善直接移植到QPainter可能要两个月而混用只需要适配接口一周就能跑通。第二类第三方库输出的是GDI对象。比如某些图像处理库、OCR库、测量仪器SDK它们提供的结果是Bitmap或GraphicsPath类型的GDI对象。把这些对象绘制到Qt界面里最直接的方式就是在Qt窗口上创建GDI的Graphics然后调用第三方库的绘制方法。第三类特殊效果需求。GDI在少数效果上有独到之处比如某些笔刷类型HatchBrush、PathGradientBrush、特殊的文字路径效果Qt的QBrush体系暂时没有直接对应的实现。为了一个效果把整个绘制框架换掉不值得局部混用反倒经济。4.2 混用时的坐标系转换与设备上下文获取在Qt的paintEvent里混用GDI最核心的问题就是“怎么拿到可用的GDI设备上下文”。常规做法是在paintEvent之外截获窗口的HDC但有一个更安全的方案使用QPainter的beginNativePainting()和endNativePainting()。这一对函数专门为原生绘制而设计调用beginNativePainting()之后QPainter会释放对绘图设备的控制权此时你可以安全地获取winId()对应的HDC并构造GDI的Graphics对象绘制完成后调用endNativePainting()把控制权交还给QPainter。void Widget::paintEvent(QPaintEvent* event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 先画Qt自己的内容 painter.drawLine(0, 0, width(), height()); // 进入原生绘制模式 painter.beginNativePainting(); HDC hdc painter.handle(); // 获取Windows设备上下文 if (hdc) { Gdiplus::Graphics graphics(hdc); graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); Gdiplus::Pen pen(Gdiplus::Color(255, 255, 0, 0), 2.0f); graphics.DrawLine(pen, 10, 10, 200, 200); } painter.endNativePainting(); // 继续画Qt内容 painter.drawRect(50, 50, 100, 100); }这套流程的关键点在于坐标系的统一。QPainter如果没有做setWindow/setViewport它的坐标就是设备像素坐标和GDI默认坐标系是一致的可以直接混用。但如果QPainter做了窗口视口变换那么painter.handle()拿到的HDC仍然处于物理像素坐标系你必须手动把逻辑坐标转换为设备坐标再传给GDI否则图形位置会错。我在项目中踩过这个坑Qt的setViewport会把绘制区域缩小到窗口的一部分此时QPainter的坐标是相对于这个视口的而GDI的HDC是相对于整个窗口的两者的原点不一致画出来偏了半个屏幕。后来统一封装了一个坐标转换函数问题才解决。4.3 混用时的三个大坑HDC生命周期、重绘闪烁、状态脏残留坑一HDC生命周期管理。painter.handle()返回的HDC只在paintEvent期间有效一旦paintEvent返回HDC就可能被系统回收。千万不要把这个HDC存起来异步使用比如在定时器或工作线程里调用GDI绘制内存访问会直接崩溃。实时绘制场景如果需要在后台线程生成图像应该在线程内部用自己的内存位图创建GDI的Bitmap对象绘制完成后通过信号把结果送回UI线程。坑二重绘闪烁。混用GDI后Qt的自动双缓冲会失效一半——因为beginNativePainting()期间Qt控制的缓冲机制被切断了GDI绘制是直接写在窗口的物理设备上。如果你的GDI绘制速度不够快就会在屏幕上留下闪烁或残影。解决思路是“先画到内存再一次贴屏”在内存里创建一个QImage或BitmapGDI在该内存设备上完成绘制最后用BitBlt或QPainter::drawImage一次性输出到窗口。// 思路先在离屏QImage上用GDI绘制 QImage image(size(), QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent); { QPainter p(image); p.beginNativePainting(); HDC hdc p.handle(); Gdiplus::Graphics g(hdc); // ... 调用GDI绘制 p.endNativePainting(); } // 再一次性贴到窗口 QPainter windowPainter(this); windowPainter.drawImage(0, 0, image);坑三状态脏残留。GDI的Graphics对象不会自动重置状态Pen、Brush、SmoothingMode等设置会一直保留。如果你在某个分支里设置了红色的粗画笔忘记改回来后面所有GDI绘制内容都受影响。Qt的QPainter也类似但它有save()/restore()的栈机制用起来更安全。混用时代码里要养成习惯每一个原生绘制片段结束前都明确重置或恢复GDI的状态。4.4 高性能实时绘制的通用优化三板斧说到这给所有做实时绘制的朋友分享一套不依赖具体API的优化套路这套思路在QPainter和GDI里都能用。第一板斧静态内容离屏缓存。网格、坐标轴、边框、背景色这些不常变化的内容只画一遍存成一个QImage或位图。实时刷新时先贴背景再画动态数据成本从“每次画几百个图元”降到“一次贴图一次画曲线”。第二板斧脏矩形刷新。不要每次update()整个控件而是计算真正的变化区域。波形滚动时只有最新数据和左侧移出区域是变化的中间大部分像素可以复用上一帧的缓存。通过QRegion传参给update(const QRect)把重绘面积缩小到原来的十分之一帧率立刻翻倍。第三板斧增量绘制。对于波形曲线可以把上一帧的曲线保存下来新帧只画新增区段和数据点而不是把整条曲线重新画一遍。这要求你的绘制逻辑支持“从某个索引开始继续画”的状态模式看似增加编码量但实时绘制性能提升最明显尤其是数据点超过1万的场景。5. 常见问题排查与技巧实录5.1 问题速查表问题现象可能原因解决方案QPainter画折线末端缺一格坐标映射到整数像素时被截断绘制前对所有坐标做qRound()或给画笔设置PenCapStyleGDI开启抗锯齿后性能骤降SmoothingModeAntiAlias对折线逐点处理动态数据用SmoothingModeNone只给文字和静态图标开抗锯齿混用GDI后窗体出现闪烁beginNativePainting切断了Qt的双缓冲改为离屏QImage 一次性贴图方案Qt窗口缩放后GDI图形错位高DPI缩放导致物理像素与逻辑像素不一致设置Qt::AA_EnableHighDpiScaling后用painter.device()-devicePixelRatio()换算波形残留上一帧的残影动态区域没被完全覆盖绘制动态区域前先用背景色填充脏矩阵频繁update()导致CPU占用高重绘风暴每次重绘都处理了全局区域缩小update区域按需合并重绘请求5.2 关于Qt国际化与文本绘制的提醒热词里出现了“qt国际化”这个和绘图也有关联。Qt的文本绘制在线程中会使用QTextLayout它对国际化文本的排版比如阿拉伯语、中文依赖系统语言环境。如果项目做了多语言切换绘制带文本的控件时要特别留意字体的本地化回退否则中文环境下设置的标准字体可能在英文系统上缺失导致绘制出的文字风格和布局变化。GDI的文本绘制也存在类似问题但它走的是系统字体链行为相对稳定。5.3 两种方案如何和QChart结合如果你的图形需要更高级的交互比如缩放时显示数据提示、拖拽选择时间范围建议在QPainter自绘的基础上嵌入QChart这样可以利用QChart的交互框架同时把性能敏感的波形区域用自绘的QPainter代码替换。QChart底层的绘制也是基于QPainter和QGraphicsView体系在大型数据点场景下可以关闭QChart的抗锯齿和动画只保留坐标轴数据曲线用“QPainter 离屏缓存”的方式叠加在图表视图之上。这种方式既保留QChart的交互能力又能达到实时绘制的性能要求。5.4 自定义进度条、桌面画线等高频控件的共用思路热词里还有“qt 自定义进度条”和“qt桌面画线”这类控件的绘制逻辑其实和实时波形一样遵循同一套优化原则背景与前景分离缓存、状态变化时只重绘局部、绘制函数尽量批量调用。我实际写过一个自定义进度条外层用QPainter画圆角矩形轨道内部水波效果用“外部预生成水波纹理 按进度裁剪”的方式实现加载耗时几乎为零和直接用控件默认样式可以做到无差别体验。这个思路完全可以反向指导桌面画线类应用画布用QImage做重放缓存每次鼠标移动只画新线段就不会出现拖拽卡顿。6. 实测下来最终我怎么选这次对比测试之后我的项目最终确定了一套混合架构界面框架和常规控件用Qt QPainter历史遗留的波形分析模块和特殊笔刷效果继续用GDI中间用离屏QImage作为交换媒介而不是直接在paintEvent里混用。这样做的原因很朴素纯QPainter方案在性能、跨平台、维护性上都占优我的新代码全部走QPainterGDI的唯一优势是兼容旧代码那我就在新旧代码之间加一层“图像交换层”让两者不直接竞争同一个绘图设备既保住了性能也保住了历史资产。如果你也在做类似的实时绘制选型我的建议是先测数据再说别凭感觉决定。把你自己项目的图元数量、刷新频率、交互复杂度代入测试用例用QElapsedTimer跑一圈对比QPainter和GDI的实际表现再决定架构方向。所有绘图框架都有各自最适合的场景没有绝对的“更好”只有匹配你场景的“更合适”。这套测试方法论比任何网上的结论都可靠。
返回列表