
做手游的同学应该都接过这种需求下个版本要上春节活动运营跑过来说“咱能不能把 App 图标换个喜庆点的等活动结束再换回来”第一次听这个需求我心里是拒绝的——Android 还好说iOS 出了名的不让碰主屏幕。但真正把两边的文档啃完、把两边的坑踩完之后结论其实比想象中乐观Android 可以做到即时换图标iOS 也能换只是限制条件多体验上没那么“即时”。这篇文章是我实际落地 Unity 手游动态更换 App 图标双端方案的过程记录。我会把 Android 和 iOS 各自的原生能力边界、Unity 工程里的具体接入姿势、构建期要做的资源注入以及上线后踩到的高频坑一次性讲清楚。适合 Unity 客户端开发、想做运营活动的技术负责人以及第一次接这个需求、正在评估工作量的同学参考。1. 双端能力边界先搞懂系统给你留了多少路1.1 Android 的“真动态”Activity-alias 与组件状态Android 桌面上那个图标本质上不是“应用图标”而是启动入口组件Launcher Activity的图标。系统桌面会扫描当前包里所有带有MAINLAUNCHER意图过滤器的组件然后把它们的图标和名称展示在桌面上。这里的关键点来了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:namecom.unity3d.player.UnityPlayerActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity activity-alias android:name.MainActivity_Default android:enabledtrue android:exportedtrue android:iconmipmap/ic_launcher android:labelstring/app_name 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_Festival android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_festival android:labelstring/app_name android:targetActivity.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias /application这里有个容易被忽略的点原来的 Activity 本身不要带 LAUNCHER 过滤器否则会出现双图标入口。正确做法是让 Activity 作为“幕后靶子”所有入口都走 alias。默认启用的那个 alias 名字通常写成MainActivity_Default它在应用安装时承担入口角色后续所有切换都在这些 alias 之间进行。另一个坑是targetActivity的写法。如果 Activity 全类是com.unity3d.player.UnityPlayerActivityalias 的android:name用.MainActivity_Default这种相对包名写法没问题但targetActivity最好写全类名避免某些构建工具做混淆或者资源合并时解析出错。2.2 组件切换代码纯 C# 也能调 PackageManager很多 Unity 团队一听到要操作 Android 原生 API第一反应是写 Java 代码、打 AAR 包。其实这个需求完全不需要Unity 的AndroidJavaObject可以直接调用PackageManager一行 Java 都不用写。核心逻辑是构造目标ComponentName然后调用setComponentEnabledSettingusing 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.GetStaticAndroidJavaObject(currentActivity)) { string packageName activity.Callstring(getPackageName); var pm activity.CallAndroidJavaObject(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。一个最小化的配置如下keyCFBundleIcons/key dict keyCFBundleAlternateIcons/key dict keyFestivalIcon/key dict keyCFBundleIconFiles/key array stringIconFestival/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dict这里的FestivalIcon是逻辑标识符也就是代码里传给setAlternateIconName的参数。IconFestival是图片文件的“裸文件名”不包含扩展名也不包含2x、3x后缀。系统会根据当前设备屏幕 scale 自动寻找IconFestival.png、IconFestival2x.png、IconFestival3x.png。在 Unity 工程中Info.plist是不能直接改工程里那份的因为它是构建时生成的。所以我们需要靠构建后的PostProcessBuild脚本去改。这个我放到 3.3 节一起讲。3.2 用 .mm 桥接 setAlternateIconNameUnity 的 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、IconFestival2x.png、IconFestival3x.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.ActionSwitchResult 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.ActionSwitchResult 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 在桌面上“消失”。这个事故我听说过真实案例。所以每次切换成功后立刻把状态写入PlayerPrefsprivate 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 到 1MBiOS 端按单张或多张 scale 图计算通常 200KB 到 500KB 左右。如果团队想要极致压缩包体可以把多档位图片都转成 WebPAndroid 端和高效压缩 PNGiOS 端。但 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 规格换前景背景套图或统一用带背景的非透明 PNGiOS 模拟器调用报错模拟器不支持必须真机验证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 的确认弹窗本身就代表用户授权成本频繁切换会让玩家对弹窗产生疲劳反而影响后续活动效果。技术上把双端能力封装好了运营侧反而要克制使用这个度比代码本身更考验团队的运营手感。