动态 Launcher Icon 与启动入口切换:换图标背后的工程治理

移动应用的桌面图标是用户进入产品的第一触点。某些业务希望在特定时间展示特殊图标,或根据地区、主题、活动状态切换启动入口。Android 提供的 activity-alias 能力允许一个应用声明多个 Launcher 入口,每个 alias 可以有不同 icon、label、enabled 状态,并指向同一个真实启动 Activity。运行时通过组件启停,就能控制桌面上展示哪一个入口。

但这种方案容易引发几个问题:桌面图标突然消失、启动入口重复、部分厂商桌面刷新延迟、升级后入口丢失。动态 Launcher icon 不能只作为一个简单工具函数实现,它需要完整设计。

我们项目里在 AppCommon 下有 LauncherSwitcherLauncherTypeMyLauncherActivityNewIconLauncherActivityNewStyleLauncherActivity 等入口相关类,同时各 App 壳又有自己的 launcher 资源、启动动画和主题图标。这不是单一品牌换图标,而是多品牌、多地区、多入口组件并存的入口治理问题。AppB 和 AppA 可能有不同默认图标,区域 A 和区域 B 可能有不同资源与启动动画,特殊活动可能临时切换入口。动态切换必须保证回滚、幂等、桌面缓存兼容、禁用当前组件的时机安全,以及升级后默认入口不会丢。

永远保留一个可用入口

动态入口切换最核心的原则是:无论远程配置错误、系统调用失败还是应用进程中断,都不能让所有 Launcher 入口同时被禁用。

切换时采用”先启用目标,再禁用旧入口”的顺序。这样即使中途失败,也更可能保留至少一个可用入口:

class LauncherEntrySwitcher(
    private val packageManager: PackageManager,
    private val registry: LauncherEntryRegistry,
    private val stateStore: LauncherStateStore,
    private val reporter: LauncherReporter
) {
    fun switchTo(requestedId: String) {
        val target = registry.findValid(requestedId, AppInfo.versionCode)
            ?: registry.defaultEntry()

        val previous = stateStore.currentEntryId()
            ?.let { registry.findValid(it, AppInfo.versionCode) }
            ?: registry.defaultEntry()

        val enabled = enable(target)
        if (!enabled) {
            reporter.reportSwitchFailed(target.id, "enable_failed")
            ensureDefaultEnabled()
            return
        }

        registry.allEntries().forEach { entry ->
            if (entry.id != target.id) {
                disable(entry)
            }
        }

        stateStore.saveCurrentEntryId(target.id)
        reporter.reportSwitchSuccess(previous.id, target.id)
    }
}

PackageManager 调用需要指定不杀进程的标记,避免切换入口时影响当前会话:

private fun setEnabled(component: ComponentName, enabled: Boolean): Boolean {
    val newState = if (enabled) {
        COMPONENT_ENABLED_STATE_ENABLED
    } else {
        COMPONENT_ENABLED_STATE_DISABLED
    }
    return runCatching {
        packageManager.setComponentEnabledSetting(component, newState, DONT_KILL_APP)
        true
    }.getOrDefault(false)
}

Manifest 预声明与入口注册表

真实启动 Activity 不直接暴露为 Launcher,或只作为默认稳定入口之一;多个 alias 分别声明不同 icon 和 label,targetActivity 指向同一个启动 Activity。运行时只控制这些 alias 的 enabled 状态:

<activity android:name=".LauncherActivity" android:exported="true" />

<activity-alias
    android:name=".entry.DefaultEntry"
    android:enabled="true"
    android:exported="true"
    android:icon="@mipmap/icon_default"
    android:label="@string/app_name"
    android:targetActivity=".LauncherActivity">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity-alias>

<activity-alias
    android:name=".entry.CampaignEntry"
    android:enabled="false"
    android:exported="true"
    android:icon="@mipmap/icon_campaign"
    android:label="@string/app_name"
    android:targetActivity=".LauncherActivity">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity-alias>

客户端维护一份内置注册表,远程配置只下发稳定 id,不触碰组件名:

data class RemoteLauncherPolicy(
    val entryId: String,
    val activeFrom: Long,
    val activeUntil: Long,
    val minAppVersion: Int
)

fun resolveEntry(policy: RemoteLauncherPolicy?, now: Long): String {
    if (policy == null) return "default"
    if (now !in policy.activeFrom..policy.activeUntil) return "default"
    if (AppInfo.versionCode < policy.minAppVersion) return "default"
    return policy.entryId
}

启动时健康检查

应用启动时要做一次入口健康检查,修正异常状态。比如进程被杀导致多个入口同时启用,或所有入口都被禁用:

class LauncherEntryHealthCheck(
    private val registry: LauncherEntryRegistry,
    private val packageManager: PackageManager,
    private val reporter: LauncherReporter
) {
    fun repairIfNeeded() {
        val enabledEntries = registry.allEntries().filter { entry ->
            packageManager.isComponentEnabled(entry.componentName)
        }

        when {
            enabledEntries.isEmpty() -> {
                enable(registry.defaultEntry())
                reporter.reportRepair("no_enabled_entry")
            }
            enabledEntries.size > 1 -> {
                val preferred = choosePreferred(enabledEntries)
                registry.allEntries().forEach { entry ->
                    if (entry.id != preferred.id) disable(entry)
                }
                reporter.reportRepair("multiple_enabled_entries")
            }
        }
    }
}

落地中的关键约束

alias 不要随意删除。已经发布过的入口即使后续不用,也建议保留一段较长兼容期,并默认 disabled。直接删除旧 alias 可能影响升级用户桌面上的历史入口。

默认入口必须稳定。默认入口资源、label、目标 Activity 应尽量少变,它是所有异常恢复的落点。任何活动入口都应该能回退到默认入口。

切换频率要低。每天或每次启动都切图标会让用户困惑,也会增加桌面兼容风险。活动类图标应有明确开始和结束时间,且避免短周期反复切换。

注意桌面缓存延迟。PackageManager 调用成功不代表用户桌面立即刷新。产品和运营预期要建立在”最终一致”上,而不是把切换当作实时视觉能力。

测试矩阵要覆盖主流厂商桌面、系统版本、升级安装、覆盖安装、冷启动、进程存活切换、切换后卸载重装等场景。尤其要验证从旧版本升级到新版本时,旧入口状态是否被正确修复。


动态 Launcher icon 是一个看似简单、实则非常接近系统边界的能力。它的核心不在于调用一次 PackageManager,而在于保证入口状态始终可恢复、配置始终可校验、异常始终可观测。一个稳健方案通常具备这些特征:所有入口提前声明,远程只选择白名单 id;切换顺序优先保证至少一个入口可用;应用启动时执行健康检查;默认入口永远可恢复;灰度数据能区分成功、失败和修复。动态图标带来的新鲜感有价值,但不能覆盖启动稳定性。每一次切换都应该有明确理由、明确窗口和明确回退,只有这样它才会成为可靠能力,而不是一次高风险活动配置。