Android 悬浮窗藏不住自己:一个标志位与 140 毫秒
结论先给。 如果你的 Android 应用既要开 SYSTEM_ALERT_WINDOW、又要是 AccessibilityService(也就是用一个悬浮层当界面、靠无障碍读屏点屏),那么这层悬浮窗会同时污染四条彼此独立的通道:读树、注入手势、截屏、以及折叠之后的时序。而常见的那个修法------IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS------在你自己的服务 上恰好无效,因为无障碍配置里的 flagIncludeNotImportantViews 会把它抵消掉。真正能把覆盖层从自己的树里摘出去的,是 API 34 才有的 setAccessibilityDataSensitive。
这篇把四条通道逐个拆开,给症状、给代码、给代价。
四条通道,四种不同的坏法
| 通道 | 症状 | 关键判据 |
|---|---|---|
| 读(节点树) | 大脑拿到的元素清单是我们自己的按钮 | 只在活动窗口给出 0 个有用节点时才发生 |
| 点(注入) | dispatchGesture 报 completed: true,屏幕没反应 |
不可触摸 ≠ 不遮挡 |
| 截(显示捕获) | 截图里有我们 | 覆盖层天然进入 display capture |
| 折(时序) | 折叠完成了,树还是 0 个节点 | 遮挡由系统在一两帧后重算 |
这四条没有共同解。第 1、3 条靠"隐藏",第 2 条靠"移出屏幕",第 4 条只能靠"等"。
通道一:覆盖层被读回自己
触发条件很窄,但撞上就很难查:只有 活动窗口的 nodes_useful 为 0 时,代码才会去扫全屏其它窗口兜底。而"活动窗口一个节点都没有"的界面,恰好就是小程序、WebView、游戏画布------也就是最需要兜底的那一类。
在这些界面上,全屏唯一「有用节点数 > 0」的树,经常是我们自己的工具栏。兜底逻辑是对的,数据源是脏的:它老老实实挑了内容最多的那个窗口,而那个窗口是我们。
同一根因的第二个症状更尴尬:设置面板必须是可聚焦的(否则里面的 EditText 收不到输入法),一旦它浮在前面,rootInActiveWindow 就是我们。此时 typeText 走 findFocus(FOCUS_INPUT),拿到的是设置面板自己的输入框------大脑的下一句话被打进了「语音服务地址」。
值得强调的一点:触摸链路本身是干净的。 派发手势前所有覆盖层都被降成不可触摸,注入的点击一定穿到下面那个真实 App。所以问题不在"点错了地方",而在"瞄准了一个属于我们的控件,然后点到了它下面的内容"。这个区别决定了修法:不能写成「球那个矩形内拒绝点击」------球下面压着的 App 内容本来就应该能点,且确实点得通,那样的规则会把正确点击一起拒掉。修在元素交给大脑的那一刻。
通道二:为什么 NO_HIDE_DESCENDANTS 不起作用
第一版就是这么写的,NO_HIDE_DESCENDANTS(连子树一起摘,而不是只摘根)。它不生效的原因不在这个 flag,在我们的服务配置:
xml
android:accessibilityFlags="flagDefault|flagIncludeNotImportantViews|..."
flagIncludeNotImportantViews 会让 View.includeForAccessibility() 无条件返回 true,无视 importantForAccessibility。结果就是「对所有服务隐藏,除了最需要这个标志起作用的那一个」------正好反了。
实测过一次,不抽象:保险箱表单打开时取树,返回的是表单本身、全部标签、以及用户的手机号。
API 34 起有了正确的工具:
kotlin
fun View.hideFromAccessibility() {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
setAccessibilityDataSensitive(View.ACCESSIBILITY_DATA_SENSITIVE_YES)
}
}
accessibilityDataSensitive 只对没有 声明 android:isAccessibilityTool 的服务隐藏该子树。我们没声明,TalkBack 声明了。所以它比上面那个钝器更精确:屏幕阅读器仍然摸得到球,只有我们自己看不见。它沿树继承,根视图调一次即可。
通道三:不可触摸,仍然遮挡
折叠态下球只占左边一小块,为了不挡注入手势,代码把它移到屏幕外,等 200 毫秒让位移落到合成器。既然已经是 FLAG_NOT_TOUCHABLE,直觉上就安全了。
2026-10-09,一台华为手机上,支付宝把这个假设打穿:dispatchGesture 对底部每个 tab 都返回 completed: true;球窗口 frame 范围内的 tab 一个都没切,范围外的切了。包括「消息」------它落在窗口的透明区域里,离画出来的球很远,照样没切。
也就是说:平台会把手势从不可信覆盖层下方穿过标记成遮挡,支付类应用有权静默丢弃,不报错。返回值和事实在这条通道上是分叉的,这是四条里唯一一条你无法靠读返回值发现的。
通道四:折叠只是请求
updateViewLayout 只是提交请求,遮挡由系统稍后重算。在这之前,卡片后面 App 的每个节点都报 isVisibleToUser == false,而 buildNode 在计数前就会丢掉这些节点------于是从一个完全健康的屏幕上读出 nodes_total = 0。
实测数字:卡片展开时,Chrome 的窗口存在、是活动窗口、有焦点、是根节点,节点数 0;折回去,同一屏 75 个。折叠本身几毫秒返回,系统认账要 4 次轮询。等待实现是轮询而不是加长 sleep,理由和输入回读一样:主线程空闲时几十毫秒,忙的时候长得多,而每条工具调用都付最坏情况是一笔对所有命令征的税。预算 600 毫秒、间隔 30 毫秒,超时也不抛------dump 自己会把原因说出来。
截屏是同一件事的镜像:覆盖层本来就在 display capture 里,所以截图前先把窗口与光晕设成不可见,等 80 毫秒让参数变更和背景模糊一起生效。有一个例外刻意保留:点击标记不隐藏。它是大脑唯一应该看到的自己------一个猜出来的坐标需要它变回屏幕上的位置。
判定:0 个节点有四种原因
这是这套东西真正的复杂度来源。四种原因,四种处置,而用户看到的只有一句提示:
- 自己的全屏卡片盖住了 ------ 折起来;
- 卡片正在折叠、系统还没认 ------ 再等;
- 无障碍服务真的僵了 ------ 让用户重开;
- 这个 App 自己在拒绝所有客户端 ------ 什么也别做。
判错的代价很具体:我们曾经把第 4 种判成第 3 种,用户被支去拨了一个本来完全正常的权限。所以现在第 1、2 种各带自己的标记,第 2 种用计数器而不是布尔------保险箱会开在设置面板上面,一层不够,得数层数。
代价
API 34 以下 ,accessibilityDataSensitive 不存在,只能退回旧的两道判据(扫窗口 + 空树),注释里的原话是「仅仅不完美,而不是坏的」。
同一段代码在低版本上还有一个副作用 :NO_HIDE_DESCENDANTS 会把覆盖层对所有无障碍服务藏起来,屏幕阅读器也够不到球和设置面板了。这是实打实的无障碍回退,接受它的前提是这是个只有一颗球和六个按钮的单用途应用。
要记得关的地方是 22 处。 现在 14 个界面类各自调 hideFromAccessibility(),共 22 个调用点;配对记账 11 处、10 个类。只要有一处忘了调,那个界面就会把手机号暴露给我们自己。这种"每个新界面都要记得做一次"的约定,本质上是靠人,不是靠编译器。
版本 0.12.1 / versionCode 25。覆盖层 3938 行、无障碍 3742 行、派发 2257 行;专门处理这件事的那个文件 136 行------它不加任何能力,只是让这个应用承认一件事:界面盖在屏幕上,就一定会被自己读到。