OverlayManagerService 的悬浮窗权限,不同厂商差异
OverlayManagerService 的悬浮窗权限,不同厂商差异
从一次线上崩溃说起
去年维护一个直播 SDK 的时候,我们收到一份来自 vivo X90 的崩溃报告。堆栈很干净,干净到让人头疼:
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
at android.view.ViewRootImpl.setView(ViewRootImpl.java:1405)
at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:400)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:150)这个崩溃发生在调用 WindowManager.addView() 添加悬浮窗的时候。但诡异的是,同样的代码在 Pixel 7 上跑了几个月都没问题,在小米 13 上也只是偶尔触发,到了 vivo 这里变成了必现。更离谱的是,这台 X90 的系统设置里,"悬浮窗权限"明明是开着的。
我最初怀疑是 Activity 生命周期的问题,加了各种 isFinishing() 判断和 try-catch,崩溃率降了一点,但没根治。后来把测试机从仓库里翻出来,一台一台试,才发现问题根本不在我们的代码里——或者说,不在我们以为的那个层面。
TYPE_APPLICATION_OVERLAY 的 AOSP 行为
Android 8.0(API 26)引入 TYPE_APPLICATION_OVERLAY 的时候,Google 干了一件好事:把之前乱七八糟的悬浮窗类型(TYPE_PHONE、TYPE_PRIORITY_PHONE、TYPE_SYSTEM_ALERT 等等)统一收拢,要求所有应用层悬浮窗必须用这个新类型。官方文档写得明明白白:
"A non-system app can use this type only if it has the ACTION_MANAGE_OVERLAY_PERMISSION permission."
这里的 ACTION_MANAGE_OVERLAY_PERMISSION 对应的就是俗称的"悬浮窗权限",在设置里叫"显示在其他应用的上层"。AOSP 的实现很直接:应用调用 Settings.canDrawOverlays(context) 查询状态,没权限就跳 Settings.ACTION_MANAGE_OVERLAY_PERMISSION 让用户手动开,开了之后 WindowManager 才允许添加 TYPE_APPLICATION_OVERLAY 类型的窗口。
我拉了一份 AOSP 13 的代码,WindowManagerService.addWindow() 里的权限检查逻辑大致长这样:
int type = attrs.type;
if (type >= FIRST_APPLICATION_WINDOW && type <= LAST_APPLICATION_WINDOW) {
// 普通应用窗口,走正常 token 校验
} else if (type == TYPE_APPLICATION_OVERLAY) {
if (!mContext.checkCallingOrSelfPermission(Manifest.permission.SYSTEM_ALERT_WINDOW)
== PackageManager.PERMISSION_GRANTED) {
return WindowManagerGlobal.ADD_PERMISSION_DENIED;
}
}注意这里检查的是 SYSTEM_ALERT_WINDOW 权限,但这个权限的授予状态实际上是由 AppOpsManager 的 OP_SYSTEM_ALERT_WINDOW(code 24)控制的。Settings.canDrawOverlays() 底层查的就是这个 AppOps 状态。AOSP 的逻辑链条很清晰:设置页面改 AppOps -> canDrawOverlays() 返回值变化 -> addWindow() 放行或拒绝。
但这条链条,到了厂商手里,每一环都可能被改。
vivo 的"二次确认"陷阱
回到那台 X90。我写了段最小复现代码:
// 在 Activity 的 onCreate 里直接调用
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
);
View floatView = LayoutInflater.from(this).inflate(R.layout.float_layout, null);
getWindowManager().addView(floatView, params); // 崩溃点
}在 Pixel 上,只要 canDrawOverlays() 返回 true,这段代码一定成功。在 vivo OriginOS 3(Android 13)上,同样的条件,同样的代码,必崩。
抓了下系统日志,发现 vivo 在 WindowManagerService 里加了一层自己的校验。logcat 里有这么一行:
W WindowManager: Attempted to add window with non-null context package=xxx, but overlay permission not granted by vivo security center"vivo security center"——不是 AOSP 的 AppOps,是 vivo 自己的安全中心应用。原来 OriginOS 在 AOSP 的权限体系之上,套了一个"应用悬浮窗"开关,位置藏在 设置 -> 快捷与辅助 -> 悬浮窗 -> 单个应用控制 里。这个开关和 AOSP 的 canDrawOverlays() 是分离的。
也就是说,用户可能在系统设置里开了"显示在其他应用的上层"(AOSP 的标准设置页面),但 vivo 安全中心里的开关是关的。这时候 canDrawOverlays() 返回 true,我们的代码以为有权限,调用 addView(),结果撞上了 vivo 在 WindowManagerService 里的额外检查,抛 BadTokenException。
更坑的是,vivo 安全中心的这个开关,没有公开的 API 可以查询状态,也没有标准的 Intent 可以跳转。用户被我们的引导跳到了 AOSP 的标准设置页,开了权限,回来继续崩,体验极差。
我们当时的 workaround 很丑陋:捕获 BadTokenException,检测到是 vivo 设备时,弹一个自定义对话框,用文字告诉用户去哪个路径里找第二个开关。这个对话框我写了五版文案,测试同事说"用户看到会骂人的",但确实没更好的办法。
后来 vivo 的技术支持(通过商务渠道)确认了这个行为,说 OriginOS 从 Android 11 开始就这样设计的,"为了安全"。我查了下,这个"安全中心悬浮窗权限"在 vivo 的 ROM 里是通过一个未公开的 ContentProvider 控制的,uri 大概是 content://com.vivo.permissionmanager.provider.permission/single_permission,但调用需要系统签名权限,第三方应用根本碰不到。
小米的"后台启动限制"连带伤害
小米的 MIUI 在这个问题上走的是另一条路。MIUI 12 之后大力推"照明弹"和隐私保护,其中有一条规则:后台应用禁止弹出悬浮窗。
这个规则本身没问题,但 MIUI 的实现方式和 AOSP 的 TYPE_APPLICATION_OVERLAY 权限检查耦合在一起,产生了一些反直觉的行为。
测试机型:小米 13,MIUI 14.0.5(Android 13)。
复现步骤:
1. 应用在前台,有悬浮窗权限,正常显示悬浮窗
2. 按 Home 键回到桌面,应用进入后台
3. 通过 Service 或者 BroadcastReceiver 尝试添加/更新悬浮窗
4. 有时候成功,有时候失败,有时候延迟几秒才出现
MIUI 的日志里能看到这样的信息:
W ActivityManager: Background start not allowed: service Intent { cmp=xxx/.FloatService }
W WindowManager: Rejecting addView from background pkg=xxx, uid=xxxxx但注意,这里不是每次都 reject。我花了两个下午做统计,发现规律大致是:应用刚退后台的 5-10 秒内,仍然可以添加悬浮窗;超过这个时间,或者被系统杀过进程再启动,就会被拦。这个"5-10 秒"不是固定值,和 MIUI 的"省电策略"设置有关,设置为"无限制"时长一些,"智能限制"时短一些,"后台运行超过 10 分钟后关闭"时基本别想。
这和 AOSP 的原生行为差异很大。AOSP 里,TYPE_APPLICATION_OVERLAY 的添加只检查 SYSTEM_ALERT_WINDOW 权限,不检查应用是否在前台。Google 的假设是:用户既然给了悬浮窗权限,就是允许这个应用随时显示悬浮窗。MIUI 额外加了一个"后台启动"的维度,而且把这个限制塞进了 WindowManager 的调用路径里。
我们遇到的实际问题是:直播 SDK 的"小窗播放"功能,用户切到别的 App 后继续播放。按照 AOSP 设计,这是悬浮窗的典型场景。但在 MIUI 上,如果用户在设置里把应用的省电策略设成默认(智能限制),切后台几秒后小窗就会消失,或者干脆弹不出来。
MIUI 提供了一个"自启动"和"后台弹出界面"的权限,位置在 设置 -> 应用设置 -> 应用管理 -> 权限 -> 后台弹出界面。这个权限和 AOSP 的 SYSTEM_ALERT_WINDOW 又是独立的。开了悬浮窗权限,没开"后台弹出界面",MIUI 的行为是:前台正常,后台随机失败。两个都开,才能接近 AOSP 的行为。
检测这个权限同样没有标准 API。MIUI 的权限状态存在 com.miui.securitycenter 的数据库里,但和 vivo 一样,第三方应用读不了。我们最后用的 heuristic 是:检测到小米设备时,在引导用户开悬浮窗权限的页面,同时用文字提示去检查"省电策略"和"后台弹出界面"。
OPPO / ColorOS 的"悬浮窗尺寸限制"
OPPO 的 ColorOS 在 Android 13 上有一个更隐蔽的差异:对悬浮窗的尺寸有额外限制。
测试机型:OPPO Find X6,ColorOS 13.1。
我们的直播小窗默认尺寸是 180dp x 320dp,在大多数设备上正常。在 ColorOS 上,如果悬浮窗的宽度超过屏幕宽度的 30%,或者高度超过屏幕高度的 40%,WindowManager.addView() 不会崩溃,但窗口会被强制缩放到限制范围内,而且缩放比例不是等比的,画面会被压扁。
这个行为没有任何文档说明,logcat 里也没有明确提示。我是怎么发现的?把测试机的开发者选项 -> GPU 呈现模式分析打开,发现小窗的 Surface 尺寸和传入的 LayoutParams 不一致,然后逐个维度二分排查,最后锁定在宽高比例上。
ColorOS 的 WindowManagerService 里有一个 OppoWindowManagerService 的扩展类,里面有一堆 OPPO_FEATURE_PORTRAIT_SUPPORT 之类的宏控制。反编译了 services.jar,找到一段大致这样的逻辑:
// ColorOS 扩展,非原始代码,基于反编译还原
if (attrs.type == TYPE_APPLICATION_OVERLAY) {
float maxWidthRatio = getFloatWindowMaxWidthRatio(callingUid);
float maxHeightRatio = getFloatWindowMaxHeightRatio(callingUid);
if (attrs.width > screenWidth * maxWidthRatio) {
attrs.width = (int)(screenWidth * maxWidthRatio);
}
if (attrs.height > screenHeight * maxHeightRatio) {
attrs.height = (int)(screenHeight * maxHeightRatio);
}
}maxWidthRatio 和 maxHeightRatio 的值似乎和应用类型有关,系统应用可能宽松一些,第三方应用就是硬编码的 0.3f / 0.4f。更麻烦的是,这个限制在横竖屏切换时行为不一致:竖屏下按宽度限制,切到横屏后限制逻辑没变,但屏幕宽高交换了,导致小窗突然变得特别大或者特别小。
我们的 fix 是在添加窗口前,自己先算一遍 ColorOS 的限制,主动调整尺寸,避免被系统强制缩放。检测 ColorOS 用 `Build.MANUFACTURER.equals("OPPO")
华为 HarmonyOS 的"智慧多窗"冲突
华为的 HarmonyOS 2/3/4 在悬浮窗上有一个独特设计:"智慧多窗"(原"平行视界"的延伸)。用户可以从侧边栏拖出应用,以悬浮窗形式运行。这个悬浮窗是系统级的,和我们应用自己调的 TYPE_APPLICATION_OVERLAY 不是一回事,但两者会竞争屏幕资源。
测试机型:Mate 50 Pro,HarmonyOS 4.0.0。
问题场景:用户正在用我们的直播小窗(TYPE_APPLICATION_OVERLAY),然后从侧边栏拖出微信的智慧多窗。这时候系统会强制把我们的小窗缩小到角落,或者干脆隐藏。这不是崩溃,但用户会反馈"小窗突然没了"。
更深层的问题是:HarmonyOS 的 WindowManager 在管理悬浮窗层级时,把智慧多窗的窗口类型设得极高(接近 TYPE_SYSTEM_OVERLAY),而 TYPE_APPLICATION_OVERLAY 在 AOSP 的层级体系里本来就低于系统级窗口。AOSP 上这没问题,因为第三方应用本来就该在系统 UI 下面。但 HarmonyOS 的智慧多窗也是"第三方应用"(微信、抖音等),却享受了系统级窗口的待遇。
我抓过 dumpsys window 的输出,智慧多窗的窗口类型显示为 TYPE_APPLICATION_OVERLAY,但它的 mBaseLayer 和 mSubLayer 被 HarmonyOS 的 HwWindowManagerService 调高了。具体机制没完全搞清楚,因为华为不公开 HarmonyOS 的 WindowManager 修改源码,但从行为推断,是在 WindowState.computeFrame() 或者 DisplayContent.assignWindowLayers() 里做了特殊处理。
| 对我们的影响是:无法通过常规手段保证小窗不被智慧多窗覆盖。我们试过在添加窗口后不断调用 `WindowManager.updateViewLayout()` 把自己的 `params.flags` 加上 `FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL`,甚至尝试设置 `params.screenBrightness` 等冷门属性来触发层级重算,都没用。 |
|---|
最后的妥协方案是:检测到华为设备且用户激活了智慧多窗时(通过 AccessibilityService 监听窗口变化,判断是否有 com.huawei.android.launcher 的特定窗口出现),主动把小窗收起成通知栏的媒体控制,避免和智慧多窗硬碰硬。这个方案很不优雅,依赖 AccessibilityService 本身就有稳定性和审核风险,但确实没更好的办法。
Samsung One UI 的"Good Lock"插件体系
Samsung 的 One UI 在悬浮窗权限上相对接近 AOSP,但有一个独特的变量:Good Lock 插件体系。
Good Lock 是三星官方推出的系统定制框架,里面有个插件叫 "MultiStar",可以修改多任务和悬浮窗的行为。如果用户装了这个插件,并且开启了"弹出视图"(Pop-up View)的增强选项,第三方应用的 TYPE_APPLICATION_OVERLAY 窗口行为会被影响。
具体表现:在 MultiStar 的"强制弹出视图"模式下,用户长按应用图标或者从多任务界面选择时,系统会尝试把应用转成弹出视图窗口。这个过程中,如果应用已经有 TYPE_APPLICATION_OVERLAY 的悬浮窗,One UI 的 WindowManager 会把这个悬浮窗的 token 重新绑定到弹出视图的 Activity token 上,导致我们后续调用 updateViewLayout() 时 token 不匹配,抛 IllegalArgumentException。
这个崩溃的堆栈:
java.lang.IllegalArgumentException: View not attached to window manager
at android.view.WindowManagerGlobal.findViewLocked(WindowManagerGlobal.java:544)
at android.view.WindowManagerGlobal.updateViewLayout(WindowManagerGlobal.java:437)
at android.view.WindowManagerImpl.updateViewLayout(WindowManagerImpl.java:133)问题在于,我们的代码里明明没有做 detach 操作,是系统在背后换了 token。而且这个过程是异步的,我们没法在应用层同步感知。
Samsung 的开发者文档对 Good Lock 的 API 暴露极少,MultiStar 的具体行为没有官方说明。我通过三星开发者论坛发帖子,得到的回复是"Good Lock 的修改属于用户自定义行为,应用应做好异常处理"。
我们的处理是:所有 WindowManager 操作包在严格的 try-catch 里,遇到 IllegalArgumentException 时尝试重新 addView 而不是 updateViewLayout。但这会导致小窗闪烁一下,体验打折。
检测与适配的工程实践
面对这些差异,我们逐渐积累了一套检测和适配的代码。核心思路是:不依赖单一检测手段,多层 fallback。
首先是设备识别。不能只看 Build.MANUFACTURER,因为子品牌、海外版、定制版 ROM 的 behavior 可能不同。我们的做法是组合多个字段:
public class RomUtils {
private static final String KEY_MIUI_VERSION = "ro.miui.ui.version.name";
private static final String KEY_EMUI_VERSION = "ro.build.version.emui";
private static final String KEY_COLOROS_VERSION = "ro.build.version.opporom";
private static final String KEY_ORIGINOS_VERSION = "ro.vivo.os.version";
private static final String KEY_ONEUI_VERSION = "ro.build.version.oneui";
public static RomType detectRom() {
Properties props = new Properties();
try (FileInputStream is = new FileInputStream(new File("/system/build.prop"))) {
props.load(is);
} catch (IOException e) {
// fallback
}
if (!TextUtils.isEmpty(props.getProperty(KEY_MIUI_VERSION))) return RomType.MIUI;
if (!TextUtils.isEmpty(props.getProperty(KEY_EMUI_VERSION))) return RomType.EMUI;
if (!TextUtils.isEmpty(props.getProperty(KEY_COLOROS_VERSION))) return RomType.COLOROS;
if (!TextUtils.isEmpty(props.getProperty(KEY_ORIGINOS_VERSION))) return RomType.ORIGINOS;
if (!TextUtils.isEmpty(props.getProperty(KEY_ONEUI_VERSION))) return RomType.ONEUI;
// 二次确认,读 Build.DISPLAY 等字段
String display = Build.DISPLAY.toLowerCase();
if (display.contains("miui")) return RomType.MIUI;
// ...
return RomType.AOSP;
}
}注意读 /system/build.prop 在 Android 8.0 以上需要 android.permission.READ_PHONE_STATE 或者通过 Runtime.getRuntime().exec("getprop") 绕一下,Google Play 的政策对后者有收紧趋势,我们目前用的是 Build 类的公开字段为主,build.prop 为辅。
其次是权限状态的"双重确认"。Settings.canDrawOverlays() 返回 true 不代表真的能加窗口,我们在真正 addView 前会做一次"探测":
public boolean canReallyDrawOverlays(Context context) {
if (!Settings.canDrawOverlays(context)) return false;
// 探测:尝试加一个 1x1 像素的透明窗口,立即移除
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
View probe = new View(context);
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
1, 1,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
: WindowManager.LayoutParams.TYPE_SYSTEM_ALERT,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
PixelFormat.TRANSPARENT
);
params.x = -9999; // 放到屏幕外,用户不可见
try {
wm.addView(probe, params);
wm.removeView(probe);
return true;
} catch (WindowManager.BadTokenException | SecurityException e) {
return false;
}
}这个探测有性能开销(跨进程 IPC),不能频繁调用。我们在应用启动时做一次,缓存结果,然后在 onResume 等关键节点按需刷新。
最后是降级策略。如果检测到某厂商的特殊限制无法绕过,要有 graceful degradation。比如 vivo 的二次确认问题,如果用户一直没去开第二个开关,我们的直播小窗可以降级成 PiP(Picture-in-Picture)模式,用 Android 原生的 enterPictureInPictureMode(),这个走系统的 PiP 框架,不受 TYPE_APPLICATION_OVERLAY 的权限限制。当然 PiP 有尺寸和交互的限制,但至少功能不丢。
Android 14 的新变量
Android 14(API 34)引入了一个变化:FOREGROUND_SERVICE_TYPE_SPECIAL_USE,以及更严格的 BOOT_COMPLETED 和 SYSTEM_ALERT_WINDOW 权限关联审查。
具体到我们的话题,Google 在 Android 14 上要求:如果应用要在后台显示悬浮窗,且目标 API 是 34+,需要确保 SYSTEM_ALERT_WINDOW 的授予是"用户明确且持续的意图"。这个措辞很模糊,实际执行上,Android 14 的 AppOpsManager 增加了一个 MODE_ASK 状态,某些情况下用户授予的悬浮窗权限会被标记为临时的,应用切后台一段时间后会自动撤销。
我们在 Pixel 8(Android 14)上测试时发现:首次安装应用,用户去设置页开了悬浮窗权限,回来能正常用。把应用切后台 5 分钟左右,再尝试通过 Service 添加悬浮窗,canDrawOverlays() 仍然返回 true,但 addView() 抛 SecurityException,logcat 里有:
W AppOps : Bad call made by uid 10xxx: specified package xxx under uid 10xxx but it is not in that uid
W WindowManager: ADD_PERMISSION_DENIED: package=xxx, uid=10xxx, type=TYPE_APPLICATION_OVERLAY这个 AppOps 的日志暗示权限状态在后台被重置了。但回到设置页看,开关仍然是"开"的。重启应用后恢复正常。
目前不确定这是 Android 14 的 intentional behavior 还是 bug,Google Issue Tracker 上有类似报告(issue #303416112),状态是"assigned",没有结论。我们的 workaround 是在 SecurityException 时重新触发权限引导,但这会导致频繁弹设置页,用户体验很差。
厂商 ROM 对 Android 14 的适配也在进行中。小米的 HyperOS(基于 Android 14)把"后台弹出界面"权限的默认策略改得更严了,以前是"询问",现在是"拒绝"。OPPO 的 ColorOS 14 增加了"悬浮窗尺寸自适应"功能,系统会根据屏幕方向和当前运行的应用,动态调整第三方悬浮窗的大小,这和我们之前遇到的尺寸限制又叠加了一层不确定性。
一个未解的问题:OverlayManagerService 的边界
标题里提到 OverlayManagerService,但前面的内容主要围绕 WindowManagerService 和 AppOpsManager。这里需要澄清一下:OverlayManagerService(OMS)在 Android 里其实是管理 RRO(Runtime Resource Overlay)的系统服务,和悬浮窗(floating window)不是一回事。RRO 是主题包、字体替换等资源的运行时覆盖机制。
但为什么很多开发者会把这两个概念混起来?因为 WindowManager 里的"overlay"和 OverlayManagerService 里的"overlay"在中文语境下都翻译成"叠加层"或"覆盖层"。更关键的是,某些厂商确实在 OverlayManagerService 的框架里做了扩展,用来管理"系统级悬浮窗"的主题和权限。
比如 Samsung 的 One UI 5 以上,Good Lock 的某些插件会通过 OverlayManagerService 注册自定义的 OverlayInfo,这些 overlay 可以包含窗口布局的修改。当 MultiStar 改变弹出视图的行为时,底层可能触发了 OMS 的 overlay 切换,间接影响到 WindowManager 里窗口的 token 和层级。
我没能完全理清这条调用链,因为 Samsung 不公开这部分修改。但从现象上推断,OverlayManagerService 和 WindowManagerService 在厂商 ROM 里的耦合,比 AOSP 里要深得多。AOSP 里 OMS 只管资源 overlay,WMS 只管窗口管理,两者通过 PackageManager 间接关联。厂商为了做主题引擎、游戏模式、悬浮窗增强等功能,把这两个服务揉在了一起,导致排查问题时很难定位根因。
这也是我写这篇文章的动机之一:当我们在文档里搜索"overlay permission"时,Google 的官方结果指向的是 SYSTEM_ALERT_WINDOW 和 TYPE_APPLICATION_OVERLAY,但厂商生态里的"overlay"是一个更宽泛、更模糊的概念,涉及 OMS、WMS、AppOps、安全中心、省电策略、后台启动限制等多个系统的交叉地带。没有一份统一的文档,只有散落的 bug 报告、反编译代码和血泪踩坑记录。
最后一点实测数据
为了写这篇文章,我重新整理了我们 SDK 的线上崩溃数据,时间范围 2023.06 - 2024.01,过滤条件:WindowManager.addView/updateViewLayout 相关的异常,且设备有悬浮窗权限(根据我们的埋点)。
总样本:约 420 万次悬浮窗添加操作,异常 18.7 万次,异常率 4.45%。
按厂商粗分(基于 Build.MANUFACTURER):
SecurityException 占 38%,BadTokenException 占 31%,IllegalArgumentException 占 21%BadTokenException 占 67%(那个二次确认问题)这些数据来自我们 SDK 的埋点上报,不是全局统计,可能有采样偏差,但量级足够说明问题:厂商定制带来的悬浮窗行为差异,是真实存在且影响显著的。AOSP 的"标准行为"在中文安卓生态里,反而是一种需要专门测试和适配的"边缘情况"。
我们的测试机柜现在有 47 台设备,覆盖 2019-2024 年的主流机型,每次发版前跑一轮悬浮窗兼容性测试,仍然会有漏网之鱼。上周刚收到一台 vivo S18 的反馈,OriginOS 4 又改了安全中心的包名,我们之前 heuristic 检测的逻辑失效了一半。
这就是 Android 悬浮窗权限的现状:一个看似简单的基础功能,在厂商生态里裂变成无数变种,每个变种都有自己的陷阱、自己的日志、自己的 workaround。作为应用开发者,能做的只有尽量覆盖、快速响应、保留降级。期待某一天厂商能收敛到 AOSP 的标准行为上?不,我不期待这个,我只期待他们的文档能多一点,技术支持回邮件能快一点。