1. 从触摸到鼠标:Android 输入映射的真实工程场景
Android 触摸事件转换为鼠标事件,本质是把MotionEvent里的手指坐标、按压状态、滑动轨迹,重新组装成系统能识别的鼠标语义事件。这件事在云手机、远程桌面、投屏控制、车机外设适配里非常常见:用户用手指在屏幕上滑动,系统却要把它当成一只虚拟鼠标来驱动光标。核心难点在于触摸事件类型只有ACTION_DOWN、ACTION_UP、ACTION_MOVE、ACTION_CANCEL这几种,而鼠标事件在触摸基础上还多出ACTION_HOVER_MOVE、ACTION_BUTTON_PRESS、ACTION_BUTTON_RELEASE、ACTION_SCROLL,按键状态要维护BUTTON_PRIMARY、BUTTON_SECONDARY,滚轮还要写入AXIS_VSCROLL。
如果你只是写一个 Demo,手动MotionEvent.obtain拼事件就够了。但一旦进入团队协作,问题就变成:多个 AI 编码工具、多个模型通道、多套 Key 散落在不同配置文件里,改一处忘一处,调试时根本分不清是映射逻辑错了还是通道配置错了。这篇就聚焦两件事:一是用 TaoToken 统一 Key 通道把多工具接入配置收敛成可复制的config.toml与settings.json骨架,二是给出触摸坐标到鼠标事件的完整映射验证动作,让你能复现一次从配置到映射的端到端测试。
适合谁看:正在做 Android 输入映射、需要统一管理多 AI 工具 Key 的开发者;已经写过sendLeftButton这类代码但配置管理混乱的人;想把触摸板逻辑接进自己 App 又不想被 Key 管理拖住的人。
2. TaoToken 前置:统一 Key 与 API 通道准备
在动手写映射代码之前,先把通道理顺。TaoToken 的作用是给多个 AI 工具提供统一的 Key 和 API 入口,你不用为每个工具单独记一套地址和密钥。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数,配置里直接写这个。
你需要先拿到一个可用的 Key。进入控制台创建:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,然后在 API Keys 页面生成密钥:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。生成后复制保存,后面config.toml和settings.json都要用到。
注意:Key 只显示一次,建议生成后立刻写入本地配置文件并做好备份,不要贴在聊天记录或截图里。
如果你后续要做长期编码或 Agent 类任务,可以了解 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。接入细节和参数说明看文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。想先验证模型通道是否通,用模型对话页面最快:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。
这里要强调一个边界:TaoToken 是统一 Key 与 API 通道,不是编辑器替代品,也不做灰色中转。你的映射代码、MotionEvent组装逻辑仍然跑在 Android 工程里,TaoToken 只负责让多工具的模型调用配置统一、可复制、可迁移。
3. 可复制配置:config.toml 与 settings.json 骨架
配置分两层:config.toml放通道级参数(基址、超时、默认模型),settings.json放工具级参数(各工具读取哪套 Key、走哪个模型)。这样拆分的好处是换 Key 只改一处,工具侧不用动。
先看config.toml:
# config.toml —— TaoToken 统一通道配置 [provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key = "sk-你的TaoToken密钥" timeout_seconds = 60 max_retries = 3 [models] default = "claude-sonnet" coding = "claude-sonnet" fast = "gpt-4o-mini" [logging] level = "info" log_request = true log_response = false再看settings.json,这是给工具侧读取的:
{ "taotoken": { "baseUrl": "https://taotoken.net/api", "apiKeyEnv": "TAOTOKEN_API_KEY", "defaultModel": "claude-sonnet", "tools": { "android-input-mapper": { "model": "claude-sonnet", "temperature": 0.2, "maxTokens": 4096 }, "motion-debug-helper": { "model": "gpt-4o-mini", "temperature": 0.0, "maxTokens": 2048 } } } }关键点:apiKeyEnv指向环境变量而不是硬编码,这样settings.json可以进版本库,Key 留在本地环境。启动前设置:
export TAOTOKEN_API_KEY="sk-你的TaoToken密钥"如果你用 Claude Code 这类工具,Anthropic 兼容入口的配置参考:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude-code-anthropic&utm_campaign=rewrite 。配置骨架和上面一致,只是工具读取的字段名不同。
提示:
config.toml里的base_url和settings.json里的baseUrl必须一致,都指向 https://taotoken.net/api ,写错会导致 404 或鉴权失败。
4. 触摸坐标到鼠标事件的映射实现与验证
配置就绪后,进入映射核心。参考 Android 11 平台CursorInputMapper::sync的逻辑,鼠标事件在 framework 层由CursorInputMapper完成读取转换,Java 层实现基本是它的简化版。下面按四类手势拆开。
4.1 轻触点击映射为左键事件
手指轻触对应 click,需要依次发出四个MotionEvent:ACTION_DOWN、ACTION_BUTTON_PRESS、ACTION_BUTTON_RELEASE、ACTION_UP,ButtonState依次为BUTTON_PRIMARY、BUTTON_PRIMARY、0、0。
private void sendLeftButton(MotionEvent lastMotionEvent) { int buttonState = MotionEvent.BUTTON_PRIMARY; int buttonDownTime = SystemClock.uptimeMillis(); MotionEvent.PointerProperties pp1 = new MotionEvent.PointerProperties(); lastMotionEvent.getPointerProperties(0, pp1); MotionEvent.PointerCoords pc1 = new MotionEvent.PointerCoords(); lastMotionEvent.getPointerCoords(0, pc1); properties[0] = pp1; pointerCoords[0] = pc1; MotionEvent downMotionEvent = MotionEvent.obtain( buttonDownTime, buttonDownTime, MotionEvent.ACTION_DOWN, lastMotionEvent.getPointerCount(), properties, pointerCoords, lastMotionEvent.getMetaState(), buttonState, lastMotionEvent.getXPrecision(), lastMotionEvent.getYPrecision(), lastMotionEvent.getDeviceId(), lastMotionEvent.getEdgeFlags(), lastMotionEvent.getSource(), lastMotionEvent.getDisplayId(), lastMotionEvent.getFlags()); // sendMotionEvent(downMotionEvent); MotionEvent pressedMotionEvent = MotionEvent.obtain( buttonDownTime, buttonDownTime, MotionEvent.ACTION_BUTTON_PRESS, lastMotionEvent.getPointerCount(), properties, pointerCoords, lastMotionEvent.getMetaState(), buttonState, lastMotionEvent.getXPrecision(), lastMotionEvent.getYPrecision(), lastMotionEvent.getDeviceId(), lastMotionEvent.getEdgeFlags(), lastMotionEvent.getSource(), lastMotionEvent.getDisplayId(), lastMotionEvent.getFlags()); // sendMotionEvent(pressedMotionEvent); buttonState = 0; MotionEvent releaseMotionEvent = MotionEvent.obtain( buttonDownTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_BUTTON_RELEASE, lastMotionEvent.getPointerCount(), properties, pointerCoords, lastMotionEvent.getMetaState(), buttonState, lastMotionEvent.getXPrecision(), lastMotionEvent.getYPrecision(), lastMotionEvent.getDeviceId(), lastMotionEvent.getEdgeFlags(), lastMotionEvent.getSource(), lastMotionEvent.getDisplayId(), lastMotionEvent.getFlags()); // sendMotionEvent(releaseMotionEvent); MotionEvent upMotionEvent = MotionEvent.obtain( buttonDownTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, lastMotionEvent.getPointerCount(), properties, pointerCoords, lastMotionEvent.getMetaState(), buttonState, lastMotionEvent.getXPrecision(), lastMotionEvent.getYPrecision(), lastMotionEvent.getDeviceId(), lastMotionEvent.getEdgeFlags(), lastMotionEvent.getSource(), lastMotionEvent.getDisplayId(), lastMotionEvent.getFlags()); // sendMotionEvent(upMotionEvent); }4.2 滑动映射为 HOVER_MOVE
手指滑动时持续有ACTION_MOVE,把它连同ACTION_DOWN、ACTION_UP一起转成ACTION_HOVER_MOVE,同时修改PointerProperties的toolType为TOOL_TYPE_MOUSE,坐标用增量累加。
private MotionEvent.PointerCoords mTouchDownPointerCoords = null; private MotionEvent.PointerCoords mMouseDownPointerCoords = new MotionEvent.PointerCoords(); private boolean handlerFingerTouchArea(MotionEvent ev, int pointerID) { switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN: mTouchDownPointerCoords = new MotionEvent.PointerCoords(); mTouchDownPointerCoords.x = ev.getX(); mTouchDownPointerCoords.y = ev.getY(); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: mTouchDownPointerCoords = null; break; } float moveX = 0, moveY = 0; if (mTouchDownPointerCoords != null) { moveX = ev.getX() - mTouchDownPointerCoords.x; moveY = ev.getY() - mTouchDownPointerCoords.y; mTouchDownPointerCoords.x = ev.getX(); mTouchDownPointerCoords.y = ev.getY(); } MotionEvent.PointerProperties pp1 = new MotionEvent.PointerProperties(); pp1.id = 0; pp1.toolType = MotionEvent.TOOL_TYPE_MOUSE; properties[0] = pp1; MotionEvent.PointerCoords pc1 = new MotionEvent.PointerCoords(); pc1.x = Math.min(mMouseDownPointerCoords.x + moveX, 0); pc1.y = Math.min(mMouseDownPointerCoords.y + moveY, 0); mMouseDownPointerCoords.x = pc1.x; mMouseDownPointerCoords.y = pc1.y; pc1.pressure = 1; pc1.size = 1; pointerCoords[0] = pc1; long eventTime = SystemClock.uptimeMillis(); MotionEvent customEvent = MotionEvent.obtain( 0, eventTime, MotionEvent.ACTION_HOVER_MOVE, 1, properties, pointerCoords, 0, 0, 1, 1, 7, 0, InputDevice.SOURCE_MOUSE, displayID, 0); // sendMotionEvent(customEvent); return true; }4.3 按住拖动与滚轮
鼠标移动加左键按住,就是在移动事件过程中保持ButtonState一直为BUTTON_PRIMARY,直到松开。滚轮则改AXIS_VSCROLL,值取滚动距离,超过阈值才触发ACTION_SCROLL。
private boolean handlerMouseWheel(MotionEvent ev) { MotionEvent clickMotionEvent = null; MotionEvent.PointerProperties pp1 = new MotionEvent.PointerProperties(); MotionEvent.PointerCoords pc1 = new MotionEvent.PointerCoords(); switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN: mVScrollDownY = ev.getY(); break; case MotionEvent.ACTION_MOVE: float vscroll = ev.getY() - mVScrollDownY; if (Math.abs(vscroll) > 20) { mVScrollDownY = ev.getY(); ev.getPointerProperties(0, pp1); ev.getPointerCoords(0, pc1); pc1.setAxisValue(MotionEvent.AXIS_VSCROLL, vscroll > 0 ? 1 : -1); properties[0] = pp1; pointerCoords[0] = pc1; clickMotionEvent = MotionEvent.obtain( buttonDownTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_SCROLL, ev.getPointerCount(), properties, pointerCoords, ev.getMetaState(), 0, ev.getXPrecision(), ev.getYPrecision(), ev.getDeviceId(), ev.getEdgeFlags(), ev.getSource(), ev.getDisplayId(), ev.getFlags()); } break; } if (clickMotionEvent != null) { // sendMotionEvent(clickMotionEvent); } return true; }4.4 验证动作:一次可复现的映射测试
配置和代码都就位后,跑一次端到端验证。步骤是:先在 Android 工程里注入一个测试入口,模拟ACTION_DOWN到ACTION_UP的完整序列;再用 TaoToken 通道调用模型对话接口,确认通道本身可用;最后对比映射前后的事件序列。
验证通道可用性,用 curl 发一次请求:
curl -X POST "https://taotoken.net/api/v1/chat/completions" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet", "messages": [{"role": "user", "content": "ping"}], "max_tokens": 16 }'预期返回包含choices字段的 JSON。如果返回 401,检查 Key 是否设置;返回 404,检查base_url是否写成了带 UTM 的地址。
映射验证侧,在sendMotionEvent里打日志,观察一次轻触是否输出四个事件、ButtonState是否按BUTTON_PRIMARY → BUTTON_PRIMARY → 0 → 0变化。滑动时观察是否持续输出ACTION_HOVER_MOVE且toolType为TOOL_TYPE_MOUSE。滚轮时观察AXIS_VSCROLL是否被写入非零值。
5. 本篇常见错排查
事件序列不完整:轻触只发出ACTION_DOWN和ACTION_UP,漏掉ACTION_BUTTON_PRESS和ACTION_BUTTON_RELEASE。系统可能不识别为点击。对照CursorInputMapper::sync里buttonsPressed和buttonsReleased的处理顺序补齐。
坐标不更新:滑动时PointerCoords的 x/y 没变,光标不动。检查mTouchDownPointerCoords是否在ACTION_DOWN时正确初始化,以及增量计算是否用了上一次的坐标。
toolType 没设成鼠标:PointerProperties.toolType默认不是TOOL_TYPE_MOUSE,导致系统按触摸处理。显式设置pp1.toolType = MotionEvent.TOOL_TYPE_MOUSE。
滚轮阈值不触发:Math.abs(vscroll) > 20这个阈值太小或太大都会导致滚轮不响应或过于灵敏,按设备密度调整。
通道 401/404:config.toml的base_url写成带 UTM 的地址会 404,必须用 https://taotoken.net/api 。Key 没通过环境变量注入会 401,检查export TAOTOKEN_API_KEY是否在当前 shell 生效。
配置不一致:config.toml和settings.json里的模型名不一致,工具侧读到的模型和通道默认模型不同,表现为请求被拒或走错模型。统一用claude-sonnet这类明确名称。
6. 接入与排障的下一步
如果你在接入阶段卡住,优先看 API Keys 和接入文档:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 与 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。想先确认模型通道是否通,用模型对话页面发一条消息最快:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。长期做编码或 Agent 任务,Coding Plan 的配置方式更省心:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
映射代码本身建议先在一个独立InputMapper类里跑通,再接入主流程。我试过把sendLeftButton和handlerFingerTouchArea放在同一个类里,用displayID区分多屏,调试时日志按事件类型打标签,定位问题比翻代码快得多。