移动端点击事件双触发问题与解决方案

精华修改于05/1489 浏览开发心得 疑似 AI 合成内容

一句话总结

在移动端用手指点一下屏幕,游戏却以为你点了两下——因为底层引擎同时发出了"触屏"和"鼠标"两个事件。本文讲清楚为什么会这样,以及怎么优雅地修掉它。

现象

在手机上测试游戏时,点一下按钮,回调函数执行了两次:买一个道具扣了两份钱,关一个弹窗关了两层,开一局游戏开了两把。
在电脑上测试一切正常。

为什么会这样?

根源:SDL 的触屏转鼠标机制

UrhoX 底层使用 SDL 处理输入。SDL 有一个设计:在触屏设备上,每次手指触碰屏幕,SDL 除了发出正常的 Touch 事件外,还会额外模拟一个 Mouse 事件。
这个设计的初衷是好的——让那些只处理了鼠标事件的老程序也能在触屏设备上运行。但对于同时监听了鼠标和触屏的现代 UI 框架来说,就变成了一个坑。

事件流对比

电脑上点一下鼠标:
MouseButtonDown  →  UI 处理  →  onClick 触发 ✅ 只有一次
手机上点一下屏幕:
TouchBegin       →  UI 处理  →  onClick 触发 ✅ 第一次(正常)
MouseButtonDown  →  UI 处理  →  onClick 触发 ❌ 第二次(多余的!)
手指一次触碰,引擎发了两个事件,UI 框架各处理一次,回调就跑了两遍。

为什么 UI 框架不自己处理?

UrhoX 的 UI 库用统一的"指针事件"(PointerEvent)模型来处理输入。Mouse 和 Touch 被视为两个独立的指针源:
- 来源:鼠标,pointerId:0(固定),pointerType:"mouse"
- 来源:第一根手指,pointerId:1,pointerType:"touch"
- 来源:第二根手指,pointerId:2,pointerType:"touch"
框架设计上把它们当作不同的输入设备——就像你可以同时用鼠标和触控板操作电脑一样。框架无法判断"这个 Mouse 事件是真的鼠标还是 SDL 模拟的",所以两个都会处理。

解决方案

实现方式是在 UI.Init() 之后对 UI 的事件处理函数做一层包装(monkey-patch)。

方案一:时间窗口防抖

时间线:

0ms   Touch 到达 → 正常处理,记录时间戳
2ms   Mouse 到达 → 距上次 Touch < 100ms → 丢弃 ✅
5000ms Mouse 到达 → 距上次 Touch > 100ms → 正常处理 ✅

具体实现

核心代码

在 UI.Init() 之后,对 UI 的事件处理函数做一层包装(monkey-patch):
function PatchTouchMouseDedup()
    local platform = GetPlatform()
    -- 只在可能出现双触发的平台启用
    if platform ~= "Android" and platform ~= "iOS" and platform ~= "Web" then
        return
    end
    local DEBOUNCE = 0.100  -- 100ms 窗口期(单位:秒)
    local lastTouchTime = -1  -- 初始为负值,不影响启动时的鼠标操作
    -- 保存原始函数
    local origMouseDown  = UI.HandleMouseDown
    local origMouseUp    = UI.HandleMouseUp
    local origMouseMove  = UI.HandleMouseMove
    local origTouchBegin = UI.HandleTouchBegin
    -- 引擎时间(秒),可靠递增
    local function now()
        return time.elapsedTime
    end
    -- Touch 到达时记录时间
    UI.HandleTouchBegin = function(touchId, x, y, pressure)
        lastTouchTime = now()
        origTouchBegin(touchId, x, y, pressure)
    end
    -- Mouse 到达时检查:是不是紧跟在 Touch 后面的模拟事件?
    UI.HandleMouseDown = function(x, y, button)
        if now() - lastTouchTime < DEBOUNCE then return end  -- 丢弃
        origMouseDown(x, y, button)
    end
    UI.HandleMouseUp = function(x, y, button)
        if now() - lastTouchTime < DEBOUNCE then return end
        origMouseUp(x, y, button)
    end
    UI.HandleMouseMove = function(x, y)
        if now() - lastTouchTime < DEBOUNCE then return end
        origMouseMove(x, y)
    end
end

关键细节

1. 为什么用 time.elapsedTime 而不是 os.clock()?

os.clock() 返回的是"CPU 时间",在嵌入式 Lua 环境(比如 UrhoX)中可能返回 0。如果 os.clock() 返回 0:
lastTouchTime = 0          -- 初始值
os.clock() * 1000 = 0      -- 返回 0
0 - 0 = 0 < 100            -- 永远成立!
所有鼠标事件被永久拦截,游戏直接无法点击。
time.elapsedTime 是引擎提供的"程序启动后经过的秒数",值可靠且持续递增。

2. 为什么 lastTouchTime 初始值是 -1?

如果初始为 0,在应用启动的头 100ms 内:
now() = 0.05               -- 启动 50ms
0.05 - 0 = 0.05 < 0.1      -- 成立!鼠标被拦截
用户在启动瞬间的点击会丢失。设为 -1 后:
now() = 0.05
0.05 - (-1) = 1.05 > 0.1   -- 不成立,鼠标正常通过 ✅

3. 为什么是 100ms?

SDL 的模拟 Mouse 事件通常在 Touch 事件的同一帧或下一帧到达,间隔不超过 16ms(60fps 下一帧的时间)。100ms 留了足够的余量,同时不会影响真实的快速操作(人类手指两次独立点击的间隔通常 > 200ms)。

方案二:永久标记屏蔽(推荐)

具体实现
在 UI.Init() 之后,对 UI 的事件处理函数做一层包装(monkey-patch):
function PatchTouchMouseDedup()
    local platform = GetPlatform()
    if platform ~= "Android" and platform ~= "iOS" and platform ~= "Web" then
        return
    end
    local isTouchDevice = false
    local origTouchBegin = UI.HandleTouchBegin
    local origMouseDown  = UI.HandleMouseDown
    local origMouseUp    = UI.HandleMouseUp
    local origMouseMove  = UI.HandleMouseMove
    -- 收到第一次 Touch,永久标记为触屏设备
    UI.HandleTouchBegin = function(touchId, x, y, pressure)
        isTouchDevice = true
        origTouchBegin(touchId, x, y, pressure)
    end
    -- 触屏设备上,Mouse 事件全部是 SDL 模拟的,直接丢弃
    UI.HandleMouseDown = function(x, y, button)
        if isTouchDevice then return end
        origMouseDown(x, y, button)
    end
    UI.HandleMouseUp = function(x, y, button)
        if isTouchDevice then return end
        origMouseUp(x, y, button)
    end
    UI.HandleMouseMove = function(x, y)
        if isTouchDevice then return end
        origMouseMove(x, y)
    end
end
逻辑更简单,也更可靠:触屏设备上的 Mouse 事件 100% 来自 SDL 模拟,没有例外,不需要猜时序,全部丢弃。
外接鼠标不产生 Touch 事件,isTouchDevice 永远为 false,Mouse 事件照常通过,不受影响。
逻辑变成了:
第一次 TouchBegin → isTouchDevice = true(此后永远生效)
之后所有 MouseDown/Up/Move → isTouchDevice 为 true →全部丢弃
模拟事件无论延迟多久(1ms 还是 500ms)都会被拦截

最终解法

收到 Touch 即标记设备类型,此后永久屏蔽 Mouse
如果你也在用 UrhoX 或其他基于 SDL 的引擎开发移动端游戏,这个 patch 可以直接拿走用。
8
1
1