做手游的运营同学应该都经历过这种场面版本大更前跑过来问“能不能把图标换成春节主题”或者“周年庆期间应用商店里的图能不能换个活动视觉”。在 Unity 技术栈里这个需求听起来像是改一张图那么简单真落地才发现 Android 和 iOS 完全是两套机制各自都有边界条件。我最近正好在一款 Unity 手游里完整做了一遍动态更换 App 图标 的双端实现把 Android 的 Activity-alias 方案和 iOS 的系统备用图标接口都摸了一遍这里把整体思路、具体代码、资源配置、打包接入和踩坑过程一次讲透。这个功能解决的核心问题很简单不用发新包、不用强制用户更新就能在特定时间段让手机桌面上的图标切换到活动版本。适合做节日运营、版本预热、联动活动、直播推广这些场景也适合做 SDK 开发的同学给游戏方提供这类能力。无论你是 Unity 客户端开发、原生 Android/iOS 开发还是正在接此类运营需求的技术负责人这篇文章都能给你一套可直接落地的做法。1. 方案整体设计先看清双端平台的能力边界1.1 这个需求为什么不能像切图一样简单很多人第一反应是“换图标不就是替换一张图片么”。在应用未安装阶段确实是这样——商店封面、安装包里的 icon 都是打包时就固定的。麻烦的是已安装用户的桌面图标它是安装时由系统从应用包资源里读取并写入桌面数据库的应用内部想做任何操作都触碰不到桌面图标本身。所以所有动态换图标的方案本质都不是“改一张图”而是“告诉系统去换一个资源入口”。Android 和 iOS 各自给出的方案不同Android 利用的是 Activity-alias活动别名机制。桌面上显示的应用图标本质指向一个可启动的 Activity。如果我们在 Manifest 里定义多个指向同一个 Activity 的别名每个别名带不同图标和名称再通过 PackageManager 动态启用其中一个、禁用其他系统桌面上的图标就会跟着变化。iOS 用的是 UIApplication 提供的 setAlternateIconName 接口从 iOS 10.3 开始支持。我们需要在 App 的 Assets 里预先放好几套图标在 Info.plist 里声明备用图标集合运行时调用接口切换。这两条路线的共同特点是图标资源必须提前打包进安装包服务器下发一个 URL 让客户端去下载新图标当缩略图的做法是不可行的——画到桌面上是系统 Launcher 行为不归应用管。1.2 双端能力和限制对比维度AndroidActivity-aliasiOSsetAlternateIconName最低系统版本Android 5.0 左右基本通用iOS 10.3 及以上目前可忽略更低版本是否需要用户确认不需要切换后桌面图标直接变化第一次切换会弹出系统确认框用户拒绝则失败是否强制重启应用不需要但部分厂商桌面有缓存延迟不需要系统自动替换可切换图标数量接近无限Manifest 里定义多少都行必须在 Info.plist 中声明数量有限但够用审核风险低Google Play 基本不管这类行为中App Store 对频繁更换图标有一定审查倾向实现复杂度中涉及 Manifest 合并与原生调用中涉及 Xcode 资源和原生桥接所以说Android 的自由度明显高于 iOS。iOS 即使技术上能做到但要面对系统弹窗和审核策略这两道坎。设计技术方案时必须一开始就把两端行为分开处理别指望同一套伪代码两端通用。2. Android 端动态图标实现拆解2.1 Manifest 里的 Activity-alias 到底怎么配Activity-alias 配置是整个 Android 方案的核心。常规情况下Unity 生成的主 Activity 是com.unity3d.player.UnityPlayerActivity游戏入口通常在 Manifest 里指向它。我们需要保留一个真实的 Activity再挂几个 alias 指向它。简化后的 Manifest 结构如下application android:iconmipmap/ic_launcher_default ... 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_alias_default android:targetActivitycom.unity3d.player.UnityPlayerActivity android:enabledtrue android:exportedtrue android:iconmipmap/ic_launcher_default android:labelstring/app_name intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias activity-alias android:name.MainActivity_alias_spring android:targetActivitycom.unity3d.player.UnityPlayerActivity android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_spring android:labelstring/app_name_spring intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias /application这里有两个非常关键的细节。第一个细节真实 Activity 上不要带 LAUNCHER 的 intent-filter。如果主 Activity 和多个 alias 同时都带 MAIN LAUNCHER桌面会认为这是一个多入口应用导致手机上出现多个 App 图标。我们的做法是让真实 Activity 不带启动入口把入口全部交给 alias默认 alias 保持 enabledtrue其他 alias 初始均为 false。启动器实际显示的是唯一一个处于 enabled 状态的 alias 的图标和名称。第二个细节android:exportedtrue必须显式写。如果漏掉部分系统版本会因为安全策略拒绝让 alias 被启动器拉起或者直接导致安装后桌面没有图标。这一点在 targetSdkVersion 较高时尤其明显。2.2 原生 Java 代码实现图标切换配置好 Manifest 之后切换逻辑就在运行时调用 PackageManagerpackage com.example.iconutil; import android.content.ComponentName; import android.content.pm.PackageManager; import android.app.Activity; public class IconSwitcher { public static void switchIcon(Activity activity, String aliasName, boolean enable) { PackageManager pm activity.getPackageManager(); ComponentName component new ComponentName(activity, aliasName); int state enable ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; pm.setComponentEnabledSetting(component, state, PackageManager.DONT_KILL_APP); } }使用方式IconSwitcher.switchIcon(activity, com.example.game.MainActivity_alias_default, false); IconSwitcher.switchIcon(activity, com.example.game.MainActivity_alias_spring, true);需要说明几个点。一是DONT_KILL_APP标志一定要传。如果不传有的系统版本会直接把应用进程干掉正在玩的用户就掉线了。传了这个标志之后切换动作会静默进行不会打扰游戏进程。二是顺序问题。实践中最稳的顺序是先禁用当前启用中的 alias再启用目标 alias。如果你只是把新的 enabled 而不去管旧的那个桌面可能短暂出现两个入口虽然秒级内会自动收敛但依然有用户反馈桌面出现双图标的概率。三是状态持久化。setComponentEnabledSetting写入的是系统设置持久区域应用重启之后状态依然保留。所以游戏二次启动时无需再切一次但这也要求我们在服务端下发切换指令后客户端得有幂等判断当前图标已经是目标图标就别再调原生接口了。2.3 在 Unity C# 侧怎么桥接调用Unity 调 Android 原生用的是AndroidJavaClass/AndroidJavaObject这一点大家应该不陌生。但切换图标必须回到 UI 线程执行所以不要在 Unity 的任意线程里直接调标准写法是拿到UnityPlayer.currentActivity然后用runOnUiThread包一层。using UnityEngine; public class AndroidIconManager : MonoBehaviour { private static AndroidJavaObject _activity; public static void SwitchIcon(string enabledAlias, string disabledAlias) { if (Application.platform ! RuntimePlatform.Android) return; using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { _activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); } _activity.Call(runOnUiThread, new AndroidJavaRunnable(() { using (var switcher new AndroidJavaClass(com.example.iconutil.IconSwitcher)) { switcher.CallStatic(switchIcon, _activity, disabledAlias, false); switcher.CallStatic(switchIcon, _activity, enabledAlias, true); } })); } }有个容易踩的坑alias 的 name 是全限定类名格式比如com.example.game.MainActivity_alias_spring如果你在 C# 侧拼接字符串时漏了包名前缀方法执行不会报错但 ComponentName 匹配不到任何组件图标就纹丝不动。调试时最优先检查的也就是这一点。另外提醒一下Unity 2019 及以上版本默认使用 IL2CPPAndroid 平台调 Java 静态方法本身没有 AOT 问题但如果你做了复杂的AndroidJavaObject传参还是建议把参数控制在 String / boolean / int 这种基础类型上复杂对象容易碰到 JNI 引用管理的问题。3. iOS 端动态图标实现拆解3.1 Info.plist 与 AppIcon 资源集怎么组织iOS 的动态图标必须提前放在 App 包里系统不允许运行时随意指定任意图片路径。官方方案是在 Assets.xcassets 里新建多个 App 图标集合然后在 Info.plist 的CFBundleIcons下面声明CFBundleAlternateIcons。Assets 侧的做法不复杂新建一个AppIcon是主图标再新建AppIcon-Spring、AppIcon-Anniversary这类集合把对应尺寸的图拖进去。iOS 图标要求的尺寸比较多包括 20、29、40、58、60、76、80、87、120、152、167、180 以及上传商店用的 1024。虽然备用图标有些尺寸可以省略但为了兼容 iPad、通知栏和设置页最好按全套补齐。接下来是 Info.plist 声明给CFBundleAlternateIcons提供一份字典每个 key 是备用图标的逻辑名value 是包含CFBundleIconFiles和UIPrerenderedIcon的字典。keyCFBundleIcons/key dict keyCFBundleAlternateIcons/key dict keyspring/key dict keyCFBundleIconFiles/key array stringAppIcon-Spring/string /array keyUIPrerenderedIcon/key false/ /dict keyanniversary/key dict keyCFBundleIconFiles/key array stringAppIcon-Anniversary/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dict注意key 名和 Assets 里的集合名可以不一致——Info.plist 里写的spring是一个逻辑标识真正生效的是CFBundleIconFiles数组里的图片集合名。很多新手在这里绕晕改了一处漏了另一处导致图标一直切不过去。调试时先确认 Assets 里有没有对应的图片集合再确认 Plist 里的拼写完全一致。3.2 Swift 侧切换调用与权限弹窗处理iOS 的切换入口非常简洁就是 UIApplication 的单例方法import UIKit objc class IOSIconManager: NSObject { objc static func switchIcon(name: String) { DispatchQueue.main.async { UIApplication.shared.setAlternateIconName(name.isEmpty ? nil : name) { error in if let error error { print(switch icon error: \(error.localizedDescription)) } } } } }方法说明几点setAlternateIconName(nil)表示切回主图标。这里不能用字符串AppIcon代替必须传 nil否则系统找不到对应的 alternate icon 定义会回调一个错误。系统会在第一次切换时弹出对话框文案大概是“您确定要更换‘XXX’的图标吗”用户点了允许才会生效。如果用户点了取消error 回调里会带一个NSError建议在 Unity 侧把失败信息透出给运营提示文案。这个接口必须主线程调用。虽然 Unity 的 C# 主线程通常对应 iOS 主线程但为了防御性我习惯在原生层强制DispatchQueue.main.async包一层避免某些线程模型下 UIApplication 调用不在主线程导致系统拒绝执行。由于 Unity iOS 工程一般混编 Objective-C 和 Swift最省事的桥接方式是写一个 Objective-C 文件暴露 C 函数然后 C# 侧用DllImport(__Internal)直接调用。如果是纯 Swift 工程也可以用_cdecl导出 C 函数符号但遇到 Xcode 版本和 Swift 接口变动比较烦我建议在 Unity 工程里保留一个.mm桥接文件extern C { void ios_switch_app_icon(const char* name) { NSString* nsName name ? [NSString stringWithUTF8String:name] : ; dispatch_async(dispatch_get_main_queue(), ^{ if (nsName.length 0) { [[UIApplication sharedApplication] setAlternateIconName:nil completionHandler:nil]; } else { [[UIApplication sharedApplication] setAlternateIconName:nsName completionHandler:nil]; } }); } }C# 侧using System.Runtime.InteropServices; using UnityEngine; public static class IOSIconManager { [DllImport(__Internal)] private static extern void ios_switch_app_icon(string name); public static void SwitchIcon(string name) { if (Application.platform RuntimePlatform.IPhonePlayer) { ios_switch_app_icon(string.IsNullOrEmpty(name) ? null : name); } } }这里要注意__Internal这种静态调用方式只在真机或模拟器运行时生效编辑器里不能测。测试流程就是打一个开发包到真机或者走 Xcode 工程直接编译运行。3.3 双端封装成统一的 C# 接口既然 Android 和 iOS 都打通了最好在游戏业务层封装一个统一接口上层只关心图标 ID不关心平台差异。public static class DynamicIconManager { public static void Switch(string iconId) { switch (Application.platform) { case RuntimePlatform.Android: AndroidIconSwitch(iconId); break; case RuntimePlatform.IPhonePlayer: IOSIconManager.SwitchIcon(iconId); break; } } }这里有一个关键设计Android 端的 iconId 需要能映射出“启用哪个 alias、禁用哪个 alias”所以不要自己拼字符串建议维护一张映射表。最简单的做法是准备好一整套配置运营图标 IDAndroid enabled aliasAndroid disabled aliasiOS namedefaultMainActivity_alias_defaultMainActivity_alias_spring空nilspringMainActivity_alias_springMainActivity_alias_defaultspringanniversaryMainActivity_alias_anniversaryMainActivity_alias_defaultanniversary注意 Android 切换时如果从 spring 切到 anniversary要禁用的不是 default 而是 spring。映射表不能只写一个 disabled 目标要维护“当前已启用”的状态。比较稳妥的做法是调用原生方法前在 C# 层读取本机持久化的“当前图标 ID”两个 ID 一起传下去。4. 完整接入流程与资源规范4.1 资源准备Android 多尺寸 mipmap 和 iOS 整套图标万事开头难资源往往是第一个卡点。特别是 Android图标资源不能随便丢在 assets 里让代码去加载必须放在res/mipmap-*目录中由资源系统编译索引。Unity 打包 Android 时如果通过Editor菜单在Assets/Plugins/Android/res下建立 mipmap 目录最终 Gradle 构建时这些资源会被合并进 APK/AAB。建议准备的 Android 图标尺寸如下目录建议尺寸mipmap-mdpi48x48mipmap-hdpi72x72mipmap-xhdpi96x96mipmap-xxhdpi144x144mipmap-xxxhdpi192x192如果你嫌麻烦只放一套 xxxhdpi系统会自动缩放但部分国产系统桌面适配后图标会偏虚。正规做法是全部尺寸放齐。还有Android 资源名只允许小写字母、数字、下划线任何大写字母都会导致构建失败或者资源找不到所以别在文件名里写“SpringIcon”这种驼峰命名。iOS 侧资源我前面说了要按 AppIcon 的全套尺寸准备这里不再重复。但要注意一点Assets 里备用图标集合的图片名不要用纯中文或者带特殊符号Xcode 打包容易抽风。用icon_spring、icon_anniversary这种安全命名Info.plist 里引用时保持一致。4.2 Unity 工程侧的接入步骤整个接入流程我按实操顺序整理成下面几步在Assets/Plugins/Android下放置AndroidManifest.xml把多个 activity-alias 定义好alias 的 icon 资源指向Assets/Plugins/Android/res/mipmap-*下的对应文件。在Assets/Plugins/Android下放一个IconSwitcher.java包名建议用带自己业务标识的字符串避免不同插件之间类名冲突。在 Unity C# 侧创建DynamicIconManager脚本负责平台判断和调用。在 iOS 侧创建.mm桥接文件放到Assets/Plugins/iOS目录并准备好修改 Info.plist 的方式。这里最推荐的做法不是直接改Assets/Plugins/iOS/Info.plist而是用 Unity 的IPostprocessBuildPlayer回调在构建完成后用 Xcode 工程 API 自动注入CFBundleAlternateIcons字段。原因很简单手动改 plist 文件容易和 Unity 每次构建重新生成的文件冲突还会因为不同 Unity 版本生成的 plist 结构不同导致格式错误。在Assets/Editor下写一个IconPostProcessBuild.cs解析 Xcode project给PBXProject添加资源文件引用修改Info.plist。打一个双端开发包真机验证确认切换生效、重启不丢状态、用户无感知。4.3 构建后处理自动注入配置很多团队卡在第 4 步手动改 plist 上。我贴一段用 Unity 官方UnityEditor.iOS.XcodeAPI 注入 plist 的代码片段思路是构建结束后自动往Info.plist里塞CFBundleAlternateIconsusing UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; using System.IO; public class IconPostProcessBuild { [PostProcessBuild(1000)] 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 icons root.CreateDict(CFBundleIcons); PlistElementDict altIcons icons.CreateDict(CFBundleAlternateIcons); string[] iconNames { spring, anniversary }; foreach (string iconName in iconNames) { PlistElementDict item altIcons.CreateDict(iconName); PlistElementArray files item.CreateArray(CFBundleIconFiles); files.AddString(icon_ iconName); item.SetBoolean(UIPrerenderedIcon, false); } plist.WriteToFile(plistPath); } }这段代码的用途是让构建过程自动化避免每次出包都手动改 Xcode 设置。Assets 里备用图标的引用则要在 Xcode 工程中通过AddAssetTag或者直接确保 Unity 把 Assets 里icon_spring的 imageset 一并导入了 Xcode。这个环节在 Unity 自动导出时经常需要额外处理如果发现模拟器上找不到icon_spring这张图优先去 xcassets 里确认图片集有没有被 Unity 带过去。5. 实测过程中的坑与排查实录5.1 Android 桌面图标不刷新、出现双图标、应用被杀死最常见的问题是图标切完之后桌面没变化。我遇到一次是在某国产系统上setComponentEnabledSetting调用后返回值是 0日志也正常但桌面图标纹丝不动。排查到最后是桌面 Launcher 缓存问题——系统设置虽然改了Launcher 没有在第一时间感知组件状态变化。这种情况没有太完美的解法只能做两件事一是切换完成后给用户弹一个 Toast 提示“图标已更新请返回桌面查看”给 Launcher 一点刷新时间二是不要尝试在背后强杀 Launcher这在现代 Android 上既不安全也不优雅。还有一次是测试同学说“应用图标变成了两个”。原因是我在 Manifest 里同时给真实 Activity 和 alias 都配了 MAIN LAUNCHER。整改后我采用的规范是真实 Activity 只保留android:exportedfalse或干脆不配入口所有启动入口全部放在 alias 上并且同时只允许一个 alias 处于 enabled 状态。再有一个坑是DONT_KILL_APP没传切换一次直接把游戏进程杀了。用户正打着排位呢图标一换直接闪退这个体验非常糟糕。所以原生方法里默认就把DONT_KILL_APP带上除非你有特殊业务需求否则不要拿默认参数。5.2 iOS 用户拒绝弹窗、图标切换失败、审核风险iOS 上第一次切换图标会弹系统确认框。这里有一个很现实的运营问题弹窗文案是系统固定的用户可能误点“取消”导致本次运营活动图标没换成。我在实际项目里处理方式是在 C# 层拿到错误回调之后弹一个游戏内二次确认 UI提示用户“需要您在系统弹窗中选择允许”如果用户再次拒绝就只能放弃本次切换。不要尝试绕过系统弹窗这是私有 API审核必然被拒。审核风险也要单独说。App Store 审核对“动态修改 App 图标”没有明确禁止但如果你的图标每天都在变、或者在图标里塞促销信息审核被拒的概率会显著上升。我们的做法是运营规定的活动图标一次只切一次且保证图标本身是高质量设计不搞“点进来领红包”这种诱导视觉。另外 Swift 侧被审核时苹果会看到代码里对setAlternateIconName的调用所以调用场景要有业务逻辑支撑不要让审核人员觉得是随意换皮。5.3 资源命名、状态持久化与多包体兼容问题资源命名这个坑值得单独提。Android 资源文件夹mipmap-xxxhdpi下面每个文件必须小写iOS 那边虽然允许大小写但 Xcode 对图片集名字有缓存改名后经常出现“旧引用牵到新图”的问题。所以项目启动时就要定好命名规范。我用的是默认主图标叫icon_default各个活动图标叫icon_activity_spring、icon_activity_anniversary。Android Manifest 里的 alias 名称直接复用这套 ID 就行减少心智负担。状态持久化方面一定要把“当前图标 ID”用 PlayerPrefs 存一份。原因有两个一是方便上层逻辑判断当前是否已经是目标图标避免每次打开游戏都调原生接口二是万一原生侧切换成功后 Unity 侧因异常没有收到回调重启后还能根据 PlayerPrefs 校正。值得一提的是Android 的setComponentEnabledSetting状态本身就是持久化的所以 PlayerPrefs 并不是系统的持久化替代品而是业务侧的镜像缓存。多包体兼容问题主要在渠道包上。国内安卓渠道比较多部分渠道 SDK 会往 Manifest 里注入自己的 Activity 和启动入口这时如果渠道方也定义了 MAIN LAUNCHER我们再加多个 alias就非常容易出现多图标问题。遇到这种情况先让 Gradle 打出 manifest merge 之后的文件看看最终合并结果再检查哪些组件带了 MAIN LAUNCHER。原则上最终产物里只允许一个入口组件处于启用状态其他 alias 初始状态必须 disabled。6. 一点实际操作后的体会动态更换 App 图标这个功能Android 侧实现成本其实不高核心就一个 Activity-alias PackageManager 切换麻烦的是国产系统桌面缓存和 Manifest 合并冲突iOS 侧代码也不复杂但受限于系统弹窗和审核策略适合做低频运营活动不适合做高频 A/B 实验。我在实际项目里的做法是把所有图标 ID 和切换逻辑集中在一个管理类里每次运营前配置好映射表和服务端开关客户端启动时拉一次配置发现服务器要求的目标图标和当前不一致再调原生避免频繁调用导致桌面图标闪烁或者用户反复被弹窗打扰。如果你现在正准备接这个需求建议先花半天把两端资源尺寸整理齐再照着这套流程做一遍过程中最耗时间的往往不是代码而是排查资源名、alias 全限定名和拿到手机上看到的“没变化”到底是因为缓存还是因为配置错了。