输入法借 350 毫秒:给 agent 打字的两条路

输入法借 350 毫秒:给 agent 打字的两条路

先说结论。

一个 Android agent 要往别的 App 里打字,能走的路只有两条,而且它们不是"新旧关系",是两种不同的成本结构:

  1. 无障碍服务自己的输入通道(API 33+)。不装、不启用、不切换,用户键盘全程可用。代价是版本门槛和一堆边缘编辑器。
  2. 借一个无界面输入法(ADBKeyboard)。通吃,连 CodeMirror 都进得去。代价是用户得有一台电脑,才授得出那个权限。

我们的 screen.type 是一个瀑布:先试第一条,返回 null 才走第二条。整件事的关键全在这个 null 的语义里------返回 null 表示"这条路不通且一个字都没打",返回非 null 表示"已经提交过,成败在此定论"。 这两个状态混在一起,就会出现"打两遍"。

一、证据:两种写法的实测分界

2026-09-20,在两台设备上对同一个页面(bolt.new 的注册表单)做了全量对比,结论只有三行:

方式 Radix 表单 CodeMirror
ACTION_SET_TEXT / 剪贴板 PASTE 字段串位 全灭
MIUI 上 setPrimaryClip 静默丢弃(粘进旧内容) ------
ADBKeyboard(commitText) 三个字段全对 也进得去

所以最终选的是 commitText 而不是更省事的 setText:前者是键盘的通道,后者是一个可以被控件忽略的建议。 ACTION_SET_TEXT 在标准 EditText 上工作良好,一旦控件是自绘的、或者包在 WebView 里,它就什么也不做,而且不报错。

另一个数字来自同一时期的另一个坑:微信朋友圈评论,64 步 / 341 秒 ;0.5.2 之后 20 步 / 58 秒。慢的那一版里,有一次调用"成功"了却什么都没发出去------后面单独说。

二、通道一:怎么拿到 InputConnection

前提是在 accessibility_service_config.xml 里声明:

xml 复制代码
android:accessibilityFlags="...|flagInputMethodEditor"

声明之后,服务对象上多了一个 inputMethod: InputMethod?,取连接只有三步,但三步都得检查:

kotlin 复制代码
private fun connection(service: AccessibilityService): InputMethod.AccessibilityInputConnection? {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null
    val im = runCatching { service.inputMethod }.getOrNull() ?: return null
    // Started, not merely present
    if (!runCatching { im.currentInputStarted }.getOrDefault(false)) return null
    return runCatching { im.currentInputConnection }.getOrNull()
}

第二行是版本,第三行是会话是否已经开始 ------这个对象活得比编辑器久,往一个已经结束的会话里 commitText,等于往空气里打字,而且不报错。第四行才是连接本身。

还有一层等待:编辑器刚拿到焦点的那几帧,输入会话还没送到我们这边。awaitConnection 最多等 400 毫秒、每 50 毫秒探一次。这个等待看起来可有可无,其实是必须的------这里返回 null 不是"轻描淡写地错过一次",而是把调用方推去第二条路 ,而那台设备上很可能没有 WRITE_SECURE_SETTINGS,那才是硬错误。

三、replace 的契约:三步,两次读

commitText 是插入。接口对模型承诺的是替换。所以一次 replace 是:

复制代码
getSurroundingText → 清空 → commitText → 轮询读回 → 比对

清空有两种写法:光标位置有效时用 deleteSurroundingText(start, len - end);光标报 -1 时(部分自绘编辑器)改成 setSelection 把整段选中,让 commitText 覆盖。

第二种不是兜底的猜测,是另一条确定的路------因为"往一个大脑以为已经清空的框里追加"这个错误,在模型侧的表现是"他自己打了两遍",然后他会开始删,然后任务就散了。

读回是轮询,600 毫秒预算、60 毫秒一次。原因很简单:commitText 是跨进程的单向调用,返回时值还没落地。这和 ADB 那条路的读回逻辑一样,但预算短得多,因为这里读的是编辑器自己的缓冲区,不用等布局。

返回值有三种情况,调用方必须分得开:

返回 含义 调用方该做什么
null 通道不在(没焦点 / 低于 API 33 / 自绘拿不到连接),一个字都没打 可以兜底
等于入参 成功 结束
不等于入参 已提交但编辑器不同意,是真失败不是缺通道 不许兜底,报告

第三行是"打两遍"的唯一防线。

四、editorAction:先问 actionId,再问 imeOptions

发出去(或搜索、下一步)走的是另一条路:让编辑器执行它自己声明的动作。

kotlin 复制代码
val action = if (info.actionId != 0) info.actionId
             else info.imeOptions and EditorInfo.IME_MASK_ACTION
if (action == 0 || action == EditorInfo.IME_ACTION_NONE) return false
if (info.actionId == 0 && info.imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION != 0) return false

顺序不能反。actionId 是代码里设的,imeOptions 是布局里写的,两个都存在时前者才是监听器真正注册的那个。默认猜 Enter 会在搜索框上把"下一页"按成"搜索"。

第三个 if 是那个花了时间的坑。IME_FLAG_NO_ENTER_ACTION 的意思是**「回车不要执行任何动作,我只从自己的按钮发」**。微信的评论框就是这种:声明 DONE,但带了这个 flag,只从它自己的发送按钮发。

在那种框上触发 IME 动作,ACTION_IME_ENTER 返回 true,然后什么也没发生。0.5.2 之前我们就这么报"已发送"。修完之后的行为是:明确说"这个框不能这样提交",并指出"去点 App 自己的发送按钮"。

而点那个按钮还有第二层:在藏了树的 App 上,发送按钮那条细带就贴在下方的正文上,普通网格点击会落到正文里。所以打完字的瞬间会被记一个时间戳(typedBlindAt),此后一个不带 on="zoom" 的坐标点击会被直接拒绝 ,错误信息里说明原因。想点就先放大。树的盲判据是节点数 ≤ 3(BLIND_TREE_NODES)。

五、通道二:借输入法,为什么是 350 毫秒

兜底那条路要求 ADBKeyboard 是当前输入法,否则广播发出去没人接。

这里有两个常量值得单独说。

350 毫秒(BIND_SETTLE_MS)。 设置写进去之后立刻能读回来,但"被选中"和"被绑定"不是一回事。输入法的广播接收器在它自己 onCreate 里注册,一个刚选中、还没绑上的输入法,是没人在听的输入法。实测绑定上界约 400 毫秒,框架不向普通 App 暴露这个状态,只能等。发早了,广播静默消失------没有任何异常、没有日志。

0.75 秒(每次调用的借还成本)。 之所以按调用借还而不是按回合:一个三字段的表单,按调用是六次切换,按回合是两次,确实更贵。但每次切回真键盘,都会把键盘抬到正在被驱动的那个 App 上面,而 type_text 的读回是在轮询那个 App 自己的树。省下的切换成本,用"看不见屏幕"支付。 一次调用是不改变"他正在看什么"的最小单位。

另外,这个 350 不是拍脑袋的保守值,是被一个更早的 bug 逼出来的:广播 action 名一度带了包名前缀,匹配不上任何 filter,于是"发出去没人接"和"被系统拦了"长得一模一样,为此误判过一次 ROM 行为。

六、代价

API 33 是硬门槛。 低于它,通道一整个不存在,直接掉到要电脑的那条路。minSdk 是 30,所以 API 30--32 的机器全靠兜底。

8192 是清空窗口,不是文本上限。 getSurroundingText 的窗口同时决定"能清多远",窗口外的内容会活过一次清空。代码编辑器的缓冲区不是连续文本,这种情况只能靠读回长度不对来暴露。

ROM 可以撒谎。 在一台 HONOR(MagicOS / Android 15)上,Settings.Secure 对本 App 返回 null 读当前输入法,ENABLED_INPUT_METHODS 也读不到,而同一时刻 adb shell settings get 是对的。这直接导致"用户明明有输入法,却被告知没有"。修法是把 getEnabledInputMethodList()(API 34+)和设置值取并集,两个 id 先过 ComponentName 归一化再比------否则 pkg/Full.Class 和 pkg/.Class 会被当成两个输入法,而 BRAIN_IME 的比对正好建立在这个字符串上。

打字成功不等于动作完成。 上一节那个 NO_ENTER_ACTION 就是例子:字进了框,performed: true,消息没发出去。

最后一条不是工程问题。 有些 App 拒绝的不是读屏,是"这次操作不是人做的"。风控、验证码、人脸,打进去也没用。这条没有代码解法。

收尾

两条通道的差别,说到底不是技术选型,是你把能力挂在哪一层 :挂在"App 愿意配合"的那一层,天花板就是它的意愿;挂在"所有 App 都必须实现"的那一层(InputConnection),就还有事可做------代价是每一个边缘情况都得自己扛,从 API 版本到撒谎的 ROM。

仓库开源,Apache-2.0 加附加条款。上面每个数字都能 grep 到:A11yIme.kt 193 行,ImeSwitch.kt 392 行。

相关推荐
哲鲤4 小时前
Binder 不只是 IPC:从一次跨进程调用看懂 Android 与 AAOS 的架构演进
android
用户128139778164 小时前
Eyf半字节【3.0 版】正式发布|订单、单证、扫码三大核心模块全新重构
android
device_Any5 小时前
接口文档写着三个字段,接口返回了五个
android·java·linux
kingx35 小时前
Bugku Reverse 与 Android 题解总集:APK / native / PE / IPA
android·逆向·ctf·bugku
大尚来也5 小时前
PHP 内存溢出常见原因与优化排查步骤
android
美狐美颜SDK开放平台6 小时前
开发直播APP、电商短视频与视频社交平台时,能否共用一套美颜SDK?
android·图像处理·人工智能·音视频·直播美颜sdk
事圆则缓7 小时前
Android 中的 MQTT 协议:从设备状态推送到可靠消息处理
android
三雷科技7 小时前
Android 开发环境搭建指南
android
赵庆明老师7 小时前
Android Studio Google源代理配置
android·ide·android studio