事件处理与传播机制
# 第 6 章 事件处理与传播机制
本章定位:从"能点按钮"到"懂事件流转"。QML 的事件系统不是"写个 onClicked 就能干活"那么简单——MouseArea 的信号传播链、触摸事件的 MultiPointTouchArea、键盘焦点系统的 FocusScope 隔离——理解这些,才能在车机双屏互不干扰、触摸穿透、"按钮点了没反应"等问题出现时 30 秒定位根因。
# 目录介绍
- 6.1 案例引入
- 6.2 事件架构
- 6.3 触摸事件
- 6.4 多点触摸
- 6.5 键盘焦点
- 6.6 手势识别
- 6.7 双屏隔离
- 6.8 性能与陷阱
- 6.9 新手陷阱
- 6.10 训练题
- 6.11 思考题
- 6.12 速查表
# 6.1 案例引入
# 6.1.1 触摸穿透
某车机 HMI 工程师在导航地图上盖了一个弹出菜单。点菜单里的"关闭"按钮,菜单关掉了——但地图上被菜单挡住的"目的地"按钮也触发了:
import QtQuick
import QtQuick.Controls
ApplicationWindow {
width: 800; height: 480; visible: true
// ───── 地图层(底层) ─────
Rectangle {
id: mapView
anchors.fill: parent
color: "#2d2d44"
Button {
id: destBtn
text: "设为目的地"
anchors.centerIn: parent
onClicked: console.log("导航启动!") // ← 不该触发!
}
}
// ───── 弹窗层(覆盖在地图上) ─────
Popup {
id: menu
x: 200; y: 100; width: 400; height: 280
modal: false // ⚠️ 罪魁祸首
Rectangle {
anchors.fill: parent
color: "white"; radius: 12
Column {
anchors.centerIn: parent; spacing: 12
Text { text: "菜单"; font.pixelSize: 24; font.bold: true }
Button {
text: "关闭"
onClicked: {
console.log("菜单关闭")
menu.close() // 用户点这里
}
}
}
}
}
Button {
anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter; margins: 20 }
text: "打开菜单"
onClicked: menu.open()
}
}
操作步骤与现象:
1. 点击"打开菜单" → 弹出层出现在地图上方 ✅
2. 点击弹出层里的"关闭"按钮
→ 控制台先是 "菜单关闭" ✅
→ 紧接着 "导航启动!" ← ❌ 地图上的按钮也被点击了!
疑惑:
- 菜单明明盖在地图上面,为什么点击能穿透到地图?
Popup的modal属性到底管什么?- 有没有办法手动控制"事件吞掉"还是"放行"?
# 6.1.2 根因分析
QML 的事件分发从 z-order 最高 的 Item 开始。Popup { modal: false } 的意思是"弹窗不拦截穿透事件"——点击的坐标同时落在菜单按钮和地图按钮上,两个 MouseArea 都收到了事件:
事件流:
QQuickWindow::sendEvent()
→ 遍历 z-order 降序的子元素列表
→ Popup(Button: "关闭") → 坐标包含 → 触发 onClicked → mouse.accepted = true
→ 按理说这里应该停止传播了...
但 Popup modal: false 意味着 Popup 的"遮罩层"没开启
→ 坐标同时命中 Popup 内的 Button 和 MapView 内的 Button
→ MapView 的 Button 也触发了 onClicked
修复:
Popup {
modal: true // ✅ 开启模态——Popup 外的所有坐标事件被吞掉
}
或者手动控制——在弹窗放置一个"遮罩" MouseArea 吃掉穿透事件:
Popup {
// 背景遮罩:吞掉所有穿透的触摸事件
MouseArea {
anchors.fill: parent
onPressed: mouse.accepted = true // 吃了,别往下传
}
}
# 6.1.3 三大问题
| 问题 | 在哪节回答 |
|---|---|
| QQuickWindow 的分发链是如何遍历 z-order 并决定"谁先收到事件"? | §6.2 |
mouse.accepted 和 Popup modal 在底层分别对应什么机制? | §6.3 |
| 车机双屏(仪表+中控)如何做到事件完全隔离? | §6.5 + §6.7 |
读完本章你会亲手写一个车机双屏事件隔离系统,并理解 evdev → QPA → QTouchEvent → QQuickWindow 的完整触摸事件流转路径。
# 6.2 事件架构
# 6.2.1 穿透探索
在深入事件系统之前,先用一个探索性问题热身:两个重叠的 Rectangle 都绑定了 onClicked,点击在重叠区域时,谁先收到事件?又为什么可能两个都收到?
Rectangle { width: 200; height: 200; color: "red" // 底层
MouseArea { onClicked: console.log("底层") }
Rectangle { width: 100; height: 100; color: "blue" // 上层
MouseArea { onClicked: console.log("上层") }
}
}
在未设置 propagateComposedEvents 的情况下,点击蓝色区域:
- 上层被触发(视觉上在"前面"的 Item 优先命中)
- 如果底层
z值更大,底层先抢到
但真正有趣的是:视觉上的前后(子元素在前)≠ 事件分发顺序——顺序由 QQuickWindow 的全局命中测试(Hit Test) 决定。理解这个过程,就要看下一节的事件分发了。
# 6.2.2 完整路径
在嵌入式 ARM 板上,从手指触碰屏幕到 QML 的 onClicked 触发,经历五层:
Layer 1: 硬件层
触摸屏电容感应 → I2C/SPI 数字信号 → 触摸控制器固件
Layer 2: Linux Input 子系统
触摸驱动 → /dev/input/eventX
内核通过 evdev 协议向用户空间报告:
struct input_event { type=EV_ABS, code=ABS_X, value=320 }
struct input_event { type=EV_ABS, code=ABS_Y, value=200 }
struct input_event { type=EV_KEY, code=BTN_TOUCH, value=1 }
Layer 3: Qt QPA 平台抽象层
eglfs 的 evdevtouch 插件读取 /dev/input/event*
→ 把原始坐标转换为屏幕坐标
→ 把多个 event 合成为一个 QTouchEvent
Layer 4: QQuickWindow 分发
QQuickWindow::event(event)
→ QQuickWindow::sendEvent(item, event) ← 遍历 Item 树
Layer 5: QML Item / MouseArea / MultiPointTouchArea
ItemA.contains(event.pos()) → true → 触发 onClicked / onPressed
ItemB.contains(event.pos()) → 触发...
mouse.accepted = true → 停止继续向下分发
# 6.2.3 事件全景
| 事件类型 | C++ 类 | QML 处理元素 | 嵌入式是否常用 |
|---|---|---|---|
| 鼠标/单点触摸 | QMouseEvent | MouseArea | ✅ 主力 |
| 多点触摸 | QTouchEvent | MultiPointTouchArea | ✅ 双指缩放 |
| 键盘/物理按键 | QKeyEvent | Keys / KeyNavigation | ✅ 方向盘按键 |
| 滚轮 | QWheelEvent | MouseArea.onWheel | ❌ 嵌入式少见 |
| 手势 | QNativeGestureEvent | PinchArea / SwipeView | ✅ 车机翻页 |
| 定时器 | QTimerEvent | Timer | ✅ 周期性任务 |
| 拖拽 | QDragMoveEvent | DropArea / Drag | ⚠️ 有限使用 |
# 6.2.4 分发机制
每个 QQuickWindow 内部维护一个 Item 树的 z-order 列表。事件分发从列表中自上而下(z-order 降序)遍历:
// QQuickWindow 事件分发核心路径(简化)
bool QQuickWindow::sendEvent(QQuickItem* item, QEvent* event) {
// ① 先让当前 item 的子元素按 z-order 降序处理
QList<QQuickItem*> children = item->childItems();
std::sort(children.begin(), children.end(),
[](auto* a, auto* b) { return a->z() > b->z(); });
for (auto* child : children) {
if (!child->isVisible() || !child->isEnabled()) continue;
// ② 检查坐标是否在 child 的矩形范围内
QPointF localPos = child->mapFromItem(item, event->pos());
if (child->contains(localPos)) {
event->setAccepted(false); // 重置接受标志
sendEvent(child, event); // 递归分发
if (event->isAccepted()) return true; // ← 被吃掉了,停止
}
}
// ③ 子元素都没接受 → 分发给 item 自己的事件处理器
return item->event(event);
}
关键点:
z值决定遍历顺序(越大越先收到事件)contains()只检查矩形边界——圆形/不规则按钮要额外处理event->isAccepted()是"事件被消费了"的标志——由mouse.accepted控制
# 6.3 触摸事件
# 6.3.1 信号与拖拽
MouseArea 的完整信号清单——每一个信号对应一次事件循环内的状态变迁:
MouseArea {
id: ma
anchors.fill: parent
// ─── 基础点击信号 ───
onClicked: console.log("clicked") // 按下+释放 都在区域内
onPressed: console.log("pressed", mouse.x, mouse.y)
onReleased: console.log("released")
onCanceled: console.log("被系统取消(如来电话)")
onDoubleClicked: console.log("双击")
onPressAndHold: console.log("长按(默认800ms)")
// ─── 移动信号 ───
onPositionChanged: console.log("移动", mouse.x, mouse.y)
// ─── 滚动信号 ───
onWheel: console.log("滚轮", wheel.angleDelta.y)
// ─── Hover(需显式启用) ───
hoverEnabled: true
onEntered: console.log("鼠标进入")
onExited: console.log("鼠标离开")
// ─── 拖拽 ───
drag.target: draggableItem
drag.axis: Drag.XAndYAxis
drag.minimumX: 0
drag.maximumX: parent.width - draggableItem.width
}
mouse 对象——每个信号处理器里隐式传入的参数:
| 属性 | 类型 | 含义 |
|---|---|---|
mouse.x / mouse.y | real | 相对于 MouseArea 的坐标 |
mouse.button | Qt.LeftButton / Qt.RightButton | 哪个键 |
mouse.buttons | bitmask | 当前所有按下的键 |
mouse.modifiers | bitmask | Ctrl/Shift/Alt 等 |
mouse.accepted | bool | 是否接受事件——false = 放行给下层 Item |
mouse.wasHeld | bool | 是否是长按后释放 |
信号时序——点击的生命周期:
用户触摸屏幕
→ onPressed ──── 手指按下
→ (可能触发 onPositionChanged)
→ onReleased ──── 手指抬起
→ onClicked ──── 当 pressed+released 在同一区域时
↑
└─ 如果 pressed 和 released 之间有长按(默认 800ms)
→ 先触发 onPressAndHold,然后 onClicked 不再触发
# 6.3.2 穿透控制
Column {
spacing: 0
// 顶层:菜单面板
Rectangle {
width: 200; height: 100; color: "white"
z: 2
MouseArea {
anchors.fill: parent
onClicked: {
console.log("菜单被点击")
mouse.accepted = true // 吃了,不往下传
}
}
}
// 底层:地图
Rectangle {
width: 200; height: 200; color: "#2d2d44"
z: 1
MouseArea {
anchors.fill: parent
onClicked: {
console.log("地图被点击")
// 这里默认 mouse.accepted = true(隐式接受)
}
}
}
}
// 点击菜单重叠区域 → 仅打印 "菜单被点击"
// 点击菜单之外的区域 → 打印 "地图被点击"
mouse.accepted 的默认行为:
| 信号 | 默认值 | 含义 |
|---|---|---|
onClicked | true | 自动接受——事件到此为止 |
onPressed | false | 不自动接受——让 onClicked 来做决定 |
onReleased | false | 同上 |
onPositionChanged | false | 不自动接受——父级仍可见移动 |
// 典型场景:拖拽时吞掉事件
MouseArea {
anchors.fill: parent
drag.target: slider
onPressed: mouse.accepted = true // 显式接受,不让 ListView 滑动
onReleased: mouse.accepted = true
// onClicked 默认 true——无需额外设
}
# 6.3.3 光标管理
嵌入式触摸屏不需要 hover——但开发调试阶段用鼠标在 x86 桌面跑 QML 时,hover 很有用:
MouseArea {
anchors.fill: parent
hoverEnabled: true // 默认 false——必须显式开启
onEntered: parent.color = "#e0e0e0" // 鼠标进入时变灰
onExited: parent.color = "white"
onPositionChanged: {
if (containsMouse) {
tooltip.x = mouse.x + 10
tooltip.y = mouse.y + 10
}
}
}
注意:
hoverEnabled: true会在每帧额外检查鼠标位置——在高频触发的场景(如 100 个 ListView delegate 全开 hover)有性能开销。
# 6.3.4 可拖拽控件
import QtQuick
import QtQuick.Controls
ApplicationWindow {
width: 600; height: 400; visible: true
Rectangle {
id: gauge
width: 120; height: 120; radius: 60
color: "#2196F3"; z: 10
// 拖拽
MouseArea {
anchors.fill: parent
drag.target: parent
drag.axis: Drag.XAndYAxis
onPressed: {
parent.z = 100 // 抬起——盖住其他控件
mouse.accepted = true // 吃掉事件,不让下层处理
}
onReleased: parent.z = 10 // 放下——恢复层级
}
Text {
anchors.centerIn: parent
text: "拖动我"; color: "white"; font.pixelSize: 14
}
}
// 背景的 ListView——gauge 拖拽时不应该触发列表滚动
ListView {
anchors.fill: parent
model: 20
delegate: Rectangle {
width: parent.width; height: 40
color: index % 2 === 0 ? "#f0f0f0" : "white"
Text {
anchors.centerIn: parent
text: "Item " + index
}
}
}
}
案例知识融合:本案例演示了 drag.target + mouse.accepted + z 三维度协作——①拖拽时 z=100 保证视觉层级;②mouse.accepted=true 保证拖拽事件不泄露到 ListView(否则 ListView 跟着滑动);③释放时恢复 z 层级。
思考题:
- 如果把
drag.axis: Drag.XAndYAxis改为Drag.XAxis,竖直拖拽gauge时会发生什么?ListView 会滚动吗? onPressed里设了mouse.accepted = true,为什么onClicked不需要重复设?查阅 §6.3.2 的默认值表回答。
# 6.4 多点触摸
# 6.4.1 多点区域
嵌入式触摸屏通常支持 5~10 点触控。QML 的 MultiPointTouchArea 直接暴露原始触摸点:
MultiPointTouchArea {
id: mta
anchors.fill: parent
minimumTouchPoints: 1
maximumTouchPoints: 5
touchPoints: [
TouchPoint { id: tp1 },
TouchPoint { id: tp2 },
TouchPoint { id: tp3 },
TouchPoint { id: tp4 },
TouchPoint { id: tp5 }
]
onPressed: (points) => {
for (var i = 0; i < points.length; i++) {
console.log("手指", points[i].pointId,
"按下于", points[i].x.toFixed(1),
points[i].y.toFixed(1))
}
}
onUpdated: (points) => {
// 典型应用:双指缩放 / 双指旋转
if (points.length === 2) {
var dx = points[0].x - points[1].x
var dy = points[0].y - points[1].y
var dist = Math.sqrt(dx * dx + dy * dy)
// dist 变化 → 缩放比例变化
}
}
onReleased: (points) => {
console.log("手指", points[0].pointId, "抬起")
}
}
TouchPoint 的核心属性:
| 属性 | 类型 | 含义 |
|---|---|---|
pointId | int | 手指唯一标识(按下到抬起保持不变) |
x / y | real | 当前坐标 |
previousX / previousY | real | 上一帧坐标 |
startX / startY | real | 按下时的起始坐标 |
pressure | real | 压力值(0.0~1.0)——仅部分硬件支持 |
rotation | real | 触摸角度——电容屏可检测手指方向 |
MouseArea vs MultiPointTouchArea 互斥问题:
// ❌ 同一区域同时用 MouseArea 和 MultiPointTouchArea
MouseArea { anchors.fill: parent; onClicked: ... }
MultiPointTouchArea { anchors.fill: parent; onPressed: ... }
// → 两个都收到事件,行为不确定
// ✅ 分层——上层用 MultiPointTouchArea,下层用 MouseArea
MultiPointTouchArea {
anchors.fill: parent
// 单指按下时手动模拟 MouseArea 行为
onPressed: (points) => {
if (points.length === 1) handleSingleTouch()
else handleMultiTouch()
}
}
# 6.4.2 触摸链路
触摸屏硬件
→ 触摸控制器(如 GT911 / FT5x06 / ADS7846)
→ I2C / SPI / USB 接口
→ Linux 内核驱动(drivers/input/touchscreen/)
→ /dev/input/eventX(evdev 字符设备)
→ Qt QPA egfls evdevtouch 插件读取
→ 把 ABS_X / ABS_Y / ABS_MT_POSITION_X 等 raw 事件
合成为 QTouchEvent
→ QQuickWindow::event(QTouchEvent*)
→ 分发给 z-order 最高的 MultiPointTouchArea / MouseArea
调试触摸事件——在 ARM 板上直接读 raw 数据:
# 查看触摸设备
cat /proc/bus/input/devices | grep -A5 -i touch
# 实时读取触摸事件(hexdump)
hexdump /dev/input/event1
# 输出:type=3(ABS) code=53(X) value=320
# type=3(ABS) code=54(Y) value=200
# type=1(KEY) code=330(TOUCH) value=1 ← 手指按下
# 用 evtest 读取(推荐)
evtest /dev/input/event1
# 6.4.3 双指缩放
import QtQuick
import QtQuick.Controls
ApplicationWindow {
width: 800; height: 600; visible: true
Rectangle {
id: mapContainer
anchors.fill: parent
color: "#1a1a2e"
Rectangle {
id: mapContent
width: 800; height: 600; color: "#2d2d44"
scale: 1.0
Grid {
rows: 8; columns: 8; anchors.centerIn: parent; spacing: 4
Repeater {
model: 64
Rectangle {
width: 80; height: 60
color: index % 2 === 0 ? "#555" : "#444"
Text {
anchors.centerIn: parent; text: "点" + index
color: "white"
}
}
}
}
Behavior on scale { NumberAnimation { duration: 100 } }
}
MultiPointTouchArea {
anchors.fill: parent
minimumTouchPoints: 1; maximumTouchPoints: 5
property real startDist: 0
property real startScale: 1.0
onPressed: (points) => {
if (points.length === 2) {
// 记录初始距离
var dx = points[0].x - points[1].x
var dy = points[0].y - points[1].y
startDist = Math.sqrt(dx * dx + dy * dy)
startScale = mapContent.scale
}
}
onUpdated: (points) => {
if (points.length === 2) {
var dx = points[0].x - points[1].x
var dy = points[0].y - points[1].y
var dist = Math.sqrt(dx * dx + dy * dy)
var ratio = dist / startDist
mapContent.scale = Math.max(0.5, Math.min(3.0, startScale * ratio))
}
}
}
}
Text {
anchors { top: parent.top; left: parent.left; margins: 10 }
text: "双指缩放: " + mapContent.scale.toFixed(2) + "x"
color: "white"; font.pixelSize: 14
}
}
案例知识融合:本案例把多点触摸的完整链路串起来——①MultiPointTouchArea 监听所有触点,onPressed 记录双指初始距离;②onUpdated 实时计算缩放比例并对 mapContent.scale 赋值;③Behavior on scale 提供插值动画,平滑过渡;④Math.max/min 限幅防止过度缩放。整个过程不经过 MouseArea,每条触摸消息从 evdev → QPA → QTouchEvent → MultiPointTouchArea 全在 C++ 路径内。
思考题:
- 如果同时在
mapContent上放一个MouseArea { onClicked: ... },双指缩放时会不会误触发 onClicked? MultiPointTouchArea和PinchArea的区别是什么?分别适合哪种场景?
# 6.5 键盘焦点
# 6.5.1 Keys 附加属性
Rectangle {
width: 200; height: 200
color: activeFocus ? "steelblue" : "gray"
focus: true // 主动请求焦点
Keys.onPressed: (event) => {
switch (event.key) {
case Qt.Key_Left: x -= 10; break
case Qt.Key_Right: x += 10; break
case Qt.Key_Up: y -= 10; break
case Qt.Key_Down: y += 10; break
case Qt.Key_Return: console.log("回车"); break
case Qt.Key_Escape: console.log("ESC"); break
}
event.accepted = true // 吞掉按键,不传播到父级
}
Keys.onReleased: (event) => {
if (event.key === Qt.Key_Space) console.log("空格松开")
}
}
KeyNavigation——声明式焦点跳转:
Grid {
columns: 3; spacing: 8
Repeater {
model: 9
Rectangle {
width: 60; height: 60; color: "lightgray"
focus: true
KeyNavigation.right: (index + 1) % 3 !== 0 ? repeater.itemAt(index+1) : null
KeyNavigation.left: index % 3 !== 0 ? repeater.itemAt(index-1) : null
KeyNavigation.down: index + 3 < 9 ? repeater.itemAt(index+3) : null
KeyNavigation.up: index - 3 >= 0 ? repeater.itemAt(index-3) : null
}
}
}
# 6.5.2 焦点隔离
车机典型场景:主驾屏(仪表盘)和副驾屏(导航娱乐)共用同一套 QML 代码。当副驾屏的 TextField 正在编辑时,方向盘上的"确认"键应该只作用于主驾屏——这就是 FocusScope 的价值:
ApplicationWindow {
width: 1200; height: 480; visible: true
// ───── 主驾屏:仪表盘 FocusScope ─────
FocusScope {
id: driverScope
width: parent.width * 0.5; height: parent.height
focus: true
Rectangle {
anchors.fill: parent; color: "#111"
TextField {
id: driverInput
anchors.centerIn: parent
focus: true
text: "仪表搜索"
}
}
}
// ───── 副驾屏:导航 FocusScope ─────
FocusScope {
id: passengerScope
x: parent.width * 0.5
width: parent.width * 0.5; height: parent.height
Rectangle {
anchors.fill: parent; color: "#222"
TextField {
id: passengerInput
anchors.centerIn: parent
text: "导航搜索"
}
}
}
// ───── 方向盘按键:只发给当前活动的 FocusScope ─────
Keys.onPressed: (event) => {
if (event.key === Qt.Key_F1) {
console.log("方向盘 F1 → 发送到活动 FocusScope 的焦点 Item")
if (Window.activeFocusItem) {
console.log(" 当前焦点:", Window.activeFocusItem)
}
}
}
}
FocusScope 的底层机制——每个 FocusScope 内部维护一个独立的焦点链:
Window (焦点根)
├── FocusScope "driver" ── TextField (当前焦点)
└── FocusScope "passenger" ── TextField
当用户在副驾屏上触摸 TextField 时:
- MouseArea 收到触摸事件 →
passengerInput.focus = true passengerScope把焦点转移到自己内部的链driverScope失去活动焦点- 方向盘按键 →
Window的activeFocusItem切到passengerInput
# 6.5.3 焦点链
Column {
spacing: 8
TextField { focus: true; placeholderText: "用户名" }
TextField { placeholderText: "密码"; echoMode: TextInput.Password }
CheckBox { text: "记住我" }
Button { text: "登录" }
}
// 按 Tab → 焦点依次在 TextField → TextField → CheckBox → Button 之间切换
控制 Tab 顺序——用 KeyNavigation.tab:
TextField { id: user; KeyNavigation.tab: password }
TextField { id: password; KeyNavigation.tab: loginBtn }
Button { id: loginBtn; KeyNavigation.tab: user }
# 6.6 手势识别
# 6.6.1 三种手势
PinchArea——双指捏合的 QML 原生实现(封装了 MultiPointTouchArea):
PinchArea {
anchors.fill: parent
pinch.target: mapContent
pinch.minimumScale: 0.5
pinch.maximumScale: 3.0
onPinchUpdated: (pinch) => {
mapContent.scale *= pinch.scale
mapContent.rotation += pinch.rotation
}
}
PinchArea 内部等价于 §6.4.3 的手动 MultiPointTouchArea——它只是把双指距离/角度的计算包装好了。
SwipeView——滑动翻页(底层是 ListView + snapMode):
SwipeView {
currentIndex: 0
Page { title: "仪表"; Rectangle { color: "#111" } }
Page { title: "导航"; Rectangle { color: "#222" } }
Page { title: "音乐"; Rectangle { color: "#333" } }
}
Flickable——惯性拖拽:
Flickable {
width: 400; height: 300
contentWidth: image.width; contentHeight: image.height
boundsBehavior: Flickable.StopAtBounds
Image { id: image; source: "large_map.png" }
}
# 6.6.2 事件过滤器
C++ 侧可以在事件到达 QML 之前拦截——用于全局手势、调试日志、权限控制:
class GlobalTouchFilter : public QObject {
Q_OBJECT
protected:
bool eventFilter(QObject* obj, QEvent* event) override {
if (event->type() == QEvent::TouchBegin) {
auto* touch = static_cast<QTouchEvent*>(event);
qDebug() << "全局拦截: TouchBegin, 触点:" << touch->pointCount();
// return true → 吃掉事件,不让 QML 收到
}
return QObject::eventFilter(obj, event);
}
};
// main.cpp
GlobalTouchFilter* filter = new GlobalTouchFilter();
app.installEventFilter(filter); // 全局安装
# 6.6.3 定时器交互
Timer {
interval: 16; repeat: true; running: true
onTriggered: {
// 定时器回调在当前事件循环迭代中同步执行
// 如果这里写死循环 → 整个 GUI 卡死
gauge.value = fetchSensorData()
}
}
定时器与渲染帧的对齐——如果 Timer.interval = 16 且回调在 2ms 内完成,它和 VSync 并不同步。要想与帧对齐,使用 FrameAnimation:
FrameAnimation {
running: true
onTriggered: {
// 每帧触发一次——与 VSync 同步
}
}
# 6.7 双屏隔离
下面是一套完整的车机双屏事件隔离方案——主驾屏(仪表盘)只响应方向盘按键和主驾触摸;副驾屏(导航娱乐)只响应副驾触摸。方向盘上的"确认"键根据"当前谁的 FocusScope 有活动焦点"来路由:
import QtQuick
import QtQuick.Controls
ApplicationWindow {
width: 1200; height: 480; visible: true; title: "双屏车机"
// ───── 主驾屏 FocusScope ─────
FocusScope {
id: driverScope
width: parent.width * 0.5; height: parent.height
focus: true
Rectangle {
anchors.fill: parent; color: "#0d0d1a"
Column {
anchors.centerIn: parent; spacing: 12
Text { text: "仪表盘"; font.pixelSize: 24; color: "white" }
TextField {
id: driverSearch
placeholderText: "搜索..."
focus: true
}
}
MultiPointTouchArea {
anchors.fill: parent
onPressed: driverSearch.focus = true
}
}
}
// ───── 副驾屏 FocusScope ─────
FocusScope {
id: passengerScope
x: parent.width * 0.5
width: parent.width * 0.5; height: parent.height
Rectangle {
anchors.fill: parent; color: "#1a1a2e"
Column {
anchors.centerIn: parent; spacing: 12
Text { text: "导航娱乐"; font.pixelSize: 24; color: "white" }
TextField {
id: navSearch
placeholderText: "目的地..."
}
}
MultiPointTouchArea {
anchors.fill: parent
onPressed: {
passengerScope.focus = true
navSearch.focus = true
}
}
}
}
// ───── 方向盘按键 → 路由到当前活动 Scope 的焦点 Item ─────
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Return) {
var active = Window.activeFocusItem
if (active) {
if (active === driverSearch) console.log("[主驾] 搜索:", driverSearch.text)
else if (active === navSearch) console.log("[副驾] 导航到:", navSearch.text)
}
event.accepted = true
}
}
}
案例知识融合:本案例凝聚了本章全部核心机制——①MultiPointTouchArea 分别绑定两个屏幕的触摸区域,互不干扰;②FocusScope 隔离两个屏幕的焦点链,方向盘按键 Keys.onPressed 通过 Window.activeFocusItem 路由到当前活动 Item;③触摸切换焦点、键盘操作、焦点域隔离三者协作,形成一套完整的双屏车载交互逻辑。
# 6.8 性能与陷阱
# 事件处理的性能要点
| 场景 | 问题 | 优化 |
|---|---|---|
100 个 ListView delegate 全开 hoverEnabled | 每帧检查 100 个 MouseArea 的 hover | 只在顶层组件开启 hover |
onPositionChanged 里做复杂运算 | 手指每移动 1px 触发一次 | 节流:用 Timer 每 30ms 批量处理 |
MultiPointTouchArea.onUpdated 高频触发 | 5 个触点 = 每帧 5 次回调 | 在回调里只存值,在 FrameAnimation 里统一计算 |
多个重叠 MouseArea 同时 accepted = true | z-order 冲突 | 明确设置 z 值 |
// ✅ 节流示例
MouseArea {
anchors.fill: parent
property var lastProcess: 0
onPositionChanged: {
var now = Date.now()
if (now - lastProcess < 30) return // 30ms 内不重复处理
lastProcess = now
// 真正处理逻辑
}
}
# 6.9 新手陷阱
| # | 陷阱 | 说明 | 修复 |
|---|---|---|---|
| 1 | Popup { modal: false } 导致事件穿透 | Popup 是默认 non-modal——所有触摸事件直达底层 Item | 弹出层必须 modal: true |
| 2 | hoverEnabled 默认 false | 写 onEntered 不生效——因为没开 hover | 显式 hoverEnabled: true |
| 3 | mouse.accepted 忘设导致事件一路穿透 | onPressed 默认 accepted = false | 处理完的事件设 accepted = true |
| 4 | MultiPointTouchArea 和 MouseArea 同区域冲突 | 两个都收到事件 | 同一区域二选一;或上层用 MultiPointTouchArea、下层用 MouseArea |
| 5 | FocusScope 不设初始焦点 | 键盘事件无法路由 | 至少一个子 Item 设 focus: true |
# 6.10 训练题
训练题 1:修复触摸穿透
下面的弹出菜单点击"删除"时,底下的 ListView delegate 也响应了点击。修复:
ListView {
model: 100
delegate: MouseArea {
width: parent.width; height: 40
onClicked: console.log("item " + index + " clicked")
}
}
Popup {
y: 100; width: 200; height: 150
// 缺什么?
Button { text: "删除"; onClicked: popup.close() }
}
答案
加上 modal: true:
Popup {
modal: true // ← 修复
y: 100; width: 200; height: 150
Button { text: "删除"; onClicked: popup.close() }
}
训练题 2:可拖拽 + 可点击的控件
要求:写一个 Rectangle,可以被拖拽移动、同时也支持 onClicked。拖拽时不能触发 onClicked。
参考答案
Rectangle {
id: box
width: 100; height: 100; color: "steelblue"
MouseArea {
anchors.fill: parent
drag.target: parent
property bool moved: false
onPressed: { moved = false; mouse.accepted = true }
onPositionChanged: moved = true
onReleased: {
if (!moved) console.log("clicked!") // 没移动 = 点击
moved = false
}
}
}
# 6.11 思考题
QML 与 Android 事件分发:Android 的触摸事件分发是"dispatchTouchEvent → onInterceptTouchEvent → onTouchEvent"三层。QML 用
mouse.accepted+ z-order 遍历。对比两种模型,在"父容器拦截子控件事件"和"子控件拒绝父容器拦截"两个场景上各有什么优劣?双线程事件处理:第 2 章讲到渲染线程独立处理动画插值。触摸事件在 GUI 线程处理——这意味着"手指移动 → GUI 线程绑定求值 → Sync 阶段 → 渲染线程渲染"这一整条链路有延迟。什么场景下用户会感知到这个延迟?怎么优化?
物理按键 vs 触摸屏:车机中方向盘按键是物理按键、中控屏是触摸屏。物理按键走
Keys.onPressed路径,触摸屏走MouseArea/MultiPointTouchArea路径。两条路径在底层有交叠吗(QKeyEventvsQTouchEvent)?怎么设计才能让方向盘"确认键"同时能操作仪表盘和导航屏?
# 6.12 速查表
| 概念 | 一句话 |
|---|---|
| MouseArea | 鼠标/单点触摸事件——onClicked/onPressed/onReleased/drag |
| mouse.accepted | 事件消费标志——true = 吃掉、不向下层传播 |
| MultiPointTouchArea | 多点触摸——touchPoints 数组,最低 1 触点最高 5 |
| TouchPoint | 单个触点对象——pointId/x/y/pressure/startX 等 |
| Keys | 附加属性——onPressed/onReleased 监听物理键盘 |
| KeyNavigation | 声明式焦点跳转——left/right/up/down/tab |
| FocusScope | 独立焦点域——双屏车机各管各的焦点链 |
| Window.activeFocusItem | 全局当前持有焦点的 Item——键盘事件唯一的接收者 |
| PinchArea | 双指捏合手势——封装 MultiPointTouchArea |
| SwipeView | 滑动翻页——底层 ListView + snapMode |
| Flickable | 惯性拖拽容器——放大图片/地图的拖拽浏览 |
| hoverEnabled | MouseArea 默认 false——必须显式开启才触发 onEntered/onExited |
| FrameAnimation | 与 VSync 同步的帧回调——替代 Timer 用于渲染对齐 |
| eventFilter | C++ 层全局事件拦截——在所有 QML 之前过滤 |
核心哲学:
QML 的事件系统 = z-order 降序分发 + mouse.accepted 冒泡终止 + FocusScope 焦点域隔离。
触摸从 evdev 到 QTouchEvent 到 MouseArea 层层抽象,
按键从 FocusScope 的焦点链层层路由——
理解这两条路径,就掌握了嵌入式 QML 所有交互场景。