Android - 动态切换桌面图标
Android 动态应用图标(activity-alias)完全指南:原理、踩坑、可运行 Demo
你是否突然发现桌面上的某个 App 图标“焕然一新”?这并不需要发版更新——动态应用图标就能做到:在本地按需切换桌面图标与名称,为节日、运营活动、主题风格带来更多玩法。本文从 0 到 1 带你掌握 activity-alias 的原理与最佳实践,并给出一套可直接运行的 Kotlin Demo。
一、为什么用动态图标?
运营拉新/促活:双 11、春节、周年庆等换图标,视觉提醒强。
功能曝光:上线大版本或关键功能时,图标可临时加“NEW”等元素。
主题联动:跟随深浅色/节日皮肤,图标同步变化,增强整体感。
注意:Android 并不支持在运行时直接替换图标资源;官方可行方案是利用 activity-alias
:为同一个入口 Activity 配多个“别名”,每个别名绑定不同的 icon/label
,再通过 PackageManager
启用/禁用别名来“切图标”。
二、实现原理速览
一个真实的主入口 Activity(不带
LAUNCHER
)。若干
activity-alias
(带LAUNCHER
),各自绑定不同图标/名称,目标都指向主入口。切换时:用
PackageManager.setComponentEnabledSetting()
启用一个别名、禁用另一个,桌面即显示被启用的那一个。
三、快速上手(最小可运行 Demo)
运行环境示例:AGP 8.x、Gradle 8、Kotlin 1.9+、Java 17、
compileSdk/targetSdk=35
、minSdk=24
。
包名示例:com.wantime.dynamicicons
(请按你工程替换)。
1)AndroidManifest.xml
(放 app/src/main
)完整版可复制
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"><applicationandroid:allowBackup="true"android:label="@string/app_name"android:icon="@mipmap/ic_launcher"android:roundIcon="@mipmap/ic_launcher"android:theme="@style/Theme.DynamicIcons"><!-- 主入口,不带 LAUNCHER --><activityandroid:name=".MainActivity"android:exported="true" /><!-- 别名:主图标(默认启用) --><activity-aliasandroid: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-aliasandroid: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) --><receiverandroid: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
(切换核心)
package com.wantime.dynamiciconsimport android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManagerobject IconSwitcher {/*** 启用 enableAlias,禁用 disableAlias。* alias 参数既可写 ".MainActivityAlias"(相对名),也可写完整类名。*/fun switchTo(context: Context, enableAlias: String, disableAlias: String) {val pm = context.packageManagerval 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
(广播触发轮换)
package com.wantime.dynamiciconsimport android.content.BroadcastReceiver
import android.content.Context
import android.content.Intentclass IconChangeReceiver : BroadcastReceiver() {override fun onReceive(context: Context?, intent: Intent?) {val ctx = context ?: returnif (intent?.action != ACTION_CHANGE_ICON) returnval 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:发广播/直接切)
package com.wantime.dynamiciconsimport 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.WindowInsetsCompatclass 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 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"><Buttonandroid: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" /><Buttonandroid: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" /><Buttonandroid:id="@+id/btnDirectB"android:layout_width="match_parent"android:layout_height="wrap_content"android:text="直接切到备用图标(AliasB)" />
</LinearLayout>
6)res/values/strings.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 触发换图标:把
receiver
的exported
改为false
,或给广播加签名权限。
五、调试清单(强烈建议照做一遍)
卸载旧包再安装,避免历史签名/包名残留。
运行后在 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
发广播测试(ADB):
adb shell am broadcast -a com.wantime.dynamicicons.ACTION_CHANGE_ICON
再看桌面图标是否轮换。
直接切:点 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 你即可在项目内快速落地,并避免“组件不存在”“依赖缺失”等常见错误。