输入法借 350 毫秒:给 agent 打字的两条路
先说结论。
一个 Android agent 要往别的 App 里打字,能走的路只有两条,而且它们不是"新旧关系",是两种不同的成本结构:
- 无障碍服务自己的输入通道(API 33+)。不装、不启用、不切换,用户键盘全程可用。代价是版本门槛和一堆边缘编辑器。
- 借一个无界面输入法(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 行。