
做了几年手游 SDK 接入之后你会发现“动态换 App 图标”这个需求几乎每隔一两个大版本就会出现一次。运营想要节假日换个节日图标版本更新换个新角色图标活动主题换个限定款图标。这个需求在 iOS 上被系统限制得死死的在 Android 上又得跟各家桌面反复斗智斗勇真正落地时远没有产品经理想象的那么简单。这篇文章把我实际做过的 Unity 手游双端动态更换图标方案完整拆开先从原生原理讲清楚 Android 和 iOS 分别能做什么、不能做什么再给出 Unity 层的统一封装思路最后是我在真实项目里积累下来的取舍建议和踩坑清单。如果你正在接这个需求或者准备给现有项目加这个能力直接照着这套思路做可以少走不少弯路。1. 先搞清楚需求动态换图标到底解决什么问题1.1 一个典型的运营场景假设你的游戏下个月要搞周年庆运营提了这样一个需求“活动期间把桌面图标换成限定版角色皮肤活动结束后恢复默认图标。”听起来很简单但拆开看其实包含四个子需求客户端必须提前准备好“默认图标”和“周年庆图标”两套资源。运营后台要能下发“切换图标”指令。客户端收到指令后必须在没有用户手动干预的情况下完成图标切换。活动结束后还要能自动或手动切回默认图标且用户重启手机后状态要保持。这四个子需求Android 和 iOS 的可行程度完全不同。Android 上有 activity-alias 机制可以通过代码动态启用/禁用组件手机桌面上显示的图标会跟着变化。而 iOS 只有官方提供的setAlternateIconNameAPI虽然也能实现但系统会弹确认框、审核有限制、图标必须预置在包里。所以双端的技术方案必须分开设计再做统一封装。1.2 双端能力差异Android 灵活但碎片化iOS 受限但规范拿我的比喻来说Android 的做法像换门牌号——你的应用在系统里注册了多个“门牌”activity-alias每块门牌对应一块图标牌子。代码控制当前挂哪块牌子桌面就去读哪个图标。iOS 则更像打开系统设置里的头像更换页API 是现成的传一个名字就能换非常规范。但你能换的只能是系统“认识”的头像——也就是打包时预置进 App 里的那些图标。这意味着一个残酷的事实**无论是 Android 还是 iOS动态更换都不支持运行时从网络下载新图标。**所有候选图标必须在发版前就预置进安装包。凡是产品经理说“活动图周五才定稿周一要上线”技术上都只能选择等图或者砍需求。1.3 动手之前必须想清楚的三件事在写任何代码前我建议你先和产品/运营对齐以下三件事第一图标来源。确认所有候选图标都会在发包前定稿并且随版本预置。不要承诺“临时下载”这种方案技术上极其别扭兼容性还差。第二切换触发。建议走服务端下发指令比如运营配置一个“当前图标类型”的字段客户端定期拉取或在收到推送后切换。纯本地的手动切换设置页按钮也可以但只适合做调试用不适合真正的运营活动。第三回退策略。必须能一键恢复默认图标。你想活动结束后如果没能切回来用户桌面上永远挂着一个过期的活动图标体感就是你的应用“坏掉了”。Android 切回默认组件即可iOS 传空字符串即可但这条链路必须在测试中完整覆盖。另外提醒一点应用商店里的图标是商店平台审核时固定的客户端动态换图标只能改变用户手机桌面上的显示不会改变应用商店里的展示图标。这个边界要提前跟运营说清楚不然验收时又是一顿扯皮。2. Android 端方案activity-alias 与 PackageManager 的启停游戏2.1 为什么一套代码能显示多个图标Android 的桌面Launcher在展示应用图标时本质是去扫描系统组件列表里带有MAIN和LAUNCHER标记的 Activity。一个应用如果声明了多个这样的入口组件桌面就会显示出多个图标。利用这一点我们可以给同一个主 Activity 创建多个activity-alias每个 alias 引用不同的图标资源和文字标签。然后通过代码控制某个时刻只启用其中一个 alias把其他 alias 全部禁用。桌面同一时间只会看到一个图标且图标内容由当前启用的 alias 决定。这里有个关键点这些 alias 的targetActivity都指向同一个主 Activity所以用户点图标进入游戏时入口逻辑完全不变不需要任何额外判断。2.2 AndroidManifest 配置示例与容易踩的坑看一段典型的配置。假设你的游戏包名是com.example.rpg主 Activity 是MainActivityapplication android:labelstring/app_name ... !-- 主 Activity 本身不再声明 MAIN/LAUNCHER入口统一交给 alias -- activity android:name.MainActivity android:exportedtrue / !-- 默认图标入口 -- activity-alias android:name.MainActivityAlias_Default android:enabledtrue android:exportedtrue android:iconmipmap/ic_launcher_default 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.MainActivityAlias_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这里有几个特别容易踩的坑**坑一MainActivity 上还留着 MAIN/LAUNCHER 过滤器。**如果主 Activity 自己仍然有MAINLAUNCHER同时你的 alias 也带LAUNCHER那么桌面上会同时出现两个入口图标。正确做法是入口统一交给 alias主 Activity 里只保留exportedtrue不要把启动入口放在它身上。**坑二alias 的exported必须为 true。**否则桌面无法识别这个入口组件图标不会出现。**坑三android:icon只能引用编译期资源。**你必须把图标 PNG 放到res/mipmap-*目录下通过mipmap/xxx引用。assets 目录里的临时文件不可能被 alias 当作图标使用这条规则决定了整个方案“预置”的基调。2.3 切换代码与桌面刷新最容易被忽略的坑切换逻辑的核心是PackageManager.setComponentEnabledSetting。直接看代码public class DynamicIconBridge { private static final String[] ALL_ALIAS_SUFFIXES {Default, Festival}; public static boolean switchIcon(Context context, String aliasSuffix) { try { PackageManager pm context.getPackageManager(); String pkg context.getPackageName(); // 先启用目标 alias再禁用其他 alias ComponentName target new ComponentName(pkg, pkg .MainActivityAlias_ aliasSuffix); pm.setComponentEnabledSetting( target, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); for (String suffix : ALL_ALIAS_SUFFIXES) { if (suffix.equals(aliasSuffix)) continue; ComponentName alias new ComponentName(pkg, pkg .MainActivityAlias_ suffix); pm.setComponentEnabledSetting( alias, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); } return true; } catch (Exception e) { Log.e(DynamicIcon, switch icon failed: aliasSuffix, e); return false; } } }我特意写了“先启用目标再禁用其他”这个顺序是有讲究的。如果先禁用当前入口再启用新入口桌面可能在中间状态读到 0 个入口表现为图标短暂消失。反过来先启用再禁用虽然理论上也可能被桌面读到双入口但实际调用间隔极短桌面通常会在两次状态变更都完成后再统一刷新。**真正的大坑是setComponentEnabledSetting调用成功后桌面图标不会立刻变。**不同厂商桌面刷新行为差异非常大原生 Android / Pixel几乎立即生效。小米 MIUI经常不刷新需要杀桌面进程、重启桌面或等系统广播实测结果很随机。华为 EMUI部分版本响应com.huawei.android.launcher.action.UPDATE_APP_ICON广播发一下能显著加速刷新。OPPO / vivo 系多数情况几秒内自动刷新偶尔需要手动清理最近任务再回桌面。所以“刷新”这件事没有百分之百的通用解法。我一般会做“尽力刷新”——在切换完组件后尝试发一个通用的刷新广播然后接受“部分机器会延迟”这个现实private static void tryRefreshLauncher(Context context) { try { Intent intent new Intent(com.huawei.android.launcher.action.UPDATE_APP_ICON); intent.setPackage(com.huawei.android.launcher); context.sendBroadcast(intent); } catch (Exception ignored) { } }要提醒一句这段广播代码只能作为辅助手段不能保证所有华为版本都支持。2.4 图标资源放哪别幻想运行时下载接前面说的activity-alias的android:icon是编译期引用APK 打包完成后这些图标就以资源形式存在了。你在运行期无法把从网上下载的 PNG 临时绑定给 alias这是 Android 资源系统的硬限制。如果你真的遇到了“图标必须动态生成、不能预置”的需求只能考虑用桌面快捷方式Shortcut来模拟——给桌面创建一个指向游戏的新快捷方式把新图标放在快捷方式上。但那个实现的问题是用户桌面上会多一个快捷方式图标而不是“替换”原来的图标而且不同厂商桌面 Shortcut 的刷新行为更不可控。我们在手游项目里基本不推荐这个路子预置图标互切已经是最成熟的方案。3. iOS 端方案setAlternateIconName 的受限空间3.1 配置Info.plist 里的 CFBundleAlternateIconsiOS 侧没有 activity-alias 这种“组件启停”机制一切围绕 Info.plist 中的CFBundleIcons结构。你需要先在工程的 Info.plist 里声明可切换的图标keyCFBundleIcons/key dict keyCFBundleAlternateIcons/key dict keyFestival/key dict keyCFBundleIconFiles/key array stringAppIconFestival/string /array /dict /dict /dict这里面的“Festival”就是代码里传给setAlternateIconName的名字两者必须完全一致否则系统会报错。图片资源要放进 Xcode 工程并且加入到 Copy Bundle Resources 里。iOS 会根据设备型号自动查找对应的2x、3x资源所以你要准备AppIconFestival.png60x60AppIconFestival2x.png120x120AppIconFestival3x.png180x180如果是通用应用还需要 iPad 尺寸。另外图标图片不能带 alpha 通道否则系统会拒绝使用。3.2 调用与错误处理Objective-C 的调用逻辑如下extern C void _switchAppIcon(const char *iconName) { if (available(iOS 10.3, *)) { UIApplication *app [UIApplication sharedApplication]; if (iconName NULL || strlen(iconName) 0) { [app setAlternateIconName:nil completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(restore icon error: %, error.localizedDescription); } }]; return; } NSString *targetName [NSString stringWithUTF8String:iconName]; NSString *currentName app.alternateIconName; // 已经在目标图标上直接返回 if (currentName ! nil [currentName isEqualToString:targetName]) { return; } [app setAlternateIconName:targetName completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(switch icon error: %, error.localizedDescription); } }]; } }注意几点当前图标状态。alternateIconName返回 nil 表示当前是默认图标返回字符串表示当前用的是哪个候选图标。**存在性校验。**如果你传了一个 Info.plist 里没配置的名字系统回调里的 error 就会告诉你“The specified icon name is not in the list of alternate icons”。**系统弹窗。**iOS 切换图标时系统会弹一个模态确认框大概意思是“应用正在更换图标”无法用代码绕过。用户确认后图标才会变化。必须判断系统版本。setAlternateIconName是 iOS 10.3 之后的 API虽然现在项目最低版本基本都比这高但判断一下总是安全的。3.3 iOS 的隐藏限制审核、体积与更新策略很多人担心“换图标”是不是会被 App Store 审核卡我实际过审的经验是这个 API 本身是公开 API不会因为调用它而被拒。但审核侧看的是你提交包里实际带了什么图标如果图标里包含第三方 IP、品牌元素或者带有强烈的限时运营属性比如“全场五折”“限时活动”这类图有可能触发额外的审核问询。稳妥的做法是候选图标只做游戏世界观内的角色或logo不要放活动口号、日期、价格这类运营文案。体积也是个容易被忽略的点。iOS 候选图标每个尺寸都要真实打包进 IPA一套图下来通常增加 100KB 到 300KB。如果你塞 5 套进去就是 1MB 左右的体积增长。手游包本来就不小这个成本不算高但也要纳入包体评估。还有更新策略。你要在测试里验证一下“覆盖安装”这个场景用户切到了节日图标然后你发了新版本覆盖安装桌面图标是保留用户的当前选择还是重置为默认我实测下来多数情况会重置为新包的默认图标。所以活动结束后切回默认图标这件事不能完全依赖“反正发版会重置”该做的回退逻辑还是要做。4. Unity 层统一封装写一次 C# 两端复用4.1 C# 公共接口设计搞清楚双端原生原理后Unity 层就变成一个“翻译层”——把业务层的意图翻译成双端各自的原生调用。我建议的接口设计是这样的public enum AppIconType { Default 0, Festival 1, Anniversary 2 } public static class MobileAppIcon { public static bool SwitchTo(AppIconType type) { #if UNITY_ANDROID !UNITY_EDITOR return SwitchAndroid(type); #elif UNITY_IOS !UNITY_EDITOR return SwitchIOS(type); #else Debug.Log([MobileAppIcon] Editor mode, skip native call.); return true; #endif } public static void RestoreDefault() { #if UNITY_ANDROID !UNITY_EDITOR SwitchAndroid(AppIconType.Default); #elif UNITY_IOS !UNITY_EDITOR SwitchIOS(AppIconType.Default); #endif } private static bool SwitchAndroid(AppIconType type) { using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); if (activity null) return false; return activity.Callbool(switchIcon, type.ToString()); } } private static bool SwitchIOS(AppIconType type) { _switchAppIcon(type AppIconType.Default ? : type.ToString()); return true; } #if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _switchAppIcon(string iconName); #endif }业务层的调用方式就很简单了比如活动模块可以在启动时检查运营配置然后直接调MobileAppIcon.SwitchTo(AppIconType.Festival)。这个设计里我明确处理了几个细节Android 侧通过AndroidJavaClass获取currentActivityiOS 侧通过DllImport(__Internal)绑定原生符号Editor 环境下直接跳过原生调用打日志方便策划和程序在编辑器里快速验证流程。4.2 Android 原生插件实现与 Unity 调用细节Unity 调 Android 的 Java 桥接类核心就是Activity.Callbool(switchIcon, ...)。Java 侧我通常这样实现public class DynamicIconBridge { public static boolean switchIcon(Activity activity, String aliasSuffix) { // 校验白名单防止非预期值注入 if (!Arrays.asList(Default, Festival, Anniversary).contains(aliasSuffix)) { return false; } // PackageManager 操作理论上需要主线程这里 post 到 UI 线程 final boolean[] result new boolean[1]; activity.runOnUiThread(new Runnable() { Override public void run() { result[0] doSwitchIcon(activity, aliasSuffix); } }); return result[0]; } private static boolean doSwitchIcon(Context context, String aliasSuffix) { // 具体逻辑见第 2.3 节的 PackageManager 代码 ... } }两个注意点**第一是线程。**在 Unity 场景下C# 调用AndroidJavaObject.Call时不一定保证在 Android 主线程。PackageManager 的组件操作虽然不强制要求主线程但为了稳妥我习惯把操作 post 到 main looper 上执行。**第二是资源放置。**Java 代码要打成 aar 或 jar 放到Assets/Plugins/Android/目录下。AndroidManifest.xml 放在Assets/Plugins/Android/AndroidManifest.xml构建时 Unity 会把它合并进最终工程的 manifest。同理图标 PNG 放在Assets/Plugins/Android/res/mipmap-*目录下。4.3 iOS 原生插件实现与异步回调设计iOS 侧要导出一个extern C函数供 C# 调用放在Assets/Plugins/iOS/DynamicIcon.mm里。我用的是上一章给出的_switchAppIcon实现。这里必须强调一个 Unity 与 iOS 原生交互的设计陷阱setAlternateIconName的completionHandler是异步回调。如果你在 C# 侧写了一个期望同步返回结果的调用比如bool success _switchAppIcon(Festival); // 错误示范这个返回值永远只能是“已发起”而不是“切换成功”。iOS 的系统弹窗、用户确认、资源校验都是异步的你没法同步拿到最终结果。如果业务上确实需要知道结果正确做法是走 UnitySendMessage 回调[app setAlternateIconName:targetName completionHandler:^(NSError * _Nullable error) { if (error) { UnitySendMessage(IconManager, OnIconSwitchFailed, [error.localizedDescription UTF8String]); } else { UnitySendMessage(IconManager, OnIconSwitchSuccess, ); } }];对应在 C# 侧挂在一个名叫IconManager的 GameObject 上public void OnIconSwitchSuccess(string message) { Debug.Log(Icon switch success); }大部分时候我们并不需要这个结果因为活动切换图标只是“尽力而为”——切失败了下一次拉配置再试就行。但如果你想做一个“切换成功后给玩家发奖励”的活动就必须接回调否则运营数据会错。4.4 Unity 工程中资源与配置的自动注入iOS 的 Info.plist 修改和图标资源导入如果全靠每次构建后手动改 Xcode 工程效率太低还容易漏。我会写一个PostProcessBuild脚本在 Unity 构建 iOS 工程后自动完成两件事修改 Info.plist 写入CFBundleAlternateIcons把图片资源拷贝进 Xcode 工程。脚本框架大致是这样#if UNITY_IOS using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; public static class iOSIconPostProcess { [PostProcessBuild(100)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target ! BuildTarget.iOS) return; // 1. 修改 Info.plist string plistPath path /Info.plist; PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementDict icons plist.root.CreateDict(CFBundleIcons); PlistElementDict altIcons icons.CreateDict(CFBundleAlternateIcons); PlistElementDict festivalDict altIcons.CreateDict(Festival); PlistElementArray files festivalDict.CreateArray(CFBundleIconFiles); files.AddString(AppIconFestival); plist.WriteToFile(plistPath); // 2. 把图标图片加入 Xcode 工程 // 使用 PBXProject 读取 project.pbxproj向 BuildPhase 添加文件引用 // 这里省略具体文件操作实际工程中你会需要用 File.Copy 把 // Assets/Plugins/iOS/Resources/AppIconFestival*.png 拷到 Xcode 工程目录 // 并通过 PBXProject.AddFile 注册。 } } #endif这段代码省略了 PBXProject 的文件添加细节因为不同 Unity 版本 API 略有差异但思路是一致的**构建后自动把 plist 键值和图片资源都配好出包后不需要再手动碰 Xcode。**你只要保证图片源文件在 Unity 工程里即可。5. 落地对比与真实项目的取舍建议5.1 双端能力横向对比把两端的关键差异放在一张表里给做技术方案评审时用维度AndroidiOS切换 APIPackageManager 启停 activity-aliassetAlternateIconName支持版本Android 全版本iOS 10.3图标来源编译期 res 预置包内预置切换方式无系统弹窗系统确认弹窗切换速度取决于桌面刷新用户确认后即时生效刷新兼容性各家桌面差异大系统统一无碎片问题运行时下载图标不支持不支持审核风险各应用商店规则不同App Store 审核检查图标内容回退方式启用默认图标 aliassetAlternateIconName 传空5.2 真实项目里的取舍建议结合我做过的项目我的落地优先级和取舍建议是**优先做 AndroidiOS 可以排第二期。**原因很简单国内手游主要分发渠道都在 Android运营活动灵活度要求高Android 的方案一旦打通收益立竿见影。iOS 受限于审核、弹窗、图标预置可玩性小很多通常只有在“大版本营销”这种量级的需求才值得上。**候选图标控制在 3 套以内。**每套图标都要占据双端包体还要走一遍各厂商桌面的兼容性测试。图标超过 3 套边际成本急剧上升运营也不会真正用满那么多套。**服务端指令必须做白名单。**客户端收到“切换图标”指令后要校验图标类型是否在已知集合里。为什么因为 Android 的 alias 组件名是拼接出来的如果服务端被注入一个非法字符串客户端就可能去启用一个不存在的组件虽然不会崩溃但整个切换逻辑会卡在异常分支导致图标状态不可控。**不要把动态图标和奖励系统绑定。**做活动可以但不要设计成“切换图标成功后才发放奖励”这种强依赖链路。因为 Android 桌面刷新、iOS 弹窗确认都存在延迟和不确定性奖励发放就应该走正常的活动逻辑图标只是氛围展示这样才能避免客服收到大量“我切了图标为什么没收到奖励”的工单。5.3 踩坑清单本篇精华最后把我踩过的坑整体梳理一遍这些内容在官方文档里基本找不到**主 Activity 和 alias 同时带 LAUNCHER桌面出现两个图标。**这个是接入期最容易犯的错主 Activity 的 MAIN/LAUNCHER 一定要移除入口全部交给 alias。**setComponentEnabledSetting 之后图标迟迟不刷新。**不是代码错了是桌面缓存。测试时要用真机覆盖厂商桌面不要只拿 Pixel 和模拟器测。华为部分版本需要补广播。com.huawei.android.launcher.action.UPDATE_APP_ICON这个广播可以加速桌面刷新但只对部分 EMUI 版本有效要当辅助手段而不是可靠依赖。**iOS 图标名与实际文件名不一致。**Info.plist 里的 key 是“Festival”但你给代码传的是“festival”系统会直接报错。建议在 C# 层定义一个枚举到字符串的映射写死对应关系避免拼写错误。**iOS 上想在 C# 侧同步拿切换结果。**这是设计错误。iOS 的 completionHandler 是异步的要拿到结果必须走 UnitySendMessage 回调。接口设计一开始就要想清楚Android 可以同步返回iOS 只能异步回传。**运营指令下发失败导致图标状态悬空。**比如活动开始了但指令没到客户端活动结束了指令又到了。一定要在客户端启动时拉一次当前图标配置并应用而不是只依赖推送。这样做能在一定程度上自愈。**测试用例不完整。**动态换图标的测试矩阵至少包含首次安装进默认图标、热切换、杀进程重启、系统重启、覆盖安装、以及切换后进入游戏再退回桌面。每个场景都过一遍再谈上线。**Android 13 的主题图标影响。**新版 Android 支持在桌面上用主题色自动生成单色图标如果你的候选图标没有做对应的monochrome适配部分系统桌面上可能会显示出一个不协调的默认样式。这个不是功能性问题但会影响到“运营希望看到效果很精致”的验收标准。我在实际项目里的做法是把这套动态图标能力做成了 SDK 内的一个独立模块Android、iOS、Unity 三层封装好后后续项目只需要接入模块、配置几张图标、接上服务端字段就能复用。每次要上新活动图运营提前把图定稿发一个版本预置进去活动当天服务端一条指令切过去活动结束再切回来整个流程稳定跑完。最后分享一个小技巧在 Android 调试阶段不必反复打带推送功能的测试包来验证切换逻辑可以直接在调试机上用 adb 命令模拟切换快速验证桌面刷新行为和 icon 资源是否正确# 启用周年庆图标对应的 alias禁用默认图标 alias adb shell pm enable com.example.rpg/.MainActivityAlias_Festival adb shell pm disable com.example.rpg/.MainActivityAlias_Default # 查看当前组件启用状态 adb shell pm get-component-enabled-setting com.example.rpg/.MainActivityAlias_Festival这比走运营后台、等推送、再手动点包重启整个链路高效得多。至少我在最终验收前都是靠这套命令先把各厂商桌面挨个过一遍再让 QA 走正式流程的。