☰
Unity手游动态更换App图标双端方案:Android真动态与iOS半动态实践
2026/10/6 5:49:41 网站建设 项目流程

做手游的同学应该都接过这种需求:下个版本要上春节活动,运营跑过来说,“咱能不能把 App 图标换个喜庆点的,等活动结束再换回来?”第一次听这个需求,我心里是拒绝的——Android 还好说,iOS 出了名的不让碰主屏幕。但真正把两边的文档啃完、把两边的坑踩完之后,结论其实比想象中乐观:Android 可以做到即时换图标,iOS 也能换,只是限制条件多,体验上没那么“即时”。

这篇文章是我实际落地 Unity 手游动态更换 App 图标双端方案的过程记录。我会把 Android 和 iOS 各自的原生能力边界、Unity 工程里的具体接入姿势、构建期要做的资源注入,以及上线后踩到的高频坑一次性讲清楚。适合 Unity 客户端开发、想做运营活动的技术负责人,以及第一次接这个需求、正在评估工作量的同学参考。

1. 双端能力边界:先搞懂系统给你留了多少路

1.1 Android 的“真动态”:Activity-alias 与组件状态

Android 桌面上那个图标,本质上不是“应用图标”,而是启动入口组件(Launcher Activity)的图标。系统桌面会扫描当前包里所有带有MAIN+LAUNCHER意图过滤器的组件,然后把它们的图标和名称展示在桌面上。

这里的关键点来了:Android 允许你给同一个 Activity 定义多个“门面”,这个门面机制叫activity-alias。每个 alias 可以单独指定一个 icon 和 label,并且可以独立控制启用/禁用状态。我们只需要在 Manifest 里配置好几个 alias,然后通过PackageManager.setComponentEnabledSetting()把这个组件从 enabled 切成 disabled,或者反向切回来,系统会立刻感知到组件状态变化,桌面上显示的图标就会随之刷新。

整个过程不涉及重新安装、不需要 root、也不影响应用内部逻辑,所以我把 Android 称为“真动态”换图标。它换的是入口组件的状态,而不是替换 APK 里的资源文件。

1.2 iOS 的“半动态”:setAlternateIconName 的限制清单

iOS 这边的能力比 Android 窄得多。苹果在 iOS 10.3 之后开放了UIApplication.setAlternateIconName(_:completionHandler:),允许 App 在运行中切换为主图标之外的其他图标,但它的限制可以列一长串:

  • 所有备用图标必须在Info.plist里预声明,也就是说不存在“运营临时丢一张图进来”这种玩法。
  • 只能在 App 处于前台时调用,后台调用无效。
  • 切换时会弹出系统确认框,用户必须手动确认,用户拒绝就不能换。
  • 模拟器不支持这个 API,必须在真机上验证。
  • 企业签名的应用调用该接口可能直接失败。
  • 切换后的图标有时不会立即刷新,主屏幕会延迟几秒甚至更久才生效。

所以 iOS 并不是真正意义的前台动态切换,它更像“应用主动向系统申请换图标,系统确认后换”。我们做需求时要把这个体验预期跟运营讲清楚,别到时候测试一看弹窗就觉得是 bug。

1.3 一张表看清双端差异

对比项AndroidiOS
实现机制activity-alias + PackageManagersetAlternateIconName
是否需要预声明不需要必须预声明到 Info.plist
切换是否弹窗无感切换弹系统确认框
生效延迟桌面通常立即刷新可能延迟数秒到数十秒
模拟器支持支持不支持
资源要求mipmap 下多套图标Bundle 内图片文件
代码侵入性纯 Unity C# 可完成需要原生 .mm 桥接

先把边界画清楚再动手,后面所有设计决策都基于这张表展开。

2. Android 端:manifest、切换逻辑与 Unity 接入

2.1 多入口配置:activity-alias 写法与坑

Android 端的核心是 Manifest 配置。我建议在 Unity 工程的Assets/Plugins/Android/AndroidManifest.xml里维护这份文件,构建 APK/AAB 时 Unity 会自动把它合并进最终 Manifest。

一个典型的配置长这样:

<application> <activity android:name="com.unity3d.player.UnityPlayerActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity-alias android:name=".MainActivity_Default" android:enabled="true" android:exported="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:targetActivity=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity-alias> <activity-alias android:name=".MainActivity_Festival" android:enabled="false" android:exported="true" android:icon="@mipmap/ic_launcher_festival" android:label="@string/app_name" android:targetActivity=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity-alias> </application>

这里有个容易被忽略的点:原来的 Activity 本身不要带 LAUNCHER 过滤器,否则会出现双图标入口。正确做法是让 Activity 作为“幕后靶子”,所有入口都走 alias。默认启用的那个 alias 名字通常写成MainActivity_Default,它在应用安装时承担入口角色,后续所有切换都在这些 alias 之间进行。

另一个坑是targetActivity的写法。如果 Activity 全类是com.unity3d.player.UnityPlayerActivity,alias 的android:name用.MainActivity_Default这种相对包名写法没问题,但targetActivity最好写全类名,避免某些构建工具做混淆或者资源合并时解析出错。

2.2 组件切换代码:纯 C# 也能调 PackageManager

很多 Unity 团队一听到要操作 Android 原生 API,第一反应是写 Java 代码、打 AAR 包。其实这个需求完全不需要,Unity 的AndroidJavaObject可以直接调用PackageManager,一行 Java 都不用写。

核心逻辑是构造目标ComponentName,然后调用setComponentEnabledSetting:

using UnityEngine; public class AndroidIconSwitcher { private const int ENABLED = 1; private const int DISABLED = 2; private const int DONT_KILL_APP = 0; public static void SwitchIcon(string aliasSuffix) { using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) { string packageName = activity.Call<string>("getPackageName"); var pm = activity.Call<AndroidJavaObject>("getPackageManager"); // 先禁用默认入口,避免同时出现两个图标 SetComponentEnabled(pm, packageName, packageName + ".MainActivity_Default", DISABLED); // 启用目标入口 SetComponentEnabled(pm, packageName, packageName + ".MainActivity_" + aliasSuffix, ENABLED); } } private static void SetComponentEnabled(AndroidJavaObject pm, string packageName, string className, int state) { using (var component = new AndroidJavaObject("android.content.ComponentName", packageName, className)) { pm.Call("setComponentEnabledSetting", component, state, DONT_KILL_APP); } } }

有几个细节需要留意。setComponentEnabledSetting的第三个参数推荐传入DONT_KILL_APP,意思是“别杀掉进程来应用这个变更”,这样切换过程不会打断玩家当前操作。另外每次切换前都应该先显式禁用当前启用的那个 alias,而不是依赖默认状态,因为切过几次之后“当前入口”是谁已经不可控了。

调用之后系统会广播ACTION_PACKAGE_CHANGED,理论上所有桌面都会刷新。但实际测试中部分国产桌面对这个广播的响应不及时,会出现“切了但图标没变”的假象,这时候等几秒、或者手动重启桌面就能看到效果。这个我后面会在排查章节展开。

2.3 资源准备与 Adaptive Icon 的兼容提醒

既然要切多套图标,资源就得放在 Unity 构建能识别的位置。推荐放在Assets/Plugins/Android/res/下,构建时 gradle 会把这下面的资源合并到 APK 中:

Assets/Plugins/Android/res/ mipmap-mdpi/ic_launcher_festival.png mipmap-hdpi/ic_launcher_festival.png mipmap-xhdpi/ic_launcher_festival.png mipmap-xxhdpi/ic_launcher_festival.png mipmap-xxxhdpi/ic_launcher_festival.png

这里有一个新机型才有的坑:Android 8.0 之后引入了 Adaptive Icon 机制,桌面图标由前景、背景和遮罩组成。如果你的主图标用的是 adaptive icon 规格,而切换的新图标放的还是老式纯 PNG,在部分手机上会显示成“大白底小图标”,非常难看。

想稳妥就让美术把备用图标也按 adaptive icon 规格出一套:至少包含前景图(foreground)和纯色背景(background)。如果团队排期紧,也可以用同一个非透明方形底图直接作为ic_launcher_festival.png,至少保证在非 adaptive 桌面上显示正常。上线前建议拿一台 Pixel 和一台国产主流机型分别验证。

3. iOS 端:预声明、桥接与构建注入

3.1 Info.plist 里先画好“备用图标”地图

iOS 的换图标能力全部建立在“预声明”之上。我们需要在Info.plist中加入CFBundleIcons字段,并在其中声明CFBundleAlternateIcons。

一个最小化的配置如下:

<key>CFBundleIcons</key> <dict> <key>CFBundleAlternateIcons</key> <dict> <key>FestivalIcon</key> <dict> <key>CFBundleIconFiles</key> <array> <string>IconFestival</string> </array> <key>UIPrerenderedIcon</key> <false/> </dict> </dict> </dict>

这里的FestivalIcon是逻辑标识符,也就是代码里传给setAlternateIconName的参数。IconFestival是图片文件的“裸文件名”,不包含扩展名,也不包含@2x、@3x后缀。系统会根据当前设备屏幕 scale 自动寻找IconFestival.png、IconFestival@2x.png、IconFestival@3x.png。

在 Unity 工程中,Info.plist是不能直接改工程里那份的,因为它是构建时生成的。所以我们需要靠构建后的PostProcessBuild脚本去改。这个我放到 3.3 节一起讲。

3.2 用 .mm 桥接 setAlternateIconName

Unity 的 C# 层不能直接调 Objective-C 的 API,需要借助一个原生插件做桥。好在这类桥接代码非常短。

先创建一个 Objective-C++ 文件,放在Assets/Plugins/iOS/目录下,Unity 构建时会自动把这个文件编译进 Xcode 工程:

#import <UIKit/UIKit.h> #import <Foundation/Foundation.h> extern "C" { void _SwitchAppIcon(const char* iconName) { NSString *name = nil; if (iconName != NULL && strlen(iconName) > 0) { name = [NSString stringWithUTF8String:iconName]; } if ([[UIApplication sharedApplication] supportsAlternateIcons]) { [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(@"[IconSwitch] switch failed: %@", error); } else { NSLog(@"[IconSwitch] switch success"); } }]; } } }

然后在 C# 侧用DllImport声明这个函数:

using System.Runtime.InteropServices; public class IosIconSwitcher { #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); #endif } }

__Internal是 Unity iOS 插件的固定库名,意思是“从当前可执行文件里找这个符号”。调用时可以传null或空字符串来恢复默认图标:_SwitchAppIcon("")。

需要注意的是,setAlternateIconName的completionHandler回调发生在主线程,不会卡 Unity 逻辑。但回调里的NSError信息比较多,模拟器上经常会返回“unsupported”,真机上如果图标名没加进 Info.plist 也会报错。开发阶段建议把 error 打全,方便定位。

3.3 PostProcessBuild 把图片塞进 Xcode 工程

这是 iOS 侧最容易翻车的地方:备用图标的图片文件必须躺在 App 的 main bundle 里,而 Unity 默认不会把任意图片资源打进 Xcode 工程的主 bundle 根目录。如果直接扔在Assets/Plugins/iOS下,可能会被 Unity 加入工程引用,但不一定会被加进Copy Bundle Resources构建阶段。

最可靠的方式是用构建后处理脚本,在Xcode工程生成后、打包结束前手动把图片文件拷贝进去,并显式添加到对应 target 的 resource build phase。

下面是一个基于UnityEditor.iOS.Xcode的示例脚本:

#if UNITY_IOS using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; using System.IO; public class IconBuildProcessor { [PostProcessBuild(1)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target != BuildTarget.iOS) return; // 准备源文件路径(例如放工程根目录/IconResources/IconFestival.png) string sourcePath = Path.Combine(Application.dataPath, "../IconResources/IconFestival.png"); string destFileName = "IconFestival.png"; string destPath = Path.Combine(path, destFileName); File.Copy(sourcePath, destPath, true); string projectPath = PBXProject.GetPBXProjectPath(path); var project = new PBXProject(); project.ReadFromFile(projectPath); string targetGuid = project.GetUnityMainTargetGuid(); string fileGuid = project.AddFile(destPath, destFileName, PBXSourceTree.Source); project.AddFileToBuild(targetGuid, fileGuid); project.WriteToFile(projectPath); // 修改 Info.plist,加入备用图标声明 string plistPath = path + "/Info.plist"; var plist = new PlistDocument(); plist.ReadFromFile(plistPath); var root = plist.root; PlistElementDict icons = root.CreateDict("CFBundleIcons"); PlistElementDict alternate = icons.CreateDict("CFBundleAlternateIcons"); PlistElementDict festival = alternate.CreateDict("FestivalIcon"); PlistElementArray files = festival.CreateArray("CFBundleIconFiles"); files.AddString("IconFestival"); festival.SetBoolean("UIPrerenderedIcon", false); plist.WriteToFile(plistPath); } } #endif

注意我用的是GetUnityMainTargetGuid(),这是 Unity 2020.3 之后比较稳定的写法。老项目里如果用的是TargetGuidByName("Unity-iPhone"),在 Unity 2022 及以上版本可能会告警或取不到正确 target,建议统一换成新的 API。

还有一个细节:图片文件名别带@2x、@3x,而是提供多张文件或者直接给一张足够大的图(比如 180x180 或更高)。用单张图时系统会自动缩放,显示效果基本可以接受。想追求更好的清晰度,就把IconFestival.png、IconFestival@2x.png、IconFestival@3x.png三张都拷贝进去,但 Info.plist 里仍然只写IconFestival。

4. 双端统一封装:一套 C# 接口管理图标状态

4.1 接口设计:回调、降级与超时

两端底层能力都打通后,就该在 C# 层做统一封装了。我建议对外只暴露一个类,名字就叫DynamicIconManager,提供两个核心方法:切换图标和恢复默认图标。调用方不关心当前是 Android 还是 iOS,也不用关心内部是怎么实现的。

先看接口定义:

public static class DynamicIconManager { public enum SwitchResult { Success, Failed, Unsupported, UserCancelled } // 切换图标;iosIconKey 仅 iOS 需要,Android 传 null 即可 public static void SwitchIcon(string androidAliasSuffix, string iosIconKey, System.Action<SwitchResult> callback = null) { #if UNITY_IOS && !UNITY_EDITOR IosIconSwitcher.SwitchIconIOS(iosIconKey, callback); #elif UNITY_ANDROID && !UNITY_EDITOR bool ok = AndroidIconSwitcher.SwitchIcon(androidAliasSuffix); if (callback != null) callback(ok ? SwitchResult.Success : SwitchResult.Failed); #else if (callback != null) callback(SwitchResult.Unsupported); #endif } public static void RestoreDefault(System.Action<SwitchResult> callback = null) { #if UNITY_IOS && !UNITY_EDITOR IosIconSwitcher.SwitchIconIOS(null, callback); #elif UNITY_ANDROID && !UNITY_EDITOR bool ok = AndroidIconSwitcher.RestoreDefaultIcon(); if (callback != null) callback(ok ? SwitchResult.Success : SwitchResult.Failed); #else if (callback != null) callback(SwitchResult.Unsupported); #endif } }

iOS 的异步回调比 Android 麻烦,因为setAlternateIconName的结果要经过原生层再传回 Unity。前面那个.mm插件里的completionHandler目前只打了日志,没有回传结果。

建议把回调机制升级一下。比较省事的方式是让.mm层捕获一个 C 函数指针,或者用固定的 UnitySendMessage 消息通道。我实际操作中更推荐后者,实现简单、不容易出现持有失效问题:

extern "C" { void _SwitchAppIcon(const char* iconName, const char* gameObjectName) { [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError *error) { const char *result = error ? "Failed" : "Success"; UnitySendMessage(gameObjectName, "OnIconSwitchResult", result); }]; } }

C# 侧维护一个 GameObject 挂接收器即可。这样客户端逻辑里只需要关心SwitchResult这个枚举,不用管 iOS 到底走哪个渠道回来的。

4.2 状态持久化:避免双入口与错误还原

这个细节是我觉得必须写进方案里的,不是可选项。因为图标切换是一个“改了系统组件状态”的操作,而 Unity 侧的逻辑一旦重启、或者 App 被系统杀掉再启动,我们并不知道当前入口是哪个 alias。

如果重启之后用户其他地方触发了“恢复默认图标”逻辑,但实际入口本来就是默认的,那再去 disable 默认、enable 默认,就会出现极端情况:桌面图标入口被禁用,App 在桌面上“消失”。这个事故我听说过真实案例。

所以每次切换成功后,立刻把状态写入PlayerPrefs:

private const string IconStateKey = "DYNAMIC_ICON_CURRENT"; public static void SaveCurrentIcon(string state) { PlayerPrefs.SetString(IconStateKey, state); PlayerPrefs.Save(); } public static string LoadCurrentIcon() { return PlayerPrefs.GetString(IconStateKey, "default"); }

每次切换前先读这个状态,先禁用“状态里记录的当前入口”,再启用目标入口,最后写回新状态。只要这套逻辑是闭环的,就不会出现桌面无入口、或者双图标并存的脏状态。

4.3 包体与素材规划:一套图怎么放两端

动态图标毕竟要内置多套 icon,包体增量是绕不开的。这一点建议在需求评审阶段就跟策划对齐:每增加一套图标,Android 端按 mipmap 五个档位计算,大约每档一张 PNG,五张合计可能 200KB 到 1MB;iOS 端按单张或多张 scale 图计算,通常 200KB 到 500KB 左右。

如果团队想要极致压缩包体,可以把多档位图片都转成 WebP(Android 端)和高效压缩 PNG(iOS 端)。但 WebP 在部分老 Android 桌面上解析 adaptive icon 时可能出问题,建议统一先用 PNG 验证。

素材在 Unity 工程里的组织方式,我踩过几次之后固定成这样:

  • Android 图标放进Assets/Plugins/Android/res/,构建期作为 Android 资源直接打进包。
  • iOS 备用图片放在工程根目录的IconResources/文件夹,由PostProcessBuild脚本负责拷贝,不进 Unity 资源数据库,避免被 Unity 的 TextureImporter 处理出乱七八糟的压缩格式。
  • 不要把同一张图既放在Plugins/iOS又放在IconResources,否则 Xcode 工程里可能出现同名文件冲突。

5. 常见问题排查与我的实操心得

5.1 高频问题速查表

现象可能原因排查与解决
Android 切换后桌面图标没变桌面 Launcher 缓存等 5-10 秒,或重启桌面;部分 ROM 需重启手机
Android 出现两个图标之前入口未禁用检查状态持久化逻辑,是否没先禁用当前入口
Android 图标变成大白底新图未按 Adaptive Icon 规格换前景+背景套图,或统一用带背景的非透明 PNG
iOS 模拟器调用报错模拟器不支持必须真机验证
iOS 切换后桌面还是旧图标系统刷新延迟不影响逻辑,过一段时间会恢复;也可以让用户锁屏/解锁触发刷新
iOS 弹窗出现但点击确认后无变化图片不在 main bundle检查 PostProcessBuild 是否成功 AddFileToBuild
切换后重启 App 入口消失当前入口被误禁用临时安装包排查;恢复默认组件状态并修复状态存储逻辑
部分 Android 12+ 图标显示异常主题化图标 monochrome 未配置adaptive icon 的 monochrome 图层要单独给,否则系统自动取前景图

5.2 三条实战经验总结

第一条经验是:不要在 App 启动时立刻切图标。有些同学想着启动后静默切一波,给玩家“惊喜”。但 iOS 会弹系统确认框,Android 虽然无感,但部分机型桌面刷新有延迟,启动阶段切很容易跟启动流程的 UI 抢资源,甚至被玩家截图吐槽。我们最后定的是“活动页确认后切、回桌面时生效”的流程。

第二条经验是:测试时一定要覆盖桌面类型和系统版本两个维度。Android 端至少测原生桌面、小米桌面、华为桌面和三星桌面,Android 12 / 13 的个性化主题图标更是重灾区。iOS 端至少覆盖 iOS 16 和 iOS 17 的真机,确认弹窗文案和系统表现是否符合预期。

第三条经验是所有端共通的:做一套“切换结果自检”功能,接上日志或者埋点。比如切完图标后,App 重新退到后台再回到前台时主动检查一下当前桌面图标状态与本地持久化状态是否一致。发现不一致就把日志上报,后面排查线上问题时省一半时间。这个功能只是几行枚举值比较的事,但能让你在运营反馈“图标没变”时快速判断是系统刷新问题还是状态错乱问题。

最后补充一个跟运营方案相关的建议:动态图标这种能力,最合适的用法是“大版本节点 + 节日节点”,不要做成日常运营手段。iOS 的确认弹窗本身就代表用户授权成本,频繁切换会让玩家对弹窗产生疲劳,反而影响后续活动效果。技术上把双端能力封装好了,运营侧反而要克制使用,这个度比代码本身更考验团队的运营手感。

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

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

立即咨询