先说个背景:运营周五下班前丢来一句“下个版本我们把App图标换成春节活动的”,我第一反应是改一下Unity工程里的Player Settings,重新打一版包丢商店。但转念一想,老玩家手里的包根本不会因为我发新包就自动更新,指望用户主动升级不现实,于是这个需求就变成了一个绕不开的技术问题:在已安装的包上,让桌面图标跟着运营动作变。
这个需求在手游圈其实很常见,春节、周年庆、新赛季、IP联名,运营都想在用户桌面上刷一波存在感。但真正动手的时候你会发现,Android和iOS完全是两个世界:Android可以做到准实时切换,代价是桌面刷新在各家ROM上表现不一样;iOS则是“能切,但只能从预置图标里切”,而且每切一次系统都要弹窗让用户确认。这篇我就把双端都做过的完整方案、踩过的坑、以及Unity侧怎么封装出一套统一接口,一次说清楚。
1. 需求拆解:你以为在“换App图标”,其实是在换“快捷方式图标”
1.1 玩家看到的图标,和系统认识的图标是两回事
做这个功能之前,得先搞清楚一件事:你在手机桌面上看到的那个图标,并不是App包里的那张图片本身,而是系统根据配置生成的“入口”。
Android这边,Launcher(也就是桌面)展示的图标本质上是一个指向某个Component的快捷方式。这个Component在manifest里声明,带上了android:icon属性。系统默认会让你主Activity做入口,它的icon就成了桌面图标。当你把一个Activity禁用、把另一个Activity启用,桌面图标的指向就会发生变化——这是Android能动态换图标的根基。
iOS这边完全不是这个逻辑。SpringBoard直接把App沙盒里已签名的图标资源展示在主屏幕上,App运行时根本没有权限去改这个展示关系。Apple给的正路只有一条:把备用图标预置进Info.plist,然后调用系统API让它切换,而且这个切换动作系统会弹窗让用户确认。
一句话总结:Android是切换入口,iOS是替换资源。理解了这一点,后面看实现就顺了。
1.2 哪些场景真的需要动态换图标
开发侧经常觉得“换图标”就是运营拍脑袋,其实拆开看,用途很清晰:
- 节日运营:春节、中秋、周年庆,图标换成节日主题,增加用户点击冲动。
- 版本节点:王者荣耀这种游戏,新赛季开启那天,应用商店里的图不换,但老玩家桌面上的图标得换成新视觉。
- IP联动:跟某部电影、某个动漫联动,图标换成联名款,制造稀缺感。
- 定向用户:给某个测试用户群体显示特殊图标,方便客服识别版本。这个需求少,但也不是没遇到过。
在Unity里改Player Settings的icon,只影响新装用户和商店展示页,对老用户是无效的。从产品角度来说,如果这个运营动作很重要,就必须做运行时切换。
1.3 结论先放前面
双端完整做完之后,我给你的结论是:Android端可用、成熟,能做到秒切,但桌面刷新有兼容性坑;iOS端能切,但必须预置图标包、用户会看到系统确认弹窗,且不能太频繁。先做Android,iOS做基础版,这是性价比最高的落地顺序。
2. Android端:用Activity-alias让桌面图标“秒变”
2.1 原理:Launcher入口的偷梁换柱
Android的动态换图标,标准做法是Activity-alias。所谓alias就是“替身”,它不承载真实代码,只是指向你的主Activity,但它可以有自己的icon、label和intent-filter。
你可以把它理解成一个公司前台:主Activity是真正的办公区,alias是前台工位。你让哪个工位挂着公司的牌子,访客(用户)看到的就是哪个形象。系统桌面上那一堆图标,本质上就是一堆“挂着LAUNCHER牌子的入口”,你切换的只是牌子,不是办公楼本身。
控制牌子是否挂出去,靠PackageManager.setComponentEnabledSetting()。这个方法可以动态启用/禁用某个组件(Activity、Service、Receiver都行)。把旧的alias禁用、新的alias启用,桌面快捷方式指向的ComponentName就变了,图标也就跟着变了。
这里有个关键原则:同一时刻只能有一个LAUNCHER入口处于enabled状态。如果两个alias同时启用,桌面会出现两个一模一样的游戏图标,用户会以为装了两个App。
2.2 图标资源准备:一张图引发的血案
Android的Launcher图标不是随便丢一张图就行的,各密度要出全套。基础尺寸表如下:
| 密度 | 像素尺寸 |
|---|---|
| mdpi | 48x48 |
| hdpi | 72x72 |
| xhdpi | 96x96 |
| xxhdpi | 144x144 |
| xxxhdpi | 192x192 |
这是老规矩。Android 8.0(API 26)之后加入了自适应图标(Adaptive Icon),系统会把前景层和背景层分开处理,然后根据不同的形状(圆形、圆角矩形、泪珠形)进行裁切。图标设计尺寸是108dp,但真正安全区域只有中间的66dp,四周会被裁掉。
如果你设计给的图是满幅铺开的,直接塞进mipmap,在Android 9+的桌面上很可能会被裁掉边上的重要元素。我的做法是:让设计出一张1080x1080的方形图,中心720x720为核心元素,四周留安全边距,然后用脚本切成五套密度。跑过几个项目,这个方案最稳,不会出现某个ROM上图标被裁成“大头贴”的问题。
2.3 Manifest的正确姿势:主入口不直接挂Launcher
很多教程会让主Activity继续保留LAUNCHER,同时加一个alias。这种写法问题在于:切换时你有时候要禁主Activity、有时候要禁alias,很容易出现“两个图标”或者“一个图标都点不开”的边界情况。我实践下来更稳的写法是:主Activity只声明MAIN,不挂LAUNCHER,所有LAUNCHER入口都放alias。
<application android:label="@string/app_name" android:icon="@mipmap/ic_launcher_default"> <!-- 主Activity:真正干活的页面,不直接作为桌面入口 --> <activity android:name=".MainActivity" android:exported="false" /> <!-- 默认图标入口 --> <activity-alias android:name=".IconAliasDefault" android:targetActivity=".MainActivity" android:label="@string/app_name" android:icon="@mipmap/ic_launcher_default" android:exported="true" android:enabled="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=".IconAliasFestival" android:targetActivity=".MainActivity" android:label="@string/app_name" android:icon="@mipmap/ic_launcher_festival" android:exported="true" android:enabled="false"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity-alias> </application>注意三个细节:
- 每个alias都必须有
android:exported="true",否则点击图标没反应。 - 每个alias的label建议和主图标保持一致,避免切换后桌面App名出现“跳动”。
android:enabled只是初始状态,运行时由代码控制,别把这个属性当成正式开关。
2.4 C#侧切换:三行代码解决战斗
Unity里调用Android原生API,不需要写Java插件也能做,直接用AndroidJavaClass调PackageManager就行。
using UnityEngine; public static class AndroidAppIconSwitcher { public static void SwitchIcon(string aliasName, bool enabled) { 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"); using var component = new AndroidJavaObject( "android.content.ComponentName", packageName, aliasName ); // packageManager.COMPONENT_ENABLED_STATE_ENABLED = 1 // packageManager.COMPONENT_ENABLED_STATE_DISABLED = 0 // packageManager.DONT_KILL_APP = 1 int newState = enabled ? 1 : 0; pm.Call("setComponentEnabledSetting", component, newState, 1); } }这里enabled参数别理解反了:切到春节图标,要做两件事——先SwitchIcon(".IconAliasFestival", true),再SwitchIcon(".IconAliasDefault", false)。顺序一定不能错,否则中间一瞬间所有入口都被禁用,用户从桌面点图标会提示“应用未安装”。
DONT_KILL_APP这个参数很关键,值为1,意思是切换组件时不要杀掉当前运行的App进程。如果漏了这个,用户切完图标,游戏就没了。
2.5 桌面没刷新?这口锅该谁背
代码写完之后,事情远没有结束。最常遇到的反馈是:“我切了图标,桌面没变。”
实测结果是这样的:Google Pixel的原生桌面会立即刷新,基本是秒变。但MIUI、EMUI、ColorOS这些定制ROM,有的要锁屏再亮屏才刷新,有的要等Launcher重启,甚至要重启手机。网上有各种“发送桌面刷新广播”的偏方,我试过几个,兼容性都很差,不值得在这个方向上较劲。
我的土办法是:切换后弹一个Toast,“桌面图标已切换,如果未更新,重启桌面或手机即可看到”。丑是丑了点,但能解决90%的客服咨询量。
另外有个现实要注意:最近任务列表(后台卡片)里显示的图标经常是老图标。这个截图反差会让运营紧张,但实际上这是系统级别的缓存,App完全控制不了。提前跟运营说清楚,别到时候被当成bug追着打。
2.6 Android 13主题图标:新问题
Android 13开始支持THEMED_ICONS,系统会把桌面图标变成单色主题风格,跟随系统深色模式、壁纸颜色走。这个模式下,你动态换的节日图标会被系统重新“画”成单色轮廓,不是设计稿里的彩色效果。
这个没有完美的解决方式。目前Android的公开API里没有针对主题图标渲染的控制能力。只能说:如果你的用户大量开启主题图标模式,这个功能对他们的视觉影响力会打折扣,但功能本身不会出错。
3. iOS端:不是“不能换”,而是“只能从预置图标里换”
3.1 为什么iOS不能像Android那样“随便换”
iOS从10.3开始提供了Alternate Icons能力,核心API就是UIApplication.setAlternateIconName(_:completionHandler:)。表面上看起来这个API很直接,但限制藏得很深:
- 所有备用图标必须在
Info.plist的CFBundleAlternateIcons里声明,随安装包一起签名分发。 - 不能从网络下载新图标再运行时设置,签名机制不允许,审核也不允许。
- 每次切换,系统会弹窗让用户确认,App无法跳过这个确认环节。
- 主图标可以通过传
nil切回来,但同样要确认。
所以iOS的方案重点不是“怎么切换”,而是“怎么在上架之前把备用图标预置好、怎么让运营接受弹窗体验”。
3.2 Info.plist与Assets.xcassets的配置
在Xcode工程里,备用图标的添加方式很简单:把图片资源放进Assets.xcassets的AppIcon里,然后在Info.plist里声明。
<key>CFBundleIcons</key> <dict> <key>CFBundlePrimaryIcon</key> <dict> <key>CFBundleIconFiles</key> <array> <string>AppIcon60x60</string> </array> <key>UIPrerenderedIcon</key> <false/> </dict> <key>CFBundleAlternateIcons</key> <dict> <key>FestivalIcon</key> <dict> <key>CFBundleIconFiles</key> <array> <string>FestivalIcon60x60</string> </array> <key>UIPrerenderedIcon</key> <false/> </dict> </dict> </dict>实际在Unity工程里,Xcode工程是打包后生成的,所以这些配置要在iOS原生插件或PostProcessBuild脚本里完成,不是手改就行的。备用图标的图片名也是约定好之后,在构建阶段塞进xcassets。
尺寸方面,iPhone用@2x和@3x,也就是120x120和180x180。iPad虽然现在手游很少专门适配了,但如果你的游戏还支持iPad,最好也把167x167的@2x加上。图片不能带透明度之外的奇怪格式,最好是PNG。
一个容易踩的坑:备用图标文件名不能带空格,否则打包的时候Asset Catalog会报错,而且这个错误隐藏得很深,有的版本要到上传App Store时才会暴露。
3.3 Unity调用iOS原生的桥接代码
iOS侧要写的东西不多,把OC方法导出成C函数,Unity C#侧用[DllImport("__Internal")]声明即可。我建议直接用.mm文件(Objective-C++),省去C/C++互操作的一堆麻烦。
#import <UIKit/UIKit.h> #import "UnityAppController.h" extern "C" void iOS_SetAlternateIconName(const char *iconName) { if (![[UIApplication sharedApplication] supportsAlternateIcons]) { return; } NSString *name = nil; if (iconName != NULL && strlen(iconName) > 0) { name = [NSString stringWithUTF8String:iconName]; } [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(@"ChangeIconError: %@", error.localizedDescription); const char *msg = [[NSString stringWithFormat:@"error:%@", error.localizedDescription] UTF8String]; UnitySendMessage("AppIconManager", "OnSwitchResult", msg); } else { UnitySendMessage("AppIconManager", "OnSwitchResult", "success"); } }]; }C#侧:
#if UNITY_IOS && !UNITY_EDITOR using System.Runtime.InteropServices; #endif public static class IosAppIconSwitcher { #if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] private static extern void iOS_SetAlternateIconName(string iconName); #endif public static void SwitchIcon(string iconId) { #if UNITY_IOS && !UNITY_EDITOR iOS_SetAlternateIconName(iconId); #endif } }注意iconId传空字符串表示切回主图标,在OC里会转成nil。这个约定要跟运营对齐:他们眼里是“换回默认”,代码里就是“传空”。
3.4 用户实际看到的切换体验
调用之后,系统会弹一个确认框,大致意思是“是否将主屏幕图标替换为xxx”。用户点允许后,桌面图标马上更新。这个弹窗由系统UI接管,文案和样式App都无法定制,体验上是打断性的。
所以iOS端必须对运营做一次预期管理:iOS切图标不是无感的,用户会看到系统弹窗。如果活动图标切得太频繁,用户确认的次数多了,会觉得很烦。我见过有的产品直接在首次切换后不再允许主动切回,就是怕反复弹窗引发反感。
另一个要提前说的事:App Store商店页面显示的永远是提交审核时填的主图标,不是用户手机桌面上的那个。你在商店里看到的还是那个经典Logo,但老用户手机上是节日图。这不是bug,是Apple商店架构决定的。
3.5 那些“绕开API”的野路子为什么都不行
iOS社区一直有人在找“无感换图标”的方案,我帮大家把试过的路数排一下:
- 网页添加到主屏幕:本质是Web Clip快捷方式,走Safari渲染,没有角标、没有推送、没有启动动画,游戏App不可能接受。
- 快捷指令自动化:iOS 14之前能通过自动化触发换图标,现在每次执行都要弹“快捷指令想要更改主屏幕图标”的确认,比系统弹窗还烦。
- 描述文件/企业证书:只适合企业内部分发,正常上架App Store的开发者别碰。
结论很明确:官方API就是唯一正路,把备用图标预置进包里,接受弹窗,完事。
4. Unity侧的统一接口设计:双端差异收进一个Manager
4.1 对外API长什么样
业务层不应该出现#if UNITY_ANDROID满天飞的代码。我习惯封装一个静态Manager,对外暴露最小接口:
public static class AppIconManager { public static void SwitchIcon(AppIconId id, System.Action<bool> callback); public static AppIconId GetCurrentIcon(); }AppIconId是一个带字符串值的枚举,Android对应alias后缀,iOS对应备用图标名:
public enum AppIconId { Default = -1, Festival = 0, Anniversary = 1, NewSeason = 2 }底层把枚举映射成两端的不同标识。Android映射成.IconAliasFestival,iOS映射成FestivalIcon。这样运营说“切春节图”,程序里就一行:
AppIconManager.SwitchIcon(AppIconId.Festival, (success) => { Debug.Log("切换结果:" + success); });4.2 Android和iOS插件的工程目录组织
Unity工程里插件目录这样放:
Assets/ ├── Plugins/ │ ├── Android/ │ │ ├── AndroidManifest.xml # 带alias声明的manifest │ │ └── res/mipmap-*/ic_launcher_*.png │ └── iOS/ │ ├── AppIconBridge.mm │ └── AppIconBridge.hAndroid的manifest是Unity打包时要用的,如果你用自动集成SDK那一套,记得在Plugins/Android里放完整清单,别让它和主工程模板冲突。iOS的桥接代码可以放在Plugins/iOS,Unity构建Xcode工程时会自动拷贝过去,不需要手动引用。
iOS还涉及到Info.plist生成配置和xcassets的填充,建议在IPostprocessBuildWithReport里用脚本处理,别人工去每次改Xcode工程。写一次脚本,后面所有版本都省事。
4.3 启动恢复与并发保护
切完图标之后,App进程本身和桌面的快捷方式状态是分开的。千万别忘了记录当前状态,否则用户杀进程重启手机之后,你都不知道现在是哪套图标。
我的做法是:PlayerPrefs.SetString("AppIconCurrent", iconId),每次切换都写。启动时读取,如果发现记录和系统实际状态不一致,做一个静默恢复。
启动恢复要注意一个问题:iOS端恢复默认图标也会弹窗,所以不要每次冷启动都“恢复”,只在新装App后的首次启动或者版本更新后图标配置变化时做一次。
Android端虽然不会弹窗,但也要防止重复调用。比如你在OnApplicationPause(false)里做切换逻辑,用户按Home键再回来,切换动作触发两次,第二次如果判断不严,会把原来切好的又切回去。
并发保护最通用的做法是:Manager内部维护一个mutableState,切换过程中把状态标记为“切换中”,回调返回之前丢弃新的切换请求。实际操作中运营不会频繁点,但代码必须扛得住。
4.4 为什么不建议把图标资源放AssetBundle
有的同学会想:既然要动态,那我把图标放AssetBundle里,运行时下载后给原生层用,不就能做到“今天出的图明天就换上”吗?
这个思路在Android和iOS都走不通。Android的activity-alias里引用的icon是APK内mipmap资源,系统Launcher只认这个静态资源,根本没有给你代码注入图片的机会。iOS的备用图标必须在安装包内、被签名锁定,网络下载的图片不可能变成受信任的图标资源。
所以“动态换图标”里的“动态”,准确说是入口的切换是动态的,资源包必须预置。这也决定了你要在发版前确定所有候选图标。我一般建议预置2到4套,覆盖当年的大运营节点,避免下一版更新之前素材不够用。
4.5 运营侧的配合节奏
这个功能不是纯客户端的事,运营必须参与。我做过一张配合表,供参考:
| 时间节点 | 运营动作 | 客户端动作 |
|---|---|---|
| 活动前2周 | 提交图标素材(PNG/AI,含安全区标注) | 资源检查、尺寸切片、提交测试包 |
| 活动前3天 | 确认最终命名和切换时间点 | 预置进版本,走测试 |
| 活动前1天 | 下发切图指令 | 服务器开关开启,灰度切换 |
| 活动结束次日 | 下发切回指令 | 切回默认图标 |
切图动作不应该写死在客户端,而是由服务器下发开关控制。客户端进入前台时请求一次“当前应显示的图标ID”,和本地一致就不动,不一致才触发切换。这样活动时间变了,运营自己改后台就行,不用等发版。
5. 上线前后的验证清单与那些绕不开的坑
5.1 双端验证清单
功能做完不是结束,上线前要有一张验证表。我每次发版前都会过一遍:
| 检查项 | Android | iOS |
|---|---|---|
| 图标切换成功后桌面即时刷新 | Pixel原生机必测,MIUI/EMUI/ColorOS各测一轮 | 系统弹窗出现,确认后图标变化 |
| 切回默认图标 | 同切换路径反向执行 | 传nil,确认后恢复 |
| 切换后App冷启动正常 | 杀进程后从新图标进入 | 杀进程后从新图标进入 |
| 图标资源在各机型上无裁切 | 重点看自适应图标和圆形图标 | 无圆角裁切问题,但注意别设计太满 |
| 通知栏/横幅图标不受影响 | 无误 | 无误 |
| 最近任务列表显示老图标 | 确认可接受 | 确认可接受 |
| 网络切图开关稳定 | 弱网、断网、开关抖动 | 同左 |
有一个细节:Android切换后如果用户正在使用App,切换动作本身会触发一次Activity pause/resume吗?实测不会,但部分ROM上桌面图标更新时,游戏会短暂失焦一下,这是正常现象,不是闪退。
5.2 高发坑位与根因
这一节的标题我写得很直接,因为下面每一条都是真金白银换来的经验。
第一个坑:华为、荣耀桌面不刷新。这是EMUI的老毛病,切完图标桌面纹丝不动,要锁屏或重启后才变。原因在于华为Launcher缓存快捷方式的机制和原生Android不同,它不监听组件启用状态的广播。破解方案没有,只能提示用户,或者切换后主动发一个ACTION_LOCALE_CHANGED广播偶尔能触发刷新,但不保证。我们最终选择接受这个问题,客服话术备好。
第二个坑:Android 8.0后图标被裁切。前面说过安全区的问题。实际操作中,很多设计为了视觉冲击力会把Logo铺满,到了Android 13的一加、小米上边角直接被切掉。这个坑在上线后很难补,必须发版前检查。
第三个坑:iOS切换弹窗文案不能定制。系统弹窗会展示备用图标的显示名称,这个名称默认取自CFBundleIconName。如果你给备用图标起的名字是内部代号,用户看到的弹窗就很莫名其妙。所以图标资源命名要么不显示、要么起一个用户能看懂的活动名。
第四个坑:切换后ClassLoader报错。这个发生在极端场景:切换组件的同时,系统正在杀掉并重建进程。我遇到过一次ClassNotFoundException,根因是manifest里的activity在低内存环境被回收,和切换动作撞在一起。后来在切换逻辑里加了重试机制,切换失败就回滚,没有再出现过。这种偶发问题不常遇到,但建议你做切换回调的容错。
第五个坑:三星部分机型图标变成默认Android机器人形状。这是系统缓存的老bug,出现概率很低,重启可恢复。客服遇到时让用户重启一次桌面就行。
第六个坑:上线当天运营说活动延期,但图标已经切了。这个不是技术问题,是流程问题。解决办法就是前面说的:切图由服务端开关控制,运营改后台配置即可。千万别在客户端里写死在某个日期切图。
5.3 灰度与监控
切图标这种“用户可见变化”的动作,一定不要全员一刀切。我建议流程是:服务端开关先开5%用户,确认成功率、崩溃率正常后再放量到30%,最后100%。
需要监控的数据:
- 切换成功率:客户端回调为success的概率,低于98%就要查。
- 崩溃率:重点看Android端
setComponentEnabledSetting有没有抛IllegalArgumentException。入参不对时崩溃率会上去,catch层要做防护。 - iOS弹窗拒绝率:用户点了“不允许”的比例。这个指标高说明弹窗太频繁或图标名称有歧义,要考虑文案和频率。
埋点设计不要复杂,三条事件就够:
AppIcon_SwitchRequest # 发起切换 AppIcon_SwitchSuccess # 切换成功 AppIcon_SwitchFailed # 切换失败,带错误码5.4 如果产品说“我要支持服务器下发新图标”
这大概率是迟早会来的需求。我用大白话告诉你为什么做不到:Android的桌面图标必须指向一个manifest里已声明的组件,组件附带的icon资源必须在APK里。长按菜单的快捷方式(ShortcutManager)可以动态指定icon,但那不是桌面主图标,是长按App后的弹出菜单。用AppWidgetProvider做伪图标可以做到桌面显示自定义图片,但系统会云控识别并清理,不适合游戏这类要常驻桌面的场景。
iOS更不可能,签名机制锁死了所有图标资源。
所以如果产品抛出这个需求,你的回答应该是:要么预置未来几期的图标,要么发新包。不存在第三条路。这个边界清晰了,运营的排期反而会保守很多。
我自己做完这个功能,最大的感受是:Android端花了两天搞定核心,iOS端花了一天搞定代码,剩下80%的时间都花在跟ROM厂商的兼容性、跟运营对齐预期、还有给测试列验证清单上。如果你也要做,我给你一个最实际的建议:先把Android的切图逻辑做成服务端可控开关。因为一旦某个活动延期,你能做的最快动作就是不下发切图指令,而不是临时跑去找ROM厂商要补丁。iOS端记得把备用图标命名起得通俗一点,弹窗显示的是那个名字,不是你的代码变量名。最后,图标设计的安全区问题,一定要跟设计同学反复强调,这是整个功能里最容易翻车的环节。