
简介这份资源围绕 Qt 框架下 QPainter 绘图与事件过滤器的配合使用展开面向已掌握 Qt 基础控件与信号槽、希望在子控件上实现自绘的中级开发者重点解决安装 installEventFilter 后 paintEvent 无法正常触发、绘制流程被拦截中断的常见疑难。包内共 15 个文件以 cpp 源文件与 h 头文件为主体配合 ui 界面文件、pro 工程配置、makefile 构建脚本以及编译产物与可执行程序压缩包约 762KB可直接打开工程对照调试。内容涉及自定义事件过滤器类重写 eventFilter、在过滤器中识别 Paint 与 Timer 事件并转交处理、paintEvent 中启用抗锯齿绘制线条与矩形以及用 QTimer 定时 update 实现动态刷新等关键环节读者可据此理清事件分发链路掌握过滤器与绘图共存时的排错思路。目前已有 7483 人学习下载适合作为 Qt 自绘与事件机制的实操参考。1. 从一块画不出线的子控件说起QPainter 到底该在哪里落笔接手一个仪表盘界面时最容易出错的地方往往不是算法而是在子控件上画图这一步有人在构造函数里new QPainter有人把绘制代码塞进鼠标事件结果窗口一挪线条就没了。QPainter 在 Qt 里有明确的生命周期它只在paintEvent里才安全这个限制绕不过去。下面拆的是一个用 QPainter 在子控件上绘图的小工程主窗口里放三个自绘子控件父级用 eventFilter 统一接管尺寸和悬停事件QTimer 按固定节拍推进数据并触发update()。它适合刚接触 Qt 自绘、又不想只画个 Hello World 的 C 开发者代码量不大但绘制链路、坐标换算、刷新节奏这几个关键点全在里面。2. 项目骨架与绘制链路三个子控件、一个事件过滤器和一个定时器先把工程的整体结构说清楚不然后面看代码会缺少上下文。这个 Demo 的界面很朴素左边一个实时波形控件右上角一个环形仪表右下角一个标注层用来画选中框和十字准星。三个控件都继承自QWidget都自己重写paintEvent但它们的父级MainWindow并不直接调它们的绘制函数只通过eventFilter和QTimer从外部驱动。这种拆分方式的好处是每个控件对自己的画法负责父窗口只负责什么时候重画和尺寸变了要不要重算缓存。如果换成在一个大控件里用drawLine把所有东西画完短期内代码更短但一旦某个局部需要独立响应鼠标、独立布局、独立做局部刷新就会立刻变成一堆if (x 300 y 200)的判断维护成本高得离谱。2.1 目录结构与类职责划分工程用 CMake 组织Qt5 和 Qt6 都能编译核心文件如下painter-demo/ ├── CMakeLists.txt ├── main.cpp ├── mainwindow.h / mainwindow.cpp # 宿主窗口装 eventFilter、QTimer ├── widget/ │ ├── wavewidget.h / .cpp # 波形子控件自绘折线 │ ├── gaugewidget.h / .cpp # 环形仪表自绘圆弧与刻度 │ └── overlaywidget.h / .cpp # 标注层画选框与准星 └── core/ ├── sampledata.h / .cpp # 波形数据源环形缓冲 └── drawhelper.h / .cpp # 颜色、字体、网格等公共绘制函数类继承职责是否持有 QPainterMainWindowQMainWindow组装布局、安装事件过滤器、驱动定时器否WaveWidgetQWidget折线波形绘制、局部刷新仅在paintEvent内GaugeWidgetQWidget圆弧、刻度、数值文字仅在paintEvent内OverlayWidgetQWidget半透明覆盖层、十字准星仅在paintEvent内SampleData无纯 C 类生成并保存最近 N 个采样点否SampleData故意不继承任何 Qt 控件它是纯数据容器这样后续要把数据换成串口采集、网络推送或者文件回放时只需要改这一个类绘制层完全不动。常见做法是把数据和绘制耦合在一个控件里图省事但接第二批数据源的时候就得动绘制代码很容易把已经调好的坐标映射改坏。drawhelper里放的是无状态的自由函数比如drawGrid(QPainter , const QRectF , int rows, int cols)、penForSeries(int index)。这类函数不持有状态测试时可以直接传一个QPixmap上的QPainter进去验证输出不需要真的把窗口 show 出来。2.2 三条件绘制链路paintEvent、eventFilter、QTimer 的分工很多人第一次写自绘控件会把这三个东西混在一起用在eventFilter里直接QPainter p(watched)或者在定时器回调里调repaint()。能跑但很容易出现画一半被系统擦掉窗口最小化后崩溃鼠标一动就闪这类奇怪问题。正确的分工是三条互不越界的线paintEvent是唯一允许创建QPainter画在 widget 上的地方。它由 Qt 在合适的时机调用可能一次事件循环里被调用多次也可能一次都不调用。eventFilter负责感知变化子控件尺寸变了、鼠标进来了、被点了一下。它只改状态、标记缓存失效绝不直接画。QTimer负责给节拍数据更新了、动画该走了就调update()请求重绘而不是自己动手画。提示update()只是把重绘请求排进事件队列同一轮事件循环里多次调用会被合并成一次paintEventrepaint()是同步立即重绘在定时器里用它会直接把 CPU 打满。把这三者的边界定死之后调试会变得非常直接画面不更新先看update()有没有被调用update()调了但没变化看paintEvent里的数据是不是空的数据有但位置不对那就是坐标映射的问题跟事件和定时器无关。3. 核心代码拆解把画笔交给子控件这一章按调用顺序拆三段代码子控件里的paintEvent怎么画、父窗口的eventFilter怎么介入、QTimer怎么把两者串起来。3.1 子控件的 paintEvent坐标、抗锯齿与路径绘制先看波形控件。它的paintEvent做了四件事铺底色、算出绘图区、把采样值归一化映射到像素、用QPainterPath一次性描出来。// wavewidget.cpp void WaveWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); // 1. QPainter 必须在这里构造栈对象析构时自动 end() QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 2. 铺底色不依赖样式表避免父窗口背景透出造成残影 painter.fillRect(rect(), QColor(24, 28, 36)); // 3. 绘图区四周留 12px 给刻度和边距 const QRectF plot QRectF(rect()).adjusted(12, 12, -12, -12); if (m_samples.size() 2 || plot.isEmpty()) return; // 4. 归一化把真实量纲压到 [0,1]再映射到绘图区高度 qreal lo *std::min_element(m_samples.cbegin(), m_samples.cend()); qreal hi *std::max_element(m_samples.cbegin(), m_samples.cend()); if (qFuzzyCompare(lo, hi)) hi lo 1.0; // 全等值时给一个非零跨度 const qreal stepX plot.width() / (m_samples.size() - 1); QPainterPath path; for (int i 0; i m_samples.size(); i) { const qreal norm (m_samples.at(i) - lo) / (hi - lo); const QPointF pt(plot.left() i * stepX, plot.bottom() - norm * plot.height()); if (i 0) path.moveTo(pt); else path.lineTo(pt); } // 5. 先设笔再画路径圆角连接让折线拐点不发毛刺 QPen pen(QColor(64, 196, 255), 1.6); pen.setJoinStyle(Qt::RoundJoin); pen.setCapStyle(Qt::RoundCap); painter.setPen(pen); painter.drawPath(path); // 6. 画一条基准中线方便肉眼判断漂移 painter.setPen(QPen(QColor(255, 255, 255, 40), 1, Qt::DashLine)); painter.drawLine(plot.left(), plot.center().y(), plot.right(), plot.center().y()); }逻辑说明QPainter painter(this)把绘制目标绑定到当前控件构造后立即设置抗锯齿否则折线会出现明显的阶梯。fillRect(rect(), ...)主动铺满整个控件区域这一步等价于告诉 Qt这块区域我全权负责配合后面的WA_OpaquePaintEvent可以避免系统先擦一遍背景再让你画减少闪烁。参数说明adjusted(12, 12, -12, -12)的四个参数分别是左上右下向内收缩的像素数负数表示从右边和下边往回缩stepX是相邻两个采样点之间的水平像素间距当采样点数大于绘图区宽度时这个值会小于 1多个点会落在同一列像素上此时靠抗锯齿补偿视觉连续性但数据本身已经失真需要降采样。QPen的第二个参数 1.6 是线宽单位为逻辑像素在高 DPI 屏上 Qt 会自动乘以缩放因子。注意QPainterPath的moveTo/lineTo在点数超过几千时会有明显开销实测 5000 点以上的折线建议先做等间隔抽稀再送进路径。3.2 父窗口用 eventFilter 统一接管子控件事件当多个子控件都需要响应同一类事件时可以给每个子类都重写一遍也可以只在一个地方装过滤器。这个工程选后者因为悬停高亮和尺寸变化后失效缓存这两件事对所有子控件是统一的策略写在一个函数里更好改。// mainwindow.cpp MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { // ... 布局代码略 ... // 给三个子控件都装上同一个过滤器this 作为监听者 m_wave-installEventFilter(this); m_gauge-installEventFilter(this); m_overlay-installEventFilter(this); } bool MainWindow::eventFilter(QObject *watched, QEvent *event) { // 1. 尺寸变化让控件把网格缓存标记为脏不在这里画 if (event-type() QEvent::Resize) { if (auto *w qobject_castWaveWidget *(watched)) w-invalidateGridCache(); else if (auto *g qobject_castGaugeWidget *(watched)) g-invalidateGridCache(); } // 2. 鼠标进入/离开只改状态位重绘交给 update() if (event-type() QEvent::Enter || event-type() QEvent::Leave) { const bool hovered (event-type() QEvent::Enter); if (auto *w qobject_castQWidget *(watched)) { w-setProperty(hovered, hovered); w-update(); // 请求重绘不是立即重绘 } } // 3. 关键一律返回 false事件继续传给被监听对象 return QMainWindow::eventFilter(watched, event); }逻辑说明installEventFilter把this注册为watched对象的事件监听者之后watched收到的每个事件都会先经过eventFilter。这里只处理Resize和Enter/Leave其余事件原样放行。qobject_cast用来判断具体是哪个子控件比watched m_wave更耐得住后续加控件。参数说明eventFilter的返回值决定事件是否被吞掉。返回true表示事件到此为止被监听对象收不到返回false表示继续派发。很多装了过滤器之后控件不刷新的翻车现场就是对QEvent::Paint也返回了true导致子控件自己的paintEvent永远不被调用。注意不要在eventFilter里对watched构造QPainter直接画。哪怕它碰巧能显示出来也会和后续的paintEvent抢同一块 backing store表现为画面撕裂或局部残留。3.3 QTimer 驱动重绘update() 与 repaint() 的取舍数据是定时推进的所以需要一个稳定的节拍源。这里用QTimer而不是QThread因为绘制必须在 GUI 线程完成定时器回调本身就在 GUI 线程省掉了跨线程投递事件这一层。// mainwindow.cpp void MainWindow::startFeed() { m_timer new QTimer(this); // PreciseTimer 在 Windows 上会把精度压到 1ms 级别 // 对 30FPS 来说略有过剩但能避免 CoarseTimer 累积漂移 m_timer-setTimerType(Qt::PreciseTimer); m_timer-setInterval(33); // 约 30 FPS connect(m_timer, QTimer::timeout, this, MainWindow::onTick); m_timer-start(); } void MainWindow::onTick() { // 1. 推进数据源这一步不涉及任何绘制 const qreal v m_data-nextValue(); m_wave-appendSample(v); // 2. 仪表数值变化慢每 6 帧更新一次即可减少无谓重绘 if (m_frameCounter % 6 0) { m_gauge-setValue(m_data-currentRms()); m_gauge-update(); } }逻辑说明onTick里只做两件事——更新数据、请求重绘。波形的appendSample内部会调update()仪表的setValue之后手动调一次update()。这样绘制完全由 Qt 在合适的时机调度定时器不会因为某帧绘制慢而堆积。参数说明setInterval(33)对应约 30 帧每秒人眼对 24 帧以上就不敏感了30 帧足够表现波形滚动如果要做更平滑的动画比如仪表指针缓动可以提到 16ms但要注意每帧的绘制耗时必须远小于 16ms否则任务队列会越积越长。setTimerType有三个取值PreciseTimer、CoarseTimer、VeryCoarseTimer后两者会为了省电合并定时器唤醒适合分钟级的后台任务实时刷新必须用PreciseTimer。update()和repaint()的区别在定时器场景下尤其明显update()把重绘请求合并进事件队列一帧内调十次也只画一次repaint()是同步调用paintEvent在 33ms 的定时器里用repaint()相当于强制 GUI 线程每 33ms 停下来画一次一旦绘制代码里有耗时操作整个界面立刻失去响应。我一般只在需要立刻拿到绘制结果比如截图前才用repaint()。4. 让画面动起来坐标映射、局部重绘与缓存到这一步画面已经能动了但细节上的问题会陆续冒出来窗口一拉大线条跑偏鼠标点在波形上准星对不上帧率上去之后 CPU 占用飙升。这些都属于坐标与刷新策略的范畴跟QPainter本身关系不大。4.1 坐标换算逻辑坐标、物理像素与父子偏移Qt 的绘制坐标默认是逻辑像素在高 DPI 屏上会被自动乘以缩放因子。这件事对QPainter画线是透明的但对QPixmap缓存不是如果你按size()创建一个QPixmap再往上画在 2 倍屏上它会先被放大再显示结果就是糊成一片。// wavewidget.cpp void WaveWidget::rebuildGridCache() { // 按物理像素创建再告诉 Qt 它的逻辑尺寸 const qreal dpr devicePixelRatioF(); m_gridCache QPixmap(size() * dpr); m_gridCache.setDevicePixelRatio(dpr); m_gridCache.fill(Qt::transparent); QPainter p(m_gridCache); // 此时 p 的坐标系仍然是逻辑像素dpr 由 Qt 内部消化 drawHelper::drawGrid(p, QRectF(rect()).adjusted(12, 12, -12, -12), 4, 6); }逻辑说明QPixmap(size() * dpr)让位图的实际像素数量与屏幕物理像素一致setDevicePixelRatio(dpr)告诉 Qt 这张图在逻辑坐标下应该按size()显示。少了任何一步要么糊要么只占左上角四分之一。参数说明devicePixelRatioF()在普通屏返回 1.0在 2 倍屏返回 2.0某些平台支持 1.25、1.5 这种小数缩放所以要用F结尾的浮点版本别用返回整数的devicePixelRatio()。父子坐标的换算则是另一个高频坑。子控件里的(0,0)是自己的左上角不是窗口的左上角。鼠标事件给的是相对接收控件的坐标如果要在父窗口的标注层上画十字准星必须转换// overlaywidget.cpp void OverlayWidget::setCursorFromChild(const QPoint childPos, const QWidget *child) { // 先把子控件坐标映射到全局再映射回标注层 const QPoint global child-mapToGlobal(childPos); m_crossPos mapFromGlobal(global); update(); // 只重绘准星所在的旧/新区域也可以 }逻辑说明mapToGlobal和mapFromGlobal是成对使用的中间经过屏幕坐标系可以跨任意层级的父子关系。比手动加偏移量可靠得多尤其是在有滚动区域、嵌套布局的时候。参数说明childPos是子控件收到的鼠标事件坐标QMouseEvent::pos()child是产生事件的控件指针。如果做局部重绘优化可以把update()换成update(oldRect | newRect)只刷准星扫过的两条带状区域。4.2 局部重绘与静态图层缓存update()可以带一个QRect参数只刷新指定区域。这个能力在只在末尾追加数据、已有内容不动的场景下非常划算// wavewidget.cpp void WaveWidget::appendSample(qreal v) { m_samples.removeFirst(); m_samples.append(v); // 只有最右侧一小条会发生变化其余区域不需要重画 const int colWidth qMax(2, width() / qMax(1, m_samples.size())); update(QRect(width() - colWidth - 2, 0, colWidth 2, height())); }逻辑说明环形缓冲每次挤掉最前面的点、在末尾补一个新点视觉上变化的只有最右侧那一列像素所以重绘矩形只覆盖这一条。2是给线宽和抗锯齿留的余量少了这两像素折线边缘会留下没被清掉的残迹。参数说明colWidth是一个采样点占用的水平像素用控件宽度除以样本数得到当样本数大于宽度时它会被qMax兜底成 2。这个优化只适用于不滚动的波形如果波形是整体左移的滚动模式每一列的内容都会变必须整块重绘此时优化点要换成降低采样密度和缓存背景网格。背景网格正好是典型的静态图层它只在尺寸变化时改变跟数据无关完全没必要每帧重画。用QPixmap缓存住paintEvent里一次drawPixmap贴上去就行void WaveWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); if (m_gridCache.isNull() || m_gridCache.size() ! size() * devicePixelRatioF()) rebuildGridCache(); painter.drawPixmap(0, 0, m_gridCache); // 先贴静态网格 // ... 再画动态折线 ... }逻辑说明缓存失效的判断放在paintEvent开头而不是依赖外部一定记得调invalidateGridCache()属于防御性写法。drawPixmap(0, 0, m_gridCache)会按devicePixelRatio自动缩放与逻辑坐标对齐。参数说明m_gridCache.size()返回的是物理像素尺寸所以要跟size() * devicePixelRatioF()比较不能直接跟size()比否则在缩放比例变化的屏幕上比如窗口从 1 倍屏拖到 2 倍屏会一直重建缓存。5. 排查手册五类「画不出来」的现场与处置自绘控件的调试信息极少出问题时界面往往就是一片空白或者一片花屏。下面这五条是这个工程在开发过程中真实遇到过的按现象、原因、解决三段记录遇到类似症状可以先按这个顺序排查。5.1 绘制时机相关空白与不刷新现象一子控件区域全黑或者全白父窗口一切正常。原因paintEvent根本没有被调用或者调用了但里面没画东西。常见写法错误是QPainter建在了构造函数、鼠标事件或者定时器回调里那些时刻控件还没有有效的绘图设备画了也不显示另一种是在paintEvent里创建了QPainter之后中途return虽然析构函数会结束绘制但如果之前什么都没画自然就是空白。解决确认所有QPainter都只在paintEvent内构造并且构造后至少有一次fillRect(rect(), ...)或者drawXxx。可以在paintEvent开头临时加一行qDebug() paint size();确认它确实被调用了如果没被调用往上查是不是被eventFilter吞了QEvent::Paint或者控件的尺寸是 0。现象二装了事件过滤器之后某个子控件彻底不刷新其他控件正常。原因eventFilter对QEvent::Paint或QEvent::Resize返回了true。过滤器返回true意味着事件被拦截被监听对象永远收不到这个事件paintEvent自然也不会触发。解决除了确实要吃掉的事件比如拦截某个快捷键一律返回false或者调用基类的eventFilter。判断技巧是在过滤器里打印event-type()看哪些事件被误拦了。5.2 坐标与显示质量相关偏移、发虚、闪烁现象三鼠标点击位置和画出来的准星对不上越往右下偏得越多。原因坐标系混用。子控件收到的鼠标坐标是相对自己的而绘制发生在另一个控件上或者在有滚动区域、嵌套布局时手动加了父窗口的偏移量但漏掉了中间层的frameGeometry差值。解决统一走mapToGlobal/mapFromGlobal这一对函数做转换不要手工累加偏移。转换之后可以在准星旁边临时画一个drawText把坐标值打出来肉眼比对一次就能确认映射是否正确。现象四高 DPI 屏上线条发虚或者缓存图层只占了控件左上角四分之一。原因QPixmap按逻辑像素创建没有考虑devicePixelRatio。位图被放大到物理像素显示边缘自然模糊如果只设置了尺寸没有设置比例Qt 会按 1:1 贴上去于是只占四分之一。解决QPixmap(size() * devicePixelRatioF())加上setDevicePixelRatio(devicePixelRatioF())两步都要有。缓存失效的判断也要用物理尺寸比较避免缩放比例变化后不重建。现象五拖动窗口或者调整大小时画面闪烁控件背景一闪一闪。原因系统先擦背景、再让你画两帧之间露出了底色。默认情况下 Qt 会在paintEvent之前填充背景如果你的绘制覆盖不满整个控件或者绘制本身比较慢就会看到这个中间态。解决给自绘控件设置setAttribute(Qt::WA_OpaquePaintEvent, true)声明这块区域我全权负责不用系统擦背景同时在paintEvent里第一步就fillRect铺满。两个措施要一起用只设属性不铺底色会留下上一帧的残影。6. 二次开发把画布换成自己的业务图表这个工程真正能复用的不是那段折线代码而是数据源、绘制、驱动三层分离的结构。要把它改成自己的业务图表最省事的路径是抽一个轻量接口而不是复制WaveWidget再改。我一般会定义一个纯虚基类把怎么画和什么时候画彻底分开// plotitem.h class PlotItem { public: virtual ~PlotItem() default; virtual void paint(QPainter painter, const QRectF plotRect) 0; virtual void onData(const QVectorqreal values) 0; virtual QRect dirtyRect(const QRectF plotRect) const { Q_UNUSED(plotRect); return {}; } };WaveWidget变成一个薄壳持有std::unique_ptrPlotItempaintEvent里只做三件事算plotRect、贴缓存、调用item-paint(painter, plotRect)。这样加一个柱状图、一个散点图只需要新增一个PlotItem子类坐标映射和刷新逻辑完全不用碰。改造时的验证方法有两个都不需要人眼盯着看。第一个是数据层单测给SampleData灌入已知序列断言归一化后的值域落在[0,1]qFuzzyCompare(lo, hi)那个分支要单独覆盖这是最容易除零的地方。第二个是绘制层截图比对在离屏的QImage上构造QPainter调item-paint把结果存成 PNG跟基线图做逐像素差分。这比每次手动拉窗口可靠得多尤其是改线宽、改留白这种看起来差不多的改动。还有一个容易忽略的点QPainter的save()/restore()。任何会改画笔、画刷、变换矩阵、裁剪区的绘制片段都应该成对包起来。PlotItem::paint是被外部调用的调用方可能已经设了setClipRect如果子实现里改了裁剪区又没恢复后面画的所有东西都会被裁掉这种 bug 查起来非常费劲。至于刷新节奏我的习惯是把帧率做成可配置项默认 30调试时降到 5 看单帧的绘制结果压测时提到 60 看 CPU 曲线。有一回为了追求丝滑把定时器设成了 8ms结果在一台老机器上界面直接卡死查了半天才发现是每帧都在重建QPainterPath。从那以后我每次加自绘控件都强制先跑一遍5 帧模式确认没有隐藏的重计算再放开帧率。希望这些能帮到你。本文还有配套的精品资源点击获取