☰
Unity双端动态切换App图标:Android与iOS完整实现指南
2026/10/3 4:56:11 网站建设 项目流程

我写这篇文的背景,是把动态换图标这个需求从“接到运营需求”一直到“双端上线”完整跑了一遍。先说结论:这个玩法在 Android 和 iOS 上能实现,但两条路的原理完全不同、坑也完全不一样。很多帖子只讲其中一端,或者只给结论不给原因,导致 Unity 项目接的时候处处碰壁。这篇文章我按自己的落地经验,把双端方案、C# 封装、服务端配置和踩坑记录都拆开讲清楚,打算接这个需求的同学可以直接抄作业。

1. 先搞清楚需求:我们到底在谈哪种“换图标”

1.1 营销向的图标运营是什么

手游圈子里“换图标”通常不是给用户一个设置入口,而是运营在特定节点把桌面图标换成活动视觉。比如周年庆换个庆典背景、新版本上线换一张主视觉、节假日改成应景风格。目的很直接:已经下载过的玩家每天看桌面,图标一变就等于一次免费的曝光,比推送更柔和,也比“打开 App 才能看到活动”的触达效率高。

这类需求在立项时经常被描述得很简单:“后台配一下,客户端启动时读配置,把图标换掉就行。”

但我实际做下来发现,这里藏着一个非常关键的认知差异:双端都只能切换“预置在安装包里的图标”,不能像拉取公告图片一样从服务器拉一张 PNG 直接换上。这和很多运营同学的第一直觉不一样,也是后面所有方案设计的前提。

1.2 一个容易被误解的前提:图标必须随包体预置

为什么不能动态下载图片当图标?因为系统的安全机制不允许普通 App 在运行时修改自己的安装信息。Android 的 launcher 图标本质上是 Manifest 里声明的 Activity 元数据,iOS 的图标则由 Info.plist 声明并由系统统一管理,都不是直接指向一个本地文件的“快捷方式”。

所以“动态”两个字,准确说是:“包内预置多套图标,运行时通过系统能力切换当前生效的那一套。” 运营想要改图,只能从我们预先做好的几套里选一套,或者在下次发版时把新图打进去。

这也决定了项目结构从一开始就要规划好:图标素材、别名配置、服务端开关、回滚策略,缺一不可。下面分别说两端怎么做。

2. Android 侧:用 Activity-alias + PackageManager 做双 launcher 切换

2.1 Activity-alias 方案的核心原理

Android 上最常见、也最稳定的动态换图标方案,不是去改图标资源,而是利用activity-alias这个 Manifest 机制。

原理一句话:你可以在 Manifest 里声明多个指向同一个 Activity 的“快捷入口别名”,每个别名可以拥有独立的android:icon和android:label。Launcher 看到的不是 Activity 本身,而是那个带着MAIN+LAUNCHER过滤器的组件。我们通过 PackageManager 在运行期动态启用其中一个别名、禁用其他别名,相当于让系统认为“当前可用的启动入口变成了另一个”,Launcher 刷新后就会展示新的图标。

这个方案之所以稳,是因为它不修改任何资源文件,只是改动了 PackageManager 里的组件启用状态。这个状态是持久的,用户重启手机、杀掉桌面进程后依然有效。

2.2 需要你写进工程的 Manifest 配置

以 Unity 项目为例,首先得拿到 Android 的最终 Manifest 控制权。我采用的是在Assets/Plugins/Android/AndroidManifest.xml里维护一份主 Manifest,Unity 构建时会合并它。下面是一份关键配置的骨架:

<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.game"> <application android:icon="@mipmap/ic_launcher" android:label="@string/app_name"> <activity android:name="com.unity3d.player.UnityPlayerActivity" android:exported="false"> </activity> <activity-alias android:name=".LauncherDefault" android:targetActivity="com.unity3d.player.UnityPlayerActivity" 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=".LauncherFestival" android:targetActivity="com.unity3d.player.UnityPlayerActivity" android:icon="@mipmap/ic_launcher_festival" android:label="@string/app_name_festival" android:enabled="false"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity-alias> </application> </manifest>

注意几个细节:

  • 真实的UnityPlayerActivity不要加MAIN/LAUNCHER过滤器,否则桌面上会出现两个图标。
  • 默认别名LauncherDefault不写android:enabled="false",保证新装机用户一定有图标。
  • 其他活动图标别名默认都是enabled="false",需要切换时再从代码里启用。
  • android:exported="false"是写给 alias 还是 targetActivity 要看具体 gradle 版本要求,Unity 较新版本构建时如果不写会在编译期报警,建议补上并统一使用显式包名形式。

2.3 Unity C# 侧如何调用 Java 插件

Manifest 配置好后,运行时的切换逻辑就要从 C# 调到 Java 侧。我在项目里用一个极简的 Java 静态类封装 PackageManager 调用,放在Assets/Plugins/Android/下的 AAR 或 jar 中,C# 通过AndroidJavaClass调用。

Java 侧核心代码:

package com.example.game.iconswitch; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; public class IconSwitcher { public static void switchTo(Context context, String enableAlias, String[] disableAliases) { PackageManager pm = context.getPackageManager(); // 先启用目标组件,避免出现“没有任何 launcher 组件可用”的空窗期 pm.setComponentEnabledSetting( new ComponentName(context.getPackageName(), enableAlias), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ); for (String alias : disableAliases) { pm.setComponentEnabledSetting( new ComponentName(context.getPackageName(), alias), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ); } // 额外广播一次包信息变化,给部分桌面 Launcher 刷新提示 Intent refresh = new Intent(Intent.ACTION_PACKAGE_CHANGED); refresh.setData(Uri.parse("package:" + context.getPackageName())); context.sendBroadcast(refresh); } }

注意我这里的顺序:先ENABLE再DISABLE。很多人习惯先禁用原来再启用新的,这在部分 ROM 上会导致一瞬间系统找不到可用的 launcher 组件,桌面可能弹“没有可用的桌面程序”或短暂白屏。先启用再禁用能最大限度避免竞态。

C# 侧调用:

#if UNITY_ANDROID && !UNITY_EDITOR using UnityEngine; public static class AndroidIconSwitcher { private static AndroidJavaClass _helperClass; public static void SwitchTo(string enableAlias, string[] disableAliases) { if (_helperClass == null) _helperClass = new AndroidJavaClass("com.example.game.iconswitch.IconSwitcher"); using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) { var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"); // Java 方法接收 String 数组,C# 传 string[] 即可 _helperClass.CallStatic("switchTo", activity, enableAlias, disableAliases); } } } #endif

这里有个 Unity 老版本兼容问题:部分 Unity 版本对AndroidJavaObject传参string[]时偶尔出现参数不匹配,稳妥做法是在 Java 方法里只接收一个String逗号分隔,例如"LauncherDefault,LauncherFestival",然后 Java 内部用split(",")解析。虽然多一步解析,但跨 Unity 版本的稳定性更好。

2.4 三个绕不开的 Android 坑:缓存、杀进程、自适应图标

第一,图标不是“立刻刷新”的。setComponentEnabledSetting调用成功后,SystemUI 和桌面 Launcher 不一定会马上重新读取组件状态。部分原生 Android 设备上,回到桌面几秒内就能看到图标变化;但国内一些 ROM 的桌面图标缓存可能持续几分钟,甚至要手动重启桌面才会刷新。这不是你的代码错了,而是 Launcher 的缓存策略导致。测试时的取巧办法是adb shell am force-stop com.android.launcher3,或直接重启手机判断最终状态。

第二,DONT_KILL_APP不能漏。如果漏掉这个 flag,系统在禁用组件的同时可能连进程一起杀掉。对玩家来说,图标是变了,但游戏进程直接被终止,体验非常差。尤其是切完图标玩家还想立刻继续游戏时,这个坑会非常明显。

第三,Android 8.0 之后自适应图标(Adaptive Icon)是主流。如果你只给了普通 PNG,系统会自动裁剪到一个圆形遮罩里,容易切边。正确做法是用 mipmap 目录下的ic_launcher.xml这种 adaptive icon 描述文件,里面分别定义背景层和前景层。多套图标就准备多套 XML,运行时替换的是这个 XML 资源的引用。Android 12 以后如果启动器开启了“主题图标”,系统还可能拿你提供的 monochrome 单色层去统一着色,颜色表现跟设计稿不一定一样,需要在设计阶段提前说明。

3. iOS 侧:CFBundleAlternateIcons 官方备用图标方案

3.1 iOS 10.3 之后才有的 setAlternateIconName

iOS 上能做的“动态换图标”,准确说是从 iOS 10.3 开始提供的setAlternateIconName(_:completionHandler:)API。这个 API 是苹果官方给的,运行在 UIKit 层面,能力范围很清楚:你只能切换到项目里已经声明好的其他图标,不能把任意一张图片塞进去。

前提条件有三个:

  • 系统版本 >= iOS 10.3(现在要上线的基本都满足)
  • 在 Info.plist 里通过CFBundleAlternateIcons声明备用图标
  • 所有备用图标文件都打包进 App Bundle

满足这三个条件后,调用setAlternateIconName会触发系统的图标切换,completionHandler会告诉你成功还是失败。返回系统默认图标也很简单,调用时传nil即可。

3.2 Info.plist 声明备用图标

声明位置在CFBundleIcons字典下的CFBundleAlternateIcons。每个备用图标项用一段字典描述,里面通过CFBundleIconFiles数组引用包里的图片资源。

我在 Unity 项目里是用构建后处理脚本修改 Xcode 工程的 Info.plist,避免每次打完 Xcode 工程再手动点选。脚本大致结构:

#if UNITY_IOS using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; public class IconPostProcess { [PostProcessBuild(400)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target != BuildTarget.iOS) return; string plistPath = path + "/Info.plist"; PlistDocument plist = new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementDict root = plist.root; PlistElementDict bundleIcons = root.CreateDict("CFBundleIcons"); PlistElementDict alternate = bundleIcons.CreateDict("CFBundleAlternateIcons"); PlistElementDict festival = alternate.CreateDict("FestivalIcon"); festival.CreateArray("CFBundleIconFiles").AddString("FestivalIcon"); plist.WriteToFile(plistPath); // 同步把图片资源添加到 Xcode 工程 // 这里用 PBXProject 将 Assets/Plugins/iOS/FestivalIcon.png 等文件加入 Build Phase } } #endif

实际生成的 Info.plist 内容会长这样:

<key>CFBundleIcons</key> <dict> <key>CFBundleAlternateIcons</key> <dict> <key>FestivalIcon</key> <dict> <key>CFBundleIconFiles</key> <array> <string>FestivalIcon</string> </array> </dict> </dict> </dict>

图片资源的命名需要注意,iOS 会查找FestivalIcon.png、FestivalIcon@2x.png、FestivalIcon@3x.png这样的文件,无需带后缀名。图标尺寸方面,虽然没有强制校验每个备用文件的具体像素,但实际会按 App Store 对主图标的同等要求来对待,推荐直接给 1024x1024 的原图,并由 Xcode 在构建时自动生成各尺寸。这里建议做成构建后处理脚本自动拷贝,如果手抖漏了文件,运行时setAlternateIconName会直接报错。

3.3 Objective-C 插件与 Unity 回调

Unity 没法直接调 iOS 的UIApplication,所以需要一层 Objective-C 插件。我在Assets/Plugins/iOS里放一个精简的.mm文件:

#import <UIKit/UIKit.h> #import "UnityAppController.h" extern "C" { void UnityIconSwitch_Apply(const char *iconName) { NSString *name = iconName ? [NSString stringWithUTF8String:iconName] : nil; if (![[UIApplication sharedApplication] supportsAlternateIcons]) { UnitySendMessage("AppIconManager", "OnIOSResult", "0"); return; } dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(@"[IconSwitch] %@", error.localizedDescription); UnitySendMessage("AppIconManager", "OnIOSResult", "0"); } else { UnitySendMessage("AppIconManager", "OnIOSResult", "1"); } }]; }); } }

C# 侧声明DllImport:

#if UNITY_IOS && !UNITY_EDITOR using System.Runtime.InteropServices; public static class iOSIconSwitcher { [DllImport("__Internal")] private static extern void UnityIconSwitch_Apply(string iconName); public static void SwitchTo(string iconName) { UnityIconSwitch_Apply(iconName); } public static void ResetToDefault() { UnityIconSwitch_Apply(null); } } #endif

回调是通过UnitySendMessage发给场景里名为AppIconManager的 GameObject。脚本上需要挂一个接收方法OnIOSResult(string result),这一步很容易漏,最小 Demo 里可以只做日志输出,但上线版本建议把回调结果转发给更上层的管理器。

3.4 审核和尺寸方面的隐性限制

备用图标是官方 API,大多数情况下不会被 App Store 拒,但有几个隐性风险:

  • 图标内容需要符合审核规范,不能把广告位、二维码、诱导信息直接放图标上。尤其注意,切换后的图标依然会出现在桌面和设置中,本质上是 App 外观的一部分。
  • 苹果允许程序内自动切换,但审核时如果发现图标频繁变化或存在混淆用户的行为,有被打回的风险。稳妥做法是切换动作和用户行为或明确的运营活动绑定,而不是完全随机。
  • 应用商店内展示的图标不会随运行时切换变化。用户在主屏看到的图变了,商店页面还是上传包时的那张,这是正常的,提前给运营打个预防针,别到时候又来找开发兴师问罪。

4. 双端统一封装:C# 接口 + 服务器配置 + 回滚设计

4.1 抽象一个 AppIconManager

两端原理完全不同,但到了 C# 层,我希望业务逻辑只看到一个方法:SwitchTo(AppIconType type)。所以我做了一层AppIconManager,把平台差异全收进去。

大致设计:

public enum AppIconType { Default, Festival, Anniversary } public static class AppIconManager { public static bool IsSupported { get { #if UNITY_ANDROID && !UNITY_EDITOR return true; #elif UNITY_IOS && !UNITY_EDITOR return true; #else return false; #endif } } public static void SwitchTo(AppIconType type) { if (!IsSupported) return; #if UNITY_ANDROID && !UNITY_EDITOR string current = type == AppIconType.Default ? ".LauncherDefault" : ".LauncherFestival"; string disable = type == AppIconType.Default ? ".LauncherFestival" : ".LauncherDefault"; AndroidIconSwitcher.SwitchTo(current, new[] { disable }); #elif UNITY_IOS && !UNITY_EDITOR string name = type == AppIconType.Default ? null : "FestivalIcon"; iOSIconSwitcher.SwitchTo(name); #endif } }

这样上层运营活动逻辑只需要知道“今天切到 Festival”,不需要关心 Android 的 alias 命名还是 iOS 的 plist key。

关于别名映射,我不建议在代码里散落硬编码。项目里可以配一张表,例如“AppIconType -> 平台参数”,用 ScriptableObject 或 JSON 资源配置都行。多套图标上线后,这张表还能跟服务器下发的 key 对上。

4.2 服务端只下发“用哪个”,不下发图片

这是整个方案里最容易被误解的部分。很多运营后台提需求时会说“我们在后台传一张图上去,客户端换掉”。但是双端系统都不允许这么玩。

正确的做法是:服务端返回一个当前生效的 icon key,比如"festival_2025",客户端根据 key 查本地映射表,再调用对应的平台切换逻辑。服务器管的是“什么时候切到哪一套”,客户端管的是“这套图对应的系统切换”。

映射表大概长这样:

服务端 keyAndroid alias 后缀iOS plist 图标名备注
defaultLauncherDefault主图标永久保留
festivalLauncherFestivalFestivalIcon活动专用
anniversaryLauncherAnniversaryAnniversaryIcon纪念日专用

为什么不能下发图片,我在第一节已经解释过了。这里再补充一个运营侧容易忽略的成本:Android 的 alias 方案每增加一套图标,意味着 Manifest 里要加一个activity-alias节点,并且要保证图标资源被 mipmap 引用;iOS 每增加一套,要在 Info.plist 声明加图片文件。这些都必须随版本发布,不是后台配置能省掉的。

4.3 定时回滚与降级策略

换图标最尴尬的场景是活动结束了,图标还留在桌面上一周。运营同学忘配回滚,或者活动结束时间表格配错了,都会让“新版本图标”变成“旧图标事故”。所以我把回滚逻辑做成两个层级:

第一层是“服务器参数回滚”。客户端每次启动或者切后台回前台时,请求一次配置。如果endTs已经小于当前时间,就把 key 强制置为default。这个方案不依赖系统闹钟,也不依赖客户端是否有常驻进程,只要玩家打开游戏,就会自动纠正。

第二层是“本地定时兜底”。在 Android 端,切换成活动图标时顺便注册一个AlarmManager,到endTs触发广播,再调一次IconSwitcher切回默认。这个兜底能覆盖“活动期间玩家一直在玩,但没有重新走服务端配置”的情况。iOS 端没有通用的定时执行能力,所以只能靠服务端状态判断,不建议在 iOS 侧强行搞本地定时器。

降级策略同样要考虑:如果调用平台切换方法抛异常(比如 iOS 的备用图标声明缺失),AppIconManager 需要捕获并静默回退到默认,不能因为这个功能导致游戏启动白屏或崩溃。我遇到过的案例是打了测试包,没走构建后处理脚本,Info.plist 没声明备用图标,iOS 运行时supportsAlternateIcons返回 NO,插件没做保护,直接崩了。后来才在 Objective-C 侧加了保护分支。

5. 真机实测复盘与当前推荐做法

5.1 安卓设备上最常翻车的一瞬间

安卓端我在本地测试时最常踩的坑,不是代码逻辑,而是“桌面图标不刷新”。方法调用成功后,有时要等十几秒,有时怎么等都不变,重启手机才变。一开始我以为是方案不生效,后来用adb shell dumpsys package查launcher的 enabled 状态,发现状态已经正确切换了,纯粹是桌面缓存问题。

做真机验收时,建议按这个流程走:

  • 调用切换后,先检查dumpsys package里对应组件的 enabled 状态。
  • 状态正确但桌面没变,就切到后台再切回来,或者重启桌面进程。
  • 部分 ROM 上,图标资源如果通过 adaptive icon 的 XML 引用,背景层的动态取色会让“看起来没变化”,一定要确认前景图设计差异足够大。

另外,如果同时接入了“桌面快捷方式”功能,比如长按 App 图标弹出来的那些 Shortcut,它们的缓存策略和 launcher 图标不是同一个,有时图标切了,快捷方式还显示旧的。这个我没找到能在代码里强制刷新的通用办法,只能尽量接受,并提醒运营别过度依赖“立刻全量刷新”。

5.2 iOS 实测时的意外情况

iOS 端的意外主要在信息差上。第一是图标切换不是“瞬时的”,completionHandler返回成功,主屏上可能还要几秒或者回桌面才能看到。第二是setAlternateIconName的 iconName 必须和 Info.plist 里声明的完全一致,大小写差一点都不行。第三是系统如果正处于某个特殊状态(比如刚升级系统、正在备份),可能返回error,这类错误不是我们代码问题,不能一失败就重试轰炸。

更实用的一点:iOS 切换回默认图标时,传nil是可靠用法,但部分开发者会把“默认图标”也当作一个备用图标声明,然后传它的名字。这个方式也能生效,但绕了一圈,还会导致supportsAlternateIcons的逻辑变复杂。

5.3 给 Unity 项目落地的最终建议

如果你现在要在 Unity 里接这个功能,我会建议按这个优先级推进:

第一优先:把两端官方能力分别做通,Android 维护好 manifest 和 Java 插件,iOS 维护好 plist 和 Objective-C 插件,先脱离 Unity 层把原生端验证完。

第二优先:再封装 C# 层,用 AppIconManager 统一入口,把平台判断、回调、异常捕获都收进来,不要让业务侧感知双端差异。

第三优先:最后才接服务端配置。因为服务端下发只解决“什么时候切”的问题,如果两端原生能力没验证好,服务端配置再正确也白搭。

这套方案目前在我参与的项目里已经跑过两个活动周期,Android 和 iOS 都没有出现致命问题。如果你准备复用,请务必把 Manifest 和 plist 的配置排在开发计划的最前面,因为这两个配置一旦遗漏,代码写得再漂亮也切不动图标。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询