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
<?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 触发换图标:把
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 你即可在项目内快速落地,并避免"组件不存在""依赖缺失"等常见错误。