Andee 是跑在 Android 上的 agent,内部是一个工具调用循环:模型每说一句「调用某个方法」,都汇总到同一个 dispatcher 上执行。他所有「看屏幕」的能力都压在一个前提上------无障碍服务能把当前窗口的根节点交出来,那棵树上带着可见的文字、控件的类名和屏幕坐标。
这个前提在某些 App 上不成立,而且不成立的方式很脏:返回的不是 null,不是异常,是一个合法的 AccessibilityNodeInfo------没有尺寸,没有子节点,没有类名。下游只要不检查这三个字段,就会把它读成「这一屏是空的」,然后一路错下去。
这篇给的是这条链路上三个落地的东西:怎么把「空」分成三类、写入系统设置那一步为什么只加不删、以及这套做法在真机上要付什么。
一、三种「空」,下一步是三个不同的动作
| 现象 | 判据 | 下一步 |
|---|---|---|
| 被自己的卡片盖住 | 全屏卡片处于展开态 | 折卡片,重试一次 |
| 这一页自己画像素 | 有树,但节点数 ≤ 3 | 截图 + 坐标,不再问「为什么没树」 |
| App 主动藏起来 | 子节点 0、边界空矩形、类名空串 | 去查是哪个无障碍服务在要 |
必须分开的原因不是洁癖,是误判的代价不对称。错判第一种,会去让用户重开服务------他重开十次也没有用,因为问题不在这条连接上。错判第二种,会得出「这个 App 永远读不到」的结论,而实际上同一个 App 的别的页面读得很好。只有第三种,才指向真正能改的那一层。
二、判据写窄一点,两个标志位不要合并
kotlin
private fun isWithheld(root: AccessibilityNodeInfo): Boolean = runCatching {
if (root.childCount != 0 || root.packageName?.toString() == service.packageName)
return@runCatching false
val r = Rect(); root.getBoundsInScreen(r)
r.isEmpty && root.className.isNullOrEmpty()
}.getOrDefault(false)
三个条件全部成立才算数,另外还排除自己的包------自己的 Activity 是正常窗口,有树,但那棵树不该进模型的眼睛。
withheld 和 blind 是两件事,不能合并成一个「没读到」。blind 的判据是节点数 ≤ 3(BLIND_TREE_NODES = 3,注释:被藏的根是 1 个节点,一块裸画布是几个)。为什么不直接用节点数判「被藏」?因为微信被藏时是 1 个节点、自绘画布是几个节点,两者在这个维度上重合,可它们的修法完全相反:一个去开服务,一个去截图。合并了就没法分派下一步。
三、写入:只加不删,一分钟最多一次
找到的原因很具体:微信交不交树,取决于谁在要 ,不是要了什么 。把 Andee 的无障碍配置改成跟对方一致,一点用都没有。起作用的是 MIUI 自己的 com.miui.accessibility.enhance.tb.MiuiEnhanceTBService------TalkBack 的配套服务,设置界面里没有它的开关。小米平板 5(MIUI,微信 8.0.78)上两向复现:开着 219 个节点,关掉 0 个。
写它的函数只有四道闸门,任何一道不过就是一个 false,不是异常:
kotlin
if (checkSelfPermission(WRITE_SECURE_SETTINGS) != GRANTED) return false
if (runCatching { pm.getServiceInfo(HELPER, 0) }.isFailure) return false
if (lastTryAt != 0L && now - lastTryAt < 60_000L) return false
// 去重:flattenToString 与 flattenToShortString 两种写法都比一遍
过了四道,读 Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES、在末尾追加、putString 写回。只追加,从不删除列表里的任何条目。
返回值是「我刚才加了没有」,不是「现在是否启用」。这个区分是给调用方用的:只有真的加了,才值得重新 dump 一次;本来就开着,重新 dump 也只是把同一棵空树再走一遍。
节流定在 60 秒是有意的。用户可以故意关掉这个服务,那一刻他的决定应该有效------至少有效一分钟。
四、写完不能立刻读,要等绑定
设置是同步写进 Settings.Secure 的,但无障碍服务的绑定是异步的,而 App 要等到绑上之后才肯交树。所以这里不是 Thread.sleep,是轮询:
kotlin
val until = uptimeMillis() + 3_000L
while (uptimeMillis() < until) {
sleep(300L)
out = dumpOnce(false, WalkBudget(TREE_BUDGET_MS))
if (!out.optBoolean("withheld")) break
}
等待用独立的时间预算,不跟走树共用。原因很实际:第一次 dump 可能已经把 8 秒的主预算花掉了大半,如果共用一个额度池,等绑定这一步会永远抢不到时间------而它恰恰是唯一可能让结果变好的那一步。
五、代价
只加不删的副作用。 一旦被打开,只有 MIUI 自己能关。用户关掉之后,最迟一分钟会被再打开一次。这不是漏洞,是选边------我选了「尽快修好」,代价是「可能推翻用户的决定」,而且选得不彻底。
判据窄就会漏。 只有子节点数为 0 才走这条路。有些 App 会给你两三个假节点,那就落进 blind 而不是 withheld,开服务那一步根本不会触发。我没有放宽阈值,因为放宽会把裸画布也误判进来,而裸画布开服务同样没用。
没有那个权限就完全不工作。 WRITE_SECURE_SETTINGS 是 signature|privileged|development,只能在 adb 里 pm grant 授一次,设置里没有对应的开关可找。所以 SelfCheck 里那一行是 Fix.Adb,故意没有按钮。
在别的 ROM 上无解。 同一件事在我那台荣耀手机上(微信 8.0.79)没有配套服务可开,无障碍一样拿到空壳。于是所有点击退回肉眼:截图长边压到 1280 像素,一块 2K 宽的平板等于把每个像素缩掉一半。实测命中率,先放大再点 33/41,直接看整屏截图点 124/211;没打中的那些,有打开别人主页的、有关注的、有删掉评论的。
像素兜底会反向说谎。 判断「这一下点了有没有反应」时,树不可用就退回像素差:20×32 的网格、单格平均亮度变化 ≥ 10、至少 2 格变了才算变化。一段正在播的视频也会满足这三条。所以这个信号只能证明「画面动过」,不能证明「动对了」。
六、这类客户端该长什么样
读不到的时候,返回一个带原因的空对象,比返回 {} 有用得多。{} 让上游只能猜,而「被 App 藏起来」「这一页是画布」「还在动画中间」这三种猜法的下一步完全不同。
我在这里学到的不是「怎么绕过 ROM 的限制」,而是一件更朴素的事:「我不知道」和「这里没有」是两句话,返回值里必须能分开。 分不开的那一天,模型会替你把它们合成一句,然后你花两天去查一个根本不存在的问题。