1. 动态图标这件事到底值不值得做先抛结论动态更换 App 图标在手游运营里属于典型的“低成本、高感知”手段。玩家在节日活动、版本更新、周年庆这些节点看到桌面上的图标跟着变了那种“这游戏还活着、还在用心做运营”的心理暗示比推送一条公告强得多。我最早接触这个需求是在一个二次元卡牌项目上运营想在春节换一套限定图标结果客户端同学折腾了整整两天才把 Android 和 iOS 两端跑通中间踩的坑基本都集中在“以为很简单”这四个字上。这个方案适合谁看如果你是用 Unity 做手游、需要在不发新包的前提下或者至少不强制更新切换桌面图标那这篇内容基本可以照着抄。Android 端靠activity-alias实现iOS 端靠系统提供的setAlternateIconName接口Unity 这边负责把两端的调用统一封装成一套 C# 接口。整套方案的核心难点不在代码本身而在于配置的完整性和平台差异的抹平。需要提前说清楚一个前提动态图标不是“热更新图标资源”那么简单。Android 的图标切换本质是切换一个activity-alias的启用状态系统会重新读取这个 alias 指向的图标资源iOS 则是系统层面维护了一套备选图标集合你只能在这个集合里切换。两者的机制完全不同所以 Unity 层的封装不能想当然地做成“传个图片路径就完事”必须按平台分别处理。下面我会从整体设计思路开始把 Android 的activity-alias配置、iOS 的备选图标配置、Unity 的桥接封装、以及实际运营中遇到的坑一层层拆开讲。内容偏实操代码和配置都会给全你拿到之后改改包名和资源名就能用。2. 整体方案设计与平台差异拆解2.1 为什么 Android 选 activity-alias 而不是改资源Android 换图标有好几种思路我见过有人尝试直接替换mipmap下的图标文件然后重启 Launcher这种做法在真机上基本不可靠因为 Launcher 会缓存图标而且不同厂商的 Launcher 行为不一致。还有一种思路是用PackageManager的setComponentEnabledSetting去动态启用禁用组件这其实就是activity-alias方案的核心。activity-alias是 AndroidManifest 里的一个标签它可以给一个已存在的 Activity 起一个“别名”并且这个别名可以拥有自己的icon、label、enabled状态。系统在桌面展示图标时读的是当前处于 enabled 状态的那个 alias 的 icon。所以切换图标的本质就是把旧的 alias 禁用把新的 alias 启用然后通知系统刷新。这个方案的优势很明显不需要重新安装、不需要改包、切换是瞬时的。但它有几个硬性约束必须记住。第一activity-alias的targetActivity必须指向你的主 Activity且这个主 Activity 在 Manifest 里不能带LAUNCHER的 intent-filter否则会出现两个图标。第二所有 alias 的enabled状态在初始状态下只能有一个是true其余必须是false否则装完就会出现多个图标。第三切换之后部分 Launcher 需要一点时间刷新甚至需要手动触发一次桌面重绘。2.2 iOS 的备选图标机制和它的脾气iOS 这边走的是完全不同的路子。系统在Info.plist里通过CFBundleIcons和CFBundleAlternateIcons声明一组备选图标每个备选图标有一个名字key和对应的图标文件。运行时调用UIApplication.shared.setAlternateIconName(_:completionHandler:)来切换传nil表示恢复主图标。这里有几个特别容易踩的点。第一备选图标的图片文件必须放在工程根目录或者指定的资源目录下不能放在Assets.xcassets里至少传统做法是这样放 asset catalog 里系统读不到。第二图片必须是 PNG尺寸建议 60x60、120x120、180x180 三套覆盖 1x/2x/3x。第三切换图标时系统会弹一个提示框“您已更改图标”这个提示框无法去掉属于系统行为。第四setAlternateIconName是异步的回调里要处理失败情况比如图标名拼错、文件缺失。还有一个隐藏坑iOS 的备选图标数量没有硬性上限但每个图标都会增加包体体积因为它们是实实在在打进包里的资源。所以运营如果提了十几个节日图标的需求你得评估一下包体增量别到时候为了换图标把包撑大了。2.3 Unity 层怎么把两端统一起来Unity 这边我建议做成一个静态类对外暴露SetIcon(string iconKey)和GetCurrentIcon()两个方法内部用#if UNITY_ANDROID和#if UNITY_IOS分别走不同实现。iconKey 用一套自定义的字符串标识比如default、spring_festival、anniversaryAndroid 端把这个 key 映射到对应的 alias 类名iOS 端直接作为setAlternateIconName的参数。为什么不建议在 Unity 层直接传图片因为两端机制不同Android 的图标是编译期打进 Manifest 的运行时改不了图片本身只能切换预置好的 aliasiOS 也是预置好的备选图标。所以“动态”指的是动态选择不是动态生成。这一点想通了整个封装就顺了。另外要注意调用时机。Android 的 alias 切换后建议延迟一小段时间再让应用回到后台或重启否则部分机型上桌面图标不会立即刷新。iOS 的切换回调里如果要做 UI 提示记得切回主线程。这些细节后面会展开。3. Android 端 activity-alias 配置与切换实操3.1 Manifest 里到底该怎么写先看一份可以直接用的 Manifest 结构。假设你的主 Activity 是com.example.game.MainActivity我们要准备三个图标默认、春节、周年庆。activity android:namecom.example.game.MainActivity android:exportedtrue android:configChangesorientation|keyboardHidden|screenSize intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity activity-alias android:namecom.example.game.MainActivity.default android:targetActivitycom.example.game.MainActivity 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:namecom.example.game.MainActivity.spring android:targetActivitycom.example.game.MainActivity android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_spring 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:namecom.example.game.MainActivity.anniversary android:targetActivitycom.example.game.MainActivity android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_anniversary android:labelstring/app_name intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias这里的关键点主 Activity 的intent-filter里必须保留LAUNCHER否则应用装完可能没有入口。等等这里有个矛盾——如果主 Activity 带 LAUNCHERalias 也带 LAUNCHER会不会出现两个图标实测下来当 alias 处于 enabled 状态时系统会优先展示 alias 的图标主 Activity 的 LAUNCHER 入口会被 alias “接管”不会出现两个图标。但为了保险很多团队的做法是把主 Activity 的 LAUNCHER 去掉只保留 alias 的。我个人的建议是主 Activity 保留 LAUNCHERalias 也保留初始只启用一个 alias这样兼容性最好实测在主流机型上没有双图标问题。注意android:enabled的初始值非常关键。如果你有 N 个 alias初始只能有一个是true。改完 Manifest 后一定要卸载重装测试因为覆盖安装可能保留旧的组件状态。3.2 用 PackageManager 切换 alias 的完整代码Unity 这边通过 AndroidJavaObject 调用PackageManager。核心方法是setComponentEnabledSetting它有三个参数组件名、新状态、标志位。#if UNITY_ANDROID using UnityEngine; public static class AndroidIconSwitcher { private const string PackageName com.example.game; private const string MainActivity com.example.game.MainActivity; private static readonly string[] AllAliases new string[] { MainActivity .default, MainActivity .spring, MainActivity .anniversary }; public static void SetIcon(string iconKey) { string targetAlias MainActivity . iconKey; using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (AndroidJavaObject activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (AndroidJavaObject packageManager activity.CallAndroidJavaObject(getPackageManager)) { foreach (string alias in AllAliases) { int newState (alias targetAlias) ? 1 : 2; // 1 COMPONENT_ENABLED_STATE_ENABLED // 2 COMPONENT_ENABLED_STATE_DISABLED packageManager.Call(setComponentEnabledSetting, new AndroidJavaObject(android.content.ComponentName, PackageName, alias), newState, 1); // 1 DONT_KILL_APP } } } } #endif这段代码的逻辑是遍历所有 alias把目标 alias 设为 enabled其余设为 disabled。标志位用DONT_KILL_APP值为 1避免切换时直接把应用杀掉体验会好很多。有个细节值得说setComponentEnabledSetting调用后系统不会立即刷新桌面。实测在大部分机型上需要等 1 到 3 秒或者把应用切到后台再切回来桌面图标才会更新。所以运营层面要提前告知玩家“切换后可能需要稍等片刻”别让玩家以为没生效。3.3 切换后的刷新与状态持久化状态持久化很简单用PlayerPrefs存一下当前 iconKey下次启动时读出来如果和 Manifest 里的实际状态不一致再调一次切换。但这里有个坑PlayerPrefs在 Android 上存在应用私有目录卸载重装会丢这没关系因为卸载重装后 Manifest 会回到初始状态正好和默认值一致。刷新这块我试过几种方案。最直接的是切换后调用一次activity.Call(recreate)但这样会重建 Activity体验不好。更温和的做法是切换后不做任何操作让系统自己刷新。如果确实需要立即刷新可以发一个广播但不同 Launcher 对广播的响应不一致不推荐。我的经验是切换后延迟 2 秒然后提示玩家“图标已更换请查看桌面”这样最稳。还有一个容易被忽略的点部分国产 Launcher 会缓存图标即使 alias 切换了桌面还是显示旧图标。这时候需要玩家手动清理 Launcher 缓存或者重启手机。这个不是技术能完全解决的属于系统行为运营话术里要提前说明。4. iOS 端备选图标配置与调用细节4.1 Info.plist 里的 CFBundleAlternateIcons 怎么写iOS 的配置全部在Info.plist里。你需要加一个CFBundleIcons字典里面包含CFBundlePrimaryIcon和CFBundleAlternateIcons。主图标就是默认图标备选图标放在CFBundleAlternateIcons下面。keyCFBundleIcons/key dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon60x60/string /array keyCFBundleIconName/key stringAppIcon/string /dict keyCFBundleAlternateIcons/key dict keyspring_festival/key dict keyCFBundleIconFiles/key array stringicon_spring_60/string stringicon_spring_120/string stringicon_spring_180/string /array keyUIPrerenderedIcon/key false/ /dict keyanniversary/key dict keyCFBundleIconFiles/key array stringicon_anniv_60/string stringicon_anniv_120/string stringicon_anniv_180/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dict这里的 keyspring_festival、anniversary就是运行时传给setAlternateIconName的名字。CFBundleIconFiles里列的是图片文件名不带扩展名。图片文件要直接放在 Xcode 工程的根目录和Info.plist同级或者放在一个文件夹里然后加入工程但不要放进Assets.xcassets。图片尺寸方面60x60 对应 1x120x120 对应 2x180x180 对应 3x。实际上现在 iPhone 基本都是 2x 和 3x1x 可以不放但为了保险建议都放。图片必须是 PNG不能有透明通道问题iOS 图标不支持透明背景透明区域会被填成黑色。4.2 Unity 调用 iOS 原生接口的桥接写法Unity 这边不能直接调setAlternateIconName需要写一个 Objective-C 的桥接文件然后通过DllImport暴露给 C#。先写一个.mm文件比如IconSwitcher.mm#import Foundation/Foundation.h #import UIKit/UIKit.h extern C { void _SetAppIcon(const char* iconName) { NSString *name nil; if (iconName ! NULL strlen(iconName) 0) { name [NSString stringWithUTF8String:iconName]; if ([name isEqualToString:default]) { name nil; } } dispatch_async(dispatch_get_main_queue(), ^{ UIApplication *app [UIApplication sharedApplication]; if (![app supportsAlternateIcons]) { NSLog([IconSwitcher] Alternate icons not supported.); return; } [app setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error ! nil) { NSLog([IconSwitcher] Failed: %, error.localizedDescription); } else { NSLog([IconSwitcher] Success.); } }]; }); } const char* _GetCurrentAppIcon() { UIApplication *app [UIApplication sharedApplication]; if (![app supportsAlternateIcons]) { return strdup(default); } NSString *current [app alternateIconName]; if (current nil) { return strdup(default); } return strdup([current UTF8String]); } }然后在 C# 侧#if UNITY_IOS using System.Runtime.InteropServices; public static class iOSIconSwitcher { [DllImport(__Internal)] private static extern void _SetAppIcon(string iconName); [DllImport(__Internal)] private static extern string _GetCurrentAppIcon(); public static void SetIcon(string iconKey) { _SetAppIcon(iconKey); } public static string GetCurrentIcon() { return _GetCurrentAppIcon(); } } #endif这里有几个细节。第一setAlternateIconName必须在主线程调用所以桥接里用了dispatch_async(dispatch_get_main_queue())。第二传nil表示恢复主图标所以当 iconKey 是default时我们把 name 设为 nil。第三supportsAlternateIcons要先判断iPad 上某些情况下不支持。第四strdup返回的字符串在 C# 侧会被 marshaling 成 string但要注意内存管理这里简单处理实际项目里如果频繁调用最好做一下释放。4.3 iOS 切换时的系统弹窗与体验优化iOS 切换图标时系统会弹一个“您已更改图标”的提示框这个框无法通过公开 API 去掉。我试过一些取巧的办法比如在切换前先弹一个自己的提示但系统框还是会出来只是时机可能被延后。所以运营层面要接受这个现实把它当成一个“确认感”而不是“打扰”。体验优化上我建议在调用SetIcon之前先在游戏内弹一个确认框告诉玩家“即将更换桌面图标系统会弹出确认提示点击确认即可”。这样玩家不会觉得突兀。切换成功后再弹一个“图标已更换请返回桌面查看”的提示。还有一个坑如果玩家在切换图标后立即杀掉应用系统可能还没完成图标写入导致下次启动时图标状态不一致。所以切换成功后建议延迟 1 秒再允许玩家操作或者至少在回调里确认成功后再更新本地状态。5. Unity 统一封装与运营侧接入5.1 一套接口抹平两端差异把前面的 Android 和 iOS 实现包一层对外只暴露一个入口using UnityEngine; public static class AppIconManager { private const string PrefsKey current_app_icon; public static void SetIcon(string iconKey) { if (string.IsNullOrEmpty(iconKey)) { iconKey default; } #if UNITY_ANDROID !UNITY_EDITOR AndroidIconSwitcher.SetIcon(iconKey); #elif UNITY_IOS !UNITY_EDITOR iOSIconSwitcher.SetIcon(iconKey); #else Debug.Log($[AppIconManager] Editor mode, pretend set icon: {iconKey}); #endif PlayerPrefs.SetString(PrefsKey, iconKey); PlayerPrefs.Save(); } public static string GetCurrentIcon() { return PlayerPrefs.GetString(PrefsKey, default); } }这套接口的好处是运营侧只需要传一个字符串 key不用关心平台。key 的命名建议用英文小写加下划线和 iOS 的CFBundleAlternateIcons的 key 保持一致Android 那边把 key 拼到 alias 类名后面即可。5.2 运营配置表怎么设计实际项目里图标切换通常由运营活动驱动。我建议做一张配置表字段包括活动 ID、图标 key、生效开始时间、生效结束时间、优先级。客户端启动时读表判断当前时间落在哪个活动的区间内取优先级最高的那个 key和本地存的 key 比对不一致就切换。字段类型说明activity_idint活动唯一 IDicon_keystring图标标识如 spring_festivalstart_timestring生效开始时间格式 yyyy-MM-dd HH:mm:ssend_timestring生效结束时间priorityint优先级数值越大越优先这样做的好处是运营可以提前配置好一整年的节日图标客户端自动切换不需要每次发版。但要注意Android 的 alias 是编译期固定的所以新增图标 key 仍然需要发版。iOS 的备选图标也是打进包的同样需要发版。所以“动态”的边界是图标集合在发版时确定切换时机由运营配置动态决定。5.3 版本兼容与降级策略老版本客户端可能没有新的 alias 或备选图标这时候调用切换会失败。Android 端如果 alias 不存在setComponentEnabledSetting会抛异常需要 try-catch 包住。iOS 端如果 iconName 不在备选集合里回调会返回 error也要处理。降级策略很简单切换失败就保持当前图标记录日志不影响游戏主流程。千万别因为换图标失败导致游戏启动崩溃那就得不偿失了。6. 常见问题与排查技巧实录6.1 Android 装完出现两个图标怎么办这是最常见的问题原因通常是主 Activity 和 alias 同时处于 enabled 状态且都带 LAUNCHER。解决办法检查 Manifest确保初始状态下只有一个 alias 是enabledtrue主 Activity 的 LAUNCHER 可以保留但 alias 的 enabled 状态要严格控制。如果已经装出两个图标卸载重装即可覆盖安装可能不会清除旧的组件状态。还有一种情况是 alias 的targetActivity写错了导致系统认为这是一个独立入口。检查targetActivity是否和主 Activity 的完整类名一致。6.2 iOS 切换报错 “The requested icon is not available”这个错误基本就是 iconName 拼错或者CFBundleAlternateIcons里没有这个 key或者图片文件没找到。排查顺序先确认Info.plist里的 key 和代码里传的字符串完全一致大小写敏感再确认图片文件确实在工程里且 Build Phases 的 Copy Bundle Resources 里包含这些图片最后确认图片格式是 PNG 且尺寸正确。6.3 切换后桌面图标不刷新Android 端这个问题最普遍。系统需要时间刷新或者 Launcher 缓存了旧图标。可以尝试切换后把应用切到后台再切回来或者重启桌面部分机型支持或者等待几秒。如果长期不刷新可能是 Launcher 的问题不是代码问题。iOS 端一般切换后立即刷新如果没刷新检查是不是在回调成功之前就杀了应用。6.4 常见问题速查表问题现象可能原因排查方向Android 出现双图标多个 alias 同时 enabled检查 Manifest 初始 enabled 状态Android 切换无效alias 类名拼错核对 PackageName 和 alias 全名Android 切换后崩溃未捕获异常try-catch 包住 setComponentEnabledSettingiOS 切换报错iconName 不存在核对 Info.plist 的 key 和图片文件iOS 图片显示异常尺寸或格式不对确认 PNG、60/120/180 三套两端状态不一致本地缓存未同步启动时用 GetCurrentIcon 校准6.5 几个我踩过的坑第一个坑Android 的 alias 类名如果和某个真实类名冲突编译会报错。所以 alias 的 name 建议用主Activity全名.后缀的格式避免和现有类冲突。第二个坑iOS 的备选图标如果放在Assets.xcassets里setAlternateIconName会找不到。必须放在工程目录下作为普通资源文件加入。第三个坑Unity 导出 iOS 工程后Info.plist的修改可能会被覆盖。建议用 Unity 的PostProcessBuild脚本自动注入CFBundleIcons配置而不是手动改 Xcode 工程。这样每次导出都不用手动补。第四个坑Android 的setComponentEnabledSetting在部分定制 ROM 上需要CHANGE_COMPONENT_ENABLED_STATE权限但这个权限是系统级的普通应用申请不到。实测主流机型不需要额外权限但如果遇到问题可以检查一下是不是 ROM 限制。7. 一些收尾的经验之谈这套方案我在两个项目上跑过一个二次元卡牌一个休闲消除Android 和 iOS 两端都上线了。整体来说Android 的坑比 iOS 多主要集中在 Launcher 刷新和双图标问题上iOS 的坑主要集中在配置和图片资源上代码本身很简单。如果让我给一个建议我会说先把 Android 的 alias 配置和 iOS 的 Info.plist 配置做对再写代码。很多问题不是代码问题是配置问题。配置对了代码就是几行调用的事。另外运营侧一定要提前规划图标资源。节日图标不是随便找张图就行要考虑和游戏整体风格一致还要考虑小尺寸下的辨识度。我见过一个项目春节图标用了大红底配金色字结果在桌面小尺寸下糊成一团玩家根本看不清。所以图标设计出来后一定要在真机上看看实际效果别只在设计稿上看。最后分享一个小技巧可以在游戏设置里加一个“更换图标”的入口让玩家自己选。这样既增加了互动感也省得运营每次都要推活动。当然前提是备选图标数量别太多不然包体扛不住。