
做手游的都知道App 图标这东西平时不起眼一到春节、周年庆、大版本更新它就是运营眼里最想“搞事情”的入口。春节换红底、周年庆换新配色、新版本换个主视觉一个动态更换的图标往往比 push 和邮件更能刷存在感。我在 Unity 客户端接过几次这类需求说句实话功能听起来很简单真正落地时双端完全是两个量级的工程iOS 一句系统 API 搞定大半Android 得折腾 Manifest、组件启用状态、还有各家 ROM 的刷新策略。这篇文章就把我在 Unity 手游里做动态更换 App 图标的完整方案和踩坑过程都记录下来Android 与 iOS 双端的细节都会拆开讲。1. 需求场景与双端方案选型1.1 什么场景需要动态更换图标手游行业的图标更换和普通 App 的换肤需求完全不一样。普通 App 换图标更多是用户主动选择而手游换图标绝大多数是运营侧驱动的强制行为常见场景就这么几类节日/活动运营春节、端午、周年庆、联动 IP 上线图标切到对应主题视觉拉高应用商店页面的点击率。版本迭代预热新版本大版本号更新希望玩家在桌面上先感知到“游戏变了”有助于提升更新打开率。A/B 测试买量转化不同投放渠道或不同用户圈层使用不同图标测试下载转化率这是买量团队比较喜欢的手法。特殊变体包比如渠道要求某个包显示不同图标但又不希望打多个 APK/IPA运行时动态切换就成了唯一选择。我做过的项目里最常见的是节日运营。游戏团队会把节日素材提前给到客户端客户端等服务器下发开关后统一切换。服务器控制的好处是可以强制所有在线用户在同一时间看到新图标不用等用户去设置中心手动选。1.2 双端实现原理差异与选型对比动态更换图标的跨平台难点在于 iOS 和 Android 的系统机制完全不同iOSiOS 10.3 起苹果开放了setAlternateIconName这类 API系统原生支持备用图标。开发者只需要把备用图标资源打包进 App并在 Info.plist 里提前声明然后一行代码切换即可。整个流程是系统级的桌面刷新、状态管理都交给系统开发者基本不用操心。AndroidAndroid 系统本身没有“备用图标”概念。桌面图标本质上是通过 Manifest 中带有MAINLAUNCHER过滤条件的 Activity 或 Activity-alias 暴露出来的。要通过PackageManager.setComponentEnabledSetting来切换“哪个入口组件启用、哪个禁用”从而让桌面重新扫描后显示对应组件的图标和名称。这个机制看起来神奇实际上是换了一个启动入口并非直接替换图标资源。两边的实现路径差异很大我把选型对比列出来方便你在项目立项初期就能做方案评估对比项AndroidiOS系统支持无原生备用图标机制原生支持备用图标10.3核心实现Activity-alias 组件切换Info.plist 声明 API 切换资源要求每个图标对应一个 alias随包携带备用图标需在 Assets.xcassets 中补齐多尺寸刷新机制依赖 Launcher 扫描组件变化系统统一处理适配难度中高需适配 ROM 和 Android 12相对低审核风险Manifest 声明多入口需注意合规备用图标内容需符合审核要求我在实际项目里最终定下的方案是双端都做原生桥接C# 层只暴露一个统一接口由服务器下发图标 Key 驱动切换。这样运营同学不用关心平台差异客户端只需要维护一个“Key 到原生资源/组件名”的映射表就行。2. Android 端实现细节与 Unity 桥接2.1 Activity-alias 机制与 Manifest 配置Android 端的原理我用一句话给大家讲透桌面图标其实就是桌面 Launcher 能扫描到的、带有LAUNCHER分类的 Activity 组件。你在 AndroidManifest 里写了多少个这类入口桌面上就可能看到多少个图标。动态切换的做法就是提前在 Manifest 里为同一个主 Activity 声明多个activity-alias每个 alias 配置不同的icon和label并带上自己的LAUNCHER过滤条件。默认情况下只有主 Activity 或某一个 alias 是启用的需要换图标时就把当前启用的组件禁用把目标 alias 启用。桌面收到组件状态变化广播后会重新读取启动器入口于是图标和名字就变了。核心 Manifest 配置大概是下面这样application android:iconmipmap/ic_launcher android:label游戏名 !-- 主 Activity保留 LAUNCHER 入口 -- activity android:name.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity !-- 备用图标 1默认关闭切换时开启 -- activity-alias android:name.MainActivity_Icon_Spring android:targetActivity.MainActivity android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_spring android:label游戏名 intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias !-- 备用图标 2默认关闭 -- activity-alias android:name.MainActivity_Icon_Anniversary android:targetActivity.MainActivity android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_anniversary android:label游戏名 intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias /application这里有几个关键细节写的时候很容易踩坑android:targetActivity必须指向同一个主 Activity否则点击图标后打开的不是游戏主界面。android:name不能和已有 Activity 或 alias 重名它本质上是一个“虚拟组件名”包名会补全成完整名称。android:exportedtrue必须显式设置尤其是 targetSdkVersion 31Android 12以上时带有 LAUNCHER 过滤条件的组件必须声明 exported否则安装或启动时可能出问题。同一时刻只能有一个 LAUNCHER 入口处于 enabled 状态。如果启用两个部分桌面会出现两个图标点进去可能还是同一个 Activity看起来很怪。2.2 Java/Kotlin 原生切换逻辑切换组件的核心代码非常简单原生侧用 Java 或 Kotlin 写一个 Manager 就好。我给一个 Java 示例public class IconManager { public static void switchIcon(Context context, String enableComponentName, String disableComponentName) { PackageManager pm context.getPackageManager(); String packageName context.getPackageName(); ComponentName enableComp new ComponentName(packageName, packageName . enableComponentName); ComponentName disableComp new ComponentName(packageName, packageName . disableComponentName); // 先启用目标组件再禁用当前组件 pm.setComponentEnabledSetting( enableComp, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ); pm.setComponentEnabledSetting( disableComp, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ); } }注意ComponentName的构造方式。如果 Manifest 里 alias 的android:name写成了.MainActivity_Icon_Spring那么在代码里拼完整类名时要补全包名最常见的错误就是只传一个短类名结果ComponentName指向了一个不存在的类setComponentEnabledSetting会直接抛异常。切换顺序也很有讲究。我是先启用新组件再禁用旧组件这样可以避免在极短的时间窗口内出现“没有任何 LAUNCHER 入口”的情况。如果先禁用旧的、再启用新的万一新组件名写错了桌面图标就会直接消失只能重装或者用 adb 恢复非常狼狈。另外有一个状态恢复的细节应用切到后台被杀掉再冷启动时组件的启用状态是持久化的由系统保存。也就是说不需要在每次启动时主动重新执行切换。但你需要在切换完成后把当前用的图标 Key 记录到本地Preference 或 PlayerPrefs供游戏内 UI 展示和后续逻辑判断。2.3 Unity C# 侧调用封装Unity 侧调用 Java 静态方法走的是AndroidJavaClass和AndroidJavaObject这套反射 API。前提是 Java 代码被打成了 jar 或 aar放进Assets/Plugins/Android目录Unity 打包时会自动合并进最终工程。C# 侧封装我一般这样写public static class AndroidIconHelper { private static readonly string PackageName com.yourgame.nativebridge; public static void SwitchIcon(string enableComponent, string disableComponent) { using (var cls new AndroidJavaClass(PackageName .IconManager)) { if (cls null) { UnityEngine.Debug.LogError(IconManager class not found: PackageName); return; } // 拿到当前 Unity Activity 实例传给原生层 AndroidJavaObject activity null; using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); } cls.CallStatic(switchIcon, activity, enableComponent, disableComponent); } } }有个容易被忽视的点IconManager类如果要放到 Unity 工程里包名千万不要和 Unity 自带的com.unity3d.player混淆也不要去改 Unity 主 Activity 的逻辑。原生层只做组件切换不做任何生命周期操作保持代码职责单一。如果你用的是 Android 的 App Bundle 或者 IL2CPP 脚本后端这些反射调用在正式包里的表现也需要测一下。至少我在 Unity 2020/2021 的多个版本实测过只要类名、方法名、参数类型对齐反射调用没有出现过问题。2.4 Android 12 适配要点Android 12 开始系统对图标和启动入口引入了一些新规则动态换图标有两个新坑要格外注意exported显式声明上边说过了targetSdk 31 以上带 LAUNCHER 的组件必须android:exportedtrue。漏掉这个轻则安装时提示解析失败重则运行时启动不了。SplashScreen启动画面Android 12 引入系统级 SplashScreen启动画面默认使用应用图标icon作为中心元素。如果你在游戏运行中切了图标下一次冷启动时系统 SplashScreen 读取的是最新启用的 LAUNCHER 组件图标这个一般没问题。但部分厂商 ROM比如 MIUI、ColorOS会自己缓存 Launcher 图标导致切换后桌面图标的刷新存在延迟。另外Android 13 开始支持主题图标Themed Icons。如果你在 Manifest 的adaptive-icon里配置了 monochrome 图层动态切换时最好把对应图层也准备好否则用户在开主题图标模式下看到的可能还是旧图标。这个问题可以在测试用例里加一条“系统主题图标模式下切图标”的验证项。真机测试时我还会做一个小检查切完图标后用 adb 看一眼系统解析出来的启动入口对不对adb shell cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER com.yourgame.app这条命令能直接显示当前启用的启动组件名如果和你预期不一致说明切换逻辑哪里出了问题。3. iOS 端实现细节与 Unity 桥接3.1 Info.plist 与图标资源配置iOS 端的实现思路比 Android 清爽得多。苹果从 iOS 10.3 开始允许开发者提供一个或多个“备用图标”Alternate Icons本质上就是在 App 的 Info.plist 里声明一个图标字典然后把图片资源放进 Assets.xcassets。关键配置如下通常要写在 Info.plist 里keyCFBundleIcons/key dict keyCFBundleAlternateIcons/key dict keyRedIcon/key dict keyCFBundleIconFiles/key array stringAppIconRed/string /array keyUIPrerenderedIcon/key false/ /dict keyDarkIcon/key dict keyCFBundleIconFiles/key array stringAppIconDark/string /array keyUIPrerenderedIcon/key false/ /dict /dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon60x60/string /array /dict /dict这里每个备用图标的 key比如RedIcon就是后面调用 API 时传入的 name必须和 Assets.xcassets 里的 AppIcon 资源集合名保持一致。在 Xcode 工程中你需要为每个备用图标创建一个AppIconRed.appiconset、AppIconDark.appiconset这类图标资源集合里面放齐所有尺寸的 PNG。Apple 要求的 iPhone 备用图标尺寸最低要覆盖这些显示场景尺寸文件规格Spotlight / 设置29x291x、2x、3x主屏幕60x602x、3x通知20x202x、3xiPad如有76x76、83.5x83.52x如果没有 iPad 版本可以只提供 iPhone 规格。但上线时 Xcode 资产校验可能会提示缺少某些尺寸最保险的做法是找设计同事一趟把所有尺寸导齐。3.2 setAlternateIconName 调用与原生封装iOS 切换图标的方法非常简单核心就一个接口UIApplication.shared.setAlternateIconName(RedIcon) { error in if let error error { // 切换失败 } }传nil则是恢复主图标。调用前最好先判断一下当前系统是否支持if UIApplication.shared.supportsAlternateIcons { UIApplication.shared.setAlternateIconName(name, completionHandler: nil) }在 Unity 工程里建议把原生代码放到Assets/Plugins/iOS目录下。Unity 导出 Xcode 工程时会自动把这些.mm和.h文件加入编译并通过__Internal符号给 C# 调用。我习惯用 Objective-C 写一个暴露给 C 的桥接函数#import AppIconSwitcher.h #import UIKit/UIKit.h extern C { void _switchAppIcon(const char* iconName) { NSString *name iconName ? [NSString stringWithUTF8String:iconName] : nil; if ([[UIApplication sharedApplication] supportsAlternateIcons]) { [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(Switch app icon error: %, error.localizedDescription); } }]; } } }C# 侧对应的声明public static class iOSIconHelper { #if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _switchAppIcon(string iconName); #endif public static void SwitchIcon(string iconName) { #if UNITY_IOS !UNITY_EDITOR _switchAppIcon(iconName); #else Debug.Log(iOS icon switching is only available on real devices.); #endif } }这里要给读者提个醒iOS 模拟器上supportsAlternateIcons通常返回 false不支持切换测试。所以 iOS 端的图标切换一定要上真机验证模拟器里大概率会静默失败或直接走 error 回调。3.3 审核与提交注意事项iOS 动态换图标是苹果明确允许的能力但审核时仍然要注意一些隐藏规则备用图标不能是违规内容比如涉及色情、暴力、政治敏感、甚至容易误导用户的“系统弹窗”风格图标。手游项目尤其注意如果用活动期大爷素材最好提前和审核团队评估一下风险。审核时可以在“审核备注”里说明动态图标用途比如“用于节日运营活动时切换不同主视觉”。我第一次提审时没写备注苹果还真的问了一句用途后来补上说明后就顺利通过。不要高频调用。虽然苹果没有明确限制调用频率但实测频繁调用setAlternateIconName会出现系统弹窗或者短暂卡顿给用户的体验很差。运营切图标一般一天最多一次这个频率是安全的。4. 工程集成与代码组织4.1 Unity 导出 Android 时的 Manifest 合并与 Gradle 配置Android 端的动态图标方案落地时最容易出问题的环节是Manifest 合并。Unity 默认导出的 Android 工程里已经有一个主 Manifest如果你想添加多个activity-alias需要把你的自定义 Manifest 放到Assets/Plugins/Android/AndroidManifest.xml让 Unity 在打包时和主 Manifest 合并。只放置自定义 Manifest 还不够。很多游戏工程里引入了大量第三方 SDK这些 SDK 的 Manifest 也会参与合并。为了避免互相覆盖要给你的activity-alias加上tools:nodereplace之类的合并标记。举个例子manifest xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:toolshttp://schemas.android.com/tools application activity-alias android:name.MainActivity_Icon_Spring tools:nodereplace /activity-alias /application /manifest另外如果你把 Java 桥接代码打成了 aarUnity 打包时也会自动把 aar 里带有的 Manifest 合并进来。这个时候尤其要留意application节点下面是否出现了重复的组件声明一旦出现 build 报错基本就是合并规则没有写对。我的经验是把activity-alias统一写在 Unity 工程的自定义 Manifest 里而不是 aar 内部。这样修改图标不需要重新出原生包Unity 侧的运维和策划同学也能在出包时快速调整。4.2 Unity 导出 iOS 时的 Xcode 配置iOS 端的集成相对简单但有一个地方特别容易漏Xcode 不会自动为备用图标生成 Info.plist 配置。即使你把AppIconRed.appiconset拖进了 Assets.xcassets也必须在 Xcode 工程的 Info.plist 里手动补上CFBundleIcons那一段。Unity 导出 Xcode 工程后你需要做两件事在Assets.xcassets里补充所有备用图标的 appiconset 资源。在Info.plist里添加CFBundleAlternateIcons字典把每个备用图标的 key 和资源文件对应好。如果你们的发布流程高度自动化建议用post-build脚本比如 Unity 的IPostGenerateGradleProject或XcodeAPI在导出后自动往 Info.plist 里注入配置避免每次人工改。我见过团队直接用 Python 脚本改 Xcode 工程文件也没问题关键是把这个步骤写进打包文档别靠人肉记。4.3 统一调用接口与状态管理到了双端统一层的设计我会做一个简单的 C# 管理器暴露出一个SwitchTo(string iconKey)接口。内部维护一个枚举或字典把逻辑图标 Key 映射到 Android 的组件名和 iOS 的备用图标名public enum AppIconType { Default, Spring, Anniversary } public static class AppIconManager { private static DictionaryAppIconType, string androidEnableMap new DictionaryAppIconType, string { { AppIconType.Default, MainActivity }, { AppIconType.Spring, MainActivity_Icon_Spring }, { AppIconType.Anniversary, MainActivity_Icon_Anniversary } }; private static DictionaryAppIconType, string iosIconNameMap new DictionaryAppIconType, string { { AppIconType.Default, null }, { AppIconType.Spring, RedIcon }, { AppIconType.Anniversary, AnniversaryIcon } }; public static void SwitchTo(AppIconType iconType) { // 如果有调用频率限制在这里做节流 #if UNITY_ANDROID !UNITY_EDITOR // Android 侧需要当前入口这里再用一个字典维护 disable 目标 AndroidIconHelper.SwitchIcon(androidEnableMap[iconType], androidEnableMap[currentType]); #elif UNITY_IOS !UNITY_EDITOR iOSIconHelper.SwitchIcon(iosIconNameMap[iconType]); #endif PlayerPrefs.SetString(CurrentAppIcon, iconType.ToString()); PlayerPrefs.Save(); } }状态管理这里有一个细节值得多说一句游戏启动时要读取本地保存的图标状态。因为 iOS 和 Android 都把切换结果持久化到了系统层App 重新启动后桌面显示的是最近一次切换的结果但游戏内 UI 可能需要展示“当前是什么图标”所以本地缓存一份是必要的。服务器驱动的方式就更简单了登录或启动时拉取一个配置里面包含icon_key字段客户端拿到后和本地缓存比对不一致就调用SwitchTo。这样运营可以实现“零点准时换图标”不需要用户做任何操作。5. 常见问题与排查技巧实录5.1 高频问题排查表我整理了双端动态换图标时最容易遇到的一批问题按“问题现象 / 可能原因 / 解决方案”列一个速查表真机联调时直接对着查问题现象Android 可能原因iOS 可能原因处理方案切换后桌面图标没有变化Launcher 缓存了旧图标模拟器不支持备用图标重启 Launcher/桌面真机测试切换后出现两个图标启用组件期间新旧两个 LAUNCHER 入口同时生效不会出现严格保证先禁用旧组件再结束流程调用后闪退ComponentName拼错传入的图标名没有在 Info.plist 中声明检查包名补全检查 Info.plist 配置切换后 App 图标消失新组件未启用就禁用了旧组件不存在先启用新组件再禁用旧组件iOS 调用 API 无反应不支持未判断supportsAlternateIcons真机验证确保系统版本 10.3Unity 出包时报 Manifest 合并错误manifest 节点重复无使用tools:nodereplace指定覆盖规则这个表基本覆盖了我在项目中遇到的所有“低级但致命”的问题。尤其是“出现两个图标”和“图标消失”这两类都是切换顺序错误导致的真机上遇到时会让人瞬间冒汗因为桌面上那个图标真的会不见。5.2 实测心得与避坑建议最后聊几个项目实战中的经验都是文档里查不到但非常实用的第一Android 换图标后桌面刷新存在“延迟窗口”。绝大多数原生 Launcher 在组件状态变化后能很快刷新但部分厂商桌面尤其是国产 ROM 桌面会缓存图标实测可能需要几十秒甚至重启桌面才能看到新图标。运营同学零点换图标时可能会有少量用户没立刻看到。这个属于系统级限制游戏侧做不了太多只能提前跟运营对好预期。第二不要试图在 Unity 编辑器或模拟器里验证双端切换。Unity Editor 没有系统桌面图标的概念调用原生接口也会因为平台环境不对而失败。我建议进入真机联调阶段后专门留一台 Android、一台 iPhone 作为“图标切换测试机”每次跑完开关检查一下桌面显示。第三别把切换按钮直接暴露给玩家。有的团队做一个“设置中心切换图标”的入口玩家可以随便点。iOS 高频调用会在极短时间内弹出系统错误提示Android 反复禁用启用组件也有极低概率出现桌面图标刷新混乱。如果一定要做用户自选功能至少加一个冷却时间比如 24 小时内只能切一次。第四图标资源尽量做压缩和缓存的一致性检查。Android 的activity-alias指向的是 mipmap 资源iOS 是 Asset Catalog。如果你用了热更新去动态加载图标图片那其实是另一种方案运行时用 Remote Image 生成桌面图标需要配合原生 View/快捷方式实现跟本文这种“预置资源切换”不是一回事。游戏项目中稳固的方案还是预置资源靠服务器开关控制避免本地资源缺失导致图标变成空白。上面这些就是我在这类需求里攒下来的核心经验。我个人在实际操作中的体会是动态更换 App 图标这件事技术本身并不复杂最繁琐的是资源、配置、状态管理这些“边角料”。只要把 Android 的组件切换顺序、iOS 的 Info.plist 声明、双端统一接口和本地状态缓存这四块想明白后续运营要加新图标无非就是多写两个 alias、多传一套资源的事客户端不必每次大改。如果你在项目里也正准备接这个需求建议先按 Android 真机跑通一次完整流程再处理 iOS两边独立验证后再合并到主干节奏会稳很多。