悬浮窗挡住 Android:agent 读到 0 个节点
悬浮窗挡住 Android:agent 读到 0 个节点
我在做一个跑在 Android 上的 agent,叫 Andee。他没有常规主界面,也不用 Compose——整个应用就是一颗悬浮球挂在你屏幕上,点那颗球说话,他靠无障碍服务读屏幕、点按钮,替你把事办了。悬浮窗权限是他唯一「不给就完全不能用」的一项:球、字幕、设置面板、保险箱,全都是 TYPE_APPLICATION_OVERLAY。
写这篇,是因为「系统级 agent」这两天开始真往用户手机上推了。荣耀 MagicOS 11 从 11 月 6 日向 Magic V6、Magic8 系列推送,今天轮到 Magic V5、Magic7 系列;官方口径是 YOYO 综合任务准确率 91.8%、能跑上百步跨应用操作,11 月 6 日新华社那篇报道里还提到 YOYO 要和灵犀、千问、豆包、Gemini 组成「智能体网络」。而同一周有一篇实测文章说,跨 App 自动下单的成功率还不到 40%。新华社那篇其实自己也写了一句:这些指标多来自厂商自测与公开榜单。
我不觉得这个落差是模型的问题。它更像是一个词的问题:「准确率」量的是「我读到了、我点了」,至于屏幕上有没有真的发生,是另一件事。 在 Android 上这两件事可以各自为真、结论为假。而如果你的界面和我们一样,是一层盖在别人 App 上面的悬浮窗,这层窗会同时污染这两个信号。
下面是我们被骗过四次的地方,每次在不同的通道上。最后说为什么我们没有选择把它「藏干净」——藏它本身也是要付账的。
一、他会把我们的按钮当成你的 App
读屏幕这件事,正常的路径是拿当前活动窗口的根节点。可有些界面根本不给你树:微信小程序、WebView 里的页面、游戏画布。这种时候代码会走一条兜底路径,去扫全屏每一个窗口,挑内容最多的那个。
问题在这里:当真正的那个 App 一个节点都不给,全屏唯一「有用节点数大于 0」的树,往往是我们自己的工具栏。于是它赢了,大脑拿到一张元素清单,上面写着我们的按钮——而他以为那是他正要操作的那个 App。
同一个根因还有第二种表现,更难看。我们的设置面板是可聚焦的(不然里面的输入框收不到键盘),一旦它浮在上面,rootInActiveWindow 就变成了我们自己。这时「往当前焦点里打字」找焦点,找到的是设置面板自己的输入框——于是大脑的下一句话,被打进了语音服务地址那一栏。
这里有个反直觉的地方:大脑其实碰不到这颗球。 派发手势之前,所有覆盖层都会被降成不可触摸,注入的点击一定会落到下面那个 App 上。也就是说触摸链路是干净的。坏事发生在触摸之前——他瞄准的是一个本来就属于我们的控件,点下去落到了那个控件下面的真实内容上。
所以修的位置只能是「元素交给大脑的那一刻」,不能写成「球那个矩形里拒绝点击」。球下面压着的 App 内容本来就能点,而且真的能点通,那样的规则会把正确的点击也拒掉。
二、NO_HIDE_DESCENDANTS 藏不住它
第一反应当然是把这个窗口从无障碍树里摘出去。Android 有现成的标志:IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,连子树一起摘。
它在我们这儿不生效。原因在我们的无障碍配置里:flagIncludeNotImportantViews。这个标志会让 includeForAccessibility() 无条件返回 true,不管 importantForAccessibility 写了什么。于是「不让任何人看见」变成了「除了最需要这个标志的那个服务,别人都看不见」——正好反了。
实测过一次,很直接:保险箱表单开着的时候,取一次 UI 树,返回的是那张表单、所有标签、以及用户的手机号。
真正活下来的是另一个标志,API 34 起才有的 setAccessibilityDataSensitive。它只对没有声明 android:isAccessibilityTool 的服务隐藏该子树。我们没声明,TalkBack 声明了。所以它不只是能用,而且比上面那个粗暴的标志更准:屏幕阅读器还能摸到球,而我们自己摸不到了。 它会沿树继承,所以在根视图上调一次就够。
三、不可触摸,不等于不遮挡
这是最贵的一次教训。
折叠状态下,球只占屏幕左边一小块。为了让它别挡着注入的手势,我们把它挪到屏幕外(parkForGesture,等 200 毫秒让它落到合成器)。因为已经设了不可触摸,我一度以为这就安全了。
2026 年 10 月 9 日,一台华为手机上,支付宝的底部标签栏把这个假设打碎了:dispatchGesture 对每一个 tab 都返回 completed: true,而球窗口范围内的 tab 一个都没切,范围外的切了——包括「消息」,它明明落在窗口的透明区域里,离画出来的球很远。
平台会把手势从「不可信覆盖层」下方穿过标记为遮挡,而一个支付应用有权把它静默丢掉,不告诉你。返回值和事实在这里分叉了。
四、截图里也有它,和折叠之后那 140 毫秒
覆盖层不只是读和点的问题。它在显示捕获里——截屏截到的就是屏幕上真实的样子,包括我们自己。所以每次截图前会先把窗口和光晕设成不可见,然后等 80 毫秒,让参数变化和模糊一起落到合成器。有个例外被特意留着:点击标记不隐藏。那是大脑唯一该看到自己的地方——一个猜出来的坐标,要靠它变回屏幕上的一个位置。
折叠还有时间差。折叠只是「请求」,系统重算遮挡要过一两帧。在这之前,卡片后面的 App 每个节点都报「对用户不可见」,我们计数时会把它们丢掉,于是从一个完好的屏幕上读出 0 个节点。实测这台平板要 4 次轮询才等到,注释里记的是 140 毫秒;而折叠本身几毫秒就返回了——这个差就是整个 bug。
数字很清楚。卡片展开时,Chrome 的窗口存在、是活动窗口、有焦点、是根节点,但节点数是 0;把卡片折回去,同一屏读出 75 个。
五、代价:这一条现在不行
所有这些麻烦,最后都落到一句话上:0 个节点有四种完全不同的原因。 我们自己的卡片在全屏;我们的卡片折叠中还没被系统认出来;无障碍服务真的僵了;这个 App 自己在拒绝所有客户端。四种的处置完全不同,而给用户看的提示只有一句。
如果判错了,后果很具体。我们曾经把一个 App 自己拒绝给树的情况,判成「服务僵了,去把 Andee 关掉再打开」——用户真的去拨了一个本来完全正常的权限。所以现在这几种情况各自带名字:自己那张全屏卡片一个标记,聚焦卡片一个计数器(保险箱会开在设置上面,一层不够,得数层数),App 自己拒绝走另一条判断。
代价还在这两处,写在代码注释里,不藏:
API 34 以下,藏它是对所有无障碍服务藏。 屏幕阅读器也够不到球和设置面板了。这是真实的无障碍回退,我们接受了——因为这是一个只有一颗球和六个按钮的单用途应用,而代价的另一面是:在最读不到屏幕的那些界面上,大脑会把我们的按钮当成你的 App。
要记得关的地方是 22 处。 现在有 14 个界面类各自调 hideFromAccessibility(),一共 22 个调用点;配对的那本账(谁正开着)又有 11 个点、10 个类。漏一处,就漏一个能把用户手机号读出来的界面。
版本 0.12.1,versionCode 25。悬浮窗 3938 行,无障碍那套 3742 行,派发 2257 行,专门处理这件事的文件 136 行。最后这 136 行大概是最贵的——因为它不是在加功能,是在承认:他盖在你的屏幕上,所以他会读到自己。
更多推荐

所有评论(0)