客户端找过来时需求说起来很简单“活动公告上线那天App 在用户桌面上的图标要自动换成活动皮肤活动结束前再自动换回来全程不重新出包。”我在拿到这个需求之后第一反应是——Unity 手游动态更换 App 图标Android 和 iOS 双端其实都有系统级方案但真正要把它们接进 Unity 工程、用 C# 统一控制中间的细节比想象中多。这篇文章把我实际踩过的流程、代码和坑整理出来给后面准备接这个功能的团队做个参考。1. 为什么手游要换图标以及双端对“动态”的不同口径1.1 换图标不只是好看它直接服务运营指标手游生命周期里桌面图标是很值钱的入口。用户不会每天打开商店页看宣传图但会无数次扫到桌面上的 App 图标。节假日、赛事活动、版本更新、联动预热只要把图标换应景一点点击率通常都会有可感知的变化。也正因如此运营对“换图标”的诉求不是偶尔做一次而是希望成为可配置的能力今天春节皮肤明天电竞赛事后天回归经典。除了活动还有一类场景是 A/B 测试。同一个 App 用不同图标做新增观察安卓端通过渠道包就能实现但 iOS 要出多个包成本太高用动态图标能力就能在一个包内把不同入口预置好通过远程配置切换。1.2 “动态更换”的真实含义不是在运行时生成图片一定要先和产品对齐一个概念这里说的“动态”不是像换壁纸一样从服务器拉一张图再设置。Android 和 iOS 出于安全考虑都不允许运行期引入外部图标文件。所谓的动态切换本质是“在安装包内预置多套图标运行时切换系统当前识别的那一套”。Android在 Manifest 里预注册多个activity-alias每个 alias 可以指向自己的icon。运行时用PackageManager.setComponentEnabledSetting切换启用的入口桌面图标跟着变。iOS在 Info.plist 里声明CFBundleAlternateIcons运行时调用setAlternateIconName切到内置的某个图标。两个平台机制不同但有一个共同点所有图标必须在打包时就放进 App 里热更新无法增加新图标。运营如果希望下个月有新皮肤研发必须在那个版本先打进去或者预留通用占位图标。我把双端差异整理成一张表直接贴在需求文档里给产品看对比项AndroidiOS实现机制activity-alias PackageManagersetAlternateIconName Info.plist最低系统版本Android 7.0 以下也可用但刷新差建议 8.0iOS 10.3是否需要用户确认不需要直接生效每次切换都会弹系统确认框生效速度依赖桌面刷新部分 ROM 有延迟用户点击确认后立刻生效是否支持网络拉新图标不支持不支持恢复默认启用默认入口即可传 nil 给系统 API2. Android 端Activity-Alias 换图标的核心链路与配置细节2.1 入口点改用 aliasManifest 才不会出现“双图标”安卓动态换图标的经典做法是给启动入口套一层activity-alias。activity-alias本身不是真实页面它是系统里的一个“别名入口”可以指向某个真实的 Activity同时拥有自己的图标、标签和 intent-filter。桌面启动图标时实际是启动这个别名背后的 targetActivity。关键点在于要让MainActivity自己不带LAUNCHER的 intent-filter把启动入口的职责全部交给 alias。否则当某个 alias 被启用时MainActivity自带入口和 alias 入口会同时存在桌面上直接出现两个图标。下面是我在 Unity 项目中使用的简化 Manifest 配置application ... activity android:name.MainActivity android:exportedtrue android:configChangesorientation|screenSize|keyboardHidden / activity-alias android:name.entry_default android:exportedtrue android:targetActivity.MainActivity android:iconmipmap/ic_icon_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.entry_festival android:exportedtrue android:targetActivity.MainActivity android:iconmipmap/ic_icon_festival android:labelstring/app_name intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias /application注意几个容易错的地方android:exported必须显式设为true否则部分桌面在 Android 12 之后无法正常拉起入口。android:name的命名要有规律C# 传过来的 key 可以拼成组件名这样原生层不用写一堆 if。图标资源要放在 res 下的 mipmap 或 drawable 目录不能放在 StreamAssets 或者写成网络 URL。如果 Unity 使用了自己的AndroidManifest.xml在Assets/Plugins/Android/AndroidManifest.xml里直接放这段即可如果项目用 Gradle 模板合并别漏掉 alias 的声明。2.2 Java 侧切换逻辑先把旧入口全部禁用再启用新入口原生切换逻辑其实就一个方法setComponentEnabledSetting。但执行顺序有讲究如果先启用新入口再禁用旧入口中间瞬间可能出现两个可用入口。我习惯先把所有已知入口全部禁用最后启用目标入口。public class IconSwitcher { private static final String[] ALL_ENTRIES { entry_default, entry_festival }; public static void applyIcon(Context context, String iconKey) { if (context null || iconKey null) { return; } String packageName context.getPackageName(); PackageManager pm context.getPackageManager(); for (String entry : ALL_ENTRIES) { ComponentName component new ComponentName( packageName, packageName . entry ); boolean shouldEnable entry.equals(entry_ iconKey); int state shouldEnable ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; pm.setComponentEnabledSetting( component, state, PackageManager.DONT_KILL_APP ); } } }这里有一点经验如果最终要恢复默认图标不要单独写一个“删除别名”的逻辑直接让iconKey传default把entry_default重新启用即可。DONT_KILL_APP这个 flag 一定不能省。如果不传系统可能会先杀死 App 进程再应用变更。手游有登录态、有内存缓存莫名其妙被杀一次很容易被玩家遇到“回到桌面再点开要重新加载”的问题。2.3 刷新机制与实际生效时间调用setComponentEnabledSetting之后系统会发一个组件变更广播桌面 Launcher 收到后刷新图标。但“收到广播”和“刷新图标”是两件事不同桌面的处理差异比较大。Android 8.0 以来Pixel 原生桌面和多数国产主流桌面做得比较及时回桌面看一眼基本已经换好。老版本系统或者部分改过桌面的 ROM则需要等系统重新读取一次应用入口信息最典型的表现是用户切完图标回桌面图标没变但重启手机或者重启桌面进程之后图标才变。所以在实际项目里我不会把“即时可见”写进验收标准产品那边也得接受一个更稳妥的时间口径“切换到桌面后在数秒到数分钟内陆续生效极端设备需要重启桌面。”对长线运营活动来说提前一天切好基本没有影响。2.4 国产 ROM 上容易翻车的点安卓端的坑大部分不在代码逻辑而在各家桌面的“个性”。一是桌面图标缓存。某些 ROM 的桌面进程会把图标缓存做得很顽强切换后广播收到了也不刷新。测试时发现图标一直没变可以先杀掉桌面进程试一次比如在开发者选项里“结束后台进程”或者重启一次设备确认是否只是缓存问题。二是“应用双开”“应用分身”这类功能。双开出来的应用是独立入口它的图标可能不跟随主应用动态切换。如果产品要求所有场景统一变化在活动期间对双开用户可能做不到最好提前和测试约定好预期。三是目标入口被禁用引发的异常。部分系统在检测到当前桌面入口被禁用时会出现“回到桌面图标消失”的临时状态。为了防止这种问题我在切换时始终保证有一个entry_default占位而且默认入口永远只禁用不删除如果某个版本误把entry_default也禁用了就会出大事。四是 Android 12 之后个别新系统对LAUNCHER组件的启用状态管理更严格。我在 Pixel 和高通平台真机上都验证过逻辑能跑通但测试矩阵里还是要覆盖 Android 12/13/14 各一台真机不要只在模拟器上验收。3. iOS 端setAlternateIconName 的能力边界与 Objective-C 桥接3.1 Info.plist 里要先把可选图标“供”出来iOS 的机制比 Android 更直观。Info.plist 里需要加CFBundleIcons字典里面放CFBundleAlternateIcons。每个键就是图标标识值是包含CFBundleIconFiles数组的字典。keyCFBundleIcons/key dict keyCFBundleAlternateIcons/key dict keyicon_festival/key dict keyCFBundleIconFiles/key array stringicon_festival/string /array /dict /dict /dict这里有个容易忽略的地方如果应用是 iPad 兼容版本可能还需要单独配一份CFBundleIcons~ipad否则 iPad 桌面上图标切换可能会异常。Unity 构建出来的 Xcode 工程默认通常带 Universal 配置所以我会在构建完成后检查一下Target - Build Settings - Asset Catalog App Icon Set Name以及 Info.plist 的实际内容确认 alternate icon 已经合入。如果是把图标文件直接放进Assets/Plugins/iOS目录Unity 构建时会把它复制进 Xcode 工程。但要注意检查它是否被加进Copy Bundle Resources否则运行时系统找不到图片文件切换就会失败。为了避免手动检查遗漏我后来统一在 Unity 工程里建了一个Editor脚本构建完成后自动校验关键文件是否存在。3.2 Objective-C 封装主线程、错误回调、nil 恢复默认iOS 原生侧只需要一个很小的.mm文件放到Assets/Plugins/iOS下Unity 构建时会自动编进工程。基础实现如下#import UIKit/UIKit.h extern C void _SetAlternateIconName(const char *iconName) { NSString *name iconName ? [NSString stringWithUTF8String:iconName] : nil; dispatch_async(dispatch_get_main_queue(), ^{ if (available(iOS 10.3, *)) { UIApplication *app [UIApplication sharedApplication]; [app setAlternateIconName:name completionHandler:^(NSError *error) { if (error) { NSLog([IconSwitch] error: %, error.localizedDescription); } }]; } else { NSLog([IconSwitch] not supported 10.3); } }); }有两个细节我特别在意一定要切到主线程调用。setAlternateIconName的文档明确要求主线程但 Unity 原生回调并不保证在主线程所以我在函数开头就dispatch_async到主队列。恢复默认图标不是传空字符串而是传nil。如果 C# 侧把空字符串传给原生层直接setAlternateIconName:会报无效图标名的错误。我在 C# 侧封装时就约定null代表恢复默认空字符串在进入原生层之前已经屏蔽掉。如果需要把结果回传 C#可以在回调里拼一个 JSON 字符串用UnitySendMessage发给指定 GameObject 上的方法。这样 Unity 层能知道 iOS 用户是点击确认还是取消了弹窗。3.3 iOS 三条限制必须提前告诉产品iOS 端最容易被产品低估的是交互确认。每次调用setAlternateIconName系统都会弹出确认框文案大概是“是否要将主屏幕图标更改为 xxx”。用户不点确认切换就不生效。也就是说iOS 端不存在“App 自己偷偷把图标换掉”的能力这个限制没法绕过也不需要绕过审核时会因为系统弹窗而显得合规。第二图标必须随安装包预置。如果运营想在活动当天临时加一个图标技术上做不到除非走 App 审核更新。所以我会要求运营提前至少一个版本把备用图标素材给研发并且约定一个“预留位”概念包里始终放一个通用活动图标就算素材没法提前定稿也能先切到通用版本顶一下。第三不是所有 iOS 版本都支持。supportsAlternateIcons这个属性在 iOS 10.3 及以上才为 YES。虽然现在 iOS 10.3 以上的设备覆盖已经很高但代码里还是要做版本判断对不支持的系统返回 false 并做好日志。4. Unity 侧统一接入一次 C# 封装两端原生各干各的4.1 对外接口调用方只需要一个 iconKeyUnity 层不应该让业务知道 Android 的组件名也不应该让业务感知 iOS 的 plist key。我在 C# 侧提供两个方法一个切到指定图标一个恢复默认。图标标识统一用字符串 key 表示。public static class AppIconManager { public static void SwitchIcon(string iconKey) { if (string.IsNullOrEmpty(iconKey)) { return; } #if UNITY_ANDROID !UNITY_EDITOR SwitchIconAndroid(iconKey); #elif UNITY_IOS !UNITY_EDITOR SwitchIconIOS(iconKey); #else Debug.LogWarning(AppIconManager switch not supported in current environment); #endif } public static void ResetToDefault() { #if UNITY_ANDROID !UNITY_EDITOR SwitchIconAndroid(default); #elif UNITY_IOS !UNITY_EDITOR SwitchIconIOS(null); #endif } }调用方不需要关心当前是哪个平台只要传一个约定好的 key。例如activity、festival、default。原生层拿到 key 之后再拼自己的资源名。这套接口好处是后续加图标素材C# 层基本不用动只需要原生层增加对应 alias 或 plist 声明业务侧把新 key 配置下去就能切。4.2 Android 桥接AndroidJavaObject 与 AAR 的配合Unity 调用 Android 原生用AndroidJavaClass和AndroidJavaObject。因为切换图标需要Context最稳妥的做法是从UnityPlayer.currentActivity拿 Activity然后在线程安全的语境下调用原生方法。private static void SwitchIconAndroid(string iconKey) { using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (var switcher new AndroidJavaClass(com.example.icons.IconSwitcher)) { activity.Call(runOnUiThread, new AndroidJavaRunnable(() { switcher.CallStatic(applyIcon, activity, iconKey); })); } }原生代码我建议打成 AAR 而不是直接扔.java文件。原因有几个独立 AAR 可以做混淆控制不会污染 Unity 主工程在 Android Studio 里调试更顺手如果后续原生逻辑变大维护边界也更清晰。打 AAR 的流程很简单新建 Android Library 模块写下IconSwitcher类和相关配置执行gradle assembleRelease然后把生成的.aar放到 Unity 工程的Assets/Plugins/Android目录下。记得打开 ProGuard 后给IconSwitcher加 keep 规则否则被混淆后 C# 侧AndroidJavaClass找不到类名。4.3 iOS 桥接__Internal 与动态库符号iOS 侧 Unity 调用原生.mm里的 C 函数用的是[DllImport(__Internal)]。这个名称是 Unity 静态链接的约定真机上会去链接当前可执行文件里的符号编辑器里没有才会报EntryPointNotFoundException。#if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _SetAlternateIconName(string iconName); #endif调用处直接传字符串。C# 侧要把null明确传进去因为 DllImport 的参数类型string遇到null时会变成NULL这正好符合 iOS 端恢复默认的约定。iOS 的另一个注意点是建立回调通道。setAlternateIconName是异步的用户弹窗确认后回调才执行。我会在 C# 层维护一个Actionbool currentCallback原生层通过UnitySendMessage通知一个固定的 GameObject再转发给回调。这样业务侧可以感知用户是否真的切换成功。4.4 一个用于测试的调试页这个功能如果只在代码里封装好不上手实测心里是完全没底的。我建议在 Unity 工程里加一个简单的调试面板放几个按钮恢复默认切到图标 A切到图标 B输出当前调用结果不用做得很精致能真机触发接口就行。测试时我把面板按钮和远程配置共用同一套AppIconManager接口这样既能测原生链路又能直接验证远程下发后的行为。5. 联调清单与常见故障排查5.1 双端标准验证步骤动态换图标涉及启动入口的变更一旦出错轻则桌面图标不变重则用户点图标无响应。所以我的验收流程固定成一套清单每个版本都会逐条过一遍安装后确认默认图标正确。调用一次切换逻辑回桌面观察图标是否变化。从后台杀掉 App再点击桌面图标启动确认入口正常。连续切两轮以上确认不会出现双图标或图标丢失。重启手机一次确认图标状态持久化。Android 用adb shell dumpsys package 包名 | grep entry_检查各组件的 enabled 状态。iOS 在真机上观察系统确认弹窗点击同意和取消各测一次。Android 端的dumpsys输出特别有用它能明确告诉你每个entry_xxx当前是enabled还是disabled。如果桌面显示不对先看这里再找桌面的问题能省下大量猜测时间。5.2 我在项目里实际遇到过的五个问题第一个问题是 Android 桌面出现两个图标。我当时的 Manifest 里MainActivity还保留着LAUNCHER的 intent-filter同时 alias 也被启用两个入口同时存在桌面自然出两个图标。把主 Activity 的LAUNCHER移除只保留 alias 后解决。排查方法就是用dumpsys看所有入口 enabled 状态一眼就知道是谁在捣乱。第二个问题是切完图标后 Android 桌面一直不变。这更多是 ROM 问题。换图标前我会先让桌面停留在后台调用完切换逻辑后再回桌面观察一般都能及时刷新如果还是不变杀掉桌面进程重新回桌面才能看到。这个问题在部分低端国产机上比较常见我后来在代码注释里写明“不要指望即时生效”也把活动切换时间提前了一天。第三个问题是 iOS 调用setAlternateIconName后偶发崩溃。排查发现不是 API 的问题而是原生回调线程不稳定在某些启动场景下从后台线程调用了系统 API。解决办法就是开头那层dispatch_async(dispatch_get_main_queue(), ...)。第四个问题是恢复默认时图标没变。C# 侧传了空字符串而不是null原生层按字符串处理传给系统就成了一个不存在的图标名。我在 C# 侧规定空字符串统一转 null再在日志里打点这个问题再没出现过。第五个问题是 iOS 提示找不到图标资源。检查了很久发现 Unity 构建 Xcode 工程后图标 PNG 没有被复制到 Bundle 的 Copy Bundle Resources 列表。后来我改成在构建后脚本里用PBXProjectAPI 检查文件引用如果缺失直接报错从构建环节就拦住。5.3 远程配置、局部灰度和埋点功能稳定后一定要接远程配置否则每次换图标都要发版就失去了意义。我在实际项目里的方案是服务器下发一个icon_key字段和生效时间范围客户端在启动时缓存到本地时间窗到达后自动调用AppIconManager.SwitchIcon。灰度节奏建议分开做。Android 可以全量切问题不大iOS 因为每个用户都要看到弹窗比起 Android 更容易引起舆情所以第一次上线我会按玩家 ID 的 hash 做 10%、30%、100% 的三挡放量。做灰度时注意 iOS 用户可能需要长时间停留在前台到了时间窗不一定立刻弹确认框所以预留触发时机很重要。埋点方面我不只记录调用结果还会记录用户端系统版本和 iconKey方便区分是旧包不支持还是新系统行为差异。iOS 端重点记录“确认”和“取消”两种结果因为取消率高往往意味着运营文案或者弹窗时机有问题。真实数据告诉我们第一次在活动公告里弹窗时取消率明显偏高后来把切换时机从“公告弹出时”改成“用户点击公告按钮之后”才自然了一些。另外埋点字段里不要夹带用户身份信息只要设备角度的运营统计就足够合规上更省心。最后分享一点体会动态换图标这个功能代码量不大真正的成本在验收和运维。每一个图标资源、每一个系统版本、每一家桌面都可能让“看似简单”的切换变得不一样。我现在的做法是把图标 key 做成配置化所有素材都提前一个版本放进包里同时把验收清单固化成项目文档每次大版本发布前再过一遍真机。这样既能让运营快速玩起来也能保证技术侧不失控。