做手游运营的同事大概都经历过类似的场景:某个版本想蹭春节节点,运营提了个工单——“周五之前,把游戏在手机桌面上的图标换成春节版,活动结束再换回来”。这个需求听起来简单,落地却涉及 Unity 手游在 Android 与 iOS 双端如何动态更换 App 图标。很多项目做到了玩法、UI、性能优化,一碰上系统层面的功能就有点发懵:桌面图标不是打包时写死的吗?运行时还能改?这篇文章我就把两条平台路线的原理、接入步骤、边界限制和实际踩过的坑一次讲清楚,适合 Unity 客户端主程、原生扩展开发,以及所有被运营追着要功能的朋友参考。
1. 需求拆解:运营要的“换个图标”,到底是在换什么
先说结论:动态更换 App 图标,指的是系统桌面(Android Launcher / iOS 主屏幕)上显示的这个应用图标,在 App 运行期间被切换成另一套预置图标资源。它不等于游戏启动画面,不等于应用内主题,更不是桌面小组件的换肤。这个区别必须先跟运营对齐,否则后面所有工作都会跑偏。
从运营视角看,换图标的诉求这几年越来越高频:
- 节日活动:春节、中秋、双十一、周年庆,期间换一套氛围图标,提高商店页和桌面端的辨识度。
- 版本联动:大版本更新时配合美术风格,把图标提前换成新 Key Visual。
- 限时返场:某 IP 联动活动结束后,把图标改回普通版本。
- 用户自选:更进一步的玩法,让玩家在多个官方图标里选一个,作为个性入口。
工程上这四种诉求其实分属于两种技术路径:
| 诉求类型 | 实现方式 | 工作量和风险 |
|---|---|---|
| 平时换新图标(下次版本) | 直接改打包资源,重新发版 | 最低 |
| 限时/动态换图标(本次活动) | 运行时切换预置图标 | 中等,本文主题 |
| 用户自选图标 | 动态切换 + 多套预置图标 + 持久化 | 较高 |
| 从服务器拉取新图标再换 | Android/iOS 均不支持 | 不可行 |
为什么“从服务器拉一张图,运行时换到桌面”不可行?这是需求沟通时要讲清楚的第一条红线。iOS 的setAlternateIconName只认 App Bundle 内声明的图标文件,Android 的 Activity-alias 也只认 Manifest 里配置的资源,都不允许运行时下载一张图片直接当图标用。运营提的“活动图标”,必须在发版前以资源形式打进安装包。所以做这功能,本质是在“提前内置 N 套图标”和“运行时切换显示哪套”之间做文章,而不是真的动态下载。
这个功能适合的场景也很明确:游戏已经有稳定的版本节奏,运营活动可以提前规划,图标美术资源能跟着版本一起提交。如果运营每次活动前一天才给图,那谁来做都救不了,及早回复“不行”也是专业的一部分。
2. Android 老路线:Activity-alias 组件的启用与禁用细节
Android 上经典的动态换图标方案,是通过 Manifest 里的多个<activity-alias>指向同一个主 Activity,再用PackageManager.setComponentEnabledSetting在运行时切换启用/禁用状态。这也是今天绝大多数国内手游还在用的方式。
2.1 AndroidManifest 中 alias 的注册姿势
以一个MainActivity+ 默认图标 + 春节图标为例,Manifest 核心配置长这样:
<activity android:name="com.yourgame.activity.MainActivity" android:exported="true" android:screenOrientation="landscape"> </activity> <!-- 默认图标入口 --> <activity-alias android:name="com.yourgame.activity.MainActivity_Default" android:targetActivity="com.yourgame.activity.MainActivity" android:exported="true" android:enabled="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="com.yourgame.activity.MainActivity_Spring" android:targetActivity="com.yourgame.activity.MainActivity" android:exported="true" android:enabled="false" android:icon="@mipmap/ic_launcher_spring" android:label="@string/app_name_spring"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity-alias>注意几个关键点:
- 每个 alias 独立声明 icon 和 label,label 也可以随活动换文案。
android:enabled默认只有一个为true,否则桌面会出现两个相同 App 入口,这是最常见的翻车点。- 所有 alias 必须能处理 MAIN/LAUNCHER intent-filter,系统才会把它们当作可启动的桌面入口。
- Unity 工程里,这个配置要写进AndroidManifest.xml,并且要注意 2019 之后 Unity 默认把 MainActivity 写在
unityLibrary的清单里,合并规则不熟的人容易改错文件,改完发现没生效。建议把 alias 单独放一个 Manifest 片段,通过AndroidManifest.xml的tools:node="merge"合并进最终清单。
2.2 PackageManager 切换的关键代码与执行顺序
原生侧切图标的代码核心就一个方法:
public static void setAlias(Context context, String enableAlias, String disableAlias) { PackageManager pm = context.getPackageManager(); ComponentName disableComp = new ComponentName(context, disableAlias); ComponentName enableComp = new ComponentName(context, enableAlias); // 先禁用旧入口,再启用新入口 pm.setComponentEnabledSetting( disableComp, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); pm.setComponentEnabledSetting( enableComp, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); }为什么是“先禁用,再启用”?因为如果先启用新的,两个入口有瞬间同时存在,桌面会出现短暂双图标;虽然通常只有几十毫秒,但部分低端机 Desktop 刷新慢,可能让用户看到两个图标,体验很差。先禁用旧的虽然也可能让入口短暂消失,但至少不会两个并存。
另外一个容易忽略的点:ComponentName的 class 名要完整包名,不是简单写".MainActivity_Spring"。Android 官方虽然是点号开头,但在new ComponentName(context, String)时,如果你传的是相对名,系统在部分 ROM 上能解析、在 AOSP 上不一定。最好是完整名,实测下来最稳。
切换完成后,Launcher 是通过接收PACKAGE_CHANGED广播来刷新图标的,所以不需要我们主动去通知桌面。但不同厂商桌面对这个广播的处理时机不一样,华为 EMUI 和三星 One UI 表现都不同,有些是秒刷新,有些要等几秒,真机验证时别一看到没立即变就以为失败。
3. Android 新路线与兼容退路:targetSdk 34 之后怎么办
Activity-alias 方案虽然经典,但从Android 14(targetSdk 34)开始,这条路已经不太平了。谷歌在这个版本上收紧了运行时动态图标的支持,官方兼容性说明明确提到:通过setComponentEnabledSetting切换 activity-alias enabled 状态来更换图标,在 targetSdk 34+ 的 App 上有很大概率不再触发桌面图标刷新,你的入口状态可能变了,但桌面图标就是纹丝不动。这对做海外市场的团队几乎是致命的,因为 Google Play 从 2024 年 8 月之后新版本内更新要求 targetSdk 34+。
所以现在的 Android 侧实现,我建议做按系统版本分流:
3.1 低版本继续走 alias(Android 13 及以下)
这块按上面第二章节的配置实现即可,稳定、直接、可批量配置。唯一要注意的是如果你的图标用到了Adaptive Icon,那每个 alias 的 icon 资源要同时准备 foreground/background 层,不能只给一个普通 PNG。Android 8+ 的桌面默认用 Adaptive Icon 的遮罩,如果只给普通图,很多桌面会把它放到一个白色圆形底里,效果非常违和。
3.2 高版本降级:Pinned Shortcut
targetSdk 34+ 上,官方推荐的能力是Shortcut / Pinned Shortcut。它的好处是不改变组件 enabled 状态,而是直接创建一个“固定快捷方式”到桌面,可以带自定义图标。代码大致这样:
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class); if (shortcutManager != null) { ShortcutInfo shortcut = new ShortcutInfo.Builder(context, "shortcut_spring") .setShortLabel("春节版") .setIcon(Icon.createWithResource(context, R.mipmap.ic_launcher_spring)) .setIntent(new Intent(Intent.ACTION_MAIN) .setClassName(context, "com.yourgame.activity.MainActivity") .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) .build(); Intent intent = shortcutManager.createShortcutResultIntent(shortcut); // 需要配合 PendingIntent 调用,这里省略 shortcutManager.requestPinShortcut(shortcut, null); }注意requestPinShortcut是系统会弹窗让用户确认的,不是无声无息地替换图标,而且创建出来的是独立的快捷方式,不是替换原 App 图标,这是两个概念。所以它只能算“降级交差”方案,不能直接说支持Android 14动态换图标。
3.3 最高保底方案:弹窗引导
如果快捷方式也弹窗、用户不接受,那最后的兜底是发版前预置多套图标,活动切换时给用户一个“如何手动更换图标”的引导流程。这种方案参与感更强,但转化率低,只适合没有硬性指标的时候用。
事实上,很多只做国内安卓的团队,现在还在用targetSdk 30/33,Activity-alias 方案继续跑没什么问题,但只要哪天你准备上 Google Play 或把 targetSdk 升到 34,就必须提前把降级链路做好,否则活动期间图标没法换,运营可是会直接找上门的。
4. iOS 端实现:setAlternateIconName 与 Info.plist 的配对法则
iOS 这边比 Android 清爽很多,因为系统从 iOS 10.3 开始就提供了官方 API:UIApplication.setAlternateIconName(_:completionHandler:)。但清爽不等于没坑,最大的坑就是名字配对:你调用 API 传的名字,必须和 Info.plist 里声明的一致,否则系统直接回调错误。
4.1 Info.plist 配置必须遵守的配对关系
在 Xcode 工程或 Unity 生成的 Info.plist 里,要加这样一段:
<key>CFBundleIcons</key> <dict> <key>CFBundleAlternateIcons</key> <dict> <key>IconSpring</key> <dict> <key>CFBundleIconFiles</key> <array> <string>IconSpring</string> </array> </dict> </dict> </dict>然后往 Xcode 工程里放两张图:IconSpring@2x.png(120x120)和IconSpring@3x.png(180x180),并且确保它们被 Copy Bundle Resources。注意 CFBundleIconFiles 里写的是“无后缀的基础名”,不是带 @2x 的文件名,系统会自动按 scale 找IconSpring@2x.png/IconSpring@3x.png。
这里有几个硬性限制,踩过的人都懂:
- 图标图片不能包含 alpha 通道,否则 App Store 审核会有警告,部分审核情况下会以“图标不符合规范”驳回。
- 图片必须随包放在 main bundle 里,不能放到 StreamingAssets 或沙盒目录,运行时下载更是不行。
- 如果支持 iPad,要额外处理
CFBundleIcons~ipad下的CFBundleAlternateIcons配置,常见坑是 iPhone 配了、iPad 没配,真机一测 iPad 图标不生效。
4.2 OC 插件与 C# 侧调用
Unity 工程里要调用这个能力,最省事的做法是写一个 Objective-C 插件,用extern "C"导出给 C# 调用:
#import <UIKit/UIKit.h> extern "C" void _ChangeAppIcon(const char *iconName) { NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion]; if (version.majorVersion < 10 || (version.majorVersion == 10 && version.minorVersion < 3)) { UnitySendMessage("AppIconBridge", "OnComplete", "unsupported"); return; } NSString *name = nil; if (iconName != NULL) { name = [NSString stringWithUTF8String:iconName]; } dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError *error) { if (error) { UnitySendMessage("AppIconBridge", "OnComplete", "fail"); } else { UnitySendMessage("AppIconBridge", "OnComplete", "ok"); } }]; }); }必须在主线程调用,且一旦用户首次使用这个能力,系统会弹一个确认弹窗,如果用户点了不允许,completionHandler 里的 error 不为空,要处理这个分支而不是默认一定成功。
iOS 的动态换图标还有一些体验层面的细节,比如切换后主屏幕图标会立即变,但 App 内、Spotlight 搜索、设置列表里的图标可能存在不一致,这在 iOS 17 上会多一些同步优化,但老版本表现差异明显,上线前要跟运营讲清楚,别让他们在测试机上看到了什么奇怪反馈就紧张。
5. Unity 桥接层:把原生能力封装成双端统一的 C# 接口
按上面的方案,原生逻辑已经就绪,但 Unity 工程里不能每个调用点都去区分 Android/iOS。这里我建议做一个AppIconManager静态类,提供统一的 C# API,上层无论是 UI 按钮、Lua 脚本、服务端配置,都只跟这个类打交道。
5.1 统一接口设计与平台分发
public static class AppIconManager { public static event Action<bool> OnIconChanged; #if UNITY_ANDROID && !UNITY_EDITOR private static readonly AndroidJavaClass IconHelperClass = new AndroidJavaClass("com.yourgame.common.IconHelper"); #endif #if UNITY_IOS && !UNITY_EDITOR [System.Runtime.InteropServices.DllImport("__Internal")] private static extern void _ChangeAppIcon(string iconName); #endif public static void SetIcon(string iconId) { if (string.IsNullOrEmpty(iconId)) { RestoreDefault(); return; } #if UNITY_ANDROID && !UNITY_EDITOR IconHelperClass.CallStatic("setIcon", iconId); OnIconChanged?.Invoke(true); #elif UNITY_IOS && !UNITY_EDITOR _ChangeAppIcon(iconId); #endif } public static void RestoreDefault() { #if UNITY_ANDROID && !UNITY_EDITOR IconHelperClass.CallStatic("restoreDefault"); OnIconChanged?.Invoke(true); #elif UNITY_IOS && !UNITY_EDITOR _ChangeAppIcon(null); #endif } public void OnComplete(string result) { OnIconChanged?.Invoke(result == "ok"); } }这里要注意第三方的回调桥接:iOS 插件跑完会UnitySendMessage("AppIconBridge", "OnComplete", "ok"),所以场景里需要一个名为AppIconBridge的 GameObject,挂一个脚本接收消息,然后转发给AppIconManager.OnComplete。Android 那边因为切换是同步请求+异步桌面刷新,我一般在 C# 里直接当成成功处理,但发送完以后不要立刻让玩家切到桌面看效果,建议等 0.5~1 秒再提示“已切换”,否则玩家切出去时桌面还在刷新,会误以为失败了。
5.2 回调、状态持久化与 Lua 侧暴露
更完整的工程还会把当前图标状态持久化到PlayerPrefs,这样用户强杀游戏再回桌面,图标还保持切换后的状态。但只记自己这一侧不够,iOS 侧最好在启动时读一下系统的当前状态,因为系统可能会因某些原因回退默认图标,跟本地记录对不上。读取很简单:
NSString *currentIcon = [[UIApplication sharedApplication] alternateIconName];Android 侧也可以通过PackageManager查各 alias 的 enabled 状态来判断当前生效的是哪一套,不过写法比较繁琐。更推荐的做法是:以服务端活动配置为唯一事实来源,客户端启动时拉一下活动状态,发现活动结束就调RestoreDefault,别依赖本地记录。这样即使本地记录坏了,也能被服务端纠正回来。
如果你的游戏是 Lua 驱动的,桥接层再加一层轻封装即可:
local AppIconManager = require("Game.Utils.AppIconManager") AppIconManager.SetIcon("spring")上层代码完全感知不到平台差异,后续接入新活动也只是多配一个图标 ID 的事。
6. 恢复策略、审核红线与真机验证清单
功能上线后,真正的考验其实是“什么时候换回去”和“换不回去怎么办”。我在实际项目里没少被这个事坑过。
6.1 恢复时机与常见失效场景
恢复默认图标的时机要考虑三块:
- 服务端活动结束信号:每次启动/回前台时检查,如果活动已过期就恢复默认。
- 客户端内活动倒计时结束:活动结束后立即恢复图标,避免玩家活动都结束了,桌面上还挂着春节图标,直到运营线下催你。
- 版本更新:Unity 发新版本后,Android alias 状态和 iOS alternate icon 状态到底保不保留?
关于第三点,实测结果如下:
- iOS:系统会保留 alternate icon 状态,覆盖安装后如果新包仍含相同图标资源,一般不会自动回退。但如果你在新版本里删掉了旧的图标资源,系统会遇到资源找不到的情况,表现不稳定,所以升级版本时最好显式恢复默认一次。
- Android:
setComponentEnabledSetting的状态会持久化,覆盖安装一般会保留。但 Android 14+ 因为不吃这套,很可能“看似保留了状态,但桌面图标没刷新”,这才是最麻烦的,所以高版本还是要走 fallback 链路。
还有一类很隐蔽:资源 ProGuard/shrinkResources 混淆后,图标资源被裁剪掉了。Release 包如果没正确配置 keep,alias 引用的@mipmap/ic_launcher_spring可能在打包时被优化掉,切图标后桌面显示的是系统兜底灰色图标,用户和运营都一脸问号。解决方式是在 ProGuard 规则里 keep 所有R.mipmap资源,或者给持续引用的图标资源加tools:keep="@mipmap/ic_launcher_spring"。
6.2 提审说明与国内厂商真机验证
iOS 上使用setAlternateIconName本身是官方开放能力,一般不因为“你调用了动态换图标”而被拒。但审核风险在于提交的备用图标内容是否符合内容规范,比如不能用动态图标做赌博、色情、仿冒系统应用等擦边行为。提审时审核助手会读取 Info.plist 里的CFBundleAlternateIcons,所以备用图标也必须在设计上跟 App 图标保持同一标准。
Android 这边国内厂商虽然没有统一审核,但各家桌面的兼容性差异非常大。我建过一张真机验证清单,团队在上线前会按这个打点:
| 测试项 | 关注点 |
|---|---|
| 华为 EMUI / 小米 HyperOS / ColorOS / OriginOS | 切换后桌面图标是否自动刷新、是否出现双图标 |
| 覆盖安装更新 | 图标状态是否保留、quick settings 和相关入口是否正常 |
| Android 14+ 真机 | 是否走了 fallback 链路、用户手动确认时文案是否正确 |
| iOS 12~17 | 首次弹窗授权、拒绝后能否再次触发、恢复默认图标是否正常 |
| App 冷启动/热启动 | 启动时读服务端配置是否有延迟,是否出现短暂默认图标 |
还有一个小建议:别在代码里做太多“智能恢复”。早期我写过一个每次进游戏都检查当前桌面图标跟服务器配置是否一致的逻辑,导致用户手动改了其他图标也被强制“纠正”回去,反而收到了差评。正确的做法是:服务器只在活动开始/结束两个节点下发配置,客户端在这两个节点切一次图标,中间不干预用户的选择。
最后分享一个可以少走弯路的经验:动态换图标功能一旦接上,每次运营活动都会来排队找你要“再加一版图标”。所以接入时把表结构设计好:图标配置表字段建议是icon_id / alias_name / ios_key / resource_path / 活动开始时间 / 活动结束时间,服务端下发时把过期时间一起带下来。这样后续做节日图标、联动图标,都只是往配置表里加一行的事,不再需要客户端发版。我在项目里把这套东西跑稳之后,最直观的感受是:运营的“节日换图标”需求从两天开发量降到了半小时资源配置量,这才是这个功能真正的价值所在。