
1. 为什么我盯上了动态换图标这个需求先交代一下背景。项目是Unity开发的休闲手游上线半年后运营开始频繁做节日活动每次活动前运营和美术都要折腾一轮更新商店图、更新开屏图、更新icon外的所有露出位。最后运营提了一个需求——能不能像很多头部产品那样节假日直接把桌面图标换成活动皮肤用户还没点开App一眼就能在桌面看到品牌变化。当时团队第一反应是换图标不是得重新出包吗就算可以动态换双端限制一堆搞不好就是个吃力不讨好的脏活。后来我把Android和iOS两端的原生方案摸了一遍又做了Unity桥接层发现在Unity手游里做动态换图标完全可行但确实有不少坑。这篇文章就把完整的技术方案、踩坑过程、双端差异一次性说清楚给同样在Unity里做动态图标需求的兄弟留个参考。先说结论Unity手游动态换App图标Android靠Activity-alias多入口切换iOS靠系统级图标替换接口CFBundleAlternateIcons。Unity层不需要太多代码核心在原生桥接和很多容易被忽略的平台细节。如果你已经知道双端原理可以直接跳到第3章看Unity侧的完整调用链路和踩坑记录。2. 双端实现原理系统能力和Unity之间的关系2.1 Android端Activity-alias和桌面图标的真身切换Android的桌面图标本质上是Launcher解析到的一个入口。对同一个应用我们可以声明多个入口每一个入口由activity-alias完成映射。默认的入口一般指向启动时的主Activity通常是UnityPlayerActivity换图标时只需要把当前生效的入口切换到另一个携带不同icon资源的alias。具体来说AndroidManifest里可以这样组织activity-alias android:name.MainActivity_Skin_Default android:enabledtrue android:exportedtrue android:iconmipmap/ic_launcher_default android:targetActivity.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias activity-alias android:name.MainActivity_Skin_Summer android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_summer android:targetActivity.MainActivity ... /activity-alias系统在桌面只认enabledtrue的那个alias。所以换图标的逻辑很简单先把当前alias的enabled改为false。再把目标alias的enabled改为true。调用PackageManager的setComponentEnabledSetting应用这套改变。等系统桌面刷新桌面入口图标。这种方式有一个重要特点图标的切换不需要重新杀掉App进程但桌面上的图标刷新有一定的延迟且某些ROM在短时间频繁切换时会漏刷。后面我会讲到实测过程中怎样规避这种可靠性问题。2.2 iOS端系统级替换一次只能对应一个备用图标文件iOS端在iOS 10.3之后开放了UIApplication的setAlternateIconName接口。你在工程中把一组备用图标放进Asset Catalog或直接放到Bundle指定目录然后在代码里调用接口让系统把桌面图标换掉。[[UIApplication sharedApplication] setAlternateIconName:AppIconSummer completionHandler:^(NSError * _Nullable error) { if (error) { // 切换失败可能是文件名没配对或plist配置有问题 } }];iOS的动态换图标原理其实比Android简单但限制比Android严格备用图标数量不能太大必须在CFBundleIcons的CFBundleAlternateIcons字典里预先声明。一份备用图标必须包含所有尺寸通常建议把Icon-Small40pt、Icon-Small-50iPad、Icon60pt、Icon-72等规格都带上少尺寸会导致系统拒绝替换或图标显示模糊。切换过程需要用户在前台触发并且系统会弹一个确认提示无法静默换。从iOS 10.3到现在这套机制没有太大变化但它不会影响下一次提交审核时的截图逻辑App Store审核界面里看到的图标始终是当前生效的那个。2.3 Unity在其中扮演的角色桥接层Unity手游的代码主体是C#但换图标需要调用原生系统能力。无论Android还是iOSUnity都提供了C#到原生层的调用通道Android通过AndroidJavaObject/AndroidJavaClass调用Java代码或者用一个aar插件封装。iOS通过DllImport(__Internal)把C#方法映射到原生C/Objective-C函数或者走Unity的UnitySendMessage反向通信。把换图标理解成一个系统级开关动作的话Unity侧做的事情其实很少接收服务端下发的活动版本号调原生接口记录当前图标状态必要的场景切前台北竖屏提示具体切换动作交给原生。所以整个项目的架构可以固定为C#事件层收到活动指令 ↓ 原生桥接层Android/iOS各自实现切换协议 ↓ 系统图标刷新桌面可见变化 ↓ 把当前图标状态上报/SDK记录用于下次冷启动时恢复这套结构的核心点在于原生层只管能不能切、怎么切C#层管什么时候切、切到哪一套。分工一旦清楚后面很多问题都好排查。3. Android侧实操Activity-alias多图标切换的完整落地3.1 工程准备图标资源与Manifest规划我先在Unity导出的Android工程里做验证。注意如果你是在Unity里直接出aar再打包到Android原生工程等于是多套了一层操作路径稍有不同但Manifest调整思路是一样的。我准备了5套图标默认、春季、夏季、秋季、冬季分别放在不同的mipmap目录。建议在Unity的Plugins/Android路径下维护一个AndroidManifest.xml直接在清单里预先声明好alias。不要想着运行时动态改Manifest这条路是不通的而且如果Manifest里根本没有对应aliassetComponentEnabledSetting会直接抛IllegalArgumentException。每一项alias都要注意exported属性。如果是给桌面入口用exportedtrue是必须的否则点击图标无法拉起Activity。另外targetActivity指定的必须是实际存在的Activity类建议统一指向UnityPlayerActivity避免多个入口之间状态不一致。application android:labelstring/app_name ... activity android:namecom.unity3d.player.UnityPlayerActivity ... / activity-alias android:namecom.game.launcher.default android:enabledtrue android:exportedtrue android:iconmipmap/ic_launcher_default android:targetActivitycom.unity3d.player.UnityPlayerActivity / activity-alias android:namecom.game.launcher.spring android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_spring android:targetActivitycom.unity3d.player.UnityPlayerActivity / /application注意默认主Activity即UnityPlayerActivity本身不要加intent-filter。如果MainActivity上带了MAIN和LAUNCHER那么它本身就是一个桌面入口与alias的图标会同时存在即使你去disable它系统在某些ROM上仍会残留缓存入口。正确做法是主Activity不声明入口所有入口全部由alias承担。这里特别说一下为什么采用默认alias 多个皮肤alias而不是默认Activity不加alias、只在需要时enable某个alias。因为一旦你disable掉当前唯一入口在所有图标切换完成的瞬间系统可能找不到任何LAUNCHER入口App直接变成无桌面入口状态。某些国产ROM会把应用排序到未安装完整应用一类。所以必须保证至少有1个alias永远处于enabled状态。这也是我推荐始终保留一个默认alias的原因。3.2 Java层切换逻辑在Android端我在Unity导出的工程里加了一个类例如IconSwitchHelper.java封装切换逻辑public class IconSwitchHelper { public static final String[] ALIAS_NAMES { com.game.launcher.default, com.game.launcher.spring, com.game.launcher.summer, com.game.launcher.autumn, com.game.launcher.winter }; public static void switchIcon(Context context, int index) { if (index 0 || index ALIAS_NAMES.length) return; PackageManager pm context.getPackageManager(); String target ALIAS_NAMES[index]; // 先把所有alias关掉 for (String alias : ALIAS_NAMES) { pm.setComponentEnabledSetting( new ComponentName(context, alias), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ); } // 打开目标alias pm.setComponentEnabledSetting( new ComponentName(context, target), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ); } }这段代码在整个流程里最需要留意的是DONT_KILL_APP这个flag。如果不加这个flagPackageManager在修改组件状态时会顺带杀掉App进程。对Unity手游来说进程被杀意味着用户当前会话直接中断C#与Java的通信也会断掉。千万别漏。3.3 冷启动恢复桌面图标不是切了就永久换的保险箱Activity-alias方案有个很要命的情况你的代码里虽然切换了启用状态但用户在桌面短时间看不到变化这是正常的因为桌面Launcher的图标缓存有自己的刷新机制。但如果用户等半天还不刷新多半是以下原因之一桌面App没有收到系统广播图标缓存还是旧的。系统直接把App的最近任务卡片里的图标也缓存了。部分ROM要求桌面内应用信息页手动刷新一次。所以我强烈建议在切换后调用一次sendBroadcast来触发桌面刷新。常见做法是发系统级刷新不过这个broadcast在不同版本有权限限制。更通用的做法是调用pm.setComponentEnabledSetting之后再通过ACTION_PACKAGE_CHANGED通知桌面让Launcher重新读取入口Intent intent new Intent(Intent.ACTION_PACKAGE_CHANGED); intent.setPackage(context.getPackageName()); context.sendBroadcast(intent);部分桌面收到该广播后能立刻重读icon但另一部分尤其非Google原生的桌面依然会延迟。实测中发现最后能稳定生效的还是让用户把App从最近任务划掉或者直接杀进程重启冷启动。因为冷启动时Launcher会强制做一次完整解析。既然冷启动是保险手段那在Unity侧也要做对应的恢复逻辑。我们在C#层记住当前生效的icon版本例如存到本地PlayerPrefs或者通过SDK的远端配置读取。每次Unity启动时首帧回调里把这个版本号传给Java层Java层启动时再走一遍switchIcon到对应alias。这样即使某次切换后桌面缓存没刷新用户杀进程再点开App也能看到正确图标。这个启动恢复逻辑看起来多此一举实际项目里它是防呆用的。因为在开发自测阶段我发现我连续切换图标后即便Java层状态已经改了桌面图标却还是旧的杀进程重启一轮才正常。当时差点怀疑是代码没生效后来确认就是Launcher缓存问题。3.4 Unity C#侧与Android原生互相调用的写法在Unity工程里加桥接类方式很多。我用的是最简单的一种打一个aar放入Assets/Plugins/Android同时C#侧直接操作Java静态方法。C#示例using UnityEngine; public class AndroidIconSwitcher : MonoBehaviour { private static AndroidJavaObject _helper; private static void PrepareHelper() { if (_helper ! null) return; using (var player new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { var activity player.GetStaticAndroidJavaObject(currentActivity); var helperClass new AndroidJavaClass(com.xxx.IconSwitchHelper); _helper helperClass.CallStaticAndroidJavaObject(getInstance, activity); } } public static void SwitchIcon(int index) { if (Application.platform ! RuntimePlatform.Android) return; PrepareHelper(); _helper.Call(switchIcon, index); } }要注意C#调Java时千万不要在子线程直接操作Unity的Activity。尽量把切换动作放到Android主线程或者Unity主线程回调里。之前测试时我在一个HttpClient回调里直接调了Java层切换结果部分机型上直接闪退后来加了RunOnUiThread稳定解决。public void switchIcon(final Context context, final int index) { if (Looper.myLooper() Looper.getMainLooper()) { doSwitch(context, index); return; } activity.runOnUiThread(new Runnable() { Override public void run() { doSwitch(context, index); } }); }4. iOS侧实操备用图标声明和切换链路4.1 Info.plist与图标文件规划iOS端我按Xcode工程的方式处理在Info.plist里配置CFBundleIcons。这里有一个通用配置项和一套备用配置项keyCFBundleIcons/key dict keyCFBundleAlternateIcons/key dict keySpring/key dict keyCFBundleIconFiles/key array stringAppIconSpring/string /array keyUIPrerenderedIcon/key false/ /dict keySummer/key dict keyCFBundleIconFiles/key array stringAppIconSummer/string /array keyUIPrerenderedIcon/key false/ /dict /dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon/string /array keyUIPrerenderedIcon/key false/ /dict /dict需要注意AppIconSpring和AppIconSummer这组图标资源必须全部以实际PNG或Assets目录里的图片存在。最好是按照iOS标准图标尺寸全部提供不要只丢一个60pt的图让系统自动缩放。系统会自动降采样大图但前提是同一个名字下必须有足够大的尺寸例如至少提供Icon-App-603x.png180x180、Icon-App-602x.png120x120、Icon-Small-403x.png120x120、Icon-Small-402x.png80x80等。如果尺寸不全setAlternateIconName的completionHandler里一定会报错而且是contains unprocessed icon files那种很难排查的错误。另一件重要的事不要只在Assets里加图却忘了在Build Phase里把图片加进Copy Bundle Resources。经常有同事把图标贴到工程文件夹里但没加入Target运行时切图标就报File not found in bundle。排查半天发现工程配置问题特别浪费时间。4.2 切换代码与C#桥接iOS端原生切换代码很简单- (void)switchToIcon:(NSString *)iconName { [[UIApplication sharedApplication] setAlternateIconName:iconName completionHandler:^(NSError *error) { if (error) { NSLog([IconSwitch] failed: %, error.localizedDescription); } }]; }把这段代码放到一个能被Unity C#调用的类里即可。我这里用的是extern方式在.mm文件里直接实现extern C void _SwitchToIcon(const char *iconName) { if (iconName NULL) return; NSString *name [NSString stringWithUTF8String:iconName]; [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError *error) { if (error) { NSLog([IconSwitch] failed: %, error.localizedDescription); } }]; }C#侧using System.Runtime.InteropServices; public static class IOSIconSwitcher { [DllImport(__Internal)] private static extern void _SwitchToIcon(string iconName); public static void SwitchToIcon(string iconName) { if (Application.platform RuntimePlatform.IPhonePlayer) { _SwitchToIcon(iconName); } } }这里需要注意之所以用__Internal是因为Unity在iOS平台会把所有C/Objective-C代码链接到主二进制里调用时直接把符号映射过去。C#的string类型在传到原生侧时Unity会自动处理好UTF-8到NSString的转换不需要额外编码处理。4.3 iOS切换时的限制与用户交互iOS端切换有两个影响体验的因素系统会弹出确认框询问是否更换图标。这没法静默绕过。切换过程不是瞬间的系统有一定动画替换过程快速连续切换容易出现只生效最后一次的情况。在游戏内体验上推荐的做法是弹出一个活动公告界面用户点击换个节日图标确认后立刻调原生接口。因为确认框是系统弹的游戏内不要再做一层二次确认否则用户被问两次会很烦。另一个细节setAlternateIconName传nil表示恢复主图标。所以默认状态可以用SwitchToIcon(null)实现。C#侧调用前要判断字符串为空的情况原生侧也要做NULL防护。5. 双端方案会碰到的常见坑和可靠性处理5.1 测试机验证的结论和坑我在Android侧测试了Pixel 3、华为Mate 40 Pro、小米11、一加9TiOS侧测试了iPhone 8、iPhone 12、iPhone 13 ProiOS 15/16。把实际碰到的状况列在这里现象出现环境原因解决方案图标切换后桌面无变化多款ROMLauncher图标缓存未刷新加广播通知 冷启动兜底恢复首次安装后图标为系统默认所有机型首次安装时Launcher尚未收到alias状态更新启动时根据本地存档恢复一次切换后App闪退Android 8以下部分机型setComponentEnabledSetting触发组件状态广播导致进程被杀使用DONT_KILL_APP主线程调用图标更新后显示模糊iOS备用图标尺寸不满足所有档位补齐所有尺寸图片连续切换后图标错乱Android多次快速切换导致Launcher读取竞争每次切换之间增加时间间隔或合并成一次切换更新应用后图标恢复默认Android/iOS双端安装包更新会重置组件状态版本启动时按保存状态重设其中最坑的是首次安装后图标可能不生效这个点。最开始用aar方式集成测试时我们等了几分钟桌面图标都是默认的但代码确实切到了夏季皮肤。后来发现是Android 8之后部分桌面在图标缓存刷新之外还会读一次应用信息里的默认icon。也就是说即使你改了alias状态桌面入口的android:icon可能还是从manifest解析缓存的旧值。解决办法就是在首次启动时也走一遍恢复逻辑把当前状态强制设一遍。5.2 高版本系统的兼容性问题Android 12/13Android 12引入了主题图标monochrome iconAndroid 13也有相关优化。如果你的应用声明了adaptive-icon动态换图标换成非adaptive格式系统有可能直接用默认背景色兜底看起来就是方形色块。这一点必须提前在mipmap-anydpi-v26里给每一套皮肤配好adaptive-icon版本的资源否则在Pixel系列等原生Android 12设备上会出现图标风格突兀的问题。适配图标结构大概是adaptive-icon xmlns:androidhttp://schemas.android.com/apk/res/android background android:drawablemipmap/ic_launcher_background_summer / foreground android:drawablemipmap/ic_launcher_foreground_summer / /adaptive-icon动态切换时setComponentEnabledSetting对adaptive-icon的alias一样有效所以Android 12/13上直接能用但前提是你资源里必须真的存在对应的adaptive-icon配置。5.3 审核红线iOS更换图标不能与功能性核心无关的频繁更换绑定做iOS动态图标容易踩到一个灰色地带苹果审核要求App Store展示的图标与用户最终看到的内容一致但这不代表不能有备用图标。很多天气类、日历类App都有动态换图标功能。不过要注意如果你把换图标做成需要付费解锁、或者做得太频繁、太复杂比如每天随机换审核有概率被打回。我见过项目因为图标切换后没有恢复默认、导致用户桌面出现明显与App内容无关的图标被审核方向询问的案例。稳妥的做法是服务端下发活动图标时同时下发一个活动结束自动恢复主图标的时间戳客户端在做热更逻辑时一并处理。不要做成每次打开App自动随机换图标这会让用户对桌面上图标变化感到困惑也可能收到投诉。活动期间换上的备用图标必须与App本身内容或节日主题强相关避免搞怪图被审核方认定为误导用户。5.4 与热更新体系协同图标切换的触发链路Unity手游大多有热更新。图标要跟着运营活动变最合理的触发链路不是发一个强更包而是通过服务端配置下发服务端下发活动配置活动ID、图标ID、生效时间、失效时间。Unity客户端拉取到配置后本地记录生效图标ID。客户端在空闲时比如主界面停留超过3秒调原生桥接层切换图标。切换完成后通过SDK埋点上报结果。下次冷启动时读取本地记录做一次恢复型设置。这个链路里有一个容易踩的坑很多Unity游戏在启动阶段就收到活动配置直接尝试换图标但此时原生层组件可能还没初始化完成或者Android某个版本的PackageManager操作过慢导致卡顿。建议把图标切换动作延迟到主界面加载完成回调之后并且包一层幂等操作当前状态等于目标状态时直接跳过。6. 双端统一切换接口的设计心得6.1 状态机与幂等性原生操作不像C#上操作变量那样瞬间可见所以C#层最好抽象成一个状态机Idle → Requesting → Done → (本地永久化)不要在Done之前重复发起切换请求。应设计一个简单的防重入标记避免连续点击活动页面换图按钮导致双端同时接到多条指令。我实现的是一个单例管理器内部持有一个枚举状态字段只有状态为Idle时才允许发起新的切换请求public enum IconState { Idle, Switching, Done } public static void Switch(string iconId) { if (_currentState ! IconState.Idle) return; _currentState IconState.Switching; #if UNITY_ANDROID AndroidIconSwitcher.SwitchIcon(ParseIndex(iconId)); #elif UNITY_IOS IOSIconSwitcher.SwitchToIcon(iconId); #endif _currentState IconState.Done; SaveLocal(iconId); }6.2 图标ID命名统一Android端用索引int方便遍历alias数组但iOS端用的是字符串iconName。为避免双端逻辑混乱我们统一在服务端下发一个字符串ID例如skin_lv0_default、skin_lv2_summer。C#层在桥接时做映射Android按ID找到alias数组索引iOS直接作为备用图标名使用。这看起来是小事但项目里多个客户端共用一套配置时尤其容易乱。最好在C#层做一次iconId - MatchResult的解析避免Android和iOS用各自不同的索引规则导致服务端配置管理混乱。6.3 生命周期和内存水位换图标本身不涉及大内存操作原生层只是改几个组件开关或调一个系统API。但要注意Unity侧的配置解析、图标名映射、状态记录尽量不要频繁分配堆内存避免在活动切换期间和场景加载的内存峰值打架。尤其Android侧如果还要更新mipmap资源先解引用旧的Drawable再侦察新的资源否则会触发不必要的GC。7. 上线后的运营玩法扩展做完这套动态换图标能力后最简单的玩法是节日皮肤但还可以继续扩展新用户7日签到第7天换成庆祝图标。联动活动期间换成合作方风格的图标。赛季结算当天换成最强王者样式。电竞战队夺冠后紧急换图标做实时热点营销。这些玩法本质上都是同一套能力换的只是何时触发和换成什么图标。有了动态换图标作为基础设施很多活动创意都展开了一个新的可实施维度。美术资源成本是最主要的部分原生层代码基本不用再动。举例来说S12赛季结算那天我们临时上线了一版冠军之夜图标服务端下发配置后30分钟内全量用户被替换成活动皮肤。因为不用发版活动当天早上提交美术中午审核下午生效。这在以前完全不可想象。有一说一运营侧其实最看重这个不发版还能改桌面图标的能力远比技术侧想的要多。节日皮肤只是一个吸引用户的外衣。8. 个人踩坑记录与工程化建议做这个项目前后一共踩了比较大的坑大约5个小的细节问题不下10个。逐个说一下值得注意的Android切图标用PackageManager时必须指定DONT_KILL_APP。这个前面已经反复强调但测试时还是会有人漏写因为很多示例源码里没有这个flag。漏写一次就会发现在调完接口后Unity的Logcat突然断开进程被杀。iOS的setAlternateIconName只能在真机调试时触发。模拟器里调用永远会报一个icon.png not found之类的错误会让你以为配置有问题。实际只要真机正常模拟器忽略即可。Android上连续切换多个alias中间不要做UI展示等待。直接全部disable再启用目标alias比逐个切换要可靠。我最初写的是先切到默认再切到目标导致桌面有时会闪现默认图标。iOS备用图标不要和主图标同名。同名时系统会拒绝切换。如果你代码里写setAlternateIconName:AppIcon大概率执行会失败。备用图必须用不同于主图的名字如AppIconSummer、AppIconFestival。打包流程要注意图标资源有没有被压缩。Android的mipmap如果被shrinkResources移除会出现运行时资源找不到的问题。建议在build.gradle里对图标资源加tools:keep例如resConfig zh-rCN // 暂时保留下划线命名资源 android { buildTypes { release { shrinkResources true resValue string, keep_icon_res, true } } }具体可以直接用res/raw/keep.xmlresources xmlns:toolshttp://schemas.android.com/tools resource nameic_launcher_summer typemipmap tools:keepmipmap/ic_launcher_summer/ /resources服务端配置最好带一个回滚开关。如果某一版活动图标出现被用户大面积投诉例如颜色过于刺眼、视觉恐怖运营要能一键让所有客户端恢复默认图标。这个开关不需要客户端发版只要下次判断活动状态时读到已下线即自动恢复默认。最后提一个工程化建议把换图标能力封装成一个独立的Unity Package或插件模块不要散落在主工程里。这样以后接新项目时可以直接复用。我把Android的aar、iOS的源文件、C#管理器、服务端协议文档放在同一个插件目录下后续两个项目接入基本一周内完成。动态换图标这功能技术上确实不难但细节多、涉及系统能力稍不注意就做成看起来能换真机上各种不生效。把状态管理和恢复逻辑做扎实才是能上线运营的稳定方案。