手游上线后想换个图标做活动,结果发现应用商店的图标是打包时写死的,改一次就得重新提审、重新发版,等审核通过活动热度都过了。这个痛点做发行的朋友应该都懂。动态换图标这个需求,最早是iOS端先火起来的,后来Android端也跟上了,现在不少头部产品在节日、周年庆、渠道联运的时候都会用这套方案。我最近刚好在一个Unity项目里把双端都跑通了,踩了不少坑,这里把完整方案和排查过程整理出来,给需要的人一个可以直接抄的参考。
这篇内容适合有Unity基础、做过Android或iOS打包的开发者,也适合想了解双端原生能力差异的技术负责人。核心会围绕Unity工程如何调用Android的activity-alias机制、iOS的setAlternateIconName接口,以及两端在工程配置、权限、审核上的差异展开。读完你应该能自己动手把动态图标接进项目,并且知道哪些地方容易翻车。
1. 先搞清楚动态图标到底改的是什么
很多人一上来就问"Unity里怎么换图标",其实这个问题问偏了。Unity本身不提供换图标的能力,它只是个跨平台引擎,真正干活的是两端的原生系统接口。所以正确的思路是:Unity负责触发时机和参数传递,原生层负责执行替换。理解这一点,后面的方案才不会走歪。
1.1 Android端改的是"组件别名"而不是图标文件
Android的动态图标机制,本质上是利用<activity-alias>这个清单文件标签。系统桌面启动器在渲染图标时,读的是当前被启用的那个activity-alias的icon和label属性。你预先在AndroidManifest.xml里声明多个别名,每个别名指向同一个主Activity,但配置不同的图标资源。运行时通过PackageManager.setComponentEnabledSetting()启用其中一个、禁用其余,桌面图标就会跟着变。
这里有个关键点:主Activity本身必须保持启用状态,被切换的只是别名。如果你把主Activity也禁用了,应用会直接崩溃或者无法启动。我见过有人图省事把主Activity的icon也改了,结果切换后点图标进不去,排查了半天。
<activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> </intent-filter> </activity> <activity-alias android:name=".IconDefault" android:targetActivity=".MainActivity" android:enabled="true" android:exported="true" android:icon="@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=".IconFestival" android:targetActivity=".MainActivity" android:enabled="false" android:exported="true" android:icon="@mipmap/ic_launcher_festival" 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>注意每个别名都要带完整的intent-filter,否则桌面找不到入口。另外android:enabled初始值只能有一个是true,其余都是false,这是硬性约束。
1.2 iOS端改的是"备用图标"集合
iOS这边走的是完全不同的路子。从iOS 10.3开始,系统提供了setAlternateIconName:接口,允许应用在运行时切换图标。但前提是你必须在Info.plist里用CFBundleAlternateIcons字典预先声明所有备用图标,每个图标对应一个名字和图标文件。
和Android不同的是,iOS的备用图标必须是打包进bundle的资源,不能运行时下载替换。而且图标文件有严格的尺寸和格式要求,必须是PNG,建议提供60x60、120x120、180x180等多个尺寸,否则在某些设备上会显示模糊或者不显示。
<key>CFBundleIcons</key> <dict> <key>CFBundlePrimaryIcon</key> <dict> <key>CFBundleIconFiles</key> <array> <string>AppIcon60x60</string> </array> </dict> <key>CFBundleAlternateIcons</key> <dict> <key>FestivalIcon</key> <dict> <key>CFBundleIconFiles</key> <array> <string>FestivalIcon60x60</string> </array> <key>UIPrerenderedIcon</key> <false/> </dict> </dict> </dict>FestivalIcon这个名字就是后面代码里要传的参数,必须和plist里的key完全一致,大小写敏感。
1.3 两端机制差异带来的设计约束
把两端放在一起看,你会发现它们的约束条件完全不同,这直接决定了你的产品方案能做成什么样。
| 对比维度 | Android | iOS |
|---|---|---|
| 图标来源 | 打包内置资源 | 打包内置资源 |
| 声明方式 | AndroidManifest的activity-alias | Info.plist的CFBundleAlternateIcons |
| 切换接口 | setComponentEnabledSetting | setAlternateIconName |
| 数量限制 | 理论上无硬限制 | 系统建议不超过10个 |
| 切换生效 | 立即生效,桌面刷新 | 立即生效,可能弹系统提示 |
| 审核风险 | 较低 | 需说明用途,可能被问询 |
最要命的是iOS那个系统提示。调用setAlternateIconName时,系统会弹一个"您已更改'XX'的图标"的alert,这个提示无法通过公开API屏蔽。网上有些取巧的办法,但都有被拒风险,我不建议用。产品设计时要把这个提示考虑进去,最好在用户主动点击"切换图标"后再触发,而不是App启动时偷偷换。
2. Unity工程侧的统一调用层怎么搭
两端原生接口不一样,如果每个业务代码都去写#if UNITY_ANDROID和#if UNITY_IOS,维护起来会疯掉。我的做法是在Unity层封装一个统一接口,业务只调一个方法,平台差异全部藏在底层。
2.1 定义C#侧的接口和平台分发
先定义一个静态类作为入口,参数就用字符串标识图标名,两端各自映射。
public static class AppIconChanger { public static void ChangeIcon(string iconKey) { #if UNITY_ANDROID && !UNITY_EDITOR using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) using (var iconUtil = new AndroidJavaClass("com.yourgame.IconUtil")) { iconUtil.CallStatic("changeIcon", activity, iconKey); } #elif UNITY_IOS && !UNITY_EDITOR _ChangeIconiOS(iconKey); #else Debug.Log($"[Editor] 模拟切换图标: {iconKey}"); #endif } [System.Runtime.InteropServices.DllImport("__Internal")] private static extern void _ChangeIconiOS(string iconName); }这里有个细节:iconKey在Android端对应的是activity-alias的完整类名(比如com.yourgame.IconFestival),在iOS端对应的是plist里的key(比如FestivalIcon)。为了统一,我在C#层做了一个映射表,业务传"festival"这种语义化名字,底层再转成各平台的实际标识。
private static readonly Dictionary<string, string> AndroidMap = new Dictionary<string, string> { { "default", "com.yourgame.IconDefault" }, { "festival", "com.yourgame.IconFestival" }, { "anniversary", "com.yourgame.IconAnniversary" } }; private static readonly Dictionary<string, string> iOSMap = new Dictionary<string, string> { { "default", null }, // null表示恢复主图标 { "festival", "FestivalIcon" }, { "anniversary", "AnniversaryIcon" } };iOS恢复默认图标传null给setAlternateIconName,这点和Android不一样,Android是启用默认别名。
2.2 Android原生插件的实现细节
Android侧我写了一个IconUtil类,编译成aar或者直接放Plugins/Android目录下。核心逻辑就是遍历所有别名,启用目标、禁用其他。
public class IconUtil { public static void changeIcon(Activity activity, String aliasName) { PackageManager pm = activity.getPackageManager(); String pkg = activity.getPackageName(); String[] allAliases = { pkg + ".IconDefault", pkg + ".IconFestival", pkg + ".IconAnniversary" }; for (String alias : allAliases) { int newState = alias.equals(aliasName) ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; pm.setComponentEnabledSetting( new ComponentName(pkg, alias), newState, PackageManager.DONT_KILL_APP ); } } }DONT_KILL_APP这个flag非常重要。如果不加,切换图标时系统会杀掉应用进程,用户体验极差。加上之后进程保留,但桌面图标刷新可能有延迟,通常几百毫秒到几秒不等,取决于启动器实现。
注意:部分国产ROM(比如某些定制系统)对
setComponentEnabledSetting的响应不一致,可能出现图标不刷新或者刷新后点击无响应的情况。实测下来,切换后主动发一个桌面刷新广播能缓解,但不是所有启动器都监听这个广播。
2.3 iOS原生插件的实现细节
iOS侧写一个.mm文件放在Plugins/iOS目录,导出C函数给Unity调用。
#import <UIKit/UIKit.h> extern "C" void _ChangeIconiOS(const char* iconName) { NSString *name = [NSString stringWithUTF8String:iconName]; UIApplication *app = [UIApplication sharedApplication]; if (![app supportsAlternateIcons]) { NSLog(@"[IconChanger] 当前系统不支持备用图标"); return; } NSString *targetName = [name isEqualToString:@"default"] ? nil : name; [app setAlternateIconName:targetName completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(@"[IconChanger] 切换失败: %@", error.localizedDescription); } else { NSLog(@"[IconChanger] 切换成功: %@", name); } }]; }supportsAlternateIcons这个检查不能省,虽然iOS 10.3以上都支持,但保险起见还是判断一下。另外setAlternateIconName必须在主线程调用,Unity调用原生代码默认就在主线程,一般没问题,但如果你在子线程触发就要注意了。
2.4 编辑器下的模拟与调试
开发阶段不可能每次都打包真机测试,所以我在#else分支里做了个模拟,把当前图标名存到PlayerPrefs,然后在编辑器里用EditorGUI画个预览。这样策划调图标切换逻辑时不用等打包,直接在编辑器里就能验证流程。
#if UNITY_EDITOR [UnityEditor.InitializeOnLoad] public static class IconEditorPreview { static IconEditorPreview() { // 在Game视图角落显示当前模拟图标 } } #endif这个模拟层虽然简单,但省下的打包时间非常可观。一个完整的Android包动辄几分钟,iOS更久,有了模拟层,逻辑验证阶段基本不用出包。
3. 图标资源准备与打包配置的坑
方案跑通只是第一步,真正让人头疼的是资源准备和打包配置。这部分我踩的坑最多,单独拎出来讲。
3.1 Android图标资源的密度适配
Android的mipmap目录有mdpi、hdpi、xhdpi、xxhdpi、xxxhdpi五档,每档图标尺寸不同。如果你只放一张图,系统会拉伸,在高分屏上糊得没法看。
| 密度目录 | 图标尺寸 | 适用设备 |
|---|---|---|
| mipmap-mdpi | 48x48 | 低端机 |
| mipmap-hdpi | 72x72 | 中端机 |
| mipmap-xhdpi | 96x96 | 主流机型 |
| mipmap-xxhdpi | 144x144 | 高分屏 |
| mipmap-xxxhdpi | 192x192 | 旗舰机 |
我的做法是让美术出一张1024x1024的源图,然后用脚本批量生成各档尺寸。Unity这边可以用AssetPostprocessor自动处理,或者干脆在Android Studio里用Image Asset工具生成。
另外Android 8.0以后支持自适应图标(Adaptive Icon),需要提供前景和背景两层。如果你的目标版本包含8.0以上,建议同时配置mipmap-anydpi-v26目录下的XML,否则系统可能会给你的图标加个白底或者裁切。
<!-- mipmap-anydpi-v26/ic_launcher_festival.xml --> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/icon_bg"/> <foreground android:drawable="@mipmap/ic_launcher_festival_fg"/> </adaptive-icon>3.2 iOS备用图标的命名与尺寸陷阱
iOS这边最容易踩的坑是命名。CFBundleAlternateIcons里的key是逻辑名,但CFBundleIconFiles数组里填的是实际文件名(不带扩展名)。这两个名字可以不一样,但必须和bundle里的资源文件对应上。
我遇到过一次切换后图标变成白板的情况,排查发现是CFBundleIconFiles里写的文件名和实际放进bundle的文件名差了一个大小写。iOS的文件系统在打包后是大小写敏感的,这个坑很隐蔽。
尺寸方面,iOS备用图标建议至少提供以下规格:
- 60x60(@1x,老设备)
- 120x120(@2x,主流)
- 180x180(@3x,Plus和全面屏)
如果只提供一张,系统会缩放,但缩放质量不如原生尺寸。我一般让美术出180x180的,然后脚本降采样生成另外两张。
3.3 Unity打包时的资源剥离问题
Unity在打包时会做资源剥离(Strip Engine Code)和资源压缩,有时候会把你放在Plugins/Android/res下的图标资源当成无用资源删掉。我遇到过一次打包后图标资源丢失,切换时直接崩溃。
解决办法有两个:一是把图标资源放在Assets/Plugins/Android/res下,并在mainTemplate.gradle里确保不被混淆;二是用自定义gradle模板,把资源目录显式声明。我推荐第二种,可控性更强。
// mainTemplate.gradle android { sourceSets { main { res.srcDirs += ['src/main/res', 'src/festival/res'] } } }iOS这边相对简单,资源直接放Plugins/iOS下,Unity会自动拷进Xcode工程。但要注意Xcode工程的Copy Bundle Resources里必须包含这些图标文件,有时候Unity生成的工程会漏掉,需要手动加或者写PostProcessBuild脚本自动处理。
4. 切换时机与用户体验的取舍
技术跑通之后,真正难的是产品层面的决策:什么时候换、怎么换、换了之后怎么让用户知道。这部分没有标准答案,我分享几个实测下来比较稳的做法。
4.1 主动切换优于被动切换
最理想的方式是在设置页放一个"更换图标"入口,让用户自己选。这样既符合iOS的系统提示逻辑(用户主动操作,弹提示不突兀),也避免了"App偷偷换图标"带来的不信任感。
具体做法是在设置页列出所有可用图标,每个图标配一个预览图,用户点击后调用AppIconChanger.ChangeIcon()。切换成功后给个Toast提示,iOS那边系统alert会自己弹,不用额外处理。
4.2 活动驱动的自动切换要谨慎
有些运营需求是"节日期间自动换图标",这个在Android上可以做,因为切换无感。但iOS上如果App启动时自动切换,会弹系统alert,用户会一脸懵。我的建议是iOS端不做自动切换,或者只在用户打开App且停留超过一定时间后,用一个小气泡引导用户"点击更换节日图标",把主动权交给用户。
Android端自动切换也要注意:如果用户在活动结束后没打开过App,图标会一直停留在节日版。所以最好在活动结束后的首次启动时切回默认,并且记录一个本地标记,避免重复切换。
4.3 切换状态的持久化与恢复
用户切换图标后,这个状态是存在系统里的,App重装或者清除数据后会恢复默认。但有一种情况要注意:Android上如果用户把App移到SD卡或者做了某些系统优化操作,activity-alias的启用状态可能被重置。所以每次App启动时,最好读一下当前启用的别名,和本地记录的状态做比对,不一致就以系统为准更新本地记录。
public static string GetCurrentIconKey() { #if UNITY_ANDROID && !UNITY_EDITOR // 通过PackageManager查询当前启用的alias // 返回对应的语义化key #elif UNITY_IOS && !UNITY_EDITOR string current = _GetCurrentIconiOS(); return string.IsNullOrEmpty(current) ? "default" : current; #else return PlayerPrefs.GetString("current_icon", "default"); #endif }iOS端可以用alternateIconName属性直接读当前图标名,为nil就是默认图标。
5. 审核与合规:两端都不能忽视的红线
动态图标这个功能,技术上不难,难的是过审。两端应用商店对这个功能的态度不太一样,我分别说。
5.1 iOS审核的注意事项
iOS对备用图标的态度是"允许但需说明"。提交审核时,建议在审核备注里写清楚这个功能的用途,比如"用于节日活动期间让用户自主更换App图标,提升用户参与感"。如果审核员问询,要能提供图标切换的入口截图。
有几个雷区千万别碰:一是用备用图标伪装成其他App,这个直接拒;二是切换图标后功能发生变化,比如切到某个图标就解锁隐藏功能,这属于"功能开关",容易被判定为规避审核;三是备用图标里包含违规内容。
另外,CFBundleAlternateIcons的数量别太多,系统虽然没有硬性限制,但超过10个可能会引起审核员注意。我一般控制在5个以内。
5.2 Android审核的注意事项
Android这边相对宽松,activity-alias是官方支持的机制,只要你的图标内容合规,一般不会因为用了这个机制被拒。但要注意两点:一是别用这个机制做"应用双开"或者"隐藏应用"之类的功能,这违反政策;二是如果切换图标后App名称也变了,要确保新名称不侵权、不误导。
国内应用商店对图标切换的审核尺度不一,有的商店会要求你说明用途。我的经验是,如果只是节日活动换图标,正常提审基本没问题;如果涉及频繁切换或者和活动强绑定,最好提前和商店沟通。
5.3 版本兼容的兜底策略
不是所有设备都支持动态图标。Android 5.0以下没有setComponentEnabledSetting的完整支持,iOS 10.3以下没有setAlternateIconName。虽然现在这些老系统占比很低,但代码里还是要做判断,不支持就静默失败,别崩溃。
public static bool IsIconChangeSupported() { #if UNITY_ANDROID && !UNITY_EDITOR // 检查API Level >= 21 using (var version = new AndroidJavaClass("android.os.Build$VERSION")) { return version.GetStatic<int>("SDK_INT") >= 21; } #elif UNITY_IOS && !UNITY_EDITOR return _SupportsAlternateIcons(); #else return true; #endif }6. 实测中遇到的几个典型问题与排查过程
这部分是我踩坑的实录,每个问题都附上排查思路,方便你遇到类似情况时对照。
6.1 Android切换后图标不刷新
现象:调用切换接口后,代码返回成功,但桌面图标没变,重启桌面后才更新。
排查过程:先确认setComponentEnabledSetting的返回值,发现是COMPONENT_ENABLED_STATE_ENABLED,说明设置成功了。然后怀疑是启动器缓存问题,换了几台设备测试,发现部分国产ROM确实不监听组件变更广播。
解决方案:切换后主动发一个Intent.ACTION_PACKAGE_CHANGED广播,或者用ShortcutManager触发一次快捷方式更新,间接促使桌面刷新。实测在大部分设备上能立即生效,少数顽固的只能等系统自己刷新。
Intent intent = new Intent(Intent.ACTION_PACKAGE_CHANGED); intent.setData(Uri.parse("package:" + pkg)); activity.sendBroadcast(intent);6.2 iOS切换后图标显示为白板
现象:调用setAlternateIconName成功,但图标位置显示一个白色方块。
排查过程:检查CFBundleIconFiles里的文件名,发现和实际资源文件名不一致。进一步发现是Xcode打包时把图标文件重命名了,因为文件名里带了特殊字符。
解决方案:图标文件名只用字母、数字和下划线,别用中文、空格、连字符。改完重新打包,问题消失。
6.3 Unity打包后Android别名丢失
现象:编辑器里测试正常,打包成APK后切换图标无效,反编译APK发现activity-alias节点没了。
排查过程:检查AndroidManifest.xml,发现Unity在合并manifest时把自定义的alias节点过滤掉了。原因是Unity的manifest合并规则对未知节点处理不一致。
解决方案:把alias配置写进Assets/Plugins/Android/AndroidManifest.xml,并在mainTemplate.gradle里确保这个manifest被正确合并。或者用[Preserve]属性标记相关类,防止被剥离。
6.4 切换图标后App启动崩溃
现象:Android上切换图标后,点击新图标启动App直接闪退。
排查过程:看logcat发现是ClassNotFoundException,找不到主Activity。原因是主Activity被误禁用了,或者alias的targetActivity写错了。
解决方案:确保主Activity始终处于enabled状态,alias的targetActivity必须和主Activity的完整类名一致。另外检查混淆配置,别把Activity类名混淆了。
-keep public class * extends android.app.Activity -keep class com.yourgame.MainActivity { *; }6.5 iOS系统提示无法屏蔽的应对
现象:每次切换图标都弹系统alert,产品经理要求去掉。
排查过程:查了公开API,确认没有合法方式屏蔽。网上有些用runtime替换presentViewController的方案,但风险极高。
解决方案:和产品沟通,把切换入口做成用户主动触发,并且在UI上提前告知"切换后系统会弹出确认提示,点击确定即可"。用户有预期后,接受度会高很多。硬要去掉提示,得不偿失。
7. 一些可以复用的工程化建议
最后分享几个把方案落地到项目里的工程化经验,都是实际项目里验证过的。
7.1 图标配置表驱动
别把图标名硬编码在代码里,用一张配置表管理。我用的是ScriptableObject,策划可以直接在Unity里配。
[CreateAssetMenu(fileName = "IconConfig", menuName = "AppIcon/Config")] public class IconConfig : ScriptableObject { [System.Serializable] public class IconEntry { public string key; // 语义化key public string displayName; // 显示名 public Sprite preview; // 预览图 public string androidAlias; // Android别名 public string iOSName; // iOS图标名 } public List<IconEntry> icons; }这样新增一个图标只需要加一行配置、放一张图,不用改代码。
7.2 切换流程的埋点
动态图标是个运营功能,效果需要数据支撑。我在切换成功、切换失败、用户查看图标列表这几个节点都加了埋点,方便后续分析哪些图标受欢迎、切换转化率如何。
埋点字段建议包含:当前图标key、目标图标key、平台、系统版本、是否成功、失败原因。这些数据对后续活动策划很有参考价值。
7.3 灰度与回滚
新图标上线前,建议先小流量灰度,观察崩溃率和用户反馈。如果发现问题,能快速回滚到默认图标。回滚逻辑很简单,就是调用ChangeIcon("default"),但要确保这个操作在异常情况下也能执行,比如放在try-catch里。
public static void SafeRollback() { try { ChangeIcon("default"); } catch (System.Exception e) { Debug.LogError($"[IconChanger] 回滚失败: {e.Message}"); } }7.4 文档与交接
动态图标这个功能涉及Unity、Android、iOS三端,交接时容易漏。我一般会写一份简短的接入文档,包含:图标配置表位置、新增图标的步骤、两端资源目录、常见问题排查。这样即使换人维护,也能快速上手。
我个人在实际项目里的体会是,动态图标这个功能技术复杂度不高,但跨端协作和细节处理很磨人。真正决定成败的不是代码写得多漂亮,而是资源准备是否规范、打包配置是否正确、审核沟通是否到位。把这几块做扎实,剩下的就是按部就班地接入了。