简介:本资源是一套基于Qt实现鼠标与触屏双模平滑曲线绘制的完整示例工程,面向Qt中级开发者及嵌入式GUI应用工程师,解决触控交互场景下矢量与非矢量两种绘图路径的技术选型与实现难点。项目清晰区分了QMouseEvent单点绘制与QTouchEvent多点触控的事件处理逻辑,并深入对比矢量绘图(基于QGraphicsItem动态构建曲线对象)与非矢量绘图(直接在QPainter中实时重绘像素路径)的性能、精度与内存特性。压缩包共12个文件(6个头文件定义绘图组件与数据结构,5个源文件实现画布层、临时图层、矢量线条对象及场景管理,1个pro工程配置),总大小仅10KB,结构精炼、模块职责明确,便于快速理解核心绘图机制。目前已有1772人学习下载,读者可直接运行调试、对比两种绘图模式的视觉效果与响应延迟,并复用wbcanvasitem、wblinevector等关键类封装到自有项目中。 如果你做过白板、批注、签字板这类功能,应该对“画出来的线不够顺”这件事深有体会。鼠标一动快一点,轨迹就是锯齿感十足的折线;触屏上手指一划,节点稀稀拉拉,出来的线条跟小孩涂鸦一样。最近我正好用 Qt 重写一个支持鼠标、触屏和数位笔的标注模块,核心需求就是在三种输入方式下都能画出平滑的曲线,并且同时支持矢量导出(SVG/PDF)和位图导出(PNG)两种路径。这篇文章把整个实现过程梳理一遍,顺便把采样、平滑算法、绘制优化、导出这四块容易踩坑的地方都说清楚,给正准备做类似功能的朋友一个能直接参考的方案。
1. 先理清需求:矢量输出和非矢量输出到底差在哪
1.1 你的业务到底要哪种曲线
很多人一上来就写代码,结果做到一半才发现“矢量”和“非矢量”的路线根本不一样,返工成本特别高。我先说结论:矢量曲线保存的是几何信息,也就是点、控制点、路径命令,它可以在任意缩放倍数下保持清晰,并且能二次编辑;非矢量曲线保存的是像素颜色信息,本质是一张图片,做涂抹、橡皮擦、纹理笔刷这类效果非常方便,但放大到一定程度就糊了。
我在项目里把两种需求都做了。矢量路径用于签名、批注、轮廓标注,用户希望导出SVG或PDF后还能在Illustrator里继续编辑,所以必须用QPainterPath这类带几何结构的对象来组织数据。非矢量输出则用于生成带纹理的笔迹,比如模拟毛笔、荧光笔、油漆刷,这些效果用几何路径不太好表达,干脆直接渲染到QImage上,按位图保存。
如果只让我选一条路,我会建议优先做矢量。原因很简单:从矢量转位图是秒级操作,只要在内存里创建一个QImage,用QPainter把路径重绘一遍再保存就行;但如果你一开始就存了位图,想再提取出平滑的矢量轮廓,那就要做边缘检测和贝塞尔拟合,工作量大一个数量级。所以架构上一定要把“几何数据”和“渲染结果”分开,两条腿走路。
1.2 为什么事件采集方式决定了曲线质量的起点
平滑曲线的质量不取决于绘制算法,而取决于输入点数据的质量。这是我在项目里反复验证过的结论。很多人以为装了平滑算法就万事大吉,实际上如果事件层没有做好,采集到的点既稀疏又不稳定,再牛的算法也救不回来。
鼠标输入时,QMouseEvent的位置是离散的,但移动事件并不是匀速触发的。你快速滑动鼠标时,两次事件之间的空间距离可能超过几十像素,这时候不做插值直接连线,看起来就是一条折线。触屏输入的情况更复杂,QTouchEvent在 Qt5 和 Qt6 的默认行为还不一样,而且在某些设备上会频繁产生重复坐标。数位笔则多了一个压感维度,如果压感数据没有正确读取,绘制的笔锋效果就会完全丢失。
所以在动手写平滑算法之前,我先把采集层做扎实:统一处理三类输入事件、过滤脏数据、判断是否需要插值、记录时间戳用于后续的轨迹分析。这一层花的时间不多,但对最终效果的影响是决定性的。
1.3 整体架构分几层
我最后把模块拆成了四层:
- 输入层:监听鼠标、触屏、数位笔事件,统一封装成
StrokePoint结构体,里面包含坐标、压力值、时间戳。 - 预处理层:去抖、合并重复点、根据距离阈值决定是否插值,把质量不高的原始点处理成干净的点序列。
- 绘制层:用
QPainterPath加上平滑样条算法生成几何路径,再交给QPainter渲染到控件或图片上。 - 导出层:根据目标格式,把几何路径渲染成SVG、PDF或PNG。
这样做的好处是每一层都可以独立测试。比如输入层有问题,我可以打印StrokePoint序列来排查;绘制层调参也不会影响到已经采集到的数据。如果你的项目还没分层,建议尽早重构,后面调起来会轻松很多。
2. 输入采集:鼠标、触屏、数位板一个都不能少
2.1 三个事件类的分工与选择
Qt 里监听输入事件有几个入口,选错了会非常折腾。
鼠标事件:QMouseEvent,通过重写mousePressEvent、mouseMoveEvent、mouseReleaseEvent获取。常见坑是移动事件默认只在按住鼠标按键时才会连续触发,如果只想在按下时绘制,那这个行为正好满足需求。
触屏事件:QTouchEvent,通过重写touchEvent获取。触屏事件和鼠标事件在 Qt 5 里默认会互相模拟,也就是说你点击触屏,Qt 会同时给你发QTouchEvent和QMouseEvent,如果代码里两个事件都处理,就会重复绘制。从 Qt 5.9 开始增加了Qt::AA_SynthesizeMouseForUnhandledTouchEvents这个属性,可以通过设置应用属性来控制。到了 Qt 6,默认行为更干净,触屏事件不会主动合成鼠标事件。
数位板事件:QTabletEvent,通过重写tabletEvent获取。数位板设备通常可以配置成触摸或鼠标模拟模式,如果只处理QMouseEvent,压感数据会丢失。好在 Qt 也提供了合成支持,在部分平台上数位笔移动时同样会发送鼠标事件,前提是你没有明确接受QTabletEvent。
我有一个比较通用的做法:优先处理QTabletEvent,如果没有数位板事件到达,再处理QTouchEvent,最后才回退到QMouseEvent。判断条件就是事件类型里是否包含有效的压感数据。用event->deviceType()能区分当前事件的来源,这个接口在三个事件类里都有。
2.2 事件接受与压感数据
一个在触屏项目里特别容易踩的坑是:你在touchEvent里做了绘制逻辑,但是如果不调用event->accept(),事件会继续往下传递,可能触发系统手势、导致窗口拖动或者画布滚动,用户体验非常奇怪。所以我处理触屏事件时的第一行代码就是event->accept(),确保绘制手势和系统手势互不干扰。
压感数据主要来自两个地方:QTabletEvent的pressure()方法,取值范围在 0.0 到 1.0;QTouchEvent的TouchPoint也提供了pressure()方法,但并不是所有触屏设备都支持压感,很多触摸屏的 pressure 值永远是 1.0。我在封装StrokePoint的时候增加了pressure字段,并用一个布尔标记区分这个值是否可靠,后续算法在读数据时先判断标记,避免算出一个无效的笔刷宽度。
还有一个细节:不同设备返回坐标的精度不一样。鼠标用的是整数坐标pos(),数位板和某些触屏设备支持浮点坐标posF()。为了不丢失精度,我统一用posF()获取 QPointF,再转换为StrokePoint。这样即使用户在放大 400% 的标注模式下书写,小笔画也能保留足够细节。
2.3 采集频率与点距统计
如果打开调试模式,把采集到的点打印出来,你会发现不同输入方式的点距差异非常大。鼠标在慢速移动时,点距可能是 2 到 3 像素;快速甩动时,单次事件移动距离可能超过 50 像素。触屏设备更是夸张,部分 Android 模拟触屏的采样率只有 60Hz,快速画线时相邻点距能到 80 像素。
所以我写了一个简单的统计工具,在绘制过程中维护一个“最近 N 个点”的窗口,算出平均点距和最大点距。如果最大点距超过 10 像素,就在预处理阶段做插值补点。插值不是越密越好,过密的点会让平滑算法的效果变差,因为控制点之间距离太近,生成的曲线容易出现轻微抖动。我的经验值是目标点距控制在 2 到 4 像素之间,既保证视觉平滑,又不给绘制带来额外负担。
还有人会问:能不能直接调用系统高采样率接口?比如 Windows 上的 Raw Input 或者某些触屏厂商的驱动接口。我的建议是如果只是做应用层功能,不要依赖这些非 Qt 路径,跨平台兼容性会变得非常差。Qt 事件层的采样率够用了,配合插值策略完全能得到满意的效果。
2.4 坐标变换与高DPI适配
小屏幕设备、4K 显示器、系统缩放比例不一致,这些都会导致坐标出错。最典型的症状是:鼠标在屏幕上画一条线,笔迹和光标位置错开半厘米,看起来特别难受。
正确的做法是不要直接用窗口坐标,而是用widget->viewport()来做坐标变换,或者直接重写paintEvent时依赖传入的event->rect()计算绘制区域。如果控件上有滚动、缩放、自定义变换,一定要在采集点时就完成坐标变换,把StrokePoint统一存放到“画布坐标系”里,而不是“窗口坐标系”。
高 DPI 场景下,Qt 6 默认启用高 DPI 缩放,逻辑坐标和物理像素之间有换算关系。渲染到屏幕时,QPainter会自己处理缩放,不会出问题;但如果你直接操作QPixmap或QImage,需要记住用devicePixelRatio()把图像尺寸乘以缩放倍数,否则导出的位图会很模糊。这个坑我后面在导出章节专门讲。
3. 平滑算法:从折线到曲线的三条路
3.1 移动平均与去抖:最简单但别抱太高期望
移动平均的做法是对点序列取滑动窗口均值,比如每 3 个点求一个平均坐标,连接这些平均点形成新轨迹。它的优点是代码极其简单、计算量小,适合对实时性要求极高的场景;缺点是会把轨迹整体“拉钝”,写字时笔画的开头和结尾会被明显收缩,曲线在拐弯处容易产生过冲。
我在项目里只把移动平均用在“去抖”阶段,不把它作为主平滑算法。具体逻辑是:当相邻点距离小于 1.5 像素时,直接丢弃新点;当距离在 1.5 到 5 像素之间时,用新点和旧点的加权平均替换旧点,权重设置为 3:7。这样既去除了点击抖动产生的毛刺,又不会对有效输入造成大的形变。
如果你签字场景需要比较“锐利”的折角,比如写“李”字时横折那一笔,移动平均的权重不能太高。我试过把权重调到 5:5,结果所有直角都变成了圆角,审美上很别扭。一般平滑权重保持 0.3 以内比较安全。
3.2 Catmull-Rom 样条:性价比之王
Catmull-Rom 样条是这次项目里我最推荐的一种插值算法。它是一种通过原始控制点的分段三次插值曲线,不会把轨迹“拉离”原始点击位置,非常适合手写、签字、批注这类需要保真的场景。实现的关键公式是:
P(t) = 0.5 * ( (2 * P1) + (-P0 + P2) * t + (2*P0 - 5*P1 + 4*P2 - P3) * t^2 + (-P0 + 3*P1 - 3*P2 + P3) * t^3 )其中 P0 到 P3 是连续的四个控制点,t 从 0 到 1。实际绘制时,对每一段相邻的两点 P1 和 P2,用它们的前一个点 P0 和后一个点 P3 作为切向参考,生成一条三次曲线。这样做的好处是曲线严格经过 P1 和 P2,不会偏离用户书写的轨迹。
Qt 里可以直接通过QPainterPath::cubicTo来实现。我在实际项目里写了一个辅助函数:
void addCatmullRomSegment(QPainterPath &path, const QPointF &p0, const QPointF &p1, const QPointF &p2, const QPointF &p3) { // 以 p1->p2 这一段为例,计算中间的控制点 QPointF c1 = p1 + (p2 - p0) / 6.0; QPointF c2 = p2 - (p3 - p1) / 6.0; path.cubicTo(c1, c2, p2); }这里 c1 和 c2 就是三次贝塞尔曲线的两个控制点。核心思想是通过相邻点的斜率来推算当前段所需的切线方向,让段与段之间衔接自然。如果点数非常多,生成这条曲线的时间几乎可以忽略不计,性能完全不是瓶颈。
3.3 Bézier 曲线:经典但要注意控制点位置
贝塞尔曲线是 Qt 里最基础的曲线实现,QPainterPath::quadTo和cubicTo直接对应二次和三次贝塞尔。贝塞尔曲线的特点是曲线整体位于控制点的凸包内,默认情况下并不穿过中间的控制点。如果你直接把采集到的点作为控制点传给cubicTo,画出来的曲线会“浮在”采样点外侧,用户写了一个字,结果笔迹边缘跟手指滑过的路径明显不吻合。
要解决这个问题,通常的做法是取相邻两个采样点的中点作为贝塞尔路径的端点,把原始采样点作为控制点。这样曲线一定会穿过每两个点的中点,视觉上跟原始轨迹非常接近。具体代码如下:
void buildBezierPath(const QVector<QPointF> &points, QPainterPath &path) { if (points.size() < 2) return; path.moveTo(points.first()); for (int i = 1; i < points.size() - 1; ++i) { QPointF mid = (points[i] + points[i + 1]) / 2.0; path.quadTo(points[i], mid); } path.lineTo(points.last()); }这个方法实现简单,效果也不错,但缺点是曲线在原始点处是平滑过渡的,在极端情况下可能产生轻微“过冲”。如果你只画荧光笔或装饰线条,完全够用;如果是签名场景要求笔画位置精确,我建议使用 Catmull-Rom。
3.4 参数选择的实测经验
在实际调参过程中,我总结了几个重要经验:
- Catmull-Rom 的张力参数可以统一用 0.5,也就是向心版本。它能在拐弯处减小自交的概率,写数字“8”或者字母“g”这类闭合笔画时不容易产生圈圈里的小环。
- 移动平均窗口大小不要超过 5 个点,太大的窗口会让动态笔迹变得迟钝,用户明显感觉“跟不上手”。
- 触屏设备和鼠标设备的平滑参数建议分开调。触摸屏的采样噪声更大,我会把去抖阈值从 1.5 像素提高到 2.5 像素;数位板精度很高,去抖阈值反而要调低,不然会把细微的笔锋变化抹掉。
- 如果卡顿不是瓶颈,在生成路径后调用一次
QPainterPath::simplified()可以合并不需要的基础图元,减少后续绘制的顶点数量。实测在长笔画中能减少 30% 左右的路径元素,对渲染性能有一点帮助。
4. 绘制与导出:矢量/非矢量的落地实现
4.1 用 QPainterPath 组织矢量数据
QPainterPath是 Qt 里最核心的矢量数据容器。它保存了一系列的路径命令,比如 moveTo、lineTo、quadTo、cubicTo,还有闭合、矩形、椭圆等基础图元。你可以把它理解成一份“矢量绘图指令表”,渲染时交给QPainter执行,缩放时重新计算几何位置,不会损失精度。
我在项目里的核心数据组织方式是:每画一笔,就创建一个独立的QPainterPath,存放到一个QVector<StrokeItem>里。每个StrokeItem除了包含路径,还包含颜色、线宽、笔刷类型等绘制属性。这样做的好处是方便撤销回退,也方便按笔迹粒度导出 SVG 时保留图层结构。
增量更新方面,我维护了一个dirtyPath,专门存“正在绘制中”的临时路径。鼠标按下时moveTo到一个点,移动过程中不停加入新的曲线段,松开时把这个临时路径合并到完整路径列表里。这样每帧只需绘制当前笔迹,不用把之前几百条路径全部重画一遍。
4.2 增量绘制与局部更新
如果你每次移动事件都调用update()刷新整个窗口,很快就会发现两个问题:一是 CPU 占用飙升,二是长笔迹的绘制时间越来越长,最终卡顿到无法接受。
解决方案是只刷新发生变化的那一小块区域。update(QRect)接收一个矩形参数,paintEvent里对应的event->rect()就只包含这个更新的区域。我在每次新增采样点后,计算该点和前一个点包围盒的外扩矩形,加上线宽 2 倍的余量,然后只对这个矩形调用update()。窗口越大,这个优化带来的收益越明显。
还有一种更彻底的办法:把已经固定在画布上的历史笔画缓存到一张QPixmap上。鼠标移动时只把当前新笔画绘制到缓存外的临时层,松开后才把临时层合并进缓存。这样绘制事件里的工作量始终很小,不随历史笔画数量增长。我在触屏标注场景实测,长画布连续画几百笔也没有明显掉帧。这个方案唯一需要注意的地方是内存占用,如果画布特别大,比如 8000x8000 像素,缓存图的内存轻松超过 100MB,必要时要考虑动态分块。
4.3 SVG、PDF 与 PNG 导出
导出矢量格式时,Qt 提供了非常方便的QSvgGenerator。你可以直接把QPainter绘制到它上面,所有QPainterPath都会被序列化成 SVG 的 path 元素:
QSvgGenerator generator; generator.setFileName("output.svg"); generator.setSize(QSize(800, 600)); generator.setViewBox(QRect(0, 0, 800, 600)); QPainter painter; painter.begin(&generator); // 在这里用同样的代码绘制所有笔画 painter.end();注意 SVG 的 viewBox 和窗口尺寸要匹配,否则导出结果在浏览器里显示时会存在缩放位移。如果你同时在屏幕上绘制和导出 SVG,可以把绘制代码抽成一个接收QPainter*的函数,从界面绘制和文件绘制共用它,避免同一套逻辑写两遍。
PDF 导出用QPrinter,生成逻辑和 SVG 很相似,只是输出设备从QSvgGenerator换成了打印机对象。我在做批注工具的“打印成PDF”功能时,发现一个坑:QPrinter默认的页面坐标系统以点为单位,和屏幕像素单位不匹配,需要在绘制前设置好setPageSize和setPageOrientation,并用painter.scale()把坐标统一到目标画面尺寸。
位图导出用QImage最直接:
QImage image(canvasSize * devicePixelRatio, QImage::Format_ARGB32_Premultiplied); image.setDevicePixelRatio(devicePixelRatio); image.fill(Qt::transparent); QPainter painter(&image); // 重绘所有笔画 painter.end(); image.save("output.png");如果不设置devicePixelRatio,在高 DPI 屏幕上截取的图像保存后只有实际逻辑分辨率,打印或放大时明显模糊。我一开始就是漏了这一步,导出的图片在手机上打开特别糊,查了半天才发现是设备像素比的问题。
4.4 屏幕绘制性能调优
除了增量更新,还有几个性能优化点值得一提:
抗锯齿开销并不像想象中那么大。QPainter::Antialiasing是基础的 2D 抗锯齿,开启后线条边缘会平滑很多,在 PC 上性能开销可忽略,但在低端嵌入式设备上会明显增加 CPU 时间。如果目标设备性能有限,可以考虑通过setRenderHint(QPainter::Antialiasing, false)关闭,用高分辨率渲染后缩小代替抗锯齿,效果也不错。
线的宽度变化是笔刷特效里最常见的一种。通过压感值动态改变QPainter的 pen width 会让线条看起来有笔触感,但频繁设置画笔会造成状态切换开销。我的做法是把每一段路径先拆成独立的“子路径”,根据该段平均压感设置一次画笔宽度,然后绘制这一段。这样既能表现笔锋,又不会每帧切换几十次画笔。
如果未来有更复杂的笔刷纹理需求,比如水墨、油画棒、喷枪,光靠QPainterPath的线条就不够了。方案是把笔迹按间隔采样成多个圆点,然后用QPainter绘制位图填充,或者直接把纹理渲染到缓存层。非矢量侧的性能优化方向是控制采样密度,避免一张大画布上堆几万个绘制指令。
5. 常见问题与实测排查
5.1 排查速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 画出来的线是折线,明显有棱角 | 没有做平滑插值,或平滑算法权重太低 | 改用 Catmull-Rom 分段三次插值,目标点距控制在 2-4 像素 |
| 线条边缘有明显锯齿 | 没有开启抗锯齿,或渲染到低分辨率缓存 | 检查setRenderHint(QPainter::Antialiasing);导出图片时设置正确的 devicePixelRatio |
| 触屏上绘制时页面跟着滚动 | 触屏事件没有调accept(),被系统手势拦截 | 在touchEvent第一行调用event->accept(),必要时设置WA_AcceptTouchEvents |
| 鼠标绘制时偶尔漏掉一个点,出现断笔 | 快速移动时点距过大,超过了相邻点的连线阈值 | 在预处理层添加基于距离阈值的插值逻辑,或者使用更高采样率 |
| 数位笔没有压感效果 | 代码只处理了鼠标事件,QTabletEvent未处理 | 重写tabletEvent并读取pressure(),确保事件被接受 |
| 导出 SVG 后位置偏移 | SVG 的 viewBox 与画布坐标不一致 | 设置与逻辑画布匹配的 viewBox,并统一坐标系变换 |
| 长画布连续绘制后越来越卡 | 每次刷新全量重绘所有笔画 | 实现增量绘制,配合位图缓存或局部update(rect) |
5.2 触屏时鼠标事件重复触发
这是 Qt 5 踩坑记录里排得上号的问题。默认情况下,Qt 5 在触屏事件未被处理后,会把触摸模拟成鼠标事件,导致你的touchEvent和mousePressEvent同时执行,画布上出现双笔迹或重复点。我当时的解决方法是在构造函数中设置:
setAttribute(Qt::WA_AcceptTouchEvents);然后在touchEvent里坚决调用event->accept()。这样系统就知道触屏事件已经有处理者,不会再额外合成鼠标事件。到了 Qt 6,这个行为已经更干净,但为了兼容老代码,我依然保留了这套处理逻辑。
5.3 坐标偏移和触控错位
坐标错位通常发生在窗口缩放、移动、滚动后。最常见原因是你在采集点时用的是event->pos()的本地坐标,但后续绘制时经过了 QPainter 的变换矩阵,导致两者不一致。我的统一策略是:采集点后立刻转换到固定画布坐标:
QPointF canvasPos = widget->transform().inverted().map(eventPos);这样后续无论窗口怎么滚动缩放,路径数据始终对应画布的逻辑坐标,不会再错位。高 DPI 设备还要额外注意devicePixelRatio,画布坐标和屏幕物理像素坐标是两套体系,不要把两者混用。
5.4 卡顿问题的排查思路
如果你的绘制越来越卡,不要第一时间怀疑平滑算法,先检查是不是做了全量重绘。最简单的排查方法是在paintEvent里打印event->rect(),如果每次更新区域都是整个控件大小,说明update()的调用方式有问题。另一个隐藏瓶颈是QPainterPath过于庞大,如果一直用lineTo添加几千个微小的直线段,绘制性能会显著下降,改成quadTo或cubicTo分段生成曲线能明显减少图元数量。
最后再分享一个小技巧:如果你需要同时支持鼠标和触屏,但又想在不同设备上做不同的平滑参数,可以在StrokePoint里增加一个inputType字段。保存原始输入类型数据,调参和复现问题时会非常有用——很多“用户报告笔迹异常”的情况,其实是因为用户拿手指在数位屏上乱划,采样特性和鼠标完全不一样。数据字段多留几个,排查时能少掉不少头发。
本文还有配套的精品资源,点击获取