为什么你的Android悬浮窗"挡住"了无障碍服务
Android 可见即可说 (语音悬浮窗口在,无障碍服务扫描不出系统卸载window)
1.完整需求: 语音说一句话:帮我把抖音里面的今天的奖励全部领完,红包领完!
1)语音悬浮窗交互:
能自动点击悬浮窗下方的所有窗口(如抖音红包按钮)
点击悬浮窗空白区域 → 悬浮窗消失
悬浮窗内 RecyclerView 的点击事件需响应
效果图

1.2 具体详细需求:
系统弹窗支持:
当语音指令为"卸载抖音"时:
先显示语音悬浮窗
再触发系统卸载弹窗(com.android.packageinstaller)
用户可通过语音控制点击"确定"或"取消"
2.核心问题:无障碍服务无法识别被悬浮窗完全遮挡的系统弹窗
语音把系统的卸载window弹出,
语音可见即可说扫描不出卸载window,
导致无法实现确认按钮的可见即可说
2.2 问题现象
当语音悬浮窗完全覆盖系统卸载弹窗时:
AccessibilityService 的 windows 列表中扫描不到 com.android.packageinstaller
只能扫描到 launcher 或 systemui
导致"可见即可说"功能失效,无法点击"确定/取消"
2.3 根本原因(Android 系统机制限制)
表格
| 原因 | 说明 |
|---|---|
| 窗口可见性判定 | Android Accessibility 服务不会返回被完全遮挡且不可交互的窗口的节点 |
| 安全限制 | 出于隐私和安全考虑,系统禁止通过无障碍服务读取被其他应用完全覆盖的 UI 层 |
| 层级与类型相同 | 语音悬浮窗 (TYPE_SYSTEM, layer=1) 与 launcher (TYPE_APPLICATION, layer=0) 不同;但卸载弹窗也是 TYPE_APPLICATION, layer=0,与 launcher 同级。当悬浮窗高度 95% 屏幕时,完全遮挡卸载弹窗 → 系统认为其"不可见" |
✅ 验证结论 :只要悬浮窗未完全遮挡 卸载弹窗(例如高度 ≤85%),无障碍服务就能正常扫描到
packageinstaller。
3.分析问题
3.1 之前同事写的代码逻辑:
ini
layoutParams = new WindowManager.LayoutParams(
MATCH_PARENT,
WRAP_CONTENT,
TYPE_APPLICATION_OVERLAY,
FLAG_NOT_FOCUSABLE,
TRANSLUCENT
);
layoutParams.flags |= FLAG_LAYOUT_IN_SCREEN;
layoutParams.flags |= FLAG_NOT_TOUCH_MODAL; // ✅ 允许区域外触摸透传
layoutParams.flags |= FLAG_WATCH_OUTSIDE_TOUCH; // 配合外部点击关闭
3.2 无障碍服务中的"窗口替换"逻辑
ini
if (rootNode?.packageName == packageName || rootNode?.childCount == 0) {
windows.forEach { window ->
window.root?.takeIf { rn ->
rn.packageName != packageName && rn.childCount != 0
}?.apply {
rootNode = this // 替换为非自身、非空的窗口根节点
}
}
}
目的:跳过语音悬浮窗自身,尝试使用其他窗口(如卸载弹窗)作为操作目标 失败原因:当卸载弹窗被完全遮挡 → windows 列表中根本不存在该窗口 → 替换失败
3.2 无障碍服务扫描的结果
3.2.1 具体结果分3步骤:
1).在桌面扫描的结果:
bash
2026-01-06 15:48:48.918 59779-59779 VoiceAcces...ityService com.tencent.voice E 开始windows-------index: 0, window: AccessibilityWindowInfo[title=导航栏, displayId=0, id=258, type=TYPE_SYSTEM, layer=3, region=SkRegion((0,1824,864,1920)), bounds=Rect(0, 1824 - 864, 1920), focused=false, active=false, pictureInPicture=false, hasParent=false, isAnchored=false, hasChildren=false]; com.android.systemui
2026-01-06 15:48:48.919 59779-59779 VoiceAcces...ityService com.tencent.voice E 开始windows-------index: 1, window: AccessibilityWindowInfo[title=null, displayId=0, id=252, type=TYPE_SYSTEM, layer=2, region=SkRegion((0,0,864,115)), bounds=Rect(0, 0 - 864, 115), focused=false, active=false, pictureInPicture=false, hasParent=false, isAnchored=false, hasChildren=false]; com.android.systemui
2026-01-06 15:48:48.919 59779-59779 VoiceAcces...ityService com.tencent.voice E 开始windows-------index: 2, window: AccessibilityWindowInfo[title=null, displayId=0, id=265, type=TYPE_SYSTEM, layer=1, region=SkRegion((0,192,864,1824)), bounds=Rect(0, 192 - 864, 1824), focused=true, active=true, pictureInPicture=false, hasParent=false, isAnchored=false, hasChildren=false]; com.tencent.voice
2026-01-06 15:48:48.920 59779-59779 VoiceAcces...ityService com.tencent.voice E 开始windows-------index: 3, window: AccessibilityWindowInfo[title=大桌面, displayId=0, id=250, type=TYPE_APPLICATION, layer=0, region=SkRegion((0,0,864,1920)), bounds=Rect(0, 0 - 864, 1920), focused=false, active=false, pictureInPicture=false, hasParent=false, isAnchored=false, hasChildren=false]; com.tencent.launcher
launcher: type=TYPE_APPLICATION, layer=0
2). 在桌面,然后弹出卸载框:
com.android.systemui -
com.android.packageinstaller
卸载window:type=TYPE_APPLICATION, layer=0 不会扫描到桌面的window,被卸载弹框全部挡住, 处于同一层级 得出层级关系:卸载在最底层,layer=0
3).在桌面,语音悬浮窗出现之后,然后弹出卸载框:
不正常是这个:
yaml
2025-12-29 13:58:34.996 1900-1900 VoiceAcces...ityService com.tencent.voice D windows size: 4
windowArrStr: com.android.systemui - visible:true - active:false
com.android.systemui - visible:true - active:false
com.tencent.voice - visible:true - active:true
com.tencent.launcher - visible:true - active:false
2025-12-29 13:58:34.998 1900-1900 VoiceAcces...ityService com.tencent.voice D packageName:com.android.systemui
isActive:false
isAccessibilityFocused:false
2025-12-29 13:58:34.999 1900-1900 VoiceAcces...ityService com.tencent.voice E 替换节点前.......com.tencent.voice
2025-12-29 13:58:34.999 1900-1900 VoiceAcces...ityService com.tencent.voice E 替换节点后.......com.android.systemui
2025-12-29 13:58:35.000 1900-1900 VoiceAcces...ityService com.tencent.voice D packageName:com.android.systemui
isActive:false
isAccessibilityFocused:false
2025-12-29 13:58:35.000 1900-1900 VoiceAcces...ityService com.tencent.voice E 替换节点前.......com.android.systemui
2025-12-29 13:58:35.000 1900-1900 VoiceAcces...ityService com.tencent.voice E 替换节点后.......com.android.systemui
2025-12-29 13:58:35.001 1900-1900 VoiceAcces...ityService com.tencent.voice D packageName:com.tencent.voice
isActive:true
isAccessibilityFocused:false
2025-12-29 13:58:35.003 1900-1900 VoiceAcces...ityService com.tencent.voice D packageName:com.tencent.launcher
isActive:false
isAccessibilityFocused:false
2025-12-29 13:58:35.003 1900-1900 VoiceAcces...ityService com.tencent.voice E 替换节点前.......com.android.systemui
2025-12-29 13:58:35.003 1900-1900 VoiceAcces...ityService com.tencent.voice E 替换节点后.......com.tencent.launcher
原因应该是优先级:他们type相同 语音: type=TYPE_SYSTEM, layer=1, launcher: type=TYPE_APPLICATION, layer=0
期望正常是这个结果:
arduino
2025-12-29 13:55:54.989 565-565 VoiceAcces...ityService com.tencent.voice D windows size: 4
windowArrStr: com.android.systemui - visible:true - active:false
com.android.systemui - visible:true - active:false
com.tencent.voice - visible:true - active:true
com.android.packageinstaller - visible:true - active:false
2025-12-29 13:55:54.990 565-565 VoiceAcces...ityService com.tencent.voice D packageName:com.android.systemui
isActive:false
isAccessibilityFocused:false
2025-12-29 13:55:54.990 565-565 VoiceAcces...ityService com.tencent.voice E 替换节点前.......com.tencent.voice
2025-12-29 13:55:54.990 565-565 VoiceAcces...ityService com.tencent.voice E 替换节点后.......com.android.systemui
2025-12-29 13:55:54.991 565-565 VoiceAcces...ityService com.tencent.voice D packageName:com.android.systemui
isActive:false
isAccessibilityFocused:false
2025-12-29 13:55:54.992 565-565 VoiceAcces...ityService com.tencent.voice E 替换节点前.......com.android.systemui
2025-12-29 13:55:54.992 565-565 VoiceAcces...ityService com.tencent.voice E 替换节点后.......com.android.systemui
2025-12-29 13:55:54.992 565-565 VoiceAcces...ityService com.tencent.voice D packageName:com.tencent.voice
isActive:true
isAccessibilityFocused:false
2025-12-29 13:55:54.994 565-565 VoiceAcces...ityService com.tencent.voice D packageName:com.android.packageinstaller
isActive:false
isAccessibilityFocused:false
2025-12-29 13:55:54.995 565-565 VoiceAcces...ityService com.tencent.voice E 替换节点前.......com.android.systemui
2025-12-29 13:55:54.995 565-565 VoiceAcces...ityService com.tencent.voice E 替换节点后.......com.android.packageinstaller
4. 解决方案
4.1 模仿手机厂商怎么处理的! 看了下VIVO手机,
先卸载, 发现语音悬浮后,不能再进行下一步的可见即可说! 命中的是大模型,卸载按钮一样无法命中,如下图!

如果悬浮窗完全挡住了下面的window,下面的window就不会被系统扫描到! 发现一个问题,我的悬浮窗完全挡住了.com.android.packageinstaller,window里面就扫描不出,扫描出了com.tentent.launcher, 如果我的悬浮窗没用完全挡住,就可以window里面就扫描出了.com.android.packageinstaller
📌 总结
| 问题原因 | 系统将完全被悬浮窗遮挡的窗口视为"不可见",故 AccessibilityService 无法获取其根节点 |
|---|---|
| 根本限制 | Android 安全机制:不能通过 Accessibility 读取被完全遮挡/不可交互的窗口 |
| 推荐做法 | 1. 避免完全覆盖 2. 过滤掉自身和 SystemUI/Launcher 3. 结合 UsageStats 做兜底 |
当时产品看了竞品没用实现,让我放手! 我觉得修改系统framwork层可以解决,但是公司的framwork层太low了,只有自己想办法
4.1 避免完全遮挡(推荐临时方案)
修改悬浮窗高度,留出顶部或底部空间
具体措施如下: 第一种临时方案:语音的悬浮窗不完全挡住系统的弹框,是可以扫描出来的! 翻看了源码: 无障碍服务扫描原理,从上往下扫描,当同级layer 的window,会根据优先级判断! 前面的窗口完全挡住,会扫描不出 所以很容易想到临时方案:修改悬浮窗的宽度或者高度,
ini
// 设置窗口大小和位置
int screenHeight = DisplayUtils.getScreenHeight(MainApplication.getApplication());
layoutParams.height = (int) (screenHeight * 0.85);
layoutParams.format = PixelFormat.TRANSLUCENT;
layoutParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
因为治标不治本!我不考虑,打算先看现象
4.1.1 第二种临时方案 解决方案:.改变点击事件
核心思路:
默认悬浮窗可点击(处理内部 RecyclerView)
当检测到系统弹窗出现时(如通过 UsageStats 或广播):
将悬浮窗设为 不可点击 + 事件透传
即:FLAG_NOT_TOUCHABLE | FLAG_NOT_FOCUSABLE
用户操作完成后,恢复可点击状态
点击事件的详细分析: 1.如果是点击了安装之后,2s后,设置为不可点击, 上报后,如果是采集到了,设置为可点击!
arduino
protected WindowManager.LayoutParams getLayoutParams(boolean clickable,int orientation) {
LogUtils.d(TAG, "getLayoutParams: clickable = " + clickable + " cardClickable = " + cardClickable);
mCurAsrClickable = clickable;
if (layoutParams == null) {
layoutParams = new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION);
layoutParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
layoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
layoutParams.format = PixelFormat.TRANSLUCENT;// 支持透明
}
int canTouchFlag = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
int cantTouchFlag = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
if (clickable) {
layoutParams.flags |= canTouchFlag;
layoutParams.flags |= cantTouchFlag;
layoutParams.flags ^= cantTouchFlag;
} else {
layoutParams.flags |= cantTouchFlag;
layoutParams.flags |= canTouchFlag;
layoutParams.flags ^= canTouchFlag;
}
4.2 方案二: 最优化终极解决方案 结合悬浮窗属性优化 + 无障碍服务智能处理:
通过悬浮窗属性+ 替换可见即可说的window对象,替换节点解决!
4.2.1 悬浮窗的属性核心代码如下:
arduino
protected WindowManager.LayoutParams getLayoutParams(boolean clickable) {
LogUtils.d(TAG, "getLayoutParams: clickable = " + clickable + " cardClickable = " + cardClickable);
mCurAsrClickable = clickable;
if (layoutParams == null) {
layoutParams = new WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT);
layoutParams.format = PixelFormat.TRANSLUCENT;
layoutParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
layoutParams.flags |= WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
// 设置窗口标志 - 允许点击事件传递到下层
layoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
layoutParams.flags |= WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
}
// 设置窗口大小和位置
int screenHeight = DisplayUtils.getScreenHeight(MainApplication.getApplication());
layoutParams.height = (int) (screenHeight * 0.95);
//根据屏幕方向,动态设置宽度
if (DisplayUtils.isLandscape(GlobalApplication.mGlobalContext)) {
layoutParams.width = DisplayUtils.dp2px(MainApplication.getApplication(), 348f);
} else {
layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT;
}
LogUtils.d(TAG, "-----height: " + layoutParams.height);
return layoutParams;
}
4.2.2 可见即可说,无障碍服务替换window的核心代码如下:
javascript
// 第一步:在 threadRoot 线程中获取根节点
GlobalScope.launch(threadRoot) {
try {
val startTime = System.currentTimeMillis()
// 获取根节点
var rootNode = rootInActiveWindow
activeRootNodeVisible = rootNode?.isVisibleToUser ?: false
printLogOnDebug("rootInActiveWindow-------${rootNode?.packageName}; ${rootNode?.windowId}")
// 窗口过滤逻辑(已注释,可根据需要启用)
if (rootNode?.packageName == packageName || rootNode?.childCount == 0) {
windows?.forEach { window ->
window?.root?.takeIf { rn ->
rn.packageName != packageName && rn.childCount != 0
}?.apply {
rootNode = this
LogUtils.d(TAG, "替换节点.......")
}
}
}
更详细一点的代码片段:
kotlin
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// windows.forEach {
// LogUtils.e(TAG, "开始windows-------$it; ${it.root?.packageName}")
// }
if(!SettingsChangeObserver.checkIsCarSystem()){
var rootNodeOrigin = getRootNodeWithCache(event)
lastCallBackTime = System.currentTimeMillis()
lastActiveRootNodePackage = "${rootNodeOrigin?.packageName?.toString() ?: ""} child:${rootNodeOrigin?.childCount}"
windowArrStr = ""
if(isAbleToLog){
windows.forEach {
windowArrStr += "${it.root?.packageName} - visible:${it.root?.isVisibleToUser} - active:${it.isActive} \n"
}
}
activeRootNodeVisible = rootNodeOrigin?.isVisibleToUser ?: false
printLogOnDebug("rootInActiveWindow-------${rootNodeOrigin?.packageName}; ${rootNodeOrigin?.windowId}")
// for (w in windows) {
// w.getBoundsInScreen(mRect)
// //过滤顶部状态栏窗口
// if (mRect.equalsXY(0, 0, 1920, 32)) continue
// if (mRect.equalsXY(144, 0, 1920, 32)) continue
// //过滤左边Dock栏窗口
// if (mRect.equalsXY(0, 0, 144, 720)) continue
// val pkName = w.root?.packageName
//
// if (pkName.isNullOrEmpty()) break
// rootNode
// // TODO V2处理
//// rootNode = HotwordsExecutorFactory.getHotwordsExecutor(pkName).interceptRootNode(windows, rootNode)
// break
// }
if (rootNodeOrigin?.packageName == packageName || rootNodeOrigin?.childCount == 0) {
windows.forEach {
Log.d(
TAG, """
packageName:${it?.root?.packageName}
isActive:${it?.isActive}
isAccessibilityFocused:${it?.isAccessibilityFocused}
""".trimIndent()
)
it.root?.takeIf { rn ->
rn.packageName != packageName && rn.childCount != 0
}?.apply {
LogUtils.d(TAG, "替换节点......."+ rootNodeOrigin?.packageName )
rootNodeOrigin = this
}
}
}
val rootNode = rootNodeOrigin
lastLoadRootNodePackage = rootNode?.packageName?.toString() ?: ""
printLogOnDebug("rootNode-------${rootNode?.packageName}; ${rootNode?.windowId}")
if (rootNode == null || event == null|| event.packageName.isNullOrEmpty()) {
return
}
if (rootNode.packageName == packageName) {
return
}
try {
logNodeInfo(rootNode, event)
} catch (e: Exception) {
}
GlobalScope.launch(threadContext) {
try {
processBusinessLogic(rootNode)
} catch (e: Exception) {
LogUtils.e(TAG, e.message)
}
}
}else{
// 第一步:在 threadRoot 线程中获取根节点
GlobalScope.launch(threadRoot) {
try {
val startTime = System.currentTimeMillis()
// 获取根节点
var rootNode = rootInActiveWindow
val endTime = System.currentTimeMillis()
val timeCost = endTime - startTime
if (timeCost > 1000) {
LogUtils.e(TAG, "获取最新rootInActiveWindow耗时超过1秒: ${timeCost}ms"+Thread.currentThread().name)
val eventType = event?.let { AccessibilityEvent.eventTypeToString(it.eventType) } ?: "unknown"
voiceAccessibilityPerformance.reportAccessibility(timeCost,eventType)
} else if(isAbleToWindowLog){
LogUtils.i(TAG, "获取最新rootInActiveWindow耗时: ${timeCost}ms"+Thread.currentThread().name)
}
lastCallBackTime = System.currentTimeMillis()
lastActiveRootNodePackage = "${rootNode?.packageName?.toString() ?: ""} child:${rootNode?.childCount}"
windowArrStr = ""
activeRootNodeVisible = rootNode?.isVisibleToUser ?: false
printLogOnDebug("rootInActiveWindow-------${rootNode?.packageName}; ${rootNode?.windowId}")
// 窗口过滤逻辑(已注释,可根据需要启用)
if (rootNode?.packageName == packageName || rootNode?.childCount == 0) {
windows?.forEach { window ->
window?.root?.takeIf { rn ->
rn.packageName != packageName && rn.childCount != 0
}?.apply {
rootNode = this
LogUtils.d(TAG, "替换节点.......")
}
}
}
为什么修改悬浮窗的属性和替换window可以解决呢?
先整理需求:
1.).需要能把悬浮窗的事件能触摸,就要用到 WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
2).期望不影响下面window的交互,FLAG_NOT_FOCUSABLE
FLAG_NOT_FOCUSABLE : 焦点是干嘛的? 只有设置了这个,才能扫描到window对象
场景1:设置了 FLAG_NOT_FOCUSABLE
// 你的悬浮窗代码: layoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; // 用户操作和结果:
- 用户点击悬浮窗内的 EditText → ❌ 软键盘不会弹出
- 用户按物理键盘 → ❌ 悬浮窗收不到按键事件
- 用户按返回键 → ❌ 悬浮窗收不到返回键事件
- 用户按音量键 → ❌ 悬浮窗收不到音量键事件
场景2:没有设置 FLAG_NOT_FOCUSABLE // 不设置 FLAG_NOT_FOCUSABLE: // layoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; // 注释掉
// 用户操作和结果:
- 用户点击悬浮窗内的 EditText → ✅ 软键盘弹出
- 用户按物理键盘 → ✅ 悬浮窗收到按键事件
- 用户按返回键 → ✅ 悬浮窗收到返回键事件
- 用户按音量键 → ✅ 悬浮窗收到音量键事件
Q1:设置了 FLAG_NOT_FOCUSABLE,为什么还能收到触摸事件? A:焦点和触摸是分开的。FLAG_NOT_FOCUSABLE 只影响键盘/按键事件,不影响触摸事件。要阻止触摸,需要设置 FLAG_NOT_TOUCHABLE。
Q2:WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, layoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL; 和只设置了WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,有什么区别
FLAG_NOT_TOUCH_MODAL 的作用? 默认情况下,悬浮窗会模态拦截所有触摸事件(即使点击空白区,底层也收不到) 加上此 flag 后: 悬浮窗区域内:自己处理事件 区域外:事件传递给下层窗口 Q3:// 设置窗口标志 - 允许点击外部关闭 params.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND;
Q4:// 设置窗口标志 - 允许点击外部关闭 这个必须配合flag一起使用, 否则也不生效 WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
Q5:这3个步骤到底事什么意思
int canTouchFlag = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL; int cantTouchFlag = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
ini
layoutParams.flags |= canTouchFlag;
layoutParams.flags |= cantTouchFlag;
layoutParams.flags ^= cantTouchFlag;
这个是什么意思 经过这三步运算后:
✅ 设置了 FLAG_NOT_TOUCH_MODAL
❌ 移除了 FLAG_NOT_TOUCHABLE
❌ 移除了 FLAG_NOT_FOCUSABLE
等价于:
// 设置 NOT_TOUCH_MODAL layoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
4.3 总结: 焦点和触摸事件是2回事!
1).触摸响应: 只和这个有关系
layoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
2). 悬浮窗的事件分发问题:
设置 FLAG_NOT_FOCUSABLE + FLAG_NOT_TOUCH_MODAL onTouchEvent 返回 false, 我发现下面的launcher应用,不能接收到点击事件
结论:事件透传,(只和FLAG_NOT_TOUCHABLE有关系,注意不是FLAG_NOT_FOCUSABLE ,不遵循事件分发原理)! 权限和系统限制:一些系统为了安全,限制了悬浮窗与底层应用的交互
系统安全机制禁止应用直接干预其他应用的事件流。
悬浮窗事件透传机制
● FLAG_NOT_FOCUSABLE:窗口不获取焦点,但仍可接收触摸事件(自己得到事件)
● FLAG_NOT_TOUCH_MODAL:窗口区域内的触摸事件自己处理,区域外的传递给后面
● FLAG_NOT_TOUCHABLE:完全不接收任何触摸事件,全部传递给后面(全部给了底层)
Android悬浮窗事件分发遵循以下基本流程:
- 输入系统检测触摸事件
- WindowManagerService确定目标窗口
- 悬浮窗的事件分发只能针对自己的窗口,不能分发给其他窗口
csharp
/**
* 设置红色区域点击关闭功能, 点击到cardView不消失
*/
private void setupTransparentAreaClick() {
if (mAsrView == null) {
LogUtils.d(TAG, "setupTransparentAreaClick: mAsrView is null");
return;
}
// 获取根布局
View rootView = mAsrView.findViewById(R.id.root_view);
if (rootView == null || cardView == null) {
LogUtils.d(TAG, "setupTransparentAreaClick: rootView or cardView is null");
return;
}
LogUtils.d(TAG, "setupTransparentAreaClick: rootView=" + rootView + ", cardView=" + cardView);
rootView.setOnTouchListener(new View.OnTouchListener() {
private long touchStartTime = 0;
private float touchStartX = 0;
private float touchStartY = 0;
@Override
public boolean onTouch(View v, MotionEvent event) {
if (mAsrView == null) {
LogUtils.e(TAG, "onTouch: mAsrView=null!!");
return false;
}
int action = event.getAction();
float currentX = event.getX();
float currentY = event.getY();
switch (action) {
case MotionEvent.ACTION_DOWN:
touchStartTime = System.currentTimeMillis();
touchStartX = currentX;
touchStartY = currentY;
LogUtils.d(TAG, "ACTION_DOWN: x=" + currentX + ", y=" + currentY);
LogUtils.d(TAG, "rootView bounds: width=" + rootView.getWidth() + ", height=" + rootView.getHeight());
LogUtils.d(TAG, "cardView bounds: left=" + cardView.getLeft() + ", top=" + cardView.getTop() +
", right=" + cardView.getRight() + ", bottom=" + cardView.getBottom());
// 判断点击是否在cardView区域内
boolean isInsideCardView = isPointInsideView(currentX, currentY, cardView);
LogUtils.d(TAG, "isInsideCardView: " + isInsideCardView);
// 如果点击在cardView内,不处理,让子View处理
if (isInsideCardView) {
LogUtils.d(TAG, "点击在cardView内,不处理触摸事件");
return false;
}
return true;
case MotionEvent.ACTION_MOVE:
// LogUtils.d(TAG, "ACTION_MOVE: x=" + currentX + ", y=" + currentY);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
long touchDuration = System.currentTimeMillis() - touchStartTime;
float deltaX = Math.abs(currentX - touchStartX);
float deltaY = Math.abs(currentY - touchStartY);
LogUtils.d(TAG, "ACTION_UP: x=" + currentX + ", y=" + currentY +
", duration=" + touchDuration + "ms, deltaX=" + deltaX + ", deltaY=" + deltaY);
// 判断是否是点击事件(不是滑动)
if (touchDuration < 500 && deltaX < 50 && deltaY < 50) {
boolean isInside = isPointInsideView(currentX, currentY, cardView);
LogUtils.d(TAG, "点击事件处理: isInsideCardView=" + isInside);
// 如果点击在cardView外,关闭界面
if (!isInside) {
LogUtils.i("baidu", "点击在cardView外部,触发关闭");
stopDialog();
LogUtils.i("baidu", "点击在cardView外部,触发关闭=====");
AsrUiManger.getPhone().sendTimeoutDelayTime(1500);
return true;
} else {
LogUtils.d(TAG, "点击在cardView内部,不触发关闭");
}
} else {
LogUtils.d(TAG, "滑动事件,不触发关闭");
}
break;
}
return false;
}
});
}
3).FLAG 标志含义
FLAG_NOT_FOCUSABLE: 窗口不获取输入焦点,但可接收触摸
FLAG_NOT_TOUCH_MODAL: 窗口外部触摸传递给下层
FLAG_WATCH_OUTSIDE_TOUCH: 可接收窗口外部的触摸事件
FLAG_NOT_TOUCHABLE: 完全不接收触摸事件
5.最终建议
采用方案三的优化悬浮窗属性 + 智能无障碍服务处理
悬浮窗设置为半透明、不获取焦点、允许触摸穿透
在系统弹框出现时,自动将悬浮窗缩小/淡化
无障碍服务实现智能根节点查找算法
这样既能保证悬浮窗的功能,又能让系统弹框正常被无障碍服务扫描到,实现"可见即可说"的完整功能。