做运营的朋友应该都有过这种冲动:版本大更新、节日活动、甚至某个赛事节点,都想第一时间把手机桌面上那个 App 图标换成对应主题,让玩家一点开桌面就看到活动入口。这个需求落到 Unity 手游上,就变成了一件需要同时打通 Android 和 iOS 两套系统机制的事。我在项目里完整落地过“动态更换 App 图标”的双端方案,过程中踩了不少坑,也把方案沉淀成了一套可复用的代码结构。这篇就把它拆开讲清楚:Android 怎么通过 activity-alias 切换,iOS 怎么用系统 API 切换,Unity 层怎么封装出统一的 C# 接口,以及真机测试时最容易翻车的几个点。
1. 方案总览:双端机制差异决定实现路径
1.1 需求到底长什么样
动态更换 App 图标,本质上不是一个“运行时换图片”的需求,而是一个“运行时替换桌面入口组件”的需求。你希望玩家在手机桌面上看到的那个图标,能根据运营配置变成不同的视觉版本,同时点击图标之后仍然能正常进入游戏主界面。
这句话听起来简单,但拆开之后包含三个点:图标资源必须提前内置、切换动作发生在运行时、切换后桌面上的入口要依然可用。这三个点直接决定了方案的技术选型。如果你告诉运营“图标可以任意换,从服务器拉一张图就行”,那这个需求就变味了,因为在 Android 和 iOS 的系统层面都不允许应用直接修改桌面上图标显示的自定义图片资源。App 图标必须存在于安装包内,系统才能安全地渲染和回退。
所以立项之前,一定要和运营对齐边界:动态更换不等于动态下载,能做到的是“在包内预置多套图标,运行时切到其中一套”。预置数量一般控制在 3 到 10 套以内,这样包体增量可控,原生层的配置工作量也可控。如果运营确实想要完全自定义的图,只能引导他们走“版本更新时替换图标”或者“下载图片后生成桌面快捷方式”这种旁路方案,而不是真正改掉 App 本身的 Launcher 图标。
1.2 为什么不能“一套代码通吃”双端
双端差异不是简单的 API 不同,而是系统机制完全不同。Android 的桌面图标本质上是 Launcher 解析到的“具有 MAIN 和 LAUNCHER 属性的组件入口”,它不一定是 MainActivity 本身,也可以是 activity-alias。而 PackageManager 允许你在运行时切换某个组件的 enabled 状态,这就给了我们操作空间:把每一套图标做成一个独立的 activity-alias,切换时把当前 alias 禁用、把目标 alias 启用,桌面图标自然就变了。
iOS 这边更简单粗暴,系统直接提供了setAlternateIconName:接口,专门用于切换备用图标。但它要求所有图标必须在 Info.plist 的CFBundleAlternateIcons中提前声明,并且图标文件要打包进 App bundle。系统切换时会自动弹确认框,用户同意后才生效。
两套机制各有各的脾气:Android 灵活但受 ROM 影响大,iOS 规范但有弹窗和审核限制。我做了一张表帮助理解差异:
| 对比项 | Android(activity-alias 方案) | iOS(setAlternateIconName 方案) |
|---|---|---|
| 系统版本要求 | Android 基本全版本可用 | 需要 iOS 10.3 及以上 |
| 是否需要弹窗 | 不需要,直接切换 | 系统弹窗确认 |
| 能否同时改应用名 | 可以,alias 可以带不同 label | 不能,只能改图标不能改名称 |
| 图标资源位置 | res/mipmap 或多密度目录 | App bundle 根目录 |
| 主要风险 | 国产 ROM 不刷新桌面 | Info.plist 配置错误导致回调失败 |
| 恢复默认图标 | 启用默认 alias | 传入 nil 恢复,但同样有系统弹窗 |
理解了这个差异,后面每一端的实现步骤就顺理成章了。
2. Android 端实现:activity-alias 与 PackageManager 切换
2.1 图标资源与 Manifest 配置
先说资源准备。Android 端每一套图标都需要放在 mipmap 目录下,常规做法是准备五套密度:mdpi、hdpi、xhdpi、xxhdpi、xxxhdpi,尺寸分别是 48、72、96、144、192 像素。如果你主工程用了 Adaptive Icon,还需要给每一套图标准备 foreground 和 background,并在mipmap-anydpi-v26下维护对应的 xml 文件,这个工作最好交给美术出图时一次性切好,不要运行时去拼。
接着是 Manifest。我的做法是让 MainActivity 本身不带 MAIN 和 LAUNCHER 的 intent-filter,把它做成一个纯粹的入口 Activity,再由多个 activity-alias 各自带图标和 MAIN/LAUNCHER。这样切换时只操作 alias,不会影响 MainActivity 的启动逻辑和任务栈恢复。
关键配置长这样:
<application> <activity android:name=".MainActivity" android:exported="true"> </activity> <activity-alias android:name=".Launcher_Default" android:targetActivity=".MainActivity" android:icon="@mipmap/ic_launcher_default" android:label="@string/app_name" android:enabled="true" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity-alias> <activity-alias android:name=".Launcher_Summer" android:targetActivity=".MainActivity" android:icon="@mipmap/ic_launcher_summer" android:label="@string/app_name_summer" android:enabled="false" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity-alias> </application>注意几个细节:targetSdk 31 以上的项目,Android 强制要求带 intent-filter 的组件必须显式声明android:exported,否则构建直接报错。activity-alias 的android:name在代码里是完整类名,如果当前包名是com.game.demo,那么.Launcher_Default对应的完整组件名就是com.game.demo.Launcher_Default,写切换代码时最好用完整的 ComponentName,避免包名拼接错误。
2.2 切换逻辑与桌面刷新
切换的核心代码其实非常短,本质就是把一套 alias 启用,把其余 alias 全部禁用。我在项目里封装了一个原生管理器,对外只暴露switchIcon(String key):
public class DynamicIconManager { public static void switchIcon(Context context, String key) { String pkg = context.getPackageName(); String targetAlias = mapKeyToAlias(key); // 当前启用的 alias 和全部 alias 列表 String[] allAliases = { pkg + ".Launcher_Default", pkg + ".Launcher_Summer", pkg + ".Launcher_Anniversary" }; PackageManager pm = context.getPackageManager(); for (String alias : allAliases) { int state = alias.equals(targetAlias) ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; ComponentName component = new ComponentName(pkg, alias); pm.setComponentEnabledSetting( component, state, PackageManager.DONT_KILL_APP ); } // 广播通知桌面刷新 Intent refresh = new Intent(Intent.ACTION_PACKAGE_CHANGED); refresh.setData(Uri.fromParts("package", pkg, null)); context.sendBroadcast(refresh); refresh.setPackage("android"); context.sendBroadcast(refresh); } }DONT_KILL_APP这个 flag 一定要带。setComponentEnabledSetting默认行为是修改组件状态后立即重启应用进程,如果漏掉这个 flag,Unity 游戏会在切换图标的瞬间直接闪退,玩家体验非常糟糕。另一个容易踩的点是:切换时不要只启用目标 alias 而忘记禁用当前的 alias,否则桌面上会出现两个应用图标,用户会一脸懵。
ACTION_PACKAGE_CHANGED广播是通知 Launcher 重新读取应用信息的常规手段,但我在实测中发现它的效果在原生 Android 系统上很稳定,在国产 ROM 上则要看各家 Launcher 的脸色。因此真正可靠的兜底是在Application.onCreate里做状态校正:从本地配置读取期望的 iconKey,再对比当前 PackageManager 中哪个 alias 处于 enabled 状态,如果不一致就重新调用一次切换逻辑。这样即使用户重启手机、Launcher 重新扫描,图标也会被纠正回运营配置的目标状态。
2.3 国产 ROM 的兼容坑
这部分我只能说“因机而异”,但大方向是可以总结的。小米的 MIUI/HyperOS 在收到组件状态变化后,一般能较快刷新桌面图标,但偶尔会出现图标变了、应用名没变的情况,需要等待一段时间或重启桌面。华为的 EMUI/HarmonyOS 表现不太稳定,部分版本在setComponentEnabledSetting之后完全不刷新,必须发送ACTION_PACKAGE_CHANGED或者让用户手动重启桌面。三星则比较接近原生,响应迅速,但也存在个别机型杀进程后把 alias 状态回滚的问题。
面对这些不确定性,我建议在交付测试时专门准备一份“国内主流 ROM 覆盖清单”,至少覆盖小米、华为、OPPO、vivo、荣耀、三星各一台机器。同时把“切换图标后应用被杀、重新打开时图标恢复”列为一个核心测试项,通过启动时校正逻辑兜住。这里多说一句:千万不要为了强制刷新而尝试调用ActivityManager.killBackgroundProcesses去杀桌面进程,这个操作既危险又容易被系统拦截,属于损人不利己的做法。
还有一个 Android 12 及以上版本的新问题:系统会把图标放进带主题的形状容器里。如果游戏的图标背景不是纯色,或者 foreground 区域没有留够安全边距,切换后会出现图标被裁切、四周出现奇怪留白的问题。所以出图时最好按 Adaptive Icon 的标准来,所有图标都统一提供 foreground 和 background 层。
3. iOS 端实现:系统 API 与 Info.plist 预声明
3.1 Info.plist 配置与图标资源落位
iOS 的切换流程从流程上看比 Android 简单,但配置不对照样跑不通。首先要做的就是在 Xcode 工程的 Info.plist 里声明可以切换的备用图标,格式如下:
<key>CFBundleIcons</key> <dict> <key>CFBundlePrimaryIcon</key> <dict> <key>CFBundleIconFiles</key> <array> <string>AppIcon</string> </array> </dict> <key>CFBundleAlternateIcons</key> <dict> <key>icon_summer</key> <dict> <key>CFBundleIconFiles</key> <array> <string>icon_summer</string> </array> </dict> <key>icon_anniversary</key> <dict> <key>CFBundleIconFiles</key> <array> <string>icon_anniversary</string> </array> </dict> </dict> </dict>CFBundleAlternateIcons字典里的 key 就是你在代码里传的图标标识,比如icon_summer。这个 key 必须和实际文件名对上,并且不包含扩展名。图标文件需要直接放在 App bundle 根目录,而不是放在 Assets.xcassets 里被 Asset Catalog 统一管理,否则 Info.plist 里的文件名无法被 SpringBoard 找到。
文件名命名也有讲究。系统会自动匹配@2x、@3x后缀,所以一套图标可以提供icon_summer.png、icon_summer@2x.png、icon_summer@3x.png三个文件,尺寸分别对应 60、120、180 像素。对于 Unity 工程来说,把这些图标文件放到Assets/Plugins/iOS/目录下,构建时就会自动拷贝进 Xcode 工程并进入 bundle 根目录,非常省事。
3.2 调用系统 API 的完整流程
iOS 原生层的调用核心就是UIApplication的setAlternateIconName:completionHandler:。我习惯用 Objective-C 写一个.mm桥接文件,通过 C 函数暴露给 Unity:
#import <UIKit/UIKit.h> void _switchAppIcon(const char *iconName) { if (@available(iOS 10.3, *)) { NSString *name = iconName ? [NSString stringWithUTF8String:iconName] : nil; if (![[UIApplication sharedApplication] supportsAlternateIcons]) { return; } NSString *currentName = [[UIApplication sharedApplication] alternateIconName]; if ((currentName == nil && name == nil) || (currentName != nil && name != nil && [currentName isEqualToString:name])) { // 目标图标和当前图标相同,无需切换 return; } [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(@"setAlternateIconName error: %@", error.localizedDescription); } }]; } }这段代码有两个细节值得展开。第一,setAlternateIconName传入nil表示恢复默认图标,但调用后同样会触发系统弹窗。第二,调用之前最好比较一下当前的alternateIconName,如果目标就是当前图标,系统回调会返回一个错误码,虽然不影响稳定性,但会让日志里多出一些误导信息。
还有一个冷知识:setAlternateIconName并不强制要求主线程,但 Unity 的 C# 调用通常已经发生在主线程,所以不用特别处理。如果通过某些后台线程回调再调用,建议先跳到主线程,避免 UIKit 层面的潜在并发问题。
3.3 系统确认弹窗与审核注意项
iOS 端切图标会有一个系统确认流程:调用 API 后,SpringBoard 会弹出一个对话框,询问用户是否确认更换主屏幕图标,用户点击“确认”之后图标才会真正变化。这意味着切图标这个操作不是 100% 可控的,如果用户点了取消,completion handler 会收到一个非空的 NSError,运营侧的数据统计要能区分“成功切换”“用户取消”和“系统错误”三种结果。
我建议的统计方案是:在 completion handler 里判断 error 是否为空,为空则埋点切图成功,不为空则上报 error 的 code 和 domain。不过要提醒一点,这个 error 有时是用户取消,有时是系统内部问题,字符串描述并不总是清晰,所以不要把错误文案直接展示给玩家看,更不要在弹窗里提示“切换失败请重试”之类的话。
审核方面,苹果并没有禁止动态切换图标,但要求备用图标也必须符合 App Store 的图标规范。如果某个备用图标里包含明显的第三方品牌 Logo、误导性文案或敏感元素,审核风险会提高。另外,频繁地让系统弹窗会明显影响用户体验,App Store 审核人员如果看到你的 App 在启动后立即弹窗换图标,有可能会以“影响用户体验”的理由拒绝。我的建议是:默认图标保持不变,只有运营活动真正开始时才触发切换,并且每次切换之间至少间隔一天以上。
4. Unity 桥接封装:从 C# 到原生层
4.1 原生层接口设计
既然要做成双端方案,原生层就不能只写一个零散的工具类,而是要设计成结构一致、按平台实现的模块。Android 侧我用了一个简单的 Java 类DynamicIconManager,方法签名固定为switchIcon(Context ctx, String key)。这个类可以放在两种位置:一是打成 AAR 放进Assets/Plugins/Android/,二是直接把源码放在该目录下由 Unity 编译。考虑到项目后续可能还有别的原生需求,我倾向于打成 AAR,便于统一版本管理。
iOS 侧就是一个.mm桥接文件,导出_switchAppIcon(const char *iconName)函数。这里提一个 Unity 相关的细节:.mm文件会自动被 Unity 识别并纳入 Xcode 工程,但如果你的代码里引用了其他 Objective-C 类,也需要一并放到Assets/Plugins/iOS/目录下,否则链接时会报错。
4.2 C# 调用层与构建配置
Unity C# 侧的核心任务是把双端差异封装掉,让业务层只面对一个方法。我提供一个比较简洁的示例:
public static class AppIconManager { public enum AppIconKey { Default, Summer, Anniversary } public static bool IsSupported() { #if UNITY_ANDROID && !UNITY_EDITOR return true; #elif UNITY_IOS && !UNITY_EDITOR return true; #else return false; #endif } public static void SwitchTo(AppIconKey key) { if (!IsSupported()) { Debug.LogWarning("Current platform does not support dynamic app icon."); return; } string iconKey = key.ToString().ToLower(); string androidAlias = AndroidAliasFor(key); string iosIconName = IOSIconNameFor(key); #if UNITY_ANDROID && !UNITY_EDITOR using (var manager = new AndroidJavaClass("com.game.native.DynamicIconManager")) { manager.CallStatic("switchIcon", androidAlias); } #elif UNITY_IOS && !UNITY_EDITOR _switchAppIcon(iosIconName); #endif } #if UNITY_IOS && !UNITY_EDITOR [System.Runtime.InteropServices.DllImport("__Internal")] private static extern void _switchAppIcon(string iconName); #endif }业务层调用时只需要AppIconManager.SwitchTo(AppIconManager.AppIconKey.Summer),运营配置下发后也只是把这个枚举映射成服务端下发的字符串字段,逻辑非常收敛。
这里要特别强调 Android 的调用线程问题。AndroidJavaClass和AndroidJavaObject的CallStatic方法如果在子线程执行,Unity 有时会直接抛异常,或者出现线程调度上的随机问题。最稳妥的做法是确保所有原生调用都发生在 Unity 主线程。如果业务层存在异步逻辑,建议在SynchronizationContext上回调主线程再发起切换。
构建配置方面,Android 端需要在 Unity 的Assets/Plugins/Android/AndroidManifest.xml里维护 activity-alias 声明。Unity 构建时会把这个 Manifest 和 AAR 里的 Manifest 合并,如果你使用自定义 Gradle 模板,还需要确认包名一致、没有重复声明冲突。iOS 端则要留意 Info.plist 的维护方式。Unity 提供了iOS Player Settings的Info.plist配置,也可以使用PostProcessBuildAttribute在构建后修改生成的Info.plist,两种方式都可以,但都需要确保CFBundleAlternateIcons不被 Xcode 工程默认配置覆盖。
4.3 与热更、运营系统的协作
一旦 Unity 侧有了统一的AppIconManager,后续的工作就变成运营侧的管理了。通常服务端会下发一个 iconKey,客户端收到后先判断本地是否支持,再调用SwitchTo。这里有几个容易踩坑的协作问题。
第一,图标资源必须内置于安装包,不能通过 AssetBundle 下载后再替换。热更系统无法修改系统层面的 icon 映射,即使你从服务器下载了一张新图放到沙盒里,Launcher 也不可能去读取沙盒文件。这个约束必须提前同步给运营和策划。
第二,iconKey 的映射不宜在 C# 里写死太多逻辑。更好的做法是维护一张配置表:服务端下发iconKey,客户端查找本地配置表得到 Android alias 名和 iOS 图标名。当新增一套图标时,只需要更新原生配置和配置表,不需要改 C# 枚举。否则每次加活动图标都要发版,动态切换的意义就少了一半。
第三,切换结果需要做数据回传。Android 端可以同步判断PackageManager的状态,iOS 端只能通过 completionHandler 异步获取结果。运营埋点时要区分平台,iOS 的“用户取消”不能算作失败,只能算作未生效。
5. 常见问题与排查技巧实录
5.1 图标不生效的常规检查清单
我整理了一个快速排查表,每次测试遇到图标没变的情况,按这个顺序检查能省下不少时间:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 调用了接口但图标完全没变 | 原生方法没调用到,或 alias 名称拼写错误 | 检查 C# 层的平台宏和原生代码日志 |
| Android 图标变了,但应用名没变 | Launcher 缓存了旧 label | 等待一段时间或重启桌面 |
| Android 桌面出现两个图标 | 之前的 alias 没有禁用 | 检查切换逻辑是否遍历了所有 alias |
| iOS 图标没变且无弹窗 | supportsAlternateIcons返回 NO,或 Info.plist 配置错误 | 检查系统版本和 plist 字典结构 |
| iOS 有弹窗但点确认后图标没变 | 备用图标文件名与 plist 不一致 | 检查 bundle 根目录文件名及大小写 |
5.2 桌面图标消失与双图标的处理
我在测试过程中最紧张的一次是:切换后桌面上整个应用图标消失了,任何入口都找不到,连设置里的“打开”按钮都变灰了。后来定位是切换代码里把所有 alias 都禁用了,却没有启用目标 alias,导致系统认为这个包没有可用的 launcher 组件。这属于逻辑 bug,但暴露出的问题是:动态切换功能的“最低安全线”是必须保证至少一个 alias 处于 enabled 状态。
避免这个问题的办法很简单:切换函数里先启用目标 alias,再禁用当前 alias,并且在整个循环结束后检查一下PackageManager的 state。如果发现没有一个 alias 是 enabled 的,就立刻回滚并返回错误码。这种防御性写法对玩家而言可能一辈子都用不到,但真出现问题时,至少不会让应用从桌面上彻底消失。
对于“杀进程后图标被 ROM 回滚”的问题,我采用的办法是:在 Android 的Application.onCreate里读取本地保存的 iconKey,然后调用切换逻辑。虽然这不能完全避免“切换后立刻被系统纠正”的极端情况,但至少能保证应用每次启动都会被修正到运营期望的状态。实测下来,这种“启动校正”模式在 MIUI 和 HarmonyOS 上都能有效兜底。
5.3 iOS 端回调错误的定位方法
iOS 的setAlternateIconName回调 error 信息在早期版本里比较模糊,尤其是当你传了一个 Info.plist 里不存在的 key 时,错误信息只会说“invalid icon name”,并不会告诉你当前有哪些可用的 key。我的排查方法是:写一个调试接口,在 Test 模式下调用[[UIApplication sharedApplication] alternateIconName]打印当前图标名,再检查 plist 里声明的 key 是否和传入名字完全一致。
不要忘记大小写和空格。iOS 的文件名匹配对空格敏感,如果产品同学在 plist 里写成icon_summer(末尾多一个空格),代码里传icon_summer,系统会永远找不到图标。这个坑我在联调时踩过一次,最后通过逐字符比对才查出来,很浪费时间。
另外,iOS 14 之后系统对图标切换增加了一些视觉动画,切图后图标会有一个缩放回弹效果。千万不要把系统动画误判成 bug,只要最终状态正确就是正常的。
5.4 真机调试的小技巧
Android 端调试时,我推荐用 adb 命令直接操作组件状态,比反复改代码快得多:
# 查看当前可用的 launcher 组件 adb shell cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER com.game.demo # 手动启用目标 alias adb shell pm enable com.game.demo/com.game.demo.Launcher_Summer # 手动禁用默认 alias adb shell pm disable com.game.demo/com.game.demo.Launcher_DefaultiOS 端调试没有命令行方式,只能通过代码触发。我一般会在游戏里加一个调试面板,列出所有已内置的 iconKey,点一下就调用切换,这样在真机上测试时能非常直观地验证每一步。
上线前的双端测试清单里,除了常规的功能验证,还要覆盖:低电量模式、飞行模式、切换后立即杀进程再冷启动、切换后进入后台再返回桌面、以及系统语言切换。图标在本地化上的表现也要检查,尤其是 Android 上带不同 label 的 alias,切换后应用名会跟着变,这和 iOS 的行为不一致,需要提前和运营说明。
我个人在实际项目里最感慨的一点是:动态更换 App 图标的技术难度并不高,真正的难点在于“平台差异的预判”和“运营预期的管理”。如果你能在项目初期就把 Android 的 ROM 兼容问题和 iOS 的弹窗确认机制讲清楚,后续的联调会顺畅很多。这个功能做完之后,后续还可以扩展的方向包括:通过深度链接触发切换、根据服务器状态下发 iconKey、以及在特殊节点自动恢复默认图标。不过扩展之前,一定先把“预置图标 + 启动校正 + 结果回传”这三件套底座打稳,后面加多少套图标都只是配置层面的工作量了。