QPixmap绘图全攻略:从画布创建到性能优化实战 做Qt开发有一段时间的人基本都会碰到需要自己绘图的场景。做自定义控件、画尺子、画波形、贴图平铺、做截图工具或者只是简单地把几张图合成一张这些都绕不开QPixmap。系列第12篇我打算把pixmap绘图的完整用法和背后容易踩的坑一次讲清楚内容包括画布创建、QPainter绘制、图像透明处理、缩放平铺、截图保存以及几个我实测过的性能优化方案。这篇文章适合已经写过简单Qt程序、想深入做界面绘制的开发者也适合被绘图问题折磨过、想系统梳理一遍的中级用户。1. 项目场景pixmap绘图到底在解决什么问题1.1 先看应用场景再动手很多初学者是从QLabel显示图片开始接触Qt的设置一个setPixmap就能把图贴上去。但如果只是这样始终没进入真正的绘图世界。实际的界面开发里图片很少是“完整显示”那么简单。举个例子做一个地图浏览工具底图是几千像素的大图但界面上只显示一小块视口需要拖动查看不同区域。如果用QLabel直接加载原图缩放卡顿、拖动延迟都是必然的。正确的做法是把显示区域和绘图逻辑交给QPixmap做离屏绘制先在内存画布上把当前视口的内容画好再一次性贴到界面上。这就是双缓冲绘制Qt里最经典的性能优化方案。再比如做一个画板程序用户用鼠标在上面涂鸦。如果直接在widget的paintEvent里画每次鼠标移动都会触发整个控件重绘画面容易闪烁。更稳的做法是维护一个QPixmap作为画布把每次鼠标移动产生的线条画到这个画布上在paintEvent里只需要把画布原样贴出来。这样重绘成本极低画面也不会闪。还有一类场景截图工具。用户框选一个区域我们需要把屏幕上对应区域的像素抓下来然后给它加边框、加指示箭头、加半透明遮罩再保存成图片。这些处理全部可以在QPixmap上完成。从这个角度看QPixmap就是一个“能画图的内存画布”也是界面显示和图像处理之间的桥梁。1.2 QPixmap在Qt绘图体系中的角色Qt的绘图体系核心是三个类QPainter画笔、QPaintDevice画布、QPaintEngine绘制引擎。QPainter完成所有绘制操作QPaintDevice是绘制目标QPaintEngine是底层具体实现。简单说QPainter像一支笔QPaintDevice像一张纸而QPaintEngine是笔尖和纸接触的那层机制。QPixmap就是QPaintDevice的一个重要实现。凡是继承自QPaintDevice的类都能被QPainter绘制。QWidget可以QImage可以QPixmap当然也可以。在Qt里和QPixmap经常混在一起的还有QImage和QBitmap它们其实分工明确类名存储内容主要特点推荐场景QPixmap图像平台相关绘制和显示速度极快专门针对屏幕显示优化界面显示、离屏绘制、缓存QImage图像平台无关可以读写像素适合I/O和算法处理图片读取、像素级操作、跨线程处理QBitmap单色掩码1bit内存占用极小适合做蒙版不规则窗口、裁剪遮罩实际开发中我常用的组合拳是QImage负责加载和像素算法比如验证码识别前的灰度化处理好之后转成QPixmap给界面显示。这样既拿到了QImage的像素便利性又享受了QPixmap的显示速度。2. 绘制基础创建画布和认识绘制系统2.1 画布的创建与参数选择用QPixmap做离屏绘制的第一步是创建一张看不见的画布。最常见的写法是三行QPixmap canvas(400, 300); canvas.fill(Qt::white); QPainter painter(canvas);这里有几个容易忽略的细节。第一行创建了一个宽400、高300的画布初始内容是不确定的所以第二行必须用fill填充底色。如果不填充画到一半的背景颜色可能是随机的这在某些平台上甚至会带透明通道。第三行创建画笔并绑定到画布上之后所有绘制操作实际目标都是canvas。还有一种创建方式是直接从一个图片文件加载QPixmap image(:/images/background.png);这种方式常用于加载资源文件里的图片。但需要注意加载后的QPixmap尺寸是原图尺寸如果原图太大后面绘制很容易拖慢速度。我的习惯是加载后立刻预缩放QPixmap scaledImage image.scaled(800, 600, Qt::KeepAspectRatio, Qt::SmoothTransformation);顺便说一下scaled的平滑选项在缩放大图时非常重要。Qt::FastTransformation速度快但会出现明显的锯齿Qt::SmoothTransformation会做插值处理缩放后边缘平滑代价是稍微慢一点。对视觉要求高的场景比如相册缩略图一定要用平滑模式。2.2 QPainter绘制时的状态管理QPainter不是简单的“一笔画”它维护了一套完整的状态机制。比如setPen设置画笔颜色和线宽setBrush设置填充样式setFont设置字体。这些设置会一直作用到下一次改变为止绘制过程中如果不注意状态管理后面无辜的图形会被“带偏”。一个常见的错误是painter.setPen(QPen(Qt::red, 2)); painter.drawLine(p1, p2); painter.drawRect(rect); // 这里的矩形边框也是红色2px但你可能想要默认黑色1px处理办法有两个一是每次绘制前都设置好对应的画笔和画刷二是用save()和restore()把状态存起来、用完再恢复。第二种方式在复杂绘制里更优雅painter.save(); painter.setPen(QPen(Qt::blue, 3)); painter.drawLine(p1, p2); painter.restore(); painter.setPen(QPen(Qt::black, 1)); painter.drawRect(rect);save/restore保存的是画笔、画刷、字体、变换矩阵、透明度等全部状态相当于给绘制过程做了一个“事务快照”。我写复杂界面时习惯在每个绘制单元开始时save结束时restore这样调试状态污染问题时能省一半时间。2.3 几个高频使用的基础绘制函数QPainter提供的基础绘制函数非常多实际开发里使用频率最高的就这几个drawLine/drawLines画线段仪器仪表、波形图的基础。drawRect/drawRoundedRect画矩形自定义控件边框、图标背景常用。drawEllipse/drawArc画椭圆和弧线进度环、表盘刻度都是从这里演化。drawText绘制文字按钮上的文字、标注、坐标轴数值。drawPixmap把一个pixmap绘制到当前画布上是合成图片的核心操作。drawPath绘制任意路径复杂形状的唯一选择。比如画一个带圆角的按钮背景void paintButtonBackground(QPainter painter, const QRectF rect, bool hover) { painter.save(); QColor bg(66, 133, 244); if (hover) { bg bg.lighter(120); } painter.setPen(Qt::NoPen); painter.setBrush(bg); painter.drawRoundedRect(rect, 8, 8); painter.restore(); }这里用Qt::NoPen是为了只填充不描边否则默认会带一圈黑色边框。不设置NoPen直接画进去几十个按钮下边缘接线就会很突兀。这些看起来不起眼的细节恰恰是绘图成败的关键。3. 核心实操完整实现一个pixmap绘图示例3.1 图像合成加文字加图标一次完成我拿一个实际项目里的需求示范一下把产品图片和促销标签合成一张新图片用于商城列表展示。原始素材是一张商品主图我们需要在右下角加一个“特价”的半透明红色标签左上角加一个“新品”图标底部再压一行商品名文字。代码如下QPixmap composeProductImage(const QString productImgPath, const QString tagText) { QPixmap product(productImgPath); if (product.isNull()) { return QPixmap(); } // 创建比原图略高的画布底部预留文字区域 int textHeight 40; QPixmap canvas(product.width(), product.height() textHeight); canvas.fill(Qt::white); QPainter painter(canvas); painter.setRenderHint(QPainter::Antialiasing, true); // 画商品主图 painter.drawPixmap(0, 0, product); // 右下角画半透明红色“特价”标签 QRectF tagRect(product.width() - 100, product.height() - 40, 90, 32); painter.save(); painter.setPen(Qt::NoPen); QColor tagColor(220, 40, 40, 180); painter.setBrush(tagColor); painter.drawRoundedRect(tagRect, 6, 6); painter.setPen(Qt::white); QFont tagFont(Microsoft YaHei, 10, QFont::Bold); painter.setFont(tagFont); painter.drawText(tagRect, Qt::AlignCenter, tagText); painter.restore(); // 底部画商品名文字 QRectF textRect(0, product.height() 4, product.width(), textHeight - 8); painter.setPen(QColor(50, 50, 50)); QFont nameFont(Microsoft YaHei, 12); painter.setFont(nameFont); painter.drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, 秋季新款 简约百搭); painter.end(); return canvas; }这个函数的关键点有几个。第一创建画布时用product.width()和product.height()保证尺寸正确很多新手喜欢写死宽高一旦原图换尺寸就出问题。第二绘制带透明的标签颜色时颜色用了QColor(220, 40, 40, 180)第四个参数是alpha通道实现了半透明效果。第三绘制圆角矩形时同时设了Qt::NoPen否则半透明底色边上会有一条不透明的描边。3.2 在自定义控件里离屏绘制并显示上面合成好的图片如果直接在控件上展示最佳实践是提前把它准备好在paintEvent里只用一次drawPixmap完成绘制。下面是一个完整的自定义控件示例控件内部维护一张离屏画布鼠标拖动时画线class DrawingBoard : public QWidget { Q_OBJECT public: explicit DrawingBoard(QWidget* parent nullptr) : QWidget(parent) { m_canvas QPixmap(size()); m_canvas.fill(Qt::white); setFixedSize(400, 300); } protected: void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.drawPixmap(0, 0, m_canvas); } void mousePressEvent(QMouseEvent* event) override { if (event-button() Qt::LeftButton) { m_lastPoint event-pos(); } } void mouseMoveEvent(QMouseEvent* event) override { if (event-buttons() Qt::LeftButton) { QPainter painter(m_canvas); painter.setPen(QPen(Qt::blue, 3, Qt::SolidLine, Qt::RoundCap)); painter.drawLine(m_lastPoint, event-pos()); m_lastPoint event-pos(); update(); // 请求重绘触发 paintEvent } } private: QPixmap m_canvas; QPoint m_lastPoint; };这个模式我在多个项目里反复使用核心思路就是界面重绘时永远只画整张画布所有临时绘制动作都先发生在画布上。这样做的好处是鼠标快速拖动时paintEvent只需要执行一次简单的像素拷贝操作性能稳定。很多闪烁问题不用调setAttribute(Qt::WA_OpaquePaintEvent)也能直接消失因为绘制内容不再有无谓的重复计算。需要注意m_canvas必须在构造函数里用size()初始化不能在声明时初始化因为那时控件还没有实际大小得到的是无效尺寸。3.3 保存绘制结果到本地文件绘制完的画布最终大多数要落盘。QPixmap的保存非常直接bool savePixmap(const QPixmap pm, const QString filePath) { if (pm.isNull()) { return false; } return pm.save(filePath, PNG); }save的第二个参数是格式。PNG支持透明通道适合保存合成图JPG体积小适合照片类但会丢失透明信息BMP体积大但无损几乎只有在某些老旧流程里才用。如果不传格式Qt会从文件后缀自动推断。这里有一个我踩过很多次的坑在绘制过程中直接调用save可能保存出空白或者半截图片。因为QPainter还在作用期间部分平台驱动程序可能没有兑现所有绘制命令。所以稳妥的做法是先调用painter.end()或者干脆离开作用域让QPainter析构再执行save。上面合成商品的例子里我特意用了painter.end()再return就是这个原因。4. 进阶技巧图像缩放、透明处理、缓存加速4.1 图片缩放的三类场景三套做法QPixmap缩放看起来一个scaled搞定但不同场景有不同的最佳实践。场景一缩略图生成。需要一次把大图缩成小图用scaled向小尺寸缩放。为了保证显示质量必须用Qt::SmoothTransformationQPixmap thumb largeImg.scaled(120, 120, Qt::KeepAspectRatio, Qt::SmoothTransformation);场景二界面自适应缩放。控件尺寸变化时需要随时缩放图片如果每次都调用scaled会频繁触发CPU计算。更聪明的做法是维护一个“当前显示的尺寸”变量只有尺寸真的变化时才重新缩放用缓存尺寸做比对if (m_cachedSize ! widgetSize) { m_cachedPixmap m_originalPixmap.scaled(widgetSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); m_cachedSize widgetSize; } painter.drawPixmap(targetRect, m_cachedPixmap);场景三平铺背景。比如聊天窗口的背景纹理希望用小图连续拼接铺满整个控件。这个时候drawPixmap的平铺重载非常方便painter.setBrush(QBrush(m_patternPixmap)); painter.setPen(Qt::NoPen); painter.drawRect(this-rect());对比一下循环调用drawPixmap拼接的方式不仅代码丑性能也不如QBrush平铺稳定。用QBrush的方式在窗口大小变化时自动适配不需要自己算行列。4.2 透明处理的常见问题QPixmap的透明处理有个典型坑创建画布后直接在上面绘制带alpha的颜色看上去会“灰灰的”而不是透明效果。原因是QPixmap默认是QImage::Format_RGB32根本不带alpha通道。需要显式指定格式QPixmap canvas(width, height); canvas.fill(Qt::transparent); // 但这只能让画布全透明不能无中生有造出alpha通道正确的做法是构造时指定带alpha的格式。比较绕的是QPixmap构造函数的格式参数是受平台限制的跨平台最稳的方案是用QImage中转QImage img(width, height, QImage::Format_ARGB32); img.fill(Qt::transparent); QPixmap canvas QPixmap::fromImage(img);但如果你只是想在界面上贴一张带透明区域的PNG图那直接用QPixmap::fromImage加载就可以PNG自身的alpha通道会自动保留QPixmap icon(:/icons/close.png);这种情况下不需要额外处理。需要手动创建透明画布的场景多半是自定义悬浮窗、气泡、遮罩层这类需求我推荐直接记住上面的QImage中转写法它能保证在Windows、Linux、macOS下行为一致。4.3 高DPI屏幕下的设备像素比问题在Retina屏或者高分辨率Windows屏幕上做绘图会遇到一个非常隐蔽的问题图片显示模糊或尺寸偏小。原因是Qt在1920x1080、150%缩放比例的屏幕上逻辑坐标和物理像素不是1:1一个逻辑单位可能对应1.5个物理像素。解决办法是设置设备像素比。QPixmap提供了一个setDevicePixelRatio接口qreal dpr devicePixelRatioF(); // 或者从windowHandle()获取 QPixmap canvas(size() * dpr); canvas.setDevicePixelRatio(dpr); QPainter painter(canvas); // 绘制时依然使用逻辑坐标很多人不理解这句“绘制时依然使用逻辑坐标”的含义。举个例子界面上要绘制一个100x100逻辑像素大小的区域设备像素比是2那么真正在物理屏幕上占据的像素就是200x200。如果不设置setDevicePixelRatio绘制结果会被拉伸图片变得模糊。设置了之后Qt知道这张图是高DPI图会按物理像素精确映射。这个坑在普通PC上不明显但越来越多用户使用高分屏做通用软件时如果不处理截图工具、绘图应用在用户屏幕上都会显得发虚。我现在的习惯是所有做离屏绘制的QPixmap创建后立刻按当前设备的devicePixelRatioF()设置好。4.4 缓存与复用避免重复绘制的性能飞跃离屏绘制最大的性能收益来自“复用”。一旦画布内容不变就不应该反复重新绘制。Qt界面动画、状态切换频繁的场景如果不加缓存每一帧都会重新执行所有的绘制逻辑CPU占用率高掉帧也明显。我常用的做法是引入一个bool m_cacheDirty标志void ensureCanvas() { if (!m_canvas.isNull() !m_cacheDirty) { return; } m_canvas QPixmap(size()); m_canvas.fill(Qt::white); // ... 在这里执行耗时绘制逻辑 m_cacheDirty false; } void paintEvent(QPaintEvent*) override { ensureCanvas(); QPainter painter(this); painter.drawPixmap(0, 0, m_canvas); }数据变化时只需要m_cacheDirty true; update();。整套机制在绘制复杂波形、地图图层、图表时收益极大重绘逻辑从每帧几十次draw操作缩减到一次drawPixmap。5. 实战避坑QPixmap绘图最常见的5个问题5.1 QPixmap不能在子线程操作QPixmap是平台相关的对象Qt官方明确规定它只能在主线程GUI线程使用。如果在子线程做QPixmap绘制、save、scaled轻则崩溃重则出现随机性的绘制错乱。子线程需要处理图片的正确姿势是使用QImageQImage是纯CPU操作不依赖平台可以跨线程。处理完成后再发信号给主线程在槽函数里转成QPixmapvoid Worker::run() { QImage frame(width, height, QImage::Format_RGB32); // ... 在子线程中处理 frame emit frameReady(QPixmap::fromImage(frame)); }如果必须在线程里频繁生成QPixmap给界面显示也可以用QImage传回后转换。总之记住一条一切QPixmap的创建、修改、保存都放主线程否则迟早出问题。5.2 绘制结果不显示的几种可能新手经常问“我明明画了但界面上什么都没有”。总结下来最常见的几个原因第一paintEvent压根没被触发。修改控件状态后需要调用update()它只是要求Qt在合适时机重绘并不是立即刷新。如果急需刷新比如用户操作完要求马上反馈可以调repaint()不过repaint()会阻塞日常推荐update()。第二绘制内容被后面绘制的内容覆盖了。paintEvent里如果有两条绘制语句比如先drawPixmap再fillRect后面的会把前面的盖住。检查一下绘制顺序把背景填充放在最前面内容放在后面。第三画笔和画刷颜色相同画了也看不见。比如默认画刷是白色背景也是白色画出来的图形和背景融为一体。调试时可以先换一个高对比颜色比如红色确认图形位置再改回最终颜色。第四控件开了透明属性但没有正确绘制背景。WA_TranslucentBackground开启后控件的背景默认不绘制必须在paintEvent里显式填充透明否则整个控件都是黑色大块绘制内容会被吞掉。5.3 大图加载导致的内存尖峰加载一张几十MB的超大图时QPixmap本身需要解码整个文件再加上拷贝、缩放临时内存很容易造成内存尖峰极端情况下直接OOM。处理这类大图的标准做法是分级加载先不要直接QPixmap(path)加载原图而是用QImageReader先读取尺寸QImageReader reader(path); reader.setAutoTransform(true); QSize originalSize reader.size(); // 根据需求计算合适的缩放比例 reader.setScaledSize(originalSize / 4); QImage img reader.read(); QPixmap pm QPixmap::fromImage(img);QImageReader的setScaledSize在解码阶段就完成缩小内存占用远低于先加载原图再scaled的方式。这个方法在处理相机拍摄的高分辨率照片时非常管用能避免一次加载占用几百MB内存的尴尬。5.4 透明PNG的边缘出现白边合成图片时经常遇到一个现象PNG里的图标明明带透明区域画到QPixmap画布上之后边缘出现一圈白边或者黑边。这个问题的根源是颜色混合方式。QPainter默认的合成模式是SourceOver如果要保留透明需要注意画笔本身不能是纯色不透明的或者绘制前设置合成模式painter.setCompositionMode(QPainter::CompositionMode_SourceOver); painter.drawPixmap(0, 0, iconPixmap);还有一个比较隐蔽的原因QPixmap里包含的设备像素比与目标画布不一致缩放时插值计算产生了边缘色偏。处理方式是把源图也setDevicePixelRatio(1)并确保它和目标画布的尺寸是换算后的逻辑尺寸。5.5 save出来的图片质量差默认save成JPG时Qt使用质量参数85如果对图片质量有要求需要显式指定质量pm.save(output.jpg, JPG, 95);转成PNG格式没有质量损耗但要注意PNG不支持CMYK之类的特殊色彩空间。保存透明背景图片时务必确认画布创建时用了带Alpha的格式也就是前面说的QImage::Format_ARGB32中转方案否则透明部分保存出来是纯黑色。6. 实测经验组合方案与性能对比6.1 我推荐的图片处理标准流程踩过这么多坑之后我现在做项目里的图片处理基本固定了一条标准流程资源加载用QImageReader控制缩放像素级处理用QImage生成结果显示用QPixmap跨线程传递用QImage信号界面缓存用QPixmap离屏画布。这条流程兼顾了功能、性能和跨平台稳定性。具体到代码上典型组合是// 1. 解码阶段用 QImageReader 控制内存 QImageReader reader(imagePath); reader.setScaledSize(desiredSize); QImage img reader.read(); // 2. 处理阶段用 QImage 做像素级操作 img img.convertToFormat(QImage::Format_ARGB32); // ... 灰度化、二值化、滤镜等操作 // 3. 显示阶段转成 QPixmap QPixmap pm QPixmap::fromImage(img); // 4. 缓存阶段维护一个离屏 QPixmap m_cachedPixmap pm;这套流程我交付过好几个项目稳定性都很好。它最大的好处是每一层的分工清晰出了问题能快速定位加载不对看QImageReader像素不对看QImage显示不对看QPixmap。6.2 实测数据离屏绘制与直接绘制的性能差距我特意在自己的机器上做过一个对比测试。一次性绘制500条线段和1000个矩形分别采用直接在paintEvent里绘制和离屏绘制两种方案循环刷新100次统计总耗时。直接在paintEvent里绘制平均耗时约450毫秒会看到明显的卡顿感而且测试过程中窗口拖动会有残影。离屏绘制的方案绘制到画布这一次约12毫秒之后每次刷新只要约2毫秒整体流畅很多。这个数据说明两点。第一耗时绘制逻辑放到离屏画布上做是性能优化最直接的手段。第二离屏绘制方案在交互频繁的场景拖动、缩放、动画里优势特别明显因为每次界面更新只需要做一次像素拷贝。6.3 最后一个实用技巧截图也可以这么玩最后分享一个实际好用的扩展QScreen配合QPixmap可以实现高效的屏幕截图。抓取整个屏幕只需要两行代码QPixmap fullScreen screen-grabWindow(0);如果只想抓取某个窗口可以传窗口ID只想抓取一个区域抓取后再用copy抠图QPixmap region fullScreen.copy(QRect(x, y, w, h));这种方案比调用系统级截图API更可控因为QPixmap拿回来之后还能继续用QPainter在上面加标注、打码、画箭头。我做的截图工具就完全基于这套机制实现简单跨平台行为还一致。有兴趣的读者可以自己扩展成截图工具也可以配合前面讲的缓存机制做成带预览的截图贴图工具。绘图的实质是数据怎么变成像素而QPixmap就是Qt里最顺手的那个像素容器。我自己初期也在“为什么画不出来”“为什么图片是黑的”“为什么卡顿”这些问题上浪费过不少时间最后总结下来核心就三条画布格式提前确认好、绘制状态记得管理、耗时的东西一律离屏缓存。把这三件事刻进脑子里pixmap绘图基本不会再难住你。