Android - 动态切换桌面图标

Android 动态应用图标(activity-alias)完全指南:原理、踩坑、可运行 Demo

你是否突然发现桌面上的某个 App 图标"焕然一新"?这并不需要发版更新------动态应用图标 就能做到:在本地按需切换桌面图标与名称,为节日、运营活动、主题风格带来更多玩法。本文从 0 到 1 带你掌握 activity-alias 的原理与最佳实践,并给出一套可直接运行的 Kotlin Demo。

一、为什么用动态图标?

  • 运营拉新/促活:双 11、春节、周年庆等换图标,视觉提醒强。

  • 功能曝光:上线大版本或关键功能时,图标可临时加"NEW"等元素。

  • 主题联动:跟随深浅色/节日皮肤,图标同步变化,增强整体感。

注意 :Android 并不支持在运行时直接替换图标资源;官方可行方案 是利用 activity-alias:为同一个入口 Activity 配多个"别名",每个别名绑定不同的 icon/label,再通过 PackageManager 启用/禁用别名来"切图标"。


二、实现原理速览

  1. 一个真实的主入口 Activity (不带 LAUNCHER)。

  2. 若干 activity-alias (带 LAUNCHER),各自绑定不同图标/名称,目标都指向主入口。

  3. 切换时 :用 PackageManager.setComponentEnabledSetting() 启用一个别名、禁用另一个,桌面即显示被启用的那一个。


三、快速上手(最小可运行 Demo)

运行环境示例:AGP 8.x、Gradle 8、Kotlin 1.9+、Java 17、compileSdk/targetSdk=35minSdk=24

包名示例:com.wantime.dynamicicons(请按你工程替换)。

1)AndroidManifest.xml(放 app/src/main)完整版可复制

XML 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:icon="@mipmap/ic_launcher"
        android:roundIcon="@mipmap/ic_launcher"
        android:theme="@style/Theme.DynamicIcons">

        <!-- 主入口,不带 LAUNCHER -->
        <activity
            android:name=".MainActivity"
            android:exported="true" />

        <!-- 别名:主图标(默认启用) -->
        <activity-alias
            android:name=".MainActivityAlias"
            android:targetActivity=".MainActivity"
            android:enabled="true"
            android:icon="@mipmap/ic_launcher"
            android:roundIcon="@mipmap/ic_launcher"
            android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity-alias>

        <!-- 别名:备选图标(默认禁用) -->
        <activity-alias
            android:name=".MainActivityAliasB"
            android:targetActivity=".MainActivity"
            android:enabled="false"
            android:icon="@mipmap/ic_launcher_alt"
            android:roundIcon="@mipmap/ic_launcher_alt"
            android:label="@string/app_name_alt">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity-alias>

        <!-- 广播接收器(如只允许应用内触发,可将 exported 设为 false) -->
        <receiver
            android:name=".IconChangeReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="com.wantime.dynamicicons.ACTION_CHANGE_ICON" />
            </intent-filter>
        </receiver>

    </application>
</manifest>

关键点

  • 主 Activity 不要LAUNCHER

  • 初始只启用一个别名,避免桌面出现多个图标。

  • 图标建议使用 adaptive icon@mipmap 前景/背景)。

2)IconSwitcher.kt(切换核心)

Kotlin 复制代码
package com.wantime.dynamicicons

import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager

object IconSwitcher {

    /**
     * 启用 enableAlias,禁用 disableAlias。
     * alias 参数既可写 ".MainActivityAlias"(相对名),也可写完整类名。
     */
    fun switchTo(context: Context, enableAlias: String, disableAlias: String) {
        val pm = context.packageManager
        val enableFqcn = fqcn(context, enableAlias)
        val disableFqcn = fqcn(context, disableAlias)

        // 先校验组件存在(含禁用态),避免名字写错直接崩
        pm.getActivityInfo(ComponentName(context, enableFqcn),
            PackageManager.MATCH_DISABLED_COMPONENTS)
        pm.getActivityInfo(ComponentName(context, disableFqcn),
            PackageManager.MATCH_DISABLED_COMPONENTS)

        pm.setComponentEnabledSetting(
            ComponentName(context, enableFqcn),
            PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
            PackageManager.DONT_KILL_APP
        )
        pm.setComponentEnabledSetting(
            ComponentName(context, disableFqcn),
            PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
            PackageManager.DONT_KILL_APP
        )
    }

    private fun fqcn(ctx: Context, name: String): String {
        return if (name.startsWith(".")) ctx.packageName + name else name
    }
}

3)IconChangeReceiver.kt(广播触发轮换)

Kotlin 复制代码
package com.wantime.dynamicicons

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent

class IconChangeReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context?, intent: Intent?) {
        val ctx = context ?: return
        if (intent?.action != ACTION_CHANGE_ICON) return

        val useAlt = flipFlag(ctx) // 每次取反,实现"轮换"
        val aliasA = ".MainActivityAlias"
        val aliasB = ".MainActivityAliasB"

        IconSwitcher.switchTo(
            context = ctx,
            enableAlias = if (useAlt) aliasB else aliasA,
            disableAlias = if (useAlt) aliasA else aliasB
        )
    }

    private fun flipFlag(context: Context): Boolean {
        val sp = context.getSharedPreferences("icon_demo", Context.MODE_PRIVATE)
        val next = !sp.getBoolean("useAlt", false)
        sp.edit().putBoolean("useAlt", next).apply()
        return next
    }

    companion object {
        const val ACTION_CHANGE_ICON = "com.wantime.dynamicicons.ACTION_CHANGE_ICON"
    }
}

4)MainActivity.kt(演示 UI:发广播/直接切)

Kotlin 复制代码
package com.wantime.dynamicicons

import android.content.Intent
import android.os.Bundle
import android.widget.Button
import androidx.activity.ComponentActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 可选:边到边
        WindowCompat.setDecorFitsSystemWindows(window, false)

        setContentView(R.layout.activity_main)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { v, ins ->
            val sysBars = ins.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(sysBars.left, sysBars.top, sysBars.right, sysBars.bottom)
            ins
        }

        // 方式一:广播触发(走 Receiver 轮换逻辑)
        findViewById<Button>(R.id.btnBroadcast).setOnClickListener {
            sendBroadcast(Intent(IconChangeReceiver.ACTION_CHANGE_ICON))
        }

        // 方式二:直接切(跳过 Receiver)
        findViewById<Button>(R.id.btnDirectA).setOnClickListener {
            IconSwitcher.switchTo(
                context = this,
                enableAlias = ".MainActivityAlias",
                disableAlias = ".MainActivityAliasB"
            )
        }
        findViewById<Button>(R.id.btnDirectB).setOnClickListener {
            IconSwitcher.switchTo(
                context = this,
                enableAlias = ".MainActivityAliasB",
                disableAlias = ".MainActivityAlias"
            )
        }
    }
}

5)activity_main.xml(三按钮演示)

XML 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:padding="24dp"
    android:gravity="center"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:id="@+id/btnBroadcast"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="广播触发(轮换切换)" />

    <View android:layout_width="match_parent" android:layout_height="12dp" />

    <Button
        android:id="@+id/btnDirectA"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="直接切到主图标(Alias)" />

    <View android:layout_width="match_parent" android:layout_height="12dp" />

    <Button
        android:id="@+id/btnDirectB"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="直接切到备用图标(AliasB)" />
</LinearLayout>

6)res/values/strings.xml(示例)

XML 复制代码
<resources>
    <string name="app_name">Dynamic Icons</string>
    <string name="app_name_alt">Dynamic Icons ✨</string>
</resources>

四、难点 & 常见坑(你刚踩过的坑都在这里)

1)Component class ... does not exist

根因 :切换的别名名称与实际安装包中的不一致,或别名未合入最终 Manifest。
解决

  • 代码与 Manifest 名称逐字一致 (如本文用 .MainActivityAlias / .MainActivityAliasB)。

  • 别名写在 app 主工程的 Manifest,不要藏在某个库里被覆盖。

  • 切换前用 getActivityInfo(... MATCH_DISABLED_COMPONENTS)存在性校验(本文已内置)。

  • Android Studio 打开 Merged Manifest ,确认别名真的在且 targetActivity 正确。

2)包名/命名空间混乱

  • 你曾把 IconSwitcher 放在 com.example... 包,其他类在 com.wantime...,导致导包混乱。

  • 建议统一到同一包名 ,或确保 import 正确。

3)命名参数写错

  • 你的调用写了 enableAliasFqcn=,方法签名实际是 enableAlias=

  • Kotlin 命名参数拼错会编译不过或调用失败。

4)enableEdgeToEdge 未解析

  • androidx.activity:activity-ktx 1.6+,或直接改用:

    复制代码

    WindowCompat.setDecorFitsSystemWindows(window, false)

5)字符串常量不一致(动作、别名)

  • Manifest 的 <action> 与代码中 Intent(action) 必须完全一致

  • 建议改为 常量,别依赖 strings.xml。

6)图标资源规范

  • 优先用 @mipmap 的 Adaptive Icon(-anydpi-v26 前景/背景),避免拉伸/圆角不一致。

7)Launcher 延迟刷新

  • 大多数桌面会立刻刷新;个别机型可能缓存:轻按 Home 再返回、清除近期任务、或系统自行刷新即可。我们使用 DONT_KILL_APP 不会杀死进程。

8)安全性

  • 若不希望外部 App 触发换图标:把 receiverexported 改为 false,或给广播加签名权限。

五、调试清单(强烈建议照做一遍)

  1. 卸载旧包再安装,避免历史签名/包名残留。

  2. 运行后在 Logcat 打印实际活动清单(确认别名存在):

    复制代码

    // 加在 MainActivity onCreate 尾部,调试用 val pm = packageManager val pkg = pm.getPackageInfo( packageName, PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS ) pkg.activities?.forEach { android.util.Log.i("DynIcon", "name=${it.name}, target=${it.targetActivity}") }

    你应看到:

    复制代码

    name=com.wantime.dynamicicons.MainActivity name=com.wantime.dynamicicons.MainActivityAlias target=com.wantime.dynamicicons.MainActivity name=com.wantime.dynamicicons.MainActivityAliasB target=com.wantime.dynamicicons.MainActivity

  3. 发广播测试(ADB):

    复制代码

    adb shell am broadcast -a com.wantime.dynamicicons.ACTION_CHANGE_ICON

    再看桌面图标是否轮换。

  4. 直接切:点 Demo 中两个"直接切换"按钮,验证互斥显示。


六、进阶玩法

  • 多套图标 :增加更多 activity-alias(C、D...),代码里选择"启用其一、禁用其他全部"。

  • 节日/时间段策略shouldUseAltIcon() 按日期/服务器开关/AB 实验组来决定。

  • 动态名称 :各 alias 可配不同 android:label,图标与名称一并切换。

  • 按主题切换:配合应用内主题切换逻辑,图标也随主题变化。

  • 限制触发源:仅在用户打开某页面或完成某任务时切换,避免频繁闪烁。


七、FAQ

Q:iOS 也能这样做吗?

A:iOS 提供 UIApplication.setAlternateIconName(有系统弹窗提醒),机制不同,本文不展开。

Q:切换会重启 App 吗?

A:使用 DONT_KILL_APP 不会杀进程;Launcher 侧刷新与否依厂商实现。

Q:能"还原"到最初图标吗?

A:可以,只要把"主图标别名"再次设为启用、其余禁用即可。


八、总结

  • 动态图标 在 Android 上的正确姿势是 activity-alias + PackageManager 开关。

  • 关键三点 :别名名与 Manifest 一致、主 Activity 不带 LAUNCHER、只启用一个入口。

  • 结合本文 Demo 你即可在项目内快速落地,并避免"组件不存在""依赖缺失"等常见错误。

相关推荐
雨白17 分钟前
登录和授权:Cookie与Authorization Header机制详解
android
Frank_HarmonyOS1 小时前
【Android -- 多线程】Handler 消息机制
android
一条上岸小咸鱼2 小时前
Kotlin 基本数据类型(一):概述及分类
android·kotlin
AI 嗯啦2 小时前
SQL详细语法教程(三)mysql的函数知识
android·开发语言·数据库·python·sql·mysql
跨界混迹车辆网的Android工程师4 小时前
adb 发送广播
android
超勇的阿杰6 小时前
gulimall项目笔记:P54三级分类拖拽功能实现
android·笔记
峥嵘life7 小时前
Android 欧盟网络安全EN18031 要求对应的基本表格填写
android·安全·web安全
程序员码歌9 小时前
【零代码AI编程实战】AI灯塔导航-从0到1实现篇
android·前端·人工智能
北十南9 小时前
SODA自然美颜相机(甜盐相机国际版) v9.3.0
android·windows·数码相机