
手游运营到一定阶段图标这件事迟早会被提上台面。节日活动、版本大版本更新、渠道联运换皮、甚至是给核心玩家做一套隐藏彩蛋图标都绕不开一个需求不重新发版让用户桌面上的 App 图标动起来。Android 这边有activity-alias这个老牌方案iOS 这边从 10.3 开始也开放了setAlternateIconName接口但真正落到 Unity 工程里双端一起做的时候坑远比想象中多。这篇就把我在几个上线项目里踩过的路完整梳理一遍从原理到代码从配置到审核尽量让你少走弯路。1. 先搞清楚双端图标切换的底层机制差异很多人一上来就找插件、找现成方案结果发现 Android 能跑、iOS 死活不生效或者反过来。根本原因在于两个平台对换图标这件事的实现思路完全不同理解了这个差异后面所有配置和代码才有落脚点。1.1 Android 的 activity-alias 到底做了什么Android 允许一个应用注册多个activity-alias每个 alias 指向同一个主 Activity但可以各自声明不同的android:icon和android:label。系统桌面Launcher在扫描已安装应用时会把每个被启用android:enabledtrue的 alias 都当成一个独立入口显示出来。所以换图标的本质其实是启用目标 alias、禁用当前 alias让 Launcher 重新读取组件列表。这里有个关键点同一时刻只能有一个 alias 处于启用状态否则桌面上会出现多个图标。切换动作通过PackageManager.setComponentEnabledSetting()完成这个操作是异步生效的Launcher 收到PACKAGE_CHANGED广播后刷新。实测在大部分国产 ROM 上切换后图标会在 1 到 3 秒内刷新个别 ROM 需要用户手动触发一次桌面重绘比如滑动一下。另一个容易忽略的点主 Activity 本身如果也声明了android:icon它和 alias 会同时存在。正确做法是把主 Activity 的android:enabled设为false或者干脆不给主 Activity 配 icon只让 alias 对外暴露。我一般推荐后者结构更干净。1.2 iOS 的 alternate icon 是另一套逻辑iOS 从 10.3 起支持UIApplication.shared.setAlternateIconName(_:completionHandler:)但它和 Android 完全不是一回事。iOS 的备用图标必须在打包时全部预置进 App Bundle运行时只能在这些预置图标之间切换不能动态下载新图标。也就是说你想换的每一套图标都得在发版前就塞进工程里。图标文件放在工程目录下命名规则是PrimaryIconName2x.png、3x.png这种同时在Info.plist里通过CFBundleIcons→CFBundleAlternateIcons声明。切换时传入的alternateIconName就是你在 plist 里定义的 key。传nil表示切回主图标。还有个体验上的坑调用setAlternateIconName时系统会弹一个您已更改 App 图标的提示框这个提示框无法通过公开 API 去掉。网上有些取巧做法比如在切换瞬间临时替换 keyWindow 的 rootViewController但风险高、审核容易被拒我后面会专门讲。1.3 两端机制对比一览维度Android (activity-alias)iOS (alternate icon)图标来源打包时预置也可运行时下载替换资源必须打包时预置进 Bundle切换方式setComponentEnabledSettingsetAlternateIconName生效时机异步依赖 Launcher 刷新同步回调系统弹提示框数量限制理论上无硬限制建议不超过 10 套审核风险低中提示框、图标规范动态新增支持配合资源热更不支持这张表建议贴在工位上每次做需求前对一遍能省掉大量为什么 iOS 不能像 Android 那样动态加图标的无效沟通。2. Unity 工程侧的 Android 配置落地Unity 打包 Android 时AndroidManifest.xml是自动生成的直接改生成物没意义下次打包就被覆盖。正确姿势是在Assets/Plugins/Android/下放一份自定义 Manifest让 Unity 做合并。2.1 自定义 AndroidManifest 的正确写法在Assets/Plugins/Android/AndroidManifest.xml里你需要声明主 Activity 和若干 alias。下面是我常用的模板结构application android:labelstring/app_name android:iconmipmap/app_icon_default activity android:namecom.unity3d.player.UnityPlayerActivity android:exportedtrue android:enabledfalse intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity activity-alias android:name.icon_default android:targetActivitycom.unity3d.player.UnityPlayerActivity android:enabledtrue android:exportedtrue android:iconmipmap/app_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.icon_christmas android:targetActivitycom.unity3d.player.UnityPlayerActivity android:enabledfalse android:exportedtrue android:iconmipmap/app_icon_christmas 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在 Android 12 之后是强制要求的漏了会直接编译失败targetActivity必须写全限定名每个 alias 的intent-filter必须和主 Activity 一致否则桌面不认。图标资源放在Assets/Plugins/Android/res/mipmap-xxxhdpi/等对应目录下Unity 会一起打进包。2.2 图标资源目录与命名规范Android 的 mipmap 目录按密度分mipmap-mdpi、hdpi、xhdpi、xxhdpi、xxxhdpi。一套图标至少要覆盖xxhdpi和xxxhdpi否则在高分屏上会糊。命名建议统一前缀比如app_icon_default、app_icon_christmas、app_icon_newyear方便脚本批量生成 Manifest。我一般会写一个 Editor 脚本读取一个图标配置表ScriptableObject 或 JSON自动生成 Manifest 里的 alias 段落。这样运营加一套新图标只需要往配置表里加一行、丢两张图重新打包即可不用手改 XML。这个脚本大概长这样[MenuItem(Tools/Generate Icon Aliases)] static void GenerateAliases() { var config AssetDatabase.LoadAssetAtPathIconConfig(Assets/IconConfig.asset); var sb new StringBuilder(); foreach (var icon in config.icons) { sb.AppendLine($activity-alias android:name\.icon_{icon.key}\); sb.AppendLine($ android:targetActivity\com.unity3d.player.UnityPlayerActivity\); sb.AppendLine($ android:enabled\{(icon.isDefault ? true : false)}\); sb.AppendLine($ android:exported\true\); sb.AppendLine($ android:icon\mipmap/{icon.resName}\); sb.AppendLine($ android:label\string/app_name\); sb.AppendLine( intent-filter); sb.AppendLine( action android:name\android.intent.action.MAIN\ /); sb.AppendLine( category android:name\android.intent.category.LAUNCHER\ /); sb.AppendLine( /intent-filter); sb.AppendLine(/activity-alias); } File.WriteAllText(Assets/Plugins/Android/aliases.xml, sb.ToString()); }生成后手动贴进主 Manifest或者用 Manifest 合并的占位符机制自动注入看团队习惯。2.3 运行时切换的 C# 封装Unity 侧调用 Android 的PackageManager需要走AndroidJavaObject。核心代码如下public static void SwitchIcon(string aliasKey) { #if UNITY_ANDROID !UNITY_EDITOR var context new AndroidJavaClass(com.unity3d.player.UnityPlayer) .GetStaticAndroidJavaObject(currentActivity); var pm context.CallAndroidJavaObject(getPackageManager); var pkgName context.Callstring(getPackageName); // 先禁用所有 alias foreach (var key in AllIconKeys) { var comp new AndroidJavaObject(android.content.ComponentName, pkgName, pkgName .icon_ key); pm.Call(setComponentEnabledSetting, comp, 2, 1); // 2DISABLED, 1DONT_KILL_APP } // 启用目标 alias var target new AndroidJavaObject(android.content.ComponentName, pkgName, pkgName .icon_ aliasKey); pm.Call(setComponentEnabledSetting, target, 1, 1); // 1ENABLED #endif }setComponentEnabledSetting的第三个参数DONT_KILL_APP很关键不加的话切换瞬间应用会被系统杀掉用户体验极差。另外这个操作要在主线程调用Unity 里直接调没问题但如果你在子线程做逻辑记得切回主线程。注意部分国产 ROM尤其是定制较深的系统对 alias 切换有缓存切换后可能需要调用一次pm.Call(getInstalledPackages, 0)之类的操作触发刷新或者干脆提示用户图标将在几秒内更新。别指望所有机型都秒切。3. iOS 端备用图标的工程配置与调用iOS 这边的复杂度主要在工程配置代码反而简单。但正因为配置繁琐很多 Unity 开发者第一次做会卡在图标不显示或者审核被拒上。3.1 Info.plist 的 CFBundleIcons 结构iOS 的备用图标声明分两部分CFBundleIcons主图标和CFBundleAlternateIcons备用图标。在 Unity 里你可以通过 PostProcessBuild 脚本往生成的 Xcode 工程 Info.plist 里注入这些字段也可以直接在Assets/Plugins/iOS/下放一个 plist 片段做合并。结构大概是这样keyCFBundleIcons/key dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon60x60/string /array /dict keyCFBundleAlternateIcons/key dict keychristmas/key dict keyCFBundleIconFiles/key array stringicon_christmas_60/string /array keyUIPrerenderedIcon/key false/ /dict keynewyear/key dict keyCFBundleIconFiles/key array stringicon_newyear_60/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dictCFBundleIconFiles里填的是图标文件名不带扩展名系统会自动找2x、3x版本。图标文件要放进 Xcode 工程的资源目录命名必须严格匹配。比如icon_christmas_602x.png120x120和icon_christmas_603x.png180x180。3.2 图标尺寸与命名对照表iOS 对备用图标的尺寸要求比主图标宽松一些但为了显示效果建议按下面这套来用途文件名后缀尺寸说明iPhone 主屏 2x2x120x120必须iPhone 主屏 3x3x180x180必须iPad 主屏 2x2x152x152支持 iPad 时必填iPad Pro 2x2x167x167可选设置页小图标2x/3x58/87可选不填会用主图标命名上我踩过一个坑如果文件名里带了2x但实际尺寸不对Xcode 编译不报错但运行时图标会显示成空白或者被拉伸。建议用脚本批量校验尺寸别靠肉眼。3.3 Unity 调用 iOS 切换接口iOS 侧需要写一个 Objective-C 的桥接文件放在Assets/Plugins/iOS/下// IconSwitcher.mm #import UIKit/UIKit.h extern C void _SwitchAppIcon(const char* iconName) { NSString *name iconName ? [NSString stringWithUTF8String:iconName] : nil; if ([UIApplication sharedApplication].supportsAlternateIcons) { [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(Switch icon failed: %, error.localizedDescription); } }]; } }C# 侧用DllImport调用#if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _SwitchAppIcon(string iconName); public static void SwitchIcon(string iconKey) { _SwitchAppIcon(string.IsNullOrEmpty(iconKey) ? null : iconKey); } #endif传null或空字符串表示切回主图标。注意supportsAlternateIcons这个判断不能省虽然 iOS 10.3 都支持但某些企业签名或特殊分发场景下可能返回 false。3.4 那个绕不开的系统提示框前面提过调用setAlternateIconName时系统会弹您已更改 App 图标的提示。这个提示框在 iOS 10.3 到现在的所有版本都存在官方没有提供关闭方式。网上流传的替换 keyWindow rootViewController方案原理是在切换瞬间把 rootViewController 换成一个空的让系统找不到弹窗的宿主切换完再换回来。这个方案我实测过确实能去掉提示框但有几个致命问题一是时机极难把握稍有不慎就会导致界面白屏或崩溃二是 App Store 审核指南明确要求不能干扰系统行为被拒风险很高三是 iOS 版本更新后随时可能失效。我的建议是老老实实接受这个提示框把它当成一次正常的用户交互。如果产品实在不能接受那就别做 iOS 的动态图标只做 Android。4. 双端统一接口与状态持久化设计两端机制不同但上层业务逻辑应该统一。我一般会封装一个AppIconManager对外只暴露SwitchTo(string key)和GetCurrentKey()两个方法内部根据平台分发。4.1 统一接口的抽象层public static class AppIconManager { private const string PREF_KEY current_app_icon; public static void SwitchTo(string key) { #if UNITY_ANDROID !UNITY_EDITOR SwitchAndroid(key); #elif UNITY_IOS !UNITY_EDITOR SwitchIOS(key); #endif PlayerPrefs.SetString(PREF_KEY, key); PlayerPrefs.Save(); } public static string GetCurrentKey() { return PlayerPrefs.GetString(PREF_KEY, default); } }状态持久化用PlayerPrefs就够了但要注意Android 上 alias 的启用状态是系统层面记录的即使你清了 PlayerPrefs图标也不会自动切回默认。所以启动时要做一次状态校验读取 PlayerPrefs 里的 key和系统当前启用的 alias 对比不一致就以系统为准或者以 PlayerPrefs 为准强制同步一次。我倾向于以系统为准因为用户可能在设置里手动改过。4.2 启动时的状态同步逻辑Android 侧读取当前启用 alias 的方法public static string GetCurrentAndroidIcon() { var context new AndroidJavaClass(com.unity3d.player.UnityPlayer) .GetStaticAndroidJavaObject(currentActivity); var pm context.CallAndroidJavaObject(getPackageManager); var pkgName context.Callstring(getPackageName); foreach (var key in AllIconKeys) { var comp new AndroidJavaObject(android.content.ComponentName, pkgName, pkgName .icon_ key); int state pm.Callint(getComponentEnabledSetting, comp); if (state 1) return key; // ENABLED } return default; }iOS 侧直接读[[UIApplication sharedApplication] alternateIconName]即可。启动时把两端结果统一写回 PlayerPrefs保证 UI 上显示的当前图标和实际一致。4.3 图标资源的版本管理这里有个容易被忽视的问题如果运营要换一套新图标Android 可以配合资源热更动态下发新图但 iOS 必须重新发版。所以双端做图标功能时图标集合的版本要和 App 版本绑定。我的做法是在配置表里给每套图标加一个minAppVersion字段客户端启动时过滤掉当前版本不支持的图标避免用户点了没反应。另外Android 动态下发图标资源时alias 的android:icon指向的是打包时的资源 ID运行时替换 mipmap 文件不会生效。真要动态换图得走下载新图 → 写入应用私有目录 → 用PackageManager的setComponentEnabledSetting配合自定义 Launcher 图标的路子复杂度陡增。所以我的建议是Android 也尽量预置图标动态下发只作为极少数场景的补充。5. 上线前必须验证的兼容性与审核问题功能跑通只是第一步真正上线前还有一堆兼容性和审核的坑等着。这部分是我踩得最惨的地方单独拎出来讲。5.1 Android 各 ROM 的 alias 刷新差异我在华为、小米、OPPO、vivo、三星几个主流机型上做过测试结果差异明显机型/ROM切换生效时间是否需要手动刷新备注小米 MIUI1-2 秒否偶发需要滑动桌面华为 EMUI2-3 秒否部分版本需重启桌面OPPO ColorOS1-3 秒偶发后台限制严格时更慢vivo OriginOS2-5 秒偶发省电模式下可能不刷新三星 OneUI1 秒内否表现最稳定原生 Android1 秒内否参考基准应对策略切换后给一个 Toast 提示图标更新中请稍候并延迟 3 秒再刷新应用内 UI。如果检测到 5 秒后仍未生效提示用户请尝试滑动桌面或重启桌面。别小看这个提示能挡掉大量客服工单。5.2 iOS 审核的几条红线iOS 备用图标在审核上有几个明确要求一是所有备用图标必须和主图标风格一致不能出现完全无关的图案比如主图标是工具类备用图标放个卡通人物二是不能通过换图标诱导用户付费或做任务比如换图标解锁隐藏内容三是图标不能包含违规内容。我见过有项目因为备用图标里带了节日营销文案被拒的理由是图标作为 App 标识不应包含促销信息。另外CFBundleAlternateIcons里的图标如果尺寸不全审核时可能被标记为资源不完整。建议至少覆盖 iPhone 的 2x 和 3x支持 iPad 的话把 iPad 尺寸也补齐。5.3 切换失败的回滚与日志无论哪端切换都可能失败Android 权限问题、iOS 资源缺失。一定要做失败回滚切换前记录当前 key失败后切回原 key并上报日志。日志里带上平台、系统版本、机型、目标 key、错误码方便排查。我一般会在AppIconManager里加一个OnSwitchFailed事件业务层可以监听并做 UI 提示。提示Android 的setComponentEnabledSetting在某些 ROM 上会抛SecurityException尤其是应用被用户手动限制了后台权限时。捕获异常后不要直接崩溃降级为提示用户手动切换。6. 一些实战中攒下来的经验做这个功能前后跨了三个项目攒了几条文档里不会写的经验分享出来。第一别在启动瞬间切图标。有些项目为了每天自动换图标在Awake里就调切换接口。结果 Android 上应用还没完全起来PackageManager调用可能失败iOS 上系统提示框会盖在启动图上体验极差。正确做法是等主界面加载完、用户有交互之后再切或者干脆放到设置页里让用户手动触发。第二图标数量控制在 6 套以内。Android 虽然理论上不限但每多一个 aliasManifest 就大一圈安装包体积和启动扫描时间都会增加。iOS 这边备用图标多了Xcode 编译时间和包体也会明显上涨。我一般建议主图标 5 套备用够用了。第三测试时一定要用真机。模拟器和 Editor 里都测不出 alias 切换的真实表现iOS 的提示框也只有真机才有。我吃过亏Editor 里跑得好好的真机上一半机型不生效。第四给运营做一个后台开关。图标切换功能上线后运营可能会想今天换个节日图标。如果每次都要发版成本太高。Android 可以通过配置热更控制切哪套图标本身预置iOS 只能发版。所以后台要能区分两端Android 走热更配置iOS 走版本发布计划。第五保留一套默认图标作为兜底。用户如果遇到切换异常至少能切回默认。默认图标的 alias 建议永远保持可用不要禁用。这套方案在几个上线项目里跑下来Android 端覆盖率 95% 以上iOS 端除了那个提示框之外没有其他问题。真正麻烦的从来不是代码而是各 ROM 的差异和审核的边界。把这两块摸清楚剩下的就是体力活了。