一个朋友拿华为手机点了我的按钮, 什么都没发生 — 那天我开始删 if-manufacturer

5 阅读1分钟

去年某个晚上, 我把 App 第一版发给一个朋友测。

我用的是 Pixel, 开发了大半年, 所有按钮我都点过几百遍, 一切正常。

我朋友拿的是华为。

他点了 App 里那个"打开悬浮窗权限"按钮 —— 屏幕上什么都没发生。

不是闪退, 不是报错, 是什么都没发生

按钮像是个画上去的, 不响应。

他发给我一张截图, 截图里那个按钮还是好好的, 一切看起来都对。他打了一行字:

"我点了, 是不是我点错了?"

我心里咯噔一下 —— 不是因为这个 bug 难修, 是因为我意识到一件事:

**我做的根本不是一个 App, 我做的是"一个在 Pixel 上能跑的 App"**。

其他几亿台中国市场的手机, 在我这只是"理论上支持"。

一、第一版我就是写了一堆 if (manufacturer == "...")

第一版我是怎么修的? 加了一个分支:

if (Build.MANUFACTURER.lowercase().contains("huawei")) {    // 跳到华为系统管家的"应用启动管理"页    val intent = Intent().apply {        component = ComponentName(            "com.huawei.systemmanager",            "com.huawei.systemmanager.appcontrol.activity.StartupAppControlActivity"        )    }    startActivity(intent)} else {    // 标准 Android 路径    startActivity(        Intent(            Settings.ACTION_MANAGE_OVERLAY_PERMISSION,            Uri.parse("package:${packageName}")        )    )}

修完那天华为能跑了, 我以为这事结束了。

然后小米来了。然后 vivo 来了。然后 OPPO 来了。然后三星、一加、魅族、联想。最后荣耀单独冒出来一个 MagicOS 8+, 包名从 com.huawei.systemmanager 改成了 com.hihonor.systemmanager —— 又是一个分支。

到第 8 个分支的时候, 我打开自己那个文件, 滚动条要拉两次才能看完。

每一个分支里, 我都在写几乎一样的代码: 拼一个 ComponentName, 包一层 try/catch, 调一次 startActivity

唯一不一样的就是那两个字符串 —— 包名和 Activity 名。

我盯着那一坨 if-else 看了好久, 心里慢慢出来一个念头:

我每写一个 if (manufacturer == X), 就是把我代码的命, 押在了"X 厂商下个版本会不会改包名"这件事上

而这件事我控制不了。小米改一个 Activity 名, 我崩。OPPO 把 com.coloros.safecenter 改成 com.oplus.safecenter (它真的改了), 我崩。华为切到 HarmonyOS, 我崩。

写这种代码不是适配, 是赌。

二、转折: 不要问"你是谁", 问"你能干什么"

那几天我没写代码, 我在重新想这件事。

我意识到我之前问错问题了。

我之前在代码里问的是: "你是谁?" —— 你是华为? 是小米? 是 OPPO? —— 然后根据答案分支。

我应该问的是: "你能干什么?" —— 你现在能不能开悬浮窗? 你的全屏通知权限有没有被收走? 你有没有被加进电池白名单?

前一种问法, 我得在脑子里维护一张"品牌 → 行为"的对照表, 这张表会过时, 而且对我没见过的新品牌完全没用。

后一种问法, 我不关心你是谁, 我只问系统当前的状态, 你给我答案我就走相应的路径。这种写法对未来的设备也是对的 —— 哪天有个新品牌冒出来, 哪天某个 ROM 改了名, 我代码一行不动。

实操上, 我把所有"判断设备状态"的代码全收到了一个类里:

object DeviceCapability {    fun canShowOverlay(context: Context): Boolean {        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true        // Check 1: 标准 API        if (Settings.canDrawOverlays(context)) return true        // Check 2: AppOpsManager 兜底        // (某些 ROM 上 Settings.canDrawOverlays 缓存有延迟, 用户刚关掉权限它还说 true)        return try {            val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager            val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {                appOps.unsafeCheckOpNoThrow(                    AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW,                    Process.myUid(),                    context.packageName                )            } else {                @Suppress("DEPRECATION")                appOps.checkOpNoThrow(                    AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW,                    Process.myUid(),                    context.packageName                )            }            mode == AppOpsManager.MODE_ALLOWED        } catch (e: Exception) {            false        }    }    //pattern: canUseFullScreenIntent / isIgnoringBatteryOptimizations /    //            hasUsageStatsPermission / areNotificationsEnabled}

注意里头**没有一个 if (manufacturer == ...)**。

它问的全是"系统现在告诉我什么", 不是"你这台设备品牌叫什么"。

canShowOverlay 这一段两层检测, 不是炫技。是我真踩过坑 —— 在某些 ROM 上, 用户在设置里关掉悬浮窗权限之后, Settings.canDrawOverlays 还要好几秒才反应过来, 这段窗口里我代码以为自己还有权限, 实际已经没了, 然后 addView 直接抛异常。AppOpsManager 是更底层的一层, 它知道得更快。

写两层不是"我想显得严谨", 是单层在某些 ROM 上会撒谎。

三、那万一真的要跳到某品牌的设置页呢? 表驱动

但是有一类事情, 能力探测解决不了 —— 跳转到厂商设置页

我做"权限引导", 用户某个权限没给, 我得能把他一键带到对的设置页。这个跳转是 Activity 级的, 必须知道具体的"包名 + Activity 名"。这事躲不开。

我以前的解决方式是 if-else 8 分支。

现在我的解决方式是一张表:

data class SettingsRoute(    val packageName: String,    val activityName: String)data class VendorConfig(    val keywords: List<String>,                                  // 匹配 Build.MANUFACTURER 的关键词    val overlayRoutes: List<SettingsRoute>,                      // 悬浮窗设置入口    val batteryRoutes: List<SettingsRoute>,                      // 电池/省电入口    val autoStartRoutes: List<SettingsRoute>,                    // 自启动管理入口    val brandName: String,    val lockRecentRoutes: List<SettingsRoute> = emptyList(),     // 锁后台/最近任务保护    val batteryProfileRoutes: List<SettingsRoute> = emptyList(), // 省电策略例外)private val vendorConfigs = listOf(    // 完整示例: Honor 一项 (MagicOS 8+ hihonor 主路由 + huawei fallback)    VendorConfig(        keywords = listOf("honor"),        overlayRoutes = listOf(            SettingsRoute(                "com.hihonor.systemmanager",                "com.hihonor.systemmanager.appcontrol.activity.StartupAppControlActivity"            ),            SettingsRoute(                "com.huawei.systemmanager",  // 老 MagicOS 4-7 fallback                "com.huawei.systemmanager.appcontrol.activity.StartupAppControlActivity"            ),        ),        batteryRoutes = listOf(/* ... */),        autoStartRoutes = listOf(/* ... */),        brandName = "Honor",        lockRecentRoutes = listOf(/* ... */),        batteryProfileRoutes = listOf(/* ... */),    ),    // 其他 8 项同结构, 每一项各自填齐 6 个路由列表:    VendorConfig(keywords = listOf("huawei"),                   /* ... */ brandName = "Huawei"),    VendorConfig(keywords = listOf("xiaomi", "redmi", "poco"),  /* ... */ brandName = "Xiaomi"),    VendorConfig(keywords = listOf("oppo", "realme"),           /* ... */ brandName = "OPPO"),    VendorConfig(keywords = listOf("vivo", "iqoo"),             /* ... */ brandName = "vivo"),    VendorConfig(keywords = listOf("samsung"),                  /* ... */ brandName = "Samsung"),    VendorConfig(keywords = listOf("oneplus"),                  /* ... */ brandName = "OnePlus"),    VendorConfig(keywords = listOf("lenovo", "motorola"),       /* ... */ brandName = "Lenovo"),    VendorConfig(keywords = listOf("meizu"),                    /* ... */ brandName = "Meizu"),)

整张表是数据, 不是代码。

匹配逻辑就一行:

fun detectVendor(): VendorConfig? {    val m = Build.MANUFACTURER.lowercase()    return vendorConfigs.firstOrNull { config ->        config.keywords.any { m.contains(it) }    }}

这个改造做完, 哪天有个新品牌冒出来, 我加一行 VendorConfig 就行, 业务代码一行都不动

不只是好维护。这里有个更深的事 —— 业务代码里完全没有任何品牌名出现过。它只调 detectVendor()?.overlayRoutes, 拿到一个路由列表挨个 try。它不知道也不关心你是华为还是 vivo。

四、3 层降级 = 用户永远不会"卡在那里"

但是表驱动也只是第一层。

每一条路由都可能失败 —— Activity 名改了, 包名变了, 用户没装系统管家, ROM 升级了。

我给每条关键路径都垫了 3 层降级:

fun openOverlaySettings(context: Context): Boolean {    // 1. 标准 Android API (大多数设备都能走通, 包括华为/小米/OPPO)    if (tryOpenStandardOverlay(context)) return true    // 2. 厂商专用路由 (从表里取)    val config = detectVendor()    for (route in config?.overlayRoutes ?: emptyList()) {        if (tryOpenRoute(context, route)) return true    }    // 3. 最终兜底: app 详情页 (这个所有 Android 设备都一定能打开)    return tryOpenAppDetails(context)}

层 1 是标准 API。绝大多数情况下能成。

层 2 是厂商专用路由。标准 API 不行的时候, 表里挨个试。每一条 tryOpenRoute 会先调 resolveActivity(packageManager) 看这个 Activity 在不在, 不在就跳过这一条试下一条 —— 所以"包名改了 / Activity 改名了"不会崩, 只会跳过。

层 3 是 app 详情页 —— 也就是你长按 App 图标点"应用信息"那个页面。这是 AOSP 必有的, 任何 Android 设备都打得开。

为什么要 3 层?

**因为用户不应该被"卡在那里"**。

我开头那个朋友, 他点了按钮什么都没发生, 他不知道是 App 的问题还是他自己的问题, 他甚至怀疑是自己"点错了"。这种状态对一个用户是最糟糕的 —— 不是失败, 是"我不知道发生了什么"。

3 层降级的本质是: 我宁可把用户带到一个"差一步但他能自己找到"的页面 (app 详情), 也不能让他停在"我点了, 啥也没发生"的死胡同里。

五、唯一允许出现 Build.MANUFACTURER 的地方

最后说一件事, 这事我得诚实交代清楚。

我那个项目里, 整个 codebase 我**只允许 Build.MANUFACTURER 在一个文件里出现 —— VendorSettingsNavigator.kt**。

而且只允许在那一行 lowercase().contains(...) 匹配里, 和一行日志里。

这条规矩没有 git hook 自动拦, 没有 lint 规则。它就是我在那个文件顶部写了一段 KDoc 注释, 内容是:

"This is the ONLY place in the codebase that uses Build.MANUFACTURER. Core logic (overlay, lock, monitoring) never depends on this class."

加上我自己每隔一段时间会跑一遍:

grep -rn "Build.MANUFACTURER" --include="*.kt" .

看一眼有没有冒出来新的引用。

也就是说, 这条"守则"完全靠自觉。如果有一天我状态不好, 在 ViewModel 里塞了一个 "if 小米则延迟 500ms", 没人会拦我。

但即使是这样的"软守则", 它也救了我无数次。

因为只要它在那里、被反复写在文件顶部 + 反复 grep, 我每次想加一个新的 Build.MANUFACTURER 之前都会停一下, 问自己: 这个分支我真的非加不可吗? 我是不是其实想问的是"能不能"而不是"是不是"?

90% 的时候, 我会发现答案是后者, 然后那行代码就不会被写下来。

那条注释和那行 grep, 真正起作用的不是它拦住了什么, 是它逼我每次都停下来想一次

六、最后

我那个朋友的华为手机, 后来跑通了。

他再点那个按钮, 屏幕跳到了华为系统管家的应用启动管理页, 看到了那个开关。

他给我发了一张截图, 上面打了一行字:

"好了, 你这次写对了。"

那一刻我心里没多大的开心, 我只是觉得踏实

因为我知道我修好的不只是"华为这台手机", 我修好的是任何一台我从来没见过的 Android 设备。哪天有个用户拿一台我没听说过的新机来, 我代码不会崩, 它会优雅地降到 app 详情页, 用户至少能找到出口。

我做不到给每台手机都做"完美适配", 但我可以做到没有任何一台手机让我的用户卡在"什么都没发生"那个死胡同里

如果你也在做需要兼容国内 ROM 的事 —— 在你打第 9 个 if (manufacturer == ...) 之前, 我想跟你说一件事, 我那时候要是有人这么跟我说就好了:

停下来一秒, 问自己一个问题 —— 我现在想知道的, 到底是"你是谁", 还是"你能干什么"?

如果是后者, 那条 if 就不该被写下来。