
简介面向有一定Qt基础、希望为应用添加触摸交互能力的开发者这份示例工程完整展示了在Qt中实现多点触控与手势识别的思路适用于移动设备、嵌入式面板等交互场景。工程以QTouchEvent和QGestureManager为核心覆盖触摸事件过滤、手势注册、信号与槽响应等关键环节并演示了滑动手势与捏合缩放的具体识别与处理代码简洁可运行便于对照实验。压缩包共7个文件包含pro工程文件、2个cpp源文件、1个头文件、README说明文档及许可证等整体仅4KB精简而不失完整性。目前已有2410人学习下载适合作为Qt触控开发的入门参考。通过阅读源码与说明读者可快速掌握触摸事件从接收、分发到手势识别的完整流程并理解如何调整手势阈值、处理多点触控与鼠标事件冲突等实际问题工程文件可直接用Qt Creator打开稍作修改即可体验不同手势效果也方便在此基础上扩展自定义手势。1. 用 Qt 做多点触控与手势识别难的不是 event而是事件到手势的管线在 Windows 触摸一体机、RK3399 嵌入式屏或 macOS 触摸板上Qt 都能收到 QTouchEvent但把触摸点变成可用手势不少人会在半路放弃。多点触控与手势识别的麻烦集中在三处事件顺序与鼠标合成、每个 TouchPoint 的坐标系归属、以及手势与子控件的事件争抢。下面把 Qt 5.15 LTS 上跑通多点触控的完整路径拆开从原始 QTouchEvent 讲到 QGesture/QGestureRecognizer最后给一个可复用的“0 到 9 数字手势”识别组件。适合已经在用 Qt 开发、想从单点点击升级到双指缩放和多指绘图的团队。2. 原始触控层QTouchEvent 的开启、TouchPoint 字段与坐标处理2.1 什么场景能收到 QTouchEventQt Widgets 里接收触摸事件第一步不是重写 event而是给目标控件设置属性。常见做法是在构造函数或初始化函数里写setAttribute(Qt::WA_AcceptTouchEvents, true);这个属性只对当前 QWidget 生效不会自动传给子控件。很多触摸屏程序第一次跑的时候发现 TouchBegin 不进来多半是属性设在了父窗口而子控件抢占了触摸区域。需要区分的是这个属性和 QApplication 的全局开关不一样。Qt 5.6 以后Qt::AA_SynthesizeMouseForUnhandledTouchEvents默认为开启也就是说未处理的触摸事件会被转换成鼠标事件继续走原有逻辑。好处是老程序不修改也能点击坏处是你在做多点触控时不把触摸事件accept()掉屏幕上就会同时出现一次触摸和一次鼠标点击。如果项目是 Qt Quick规则就换成了 QQuickItem 的setAcceptTouchEvents(true)或者 QML 里的acceptTouchEvents: true。这两套体系的事件分发路径不同后面第五章会再说。2.2 TouchPoint 可以拿到什么QTouchEvent::touchPoints()返回的是QListQTouchEvent::TouchPoint里面每个点代表当前帧里的一根手指。字段并不复杂但用错的人很多。字段类型说明常见误用id()int手指唯一 ID从按下到抬起不变用索引代替 id导致手指顺序变化后状态错乱state()Qt::TouchPointStatePressed / Moved / Stationary / Released只处理 Moved忽略 Stationary 手指的几何数据pos()QPointF控件本地坐标在 paintEvent 里画点必须用它scenePos()QPointFQGraphicsScene 坐标无 Scene 时它等于全局坐标normalizedPos()QPointF0~1 归一化坐标与设备分辨率无关拿去做像素级计算pressure()qreal压感范围约 0~1不判断 device capabilities 就直接信这个数velocity()QVector2D触摸点速度的估算值在嵌入式 touchscreen 上未必更新识别“现在是哪根手指在动”不要靠 state要靠 id。两指同时按下时第一帧两个点都是 Pressed之后移动的事件里往往只有一根手指是 Moved另一根是 Stationary。如果把 Stationary 的手忽略掉双指缩放的几何关系就直接断了。2.3 处理多指事件的最小框架先写一个能稳定落地的 event 入口再在它上面加手势逻辑bool TouchCanvas::event(QEvent *e) { if (e-type() QEvent::TouchBegin || e-type() QEvent::TouchUpdate || e-type() QEvent::TouchEnd) { const auto *te static_castQTouchEvent*(e); m_activePoints.clear(); for (const QTouchEvent::TouchPoint tp : te-touchPoints()) { // 抬起的点不参与绘制但保留它会让逻辑复杂化 if (tp.state() ! Qt::TouchPointReleased) m_activePoints tp; } update(); // 触发 paintEvent把当前触摸点画出来 e-accept(); // 阻止 Qt 再合成鼠标事件 return true; } return QWidget::event(e); }这里的重点是e-accept()。如果不调用Qt 会把这个触摸事件标记为未处理然后按照AA_SynthesizeMouseForUnhandledTouchEvents的规则把第一个触点转成 QMouseEvent你的窗口就同时收到两套事件。如果确实希望全局关掉这种鼠标合成必须在 QApplication 构造之前设置QCoreApplication::setAttribute(Qt::AA_SynthesizeMouseForUnhandledTouchEvents, false);不建议默认关闭。老代码里大量依赖 mousePressEvent 处理点击关闭后触摸屏上的普通按钮会失灵。正确姿势是哪个控件真正处理触摸就从event()里接受事件。多指几何计算里最容易翻车的是“只统计 Moved 点”。双指缩放时某一帧通常只有一根手指在动另一根是 Stationary但它仍然在屏幕上。写两指距离时要跳过 Released不能跳过 Stationarybool twoFingerDistance(const QTouchEvent *te, qreal *distance) { QPointF pts[2]; int count 0; for (const QTouchEvent::TouchPoint tp : te-touchPoints()) { if (tp.state() Qt::TouchPointReleased) continue; // 抬起的点已经离开屏幕不参与计算 pts[count] tp.scenePos(); if (count 2) break; } if (count ! 2) return false; *distance QLineF(pts[0], pts[1]).length(); return true; }逻辑说明TouchUpdate 事件里touchPoints()始终包含当前所有手指哪怕是没动的那根。把 Stationary 点漏掉两指距离会在某一帧突然变成 0 或者退化成单指信息。输出距离时建议把“当前距离 / 起始距离”作为缩放比例不要直接把绝对像素值交给界面否则不同 DPI 的屏上行为不一致。2.4 坐标系先统一再做后续逻辑QTouchEvent 里有 pos、scenePos、globalPos 三种坐标很多人在手势识别中途才发现拿到的是另一个坐标系。QGesture 框架里的 centerPoint 又可能是 widget 坐标或全局坐标取决于平台 QPA 的实现。最稳妥的做法是所有进入手势算法层的数据统一先转换到目标控件的scenePos()或mapFromGlobal(globalPos)。在 Qt Widgets 程序中我一般偏爱scenePos()因为它在没有 QGraphicsScene 的时候等于全局坐标在嵌入 QGraphicsView 时又能直接视场景坐标系后续添加图元不用再转一次。3. 手势框架QGesture 与 QGestureRecognizer 的状态机与内置手势参数3.1 为什么手势要独立于事件原始 QTouchEvent 能解决“屏幕上现在有几根手指、在哪、压力多少”但它没有“手势从开始到结束”的概念。双指捏合的完整生命周期是第一根手指落下、第二根落下、两指距离变化、手指抬起、可能被打断。如果每个页面都拿 QTouchEvent 重算一遍代码会迅速失控。Qt 的手势框架把这件事拆成三层QGesture保存某个手势当前状态的载体例如缩放比例、旋转角度、滑动速度。QGestureRecognizer对事件流做判断决定手势是否开始、更新、结束、取消。QGestureEvent手势状态变化后分发给目标控件。QWidget 通过grabGesture()声明“我关心哪种手势”Qt 的手势管理器再决定把 QGestureEvent 发给谁。这个设计和 QShortcut 很像逻辑可复用不绑定具体控件。3.2 QGestureRecognizer 的状态机自定义识别器时recognize()的返回值直接决定 QGesture 的生命周期Result 值含义典型时机Ignore当前事件不改变手势状态触摸点数量不足、事件类型无关Trigger手势成立通知目标进入 Started/Updated位移或距离超过阈值Finish手势正常结束发 GestureFinished手指全部抬起Cancel手势无效发 GestureCanceled轨迹出界、突然变为多指重点在 Cancel 和 Finish 的区别。Fine 代表“识别成功但结束”Cancel 代表“前面积累的轨迹全部作废”。如果手势中途位置超出限定区域应该返回 CancelQGesture 里的历史数据会被清掉。否则上一轮的半截轨迹会带进下一轮。识别器是全局注册的同一个识别器实例会被所有 grabGesture 的目标共享。因此不能把“当前正在收集的轨迹”放在识别器成员变量里必须放在 QGesture 对象上。这个误用写自定义识别器时几乎必踩后面代码里会体现。3.3 内置手势能拿到什么Qt Widgets 内置了 Tap、TapAndHold、Pan、Pinch、Swipe 五类类名和常用字段如下手势类型主要类关键字段说明轻点QTapGestureposition类似鼠标 click长按QTapAndHoldGestureposition需要平台支持平移QPanGesturedelta / lastOffset / accelerationdelta 是本帧位移捏合QPinchGesturescaleFactor / totalScaleFactor / rotationAngle注意增量与累计值轻扫QSwipeGestureswipeAngle / horizontalDirection / verticalDirection方向枚举已经算好内置手势适合快速原型但参数可调性不高。例如 QSwipeGesture 的触发速度、QPanGesture 的横向纵向灵敏度在公开 API 层面并没有统一的阈值配置。要做产品级交互要么在拿到 QGesture 后再对 delta、速度、角度做业务过滤要么直接写自定义识别器。3.4 用 QPinchGesture 做双指缩放的最小实现在目标控件构造函数里先声明关心这两种手势grabGesture(Qt::PinchGesture); grabGesture(Qt::PanGesture);然后在 event 里统一处理bool GestureView::event(QEvent *e) { if (e-type() QEvent::Gesture) { auto *ge static_castQGestureEvent*(e); if (auto *pinch static_castQPinchGesture*(ge-gesture(Qt::PinchGesture))) { switch (pinch-state()) { case Qt::GestureStarted: // 记录基准值不用上一次的 scale 累加 m_baseScale m_currentScale; break; case Qt::GestureUpdated: m_currentScale m_baseScale * pinch-totalScaleFactor(); break; case Qt::GestureFinished: case Qt::GestureCanceled: break; default: break; } } if (auto *pan static_castQPanGesture*(ge-gesture(Qt::PanGesture))) { m_offset pan-delta(); } e-accept(); return true; } return QWidget::event(e); }参数说明totalScaleFactor()是从 GestureStarted 开始累计的缩放比例scaleFactor()是相邻两帧之间的增量。UI 层计算当前缩放比时应该用 totalScaleFactor 乘上基准值如果用 scaleFactor 反复乘浮点误差和事件丢失都会累积。centerPoint()是另一个坑。不同平台上它可能返回 widget 坐标也可能返回全局坐标。稳妥办法是统一再转换一次QPointF center mapFromGlobal(pinch-centerPoint().toPoint());这个转换不影响 Windows 触摸屏但在 Linux 的 X11/Wayland 以及 macOS 原生手势混入时能避免中心点偏移半个窗口的诡异问题。3.5 内置手势识别器的平台边界内置 Pinch 在 Windows 上通常来自系统的手势解释在 Linux evdev 触摸屏上则由 Qt 的 QPA 层从 touch 事件合成算法和触发条件并不一致。也就是说同一个grabGesture(Qt::PinchGesture)在开发机上好好的换到国产触控一体机上可能完全不触发。遇到这种情况先用触摸点可视化确认底层事件是否正常再决定是修 QPA 还是换自定义识别器。绝大多数“手势不见了”的问题最后都出在事件没到目标控件而不是识别算法本身。4. 实战自定义 QGestureRecognizer把“0 到 9”数字手势做成可复用识别器4.1 为什么内置手势覆盖不了数字轨迹内置手势关注的是缩放、位移、方向这类整体变化不关心轨迹是什么形状。要识别 0 到 9 这样的单笔数字至少要把“轨迹形状”抽象出来再和历史模板做匹配。自定义 QGestureRecognizer 的好处不只是能输出识别结果而是数字手势可以和 Pinch、Pan 共存于同一个 QGestureEvent 体系里由 Qt 统一分发、取消和重置。页面只需要 grab 新类型不需要自己管理触摸状态机。这里的数字识别采用一种工程上常见但不追求极限准确率的方案采集轨迹归一化到 100x100 逻辑区域映射到 3x3 宫格记录“经过的格子序列”再和预设模板求最长公共子序列相似度。真要上线给不同人用这个算法不够但作为 Qt 侧的实现骨架、特征预处理、可视化调试它足够清楚。4.2 特征编码轨迹如何变成可计算的数据把连续点变成离散序列抗抖动能力会强很多。3x3 宫格编号如下0 1 2 3 4 5 6 7 8把轨迹点缩放后落到格子里每次格子发生变化就记录一次。例如数字 0 从左上开始逆时针画会得到类似 0、6、8、2、0 的序列。模板与实际轨迹做最长公共子序列匹配得分除以较长的那个序列长度得到质量分 quality。数字宫格序列模板部分说明00,6,8,2,0逆时针另一个方向也需要收录12,5,8从右上到右下竖线76,7,8,4,1先横后向左下这些模板只是起点。不同人的书写习惯差异很大1 可以是从左上到左下7 也可以先写横再写斜。真正要覆盖全模板库需要收集一段时间真实轨迹再扩充。4.3 DigitGesture 与 DigitGestureRecognizer 的实现先定义携带轨迹和识别结果的 QGesture 子类// digitgesture.h #pragma once #include QGesture #include QPointF #include QVector struct MatchResult { QString digit; double score 0.0; }; class DigitGesture : public QGesture { Q_OBJECT public: explicit DigitGesture(QObject *parent nullptr) : QGesture(parent) {} QVectorQPointF points; // 压缩后的轨迹 QString recognizedDigit; // 识别出的数字 double quality 0.0; // 0~1 置信度 }; class DigitGestureRecognizer : public QGestureRecognizer { public: QGesture *create(QObject *target) override; Result recognize(QGesture *state, QObject *watched, QEvent *event) override; void reset(QGesture *state) override; };识别器实现文件里的核心逻辑QGesture *DigitGestureRecognizer::create(QObject *target) { return new DigitGesture(target); } QGestureRecognizer::Result DigitGestureRecognizer::recognize( QGesture *state, QObject *watched, QEvent *event) { auto *g static_castDigitGesture*(state); if (event-type() QEvent::TouchBegin) { g-points.clear(); g-recognizedDigit.clear(); g-quality 0.0; return QGestureRecognizer::Ignore; } if (event-type() ! QEvent::TouchUpdate event-type() ! QEvent::TouchEnd) { return QGestureRecognizer::Ignore; } const auto *te static_castconst QTouchEvent*(event); // 多指情况直接取消这套识别器只看单指笔画 int liveFingers 0; QPointF newest; for (const QTouchEvent::TouchPoint tp : te-touchPoints()) { if (tp.state() Qt::TouchPointReleased) continue; liveFingers; newest tp.scenePos(); } if (liveFingers 1) { g-points.clear(); return QGestureRecognizer::Cancel; } // 距离小于阈值的点丢掉减少重复采样对匹配的影响 if (g-points.isEmpty() || QLineF(g-points.constLast(), newest).length() 4.0) { g-points newest; } if (event-type() QEvent::TouchEnd) { if (g-points.size() 8) return QGestureRecognizer::Cancel; const auto cells encodeTrajectory(g-points); const auto best bestMatch(cells); g-recognizedDigit best.digit; g-quality best.score; return g-quality 0.55 ? QGestureRecognizer::Finish : QGestureRecognizer::Cancel; } return QGestureRecognizer::Ignore; } void DigitGestureRecognizer::reset(QGesture *state) { auto *g static_castDigitGesture*(state); g-points.clear(); g-recognizedDigit.clear(); g-quality 0.0; QGestureRecognizer::reset(state); }逻辑说明识别器在 TouchUpdate 阶段只收集点不返回 Trigger所以目标控件在笔画结束前收不到任何手势更新。这种做法适合“抬手出结果”的设计比如数字密码、签名确认。如果要实时反馈比如画到哪里算哪里识别成什么就要在移动阶段计算相似度并在相似度稳定超过阈值时返回 Trigger让控件每帧拿到中间结果。参数说明4.0 像素的距离阈值可以按屏幕 DPI 调。高 DPI 屏幕上同一段笔画采样点更密阈值可以到 8 甚至 12低分辨率工业屏上 4 更容易丢失细小笔画。quality 阈值 0.55 也是可配置项模板库越全阈值可以越严格。4.4 轨迹编码与模板匹配直接放在同一个 cpp 里核心函数尽量不做 Qt 事件相关的事方便单元测试QVectoruint encodeTrajectory(const QVectorQPointF trail) { if (trail.isEmpty()) return {}; QRectF rc(trail.first(), QSizeF(1, 1)); for (const QPointF p : trail) rc rc.united(QRectF(p, QSizeF(1, 1))); // 等比缩放到 100x100 区域避免字体大小影响 const qreal scale qMin(100.0 / rc.width(), 100.0 / rc.height()); QVectoruint cells; for (const QPointF p : trail) { const QPointF n (p - rc.topLeft()) * scale; const int cx qBound(0, int(n.x() / 34), 2); const int cy qBound(0, int(n.y() / 34), 2); const uint cell uint(cy * 3 cx); // 连续停在同一个格子里的点不重复入列 if (cells.isEmpty() || cells.constLast() ! cell) cells cell; } return cells; } static qreal lcsScore(const QVectoruint a, const QVectoruint b) { const int n a.size(); const int m b.size(); QVectorQVectorint dp(n 1, QVectorint(m 1, 0)); for (int i 1; i n; i) for (int j 1; j m; j) dp[i][j] (a[i - 1] b[j - 1]) ? dp[i - 1][j - 1] 1 : qMax(dp[i - 1][j], dp[i][j - 1]); return qreal(dp[n][m]) / qMax(n, m); } MatchResult bestMatch(const QVectoruint cells) { // 完整上线时模板会远多于 3 个并建议按书写风格分组 static const QHashQString, QVectoruint templates { { QStringLiteral(0), { 0, 6, 8, 2, 0 } }, { QStringLiteral(1), { 2, 5, 8 } }, { QStringLiteral(7), { 6, 7, 8, 4, 1 } } }; MatchResult best; for (auto it templates.cbegin(); it ! templates.cend(); it) { const qreal score lcsScore(it.value(), cells); if (score best.score) { best.score score; best.digit it.key(); } } return best; }参数说明rc.width()或rc.height()为 0 时不需要特殊处理因为初始化 QRectF 时保证了最小 1x1 范围。qBound(0, int(n.x() / 34), 2)的 34 来自 100/3 向上取整比 33.33 多留 0.67 像素容差否则落在边界上的点会随机跳格。匹配用最长公共子序列而不是直接比较字符串好处是能容忍轨迹中间插入几个抖动点代价是识别不出方向相反的图形。生产级方案建议把这套特征向量接 Edge Impulse 或 TFLite 训练的分类模型Qt 侧保留 encodeTrajectory 作为预处理入口识别结果再填回DigitGesture::recognizedDigit。4.5 在 QWidget 中注册并消费识别结果识别器注册一次全局可见// 只能在 main() 或插件初始化里做一次 Qt::GestureType digitType QGestureRecognizer::registerRecognizer(new DigitGestureRecognizer);目标控件开启监听grabGesture(digitType);在控件 event 里取结果if (e-type() QEvent::Gesture) { auto *ge static_castQGestureEvent*(e); auto *g static_castDigitGesture*(ge-gesture(digitType)); if (g g-state() Qt::GestureFinished) { qDebug() digit: g-recognizedDigit quality: g-quality; } }注销时使用QGestureRecognizer::unregisterRecognizer(digitType);参数说明registerRecognizer返回的手势类型 ID 是运行时动态分配的值不要写死在配置里。unregisterRecognizer会释放 Qt 为这个类型创建的资源但已经 grabGesture 的控件需要先销毁否则后续事件到达时会拿到悬空类型。到这里数字手势已经可以作为一个不依赖具体页面的独立组件接入工程。识别器本身没有保存任何轨迹状态所有中间数据都在 DigitGesture 里多窗口同时使用时不会串数据。5. 调试、回归与 Qt Quick 共存多点触控与手势识别的最后一段路5.1 触摸点可视化先确认事件层没问题调试触摸手势时我最先画的不是方案图而是在目标控件上把每个 TouchPoint 用不同颜色画出来void TouchDebugOverlay::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); for (const QTouchEvent::TouchPoint tp : m_touchPoints) { p.setBrush(QColor::fromHsv((tp.id() * 83) % 360, 180, 255)); const qreal r 10.0 tp.pressure() * 30.0; p.drawEllipse(tp.pos(), r, r); p.drawText(tp.pos() QPointF(5, -5), QString::number(tp.id())); } }tp.id()是稳定的手指编号用它生成色相连续帧里同一个点颜色不变。压力值可以通过圆半径体现。这套可视化放在正式功能后门里排查事件是否到达、id 是否被系统重新分配、压力是否真的有效比 qDebug 日志直观得多。5.2 用 QTest 和纯函数做回归Qt Test 里可以直接模拟触摸序列QTest::touchEvent(window()).press(0, QPoint(20, 20)); QTest::touchEvent(window()).move(0, QPoint(80, 20)); QTest::touchEvent(window()).release(0, QPoint(80, 20));逻辑说明第一个参数是触控点 id第二个是坐标。Qt 6.x 版本这个接口加入了设备参数编译报错时参考当前头文件签名即可。比 QTest 更值得回归的是 encodeTrajectory 和 bestMatch 这两个纯函数。它们不依赖 QTouchEvent不依赖窗口系统可以直接用普通单元测试覆盖QCOMPARE(encodeTrajectory({ QPointF(10, 10), QPointF(90, 10), QPointF(10, 10) }).size(), 3);这条测试验证一条“从左上到右上再回到左上”的轨迹会被压缩成 0、2、0 三个宫格序列。真正的手势识别误差大多出现在“事件采集正常但特征编码不稳定”这个阶段把编码算法放在无窗口环境下测试回归成本会低很多。5.3 Qt Quick 的触控边界纯 QML 项目里MultiPointTouchArea、PinchHandler、DragHandler 并不走 QGestureEvent也没有 QGestureRecognizer 的注册机制。C 自定义识别器要在 QML 里复用通常是把 encodeTrajectory 和 bestMatch 封装进 QQuickItem 子类在 touchEvent 里收集点、抬手时计算结果再通过 QQuickItem 的信号发回 QML。Widgets 和 QML 混排时QQuickWindow 默认接受触摸事件重叠区域下的 QWidget 可能收不到 TouchEnd。遇到这种问题先确认重叠区最上层是 Flickable 还是普通 Item再决定事件策略不要直接修改全局触摸属性。最终我会在设置页留一个隐藏的-touch-debug命令行参数启动时带上它就把触摸点可视化和轨迹日志一起打开既不影响正式功能也能让现场同事把问题一次性定位在事件层、编码层还是模型层。本文还有配套的精品资源点击获取