动态更换 App 图标这件事,说穿了就是把"图标资源"从编译期固化变成运行期可切换。听起来简单,但真到 Unity 项目里落地,Android 和 iOS 两端的实现路径完全不同,坑也完全不在一个维度上。Android 靠activity-alias做组件级别的开关,iOS 则依赖系统提供的setAlternateIconName接口,而 Unity 作为跨平台引擎,既没有现成的统一 API,又要在 IL2CPP 和 Mono 两种后端下都能跑通。我在两个上线项目里分别踩过 Android 的activity-alias被厂商 ROM 拦截、iOS 的图标切换弹窗被审核质疑这些坑,下面把整套方案从原理到代码完整拆一遍,适合已经有 Unity 基础、正在做运营活动或节日换肤需求的同学参考。
1. 先搞清楚两端图标切换的底层机制差异
很多人一上来就找"Unity 换图标插件",结果发现要么只支持一端,要么在真机上直接崩。根本原因在于 Android 和 iOS 对"应用图标"这个概念的管理方式从设计哲学上就不一样,不理解这一点,后面所有代码都是碰运气。
1.1 Android 的 activity-alias 到底在做什么
Android 里一个应用可以声明多个activity-alias,每个 alias 指向同一个入口 Activity,但可以拥有独立的android:icon、android:label和android:enabled状态。系统桌面(Launcher)读取的是当前处于 enabled 状态的那个 alias 的图标信息。所以"换图标"的本质是:禁用当前 alias,启用目标 alias,然后通知 Launcher 刷新。
这里有个关键点容易被忽略:activity-alias的android:enabled属性在AndroidManifest.xml里是静态声明的,运行时修改必须通过PackageManager.setComponentEnabledSetting()。而且这个操作是异步生效的,部分 Launcher 不会立即刷新,需要配合ACTION_MAIN+CATEGORY_LAUNCHER的广播或者直接杀掉 Launcher 进程(不推荐)来触发重绘。
<!-- AndroidManifest.xml 中的 alias 声明示例 --> <activity-alias android:name=".icon_default" android:enabled="true" android:exported="true" android:icon="@mipmap/ic_launcher_default" android:label="@string/app_name" android:targetActivity=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity-alias> <activity-alias android:name=".icon_festival" android:enabled="false" android:exported="true" android:icon="@mipmap/ic_launcher_festival" android:label="@string/app_name" android:targetActivity=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity-alias>注意targetActivity必须指向真实的入口 Activity,且这个 Activity 本身不要再声明LAUNCHER的 intent-filter,否则会出现两个图标。这是新手最容易犯的错——原来的MainActivity里那个LAUNCHER声明必须删掉,全部交给 alias 来承载。
1.2 iOS 的 setAlternateIconName 限制在哪
iOS 10.3 之后开放了UIApplication.shared.setAlternateIconName(_:completionHandler:),允许在运行时切换图标。但它的限制比 Android 多得多:
- 所有备选图标必须在
Info.plist的CFBundleIcons→CFBundleAlternateIcons中预先声明,不能动态添加。 - 图标文件必须打包进主 Bundle,不能从网络下载后直接使用(除非你走 Asset Catalog 的按需资源,但图标不支持)。
- 切换时会弹出系统级提示框"您已更改'XX'的图标",这个提示无法去除,只能通过一些取巧方式(比如在切换瞬间弹一个自己的 UI 覆盖)来弱化,但审核有风险。
- 图标必须是完整的正方形 PNG,不能有圆角、不能有透明通道(否则系统会自动加黑底),尺寸建议 180x180、120x120、87x87 等多套。
Info.plist的配置结构长这样:
<key>CFBundleIcons</key> <dict> <key>CFBundlePrimaryIcon</key> <dict> <key>CFBundleIconFiles</key> <array> <string>AppIcon60x60</string> </array> </dict> <key>CFBundleAlternateIcons</key> <dict> <key>festival</key> <dict> <key>CFBundleIconFiles</key> <array> <string>icon_festival_60x60</string> <string>icon_festival_120x120</string> </array> <key>UIPrerenderedIcon</key> <false/> </dict> </dict> </dict>这里的 key(比如festival)就是后面代码里传给setAlternateIconName的名字。传nil表示恢复主图标。
1.3 Unity 层为什么不能直接调
Unity 的 C# 层没有暴露任何图标切换 API,因为图标属于平台原生概念,Unity 的 Player 构建流程只负责把图标打进包体,不负责运行时管理。所以必须走平台原生代码 + C# 桥接的路线:Android 用AndroidJavaObject调PackageManager,iOS 用DllImport调 Objective-C 的运行时函数。
两端桥接的代码风格差异很大,Android 侧是 JNI 调用,iOS 侧是 P/Invoke,这也是为什么很多"一套代码通吃"的插件实际上内部是两套完全独立的实现。
2. Android 侧:从 Manifest 配置到运行时切换的完整链路
Android 这块的坑主要集中在 Manifest 配置和厂商 ROM 兼容性上。我见过太多项目在模拟器上跑得好好的,一到真机(尤其是某些定制系统)就失效。
2.1 Manifest 配置的三个致命细节
第一个细节是android:enabled的初始状态。默认图标对应的 alias 必须enabled="true",其他全部false。如果你把所有 alias 都设成true,桌面上会出现多个图标,用户直接卸载。
第二个细节是 alias 的android:name命名。建议用包名全路径,比如com.yourcompany.yourgame.icon_festival,不要用相对路径.icon_festival,因为某些构建工具在处理相对路径时会有歧义,尤其是在多渠道打包(比如接入某些 SDK 后自动改包名)的场景下。
第三个细节是android:exported。Android 12(API 31)之后,所有带 intent-filter 的组件必须显式声明android:exported,alias 也不例外。设成true是安全的,因为它需要被 Launcher 外部调用。
注意:如果你的项目用了自定义的
AndroidManifest.xml模板(在Assets/Plugins/Android/下),要确保 alias 声明在<application>标签内,且不要被其他构建脚本覆盖。Unity 的 Manifest 合并机制有时会保留旧版本,建议每次改完都反编译 APK 确认。
2.2 运行时切换的 Java 代码实现
在Assets/Plugins/Android/下建一个 Java 文件,或者直接用 C# 的AndroidJavaObject写。我倾向于写一个独立的 Java 工具类,因为逻辑比较长,放在 C# 里可读性差。
package com.yourcompany.iconutil; import android.content.ComponentName; import android.content.Context; import android.content.pm.PackageManager; public class IconChanger { public static void changeIcon(Context context, String aliasName) { PackageManager pm = context.getPackageManager(); String packageName = context.getPackageName(); // 先禁用所有 alias String[] allAliases = { packageName + ".icon_default", packageName + ".icon_festival", packageName + ".icon_holiday" }; for (String alias : allAliases) { pm.setComponentEnabledSetting( new ComponentName(packageName, alias), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ); } // 启用目标 alias pm.setComponentEnabledSetting( new ComponentName(packageName, packageName + "." + aliasName), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ); } }DONT_KILL_APP这个 flag 很关键。如果不加,系统会在切换组件状态时杀掉应用进程,用户体验极差。加上之后进程保留,但图标刷新可能延迟,需要额外处理。
2.3 图标刷新延迟与 Launcher 兼容处理
即使加了DONT_KILL_APP,很多 Launcher 也不会立即刷新图标。实测下来,小米、华为、OPPO 的部分机型需要几秒到几十秒才刷新,三星和原生 Android 基本是秒刷。解决方案有两个:
一是发送一个自定义广播,部分 Launcher 会监听com.android.launcher.action.INSTALL_SHORTCUT之类的动作,但这个不是标准接口,兼容性有限。二是更暴力的做法:切换完成后延迟 500ms 再调用一次pm.setComponentEnabledSetting,把目标 alias 先禁用再启用,强制触发一次状态变更通知。这个技巧在多个项目里验证有效,但要注意不要频繁调用,否则可能触发系统的组件状态异常。
// C# 侧调用示例 public static void SwitchIcon(string aliasName) { using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) using (var iconChanger = new AndroidJavaClass("com.yourcompany.iconutil.IconChanger")) { iconChanger.CallStatic("changeIcon", activity, aliasName); } }调用时机建议放在游戏启动后的 Loading 阶段或者设置界面里,不要在Awake里立刻调,因为此时 Activity 可能还没完全初始化。
3. iOS 侧:Info.plist 预声明与原生桥接的实操细节
iOS 这块的复杂度不在代码量,而在于资源准备和审核合规。代码本身很短,但配置错了就是白屏或者崩溃。
3.1 备选图标的资源规范与打包方式
每个备选图标需要准备多套尺寸,至少包括 60x60@2x(120x120)和 60x60@3x(180x180),iPad 还需要 76x76 和 83.5x83.5 系列。文件命名建议统一前缀,比如icon_festival_120x120.png、icon_festival_180x180.png,放在Assets/Plugins/iOS/下,Unity 构建时会自动拷贝到 Xcode 工程的根目录。
这里有个大坑:图标文件不能放在 Asset Catalog 里,必须作为独立文件放在 Bundle 根目录,然后在Info.plist里用文件名(不带扩展名)引用。如果你放在.xcassets里,setAlternateIconName会找不到资源,直接报错。
另外,图标不能有 alpha 通道。iOS 对备选图标的 alpha 检查比主图标更严格,带透明的 PNG 会导致切换后图标显示为黑底或者直接失败。用 Photoshop 或 ImageMagick 批量去掉 alpha 通道:
# 用 ImageMagick 批量去除 alpha 通道 for f in icon_festival_*.png; do convert "$f" -background black -alpha remove -alpha off "$f" done3.2 Objective-C 桥接代码与 C# 调用
在Assets/Plugins/iOS/下建一个.mm文件(用 Objective-C++ 以便和 Unity 的 C++ 接口兼容):
// IconChanger.mm #import <UIKit/UIKit.h> extern "C" { void _ChangeIcon(const char* iconName) { NSString *name = [NSString stringWithUTF8String:iconName]; if ([name isEqualToString:@""]) { name = nil; // 传空字符串表示恢复主图标 } dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError *error) { if (error) { NSLog(@"Change icon failed: %@", error.localizedDescription); } }]; }); } }C# 侧用DllImport声明:
#if UNITY_IOS && !UNITY_EDITOR using System.Runtime.InteropServices; public static class iOSIconChanger { [DllImport("__Internal")] private static extern void _ChangeIcon(string iconName); public static void SwitchIcon(string iconName) { _ChangeIcon(iconName ?? ""); } } #endif注意dispatch_async到主线程这一步不能省,setAlternateIconName必须在主线程调用,否则行为未定义。
3.3 系统弹窗的规避思路与审核风险
iOS 切换图标时那个"您已更改图标"的弹窗是系统强制的,无法通过公开 API 去除。市面上有些做法是在调用setAlternateIconName的瞬间弹一个自己的全屏 UI,把系统弹窗盖住,等切换完成后再关掉。这个做法在技术上可行,但审核时如果被人工发现,可能被判定为"误导用户"或"绕过系统提示",有被拒风险。
我的建议是:如果只是节日活动换图标,直接接受这个弹窗,在 UI 上提前告知用户"切换后系统会弹出提示,点击确认即可"。如果是付费换图标(比如用户花钻石买一个图标),那更要在购买前明确说明会有系统弹窗,避免用户以为没生效而重复购买。
4. Unity 层的统一封装与状态持久化
两端原生代码写完之后,Unity 层需要一个统一的入口,同时要处理状态记录、异常兜底和编辑器下的模拟。
4.1 跨平台接口设计与条件编译
public static class AppIconManager { public static void SetIcon(string iconKey) { #if UNITY_ANDROID && !UNITY_EDITOR AndroidIconChanger.SwitchIcon("icon_" + iconKey); #elif UNITY_IOS && !UNITY_EDITOR iOSIconChanger.SwitchIcon(iconKey == "default" ? "" : iconKey); #else Debug.Log($"[Editor] Switch icon to: {iconKey}"); #endif PlayerPrefs.SetString("CurrentAppIcon", iconKey); PlayerPrefs.Save(); } public static string GetCurrentIcon() { return PlayerPrefs.GetString("CurrentAppIcon", "default"); } }iconKey用语义化的名字,比如default、festival、holiday,Android 侧拼成icon_festival,iOS 侧直接用festival。这样两端的命名规则解耦,后期加图标只需要改映射表。
4.2 状态记录与异常兜底
状态记录用PlayerPrefs就够了,但要注意:Android 上如果用户在系统设置里清了应用数据,PlayerPrefs会丢失,但 alias 的 enabled 状态是存在系统里的,不会丢。这就会导致状态不一致——游戏以为还是默认图标,实际系统里已经是节日图标了。
解决办法是在启动时做一次校验:Android 侧读取当前 enabled 的 alias,反推当前图标 key,和PlayerPrefs对比,不一致就以系统为准。iOS 侧更简单,直接读UIApplication.shared.alternateIconName,为nil就是默认图标。
// Android 侧读取当前 alias 状态 public static string GetCurrentAlias() { using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) using (var pm = activity.Call<AndroidJavaObject>("getPackageManager")) { string packageName = activity.Call<string>("getPackageName"); string[] aliases = { "icon_default", "icon_festival", "icon_holiday" }; foreach (var alias in aliases) { var component = new AndroidJavaObject("android.content.ComponentName", packageName, packageName + "." + alias); int state = pm.Call<int>("getComponentEnabledSetting", component); if (state == 1) return alias; // COMPONENT_ENABLED_STATE_ENABLED } } return "icon_default"; }异常兜底方面,Android 的setComponentEnabledSetting在某些 ROM 上会抛SecurityException,必须 try-catch 包住,失败时回退到默认图标并给用户一个提示。iOS 的setAlternateIconName失败会走 completionHandler 的 error,同样要处理。
4.3 编辑器下的模拟与真机测试清单
编辑器下没法真正换图标,但可以在 Game 视图里用一个 UI 元素模拟图标预览,方便策划确认效果。真机测试必须覆盖以下场景:
| 测试项 | Android | iOS |
|---|---|---|
| 首次安装默认图标 | 确认只有一个图标 | 确认主图标正确 |
| 切换到备选图标 | 确认桌面图标变化 | 确认弹窗后图标变化 |
| 切回默认图标 | 确认恢复 | 确认恢复且无弹窗异常 |
| 杀进程后重启 | 确认图标保持 | 确认图标保持 |
| 清除应用数据 | 确认状态校验生效 | 不适用 |
| 覆盖安装新版本 | 确认 alias 状态不丢 | 确认 Info.plist 合并正确 |
覆盖安装这一项特别容易出问题。Android 覆盖安装时,如果新版本的 Manifest 里 alias 声明有变化(比如删掉了某个 alias),系统可能会重置 enabled 状态。iOS 覆盖安装时,如果Info.plist里的备选图标列表变了,已切换的图标可能会失效变回默认。所以每次发版前都要回归测试这一项。
5. 那些文档里不会写的踩坑记录
这部分是我在两个项目里实际踩过的坑,按严重程度排序。
5.1 Android 厂商 ROM 的 alias 拦截
某些定制 ROM 会对setComponentEnabledSetting做限制,尤其是当 alias 的android:exported="true"且带LAUNCHERintent-filter 时,系统可能认为这是"创建快捷方式"行为而拦截。表现是代码执行成功(没抛异常),但桌面图标不变。排查方法是打印getComponentEnabledSetting的返回值,确认状态确实变了,如果变了但图标没变,那就是 Launcher 没刷新,不是权限问题。
应对策略是准备一个降级方案:如果检测到切换后 3 秒内图标未刷新(通过读取当前 alias 状态判断),提示用户"请手动重启桌面或稍等片刻"。虽然体验不好,但比默默失败强。
5.2 iOS 备选图标在 Xcode 工程中的路径问题
Unity 构建出的 Xcode 工程,Assets/Plugins/iOS/下的文件会被拷贝到Libraries/或者根目录,具体位置取决于 Unity 版本。如果Info.plist里引用的图标文件名和实际拷贝后的路径不匹配,切换就会失败。最稳妥的做法是在 Xcode 工程里手动确认图标文件的位置,然后在Info.plist里用相对路径引用。如果用了 PostProcessBuild 脚本自动改Info.plist,一定要在脚本里同时处理图标文件的拷贝,不要只改 plist。
5.3 图标切换与热更新/资源加载的冲突
如果项目用了热更新框架(比如 HybridCLR 或 ILRuntime),图标切换的代码如果放在热更程序集里,Android 侧的 JNI 调用可能因为程序集卸载而失效。建议把图标切换的原生桥接代码放在主工程程序集(AOT 部分),热更层只负责调用接口。iOS 侧的DllImport同理,必须放在 AOT 程序集。
5.4 审核被拒的真实案例
有个项目在 iOS 审核时被拒,理由是"应用图标在未告知用户的情况下发生变化"。后来我们改成:切换图标前弹一个确认框,明确写"切换后系统会提示图标已更改,这是正常现象",并在设置界面里提供"恢复默认图标"的入口。改完之后顺利过审。所以合规的关键不是技术,而是用户知情和可逆。
6. 性能、包体与运营策略的权衡
换图标这个功能本身性能开销极小,但它对包体和运营策略的影响不小。
6.1 图标资源对包体的影响
每个备选图标在 Android 上需要准备 5 套密度(mdpi 到 xxxhdpi),iOS 上需要 3 套(@1x 到 @3x),如果做 5 个备选图标,光图标资源就可能增加 2-3MB。对于包体敏感的项目,建议:
- Android 侧只保留 xhdpi 和 xxhdpi 两套,低密度设备让系统缩放。
- iOS 侧只保留 @2x 和 @3x,@1x 设备已经很少了。
- 图标用 PNG-8 而不是 PNG-24,颜色数控制在 256 以内,体积能减半。
6.2 运营节奏与图标切换时机
图标切换最好和运营活动绑定,比如春节、周年庆、联动活动。切换时机建议放在活动开始前 1-2 天,通过服务端下发开关控制,避免客户端发版。但要注意:Android 的 alias 是静态声明的,新增图标必须发版,所以运营侧要提前规划好未来半年的图标需求,一次性把 alias 都声明进去,用服务端开关控制启用哪个。
iOS 同理,Info.plist里的备选图标列表也是静态的,新增必须发版。所以两端都要"预留坑位",比如一次性声明 8 个 alias,实际只用其中几个。
6.3 用户留存与图标切换的关系
从数据上看,主动切换图标的用户占比通常不到 5%,但这部分用户的留存和付费率明显高于平均值。所以这个功能的价值不在于覆盖率,而在于给核心用户提供"个性化"的满足感。建议把切换入口放在设置界面的显眼位置,配合活动赠送"限定图标"来提升参与度。
最后分享一个实操中的小技巧:Android 侧切换图标后,如果想让 Launcher 尽快刷新,可以在切换完成后调用一次activity.finish()再重启 Activity,虽然会闪一下,但刷新成功率接近 100%。这个做法在部分 ROM 上比延迟重试更可靠,代价是用户体验有轻微中断,适合在设置界面里由用户主动触发切换时使用。