下载 App

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

精华修改于05/1497 浏览开发交流 疑似 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 可以直接拿走用。
别再烧积分了!从每次 2000 到每次 50 的血泪经验截图
别再烧积分了!从每次 2000 到每次 50 的血泪经验
适用场景:所有在用 TapTap Code 开发游戏的小白 难度:★☆☆☆☆ 背景最近群里总有人"炫耀"一次操作花了几千积分,说实话这不是值得高兴的事——这是在告诉所有人你的开发方式有根本性问题。我也走过这条弯路,所以来说说怎么改。 积分消耗的本质是什么积分 = AI 处理的 token 数量 = 你发给 AI 的内容 + AI 回给你的内容所以烧积分的本质只有两种: 你让 AI 做了大量无效/重
精华
68 赞
21 回复
03:06
为了不被斩杀,我只能做到这些了截图
为了不被斩杀,我只能做到这些了
之前一直申请不到计划3,之前说太花哨,后面说体量完成度不足,在我一番小作文下,编辑终于给了我解答,什么原生质感,玩家开局体验啥的。 本来计划2也够用,准备以后再优化了,结果计划2赠送的积分要砍半了,学生党实在消费不起,于是只能再冲一波计划3了 从周六中午到现在一天半时间,优化了一遍角色卡,战斗卡,还有抽卡界面,优化了新手教程,该说不说确实比之前要舒服些。 前半部分为新版,后半部分为旧版。 明天再次
37 赞
6 回复
01:27
《大二暑假,我用AI做了一款属于自己的游戏》截图
《大二暑假,我用AI做了一款属于自己的游戏》
本人自制游戏现已加入taptap封闭测试(仅适配安卓机型)欢迎大家前来尝试(八月十号可以正式开始游玩) 如果有建议或者bug反馈可以加入qq群928501443 #发现好游戏 #游戏日常 #浅评一下 #今天游戏圈发生了啥
3 赞
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
123 赞
44 回复
2D 等轴测视角:完整理论截图
2D 等轴测视角:完整理论
2D 等轴测视角:完整理论 一、视角的本质 什么是等轴测 人站在高处斜着往下看地面,地面上的方格子不再是正方形,而变成了菱形。这就是等轴测的视觉本质。 俯视(正上方看下去): 等轴测(斜上方看下去): ┌──┬──┬──┐ ◇ ├──┼──┼──┤ ◇ ◇ ├──┼──┼──┤ ◇ ◇ ◇ └──┴──┴──┘ ◇ ◇ ◇ 正方形网格经过旋转 + 压缩,变成了菱形网格。 为什么是 2:1
6 赞
00:26
塔啦啦科研:三渲二(风格1)截图
塔啦啦科研:三渲二(风格1)
最近在研究像素风格的三渲二,一直想做小丑牌那种风格,但是塔啦啦一直做不出来,发现是有一部分的功能做不了,只能实现简单的材质制作和描边,因此,我从零直接搭建了一整套引擎基础。目前进度可以看视频。 @嗒啦啦 什么时候出shader 评论区随意提问 #TapTap制造 #TapTap制造社区 #TapTap制造
4 赞
5 回复
【游戏上新】商业帝国大亨截图
【游戏上新】商业帝国大亨
无名小卒还是名扬天下。 从临时工到称霸宇宙。 只需点击屏幕,就能获得财富,利用财富购置产业,获取更多财富。
6 赞
4 回复
开发心得-界面布局心得截图
开发心得-界面布局心得
大家好,我正在用 TapTap 制造制作一款2D 治愈林间徒步旅行游戏《徒步荒野》。 我分享一下我的开发心得,希望能有用,下面是一些界面布局的心得。 第一步:定版式、操作 先确定游戏是横版还是竖版、需要哪些操作按钮,敲定整体玩法思路。 我的玩法灵感参考《边境之旅》,主打散步看风景、沿途触发随机事件。 原版是 3D,考虑到自己是新手,选择2D 横版街机式来做,制作更简单、好上手。 第二步:敲定全局美
6 赞
3 回复
「游戏上新」秦王的游戏截图
「游戏上新」秦王的游戏
你是秦王嬴政身边的廷臣。 方士徐福献上一匣【帝王卡牌】,四张凶牌封死命运: 屠戮|淫欲|奢华|征伐 七日死限,必须一一折断卡牌对应的欲望任务。 办不成? 秦王殿上长剑出鞘,君臣性命,顷刻倾覆。 ⚔游戏核心玩法 ▪回合卡牌策略,抉择牵动秦廷朝堂势力 ▪朝堂派系博弈:王权、法家、军功贵族、方士暗流角力 ▪多重结局:功成辅秦王一统,或是任务失败血染咸阳宫 ▪随机事件触发,每一局嬴政的猜忌程度截然
4 赞
2 回复
永恒虚空持续开发日记1——关于游戏中的新名词:场频截图
永恒虚空持续开发日记1——关于游戏中的新名词:场频
写在前边:首先感谢TapTap制造版主大大@汪崽 对于作品潜力的肯定,给与了我积分计划二的权限[表情_不好意思]。这边也在加紧开发改进各种功能中,期望尽快给大家带来成熟的版本。[表情_猫咪举手] 大家好,我是永恒虚空的制作人黑猫不详,大家叫我黑猫就好。从今天开始我将以后置开发日记的形式给大家分享我在TapTap制造中制作的游戏永恒虚空的开发过程,新人希望大家多多关照! 今天先给大家介绍我作品中最重
投票
3 赞