ARTICLE · 1090624
Unity 手游动态更换 App 图标 — Android 与 iOS 双端技术方案
本文为 2026-09-23 的资料快照。iOS 侧正处在格式换代期——Xcode 26 引入 Icon Composer .icon 新格式后,alternate icon 在 26.0 / 26.1 / 26.2 之间的行为反复变动(详见 §5.4),Apple 官方文档尚未完全覆盖新格式下的替代图标行为。Android 侧机制多年稳定,但 Google Play 的政策口径比 API 本身更值得关注(§3.2)。引用前请复核 Apple Developer Forums、Unity 版本与 Google Play 政策页的最新状态。
数据来源:Apple Developer Documentation(setAlternateIconName、CFBundleAlternateIcons、Configuring your app to use alternate app icons)、Android Developers(<activity-alias> 元素、Android 16 behavior changes)、Xcode Build Settings 文档、Google Play Developer Program Policy、以及 Apple Developer Forums / Stack Overflow 上针对 Xcode 26 的实测反馈帖。其中"Xcode 各小版本行为差异""国产 ROM 缓存表现"两类结论来自社区实测而非官方文档,已单独标注。

1. 结论先行
PackageManager.setComponentEnabledSetting(s)<activity-alias> | UIApplication.setAlternateIconName(_:completionHandler:) | |
| 必须随包预置 | 必须随包预置 | |
会。DONT_KILL_APP 自 Android 10 起已失效 | ||
| 不能。系统强制弹「您已更改图标」Alert | ||
一句话选型:双端都能做,但**都是"预置图标集合 + 用户主动选择"**这一种形态。任何"服务端下发新图标""无人值守自动换"的方案在两端都不成立——Android 侧技术上做不到(图标必须编译期打包进 APK),iOS 侧政策上不允许(§5.5)。
2. 为什么必须预置:两端共同的硬边界
这是整个方案的地基,先把它说死,后面所有设计都受它约束。
- Android
:Launcher 通过 PackageManager读取ActivityInfo.icon的资源 ID(编译期绑定)。运行时替换res/mipmap-*下的文件不可行;Adaptive Icon 的foreground/background层同样在构建期静态打包。新增图标 = 新增activity-alias= 必须发版。 - iOS
:图标由 CFBundleIcons描述,其内容来自构建期打进Assets.car的 appiconset。setAlternateIconName只能引用已声明在CFBundleAlternateIcons里的名字,传一个不存在的名字会直接报错。 - 共同推论
:图标 = 资源,换图标 = 换资源引用。所以产品上只能做 “N 选 1 的预设皮肤”,不能做 “无限量动态下发”。这一点在需求评审阶段就要跟策划对齐,否则后面全是返工。
商店里的图标(Google Play / App Store 列表页显示的)不受此影响也无法被代码改动。用户从商店看到的一直是主图标,只有装到设备上、App 跑过一次换图标逻辑之后,桌面图标才会变。
3. Android 端
3.1 机制:<activity-alias> + 组件启停
Android 不允许运行时替换图标,但允许在多个预置图标之间切换。做法是:为每个图标声明一个 <activity-alias>,各自带不同的 android:icon,全部指向同一个真实 MainActivity;同一时刻只启用其中一个;切换时用 PackageManager.setComponentEnabledSetting() 启用新的、禁用旧的。
Manifest 关键规则(每一条踩错都有明确的坏结果):
MainActivity不能MAIN/LAUNCHER filter,该 filter 只挂在 alias 上 | |
android:enabled="true",其余全部 false | |
MainActivitylaunchMode="singleTask",且不设taskAffinity="" | |
MainActivity 上设 android:icon 对 alias 无效,必须逐个 alias 显式声明 | |
3.2 ⚠️ 政策红线(比技术更容易翻车)
Google Play 的 Deceptive Device Settings Changes 条款明确把 icons / widgets / shortcuts / 桌面上 App 的呈现方式 划入"设备设置与功能",规定:
不得在用户不知情、未同意的情况下于 App 之外修改; 即使获得同意,改动也必须易于撤销; 不得作为第三方服务或广告用途。
实测后果:有开发者的做法是「按账号配置自动切换图标」(用户无感知),提审后被以该条款原文驳回,且驳回在提交后 2~3 分钟内到达,判断是自动化模板化拦截,而非人工审查代码。
合规做法(务必照做):
换图标必须是用户在 App 内的主动操作(设置页 / 活动页里的"更换图标"入口); 界面要明说"将在桌面创建/更换图标",给出足够的知情提示; 必须提供一键恢复默认图标的入口; - 不要
把换图标绑定到账号配置、服务端下发或纯时间驱动的自动逻辑。
国内渠道(华为、小米等)目前对动态图标相对宽松,但既然 Google Play 有这个口径,出海包统一按最严标准做,成本最低。
3.3 ⚠️ DONT_KILL_APP 已失效 —— 这是 Android 侧最大的设计约束
这是全网教程里最普遍的一个过时结论。大量示例代码(包括官方文档的引用片段)仍写着:
pm.setComponentEnabledSetting(componentName, STATE_ENABLED, PackageManager.DONT_KILL_APP)并声称这样可以"切换图标而不杀死应用"。从 Android 10 起这个 flag 对启停 launcher 组件已不再生效(多方报告一致:Stack Overflow 高赞回答直接注释 // Ignored since Android 10、Termux-Widget issue #89 同样结论、专门的提问帖"DONT_KILL_APP not preventing the app from closing when <activity-alias> is enabled"描述了完全相同现象——图标换了,应用无报错地直接关闭)。
该行为被社区广泛视为平台 bug,但至今没有官方修复,也没有可靠的绕过手段。
由此推出三条设计决策:
- 不要在游戏过程中切换
。否则玩家正打着关卡被直接弹回桌面,是不可接受的体验事故。 - 延迟到后台再切
。记录用户选择,等 OnApplicationPause(true)(App 退到后台)时执行组件启停。此时进程被杀用户无感。 - 接受"下次冷启动生效"
。这是唯一诚实的产品描述:用户点"更换图标" → 返回桌面 → 图标已变。

3.4 关键实现细节
执行顺序:先启用新 alias,再禁用旧 alias。
理由:如果先禁用再启用,两次调用之间存在一个零 launcher 组件的窗口,这是"应用崩溃 / 图标消失"的高危状态。反过来先启用,最坏情况是短暂出现两个图标(持续毫秒级,肉眼基本不可见),但永远不会出现零组件。
API 33+ 用原子接口消除竞态:PackageManager.setComponentEnabledSettings(List<ComponentEnabledSetting>)(API 33+)可一次性原子应用多个组件的状态变更,彻底消除上述窗口期。低版本回退到循环调用 setComponentEnabledSetting。
注入 Manifest 的方式:不要在 Assets/Plugins/Android/AndroidManifest.xml 里手写全部 alias(容易和 Unity 的 manifest 合并逻辑、其他 SDK 插件打架)。用 IPostGenerateGradleAndroidProject 在构建后追加:
实现 UnityEditor.Android.IPostGenerateGradleAndroidProject,回调参数path指向生成的unityLibrary模块根目录;Manifest 路径 = <path>/src/main/AndroidManifest.xml;用 XmlDocument(注册xmlns:android="http://schemas.android.com/apk/res/android")解析 → 追加<activity-alias>节点 → 回写。
3.5 ⚠️ 国产 ROM 的缓存与拦截(社区实测,非官方文档)
标准方案在国产定制 Launcher 上表现显著劣化,以下为社区汇总的实测数据,上线前务必在自己的目标机型上复验:
com.miui.home) | |
com.huawei.android.launcher) |
共性原因:Launcher 图标不是应用内 UI,切换涉及 AMS → Launcher 的跨进程通信且无同步回调;OEM Launcher 普遍加了"图标变更防抖"“版本校验”"静默更新拦截"逻辑。
应对:
用 Build.MANUFACTURER做机型判定,对 MIUI/EMUI 在切换后给一句 Toast 引导(“图标将在桌面稍后更新”),把预期管理做在前面;快捷方式残留用 ShortcutManager清理;- 不要在 App 前台切换
——部分 Launcher 会因此把用户直接弹回桌面(这也是 §3.3 结论的第二个独立理由)。
3.6 Android 实现代码
3.6.1 注入 Manifest 的 Editor 脚本
// Assets/Editor/AndroidIconAliasInjector.csusing System.Collections.Generic;using System.IO;using System.Text;using System.Xml;using UnityEditor.Android;public class AndroidIconAliasInjector : IPostGenerateGradleAndroidProject{public int callbackOrder => 100;// 索引 0 视为默认图标;名称将用于生成 aliasprivate static readonly string[] IconNames = { "default", "spring_festival", "anniversary" };private const string AndroidNs = "http://schemas.android.com/apk/res/android";publicvoidOnPostGenerateGradleAndroidProject(string path){string manifestPath = Path.Combine(path, "src", "main", "AndroidManifest.xml");if (!File.Exists(manifestPath)) return;var doc = new XmlDocument();doc.Load(manifestPath);var nsMgr = new XmlNamespaceManager(doc.NameTable);nsMgr.AddNamespace("android", AndroidNs);nsMgr.AddNamespace("d", doc.DocumentElement.NamespaceURI);XmlNode application = doc.SelectSingleNode("/manifest/application", nsMgr);if (application == null) return;// 幂等:重复构建时先清掉旧的 aliasforeach (XmlNode old in application.SelectNodes("d:activity-alias[starts-with(@android:name, 'IconAlias')]", nsMgr))application.RemoveChild(old);string packageName = doc.DocumentElement.GetAttribute("package");if (string.IsNullOrEmpty(packageName)){// AGP 8+ 可能不在 manifest 写 package,改用 Unity 的 applicationIdpackageName = UnityEditor.PlayerSettings.GetApplicationIdentifier(UnityEditor.Build.NamedBuildTarget.Android);}string launcherActivity = packageName + ".MainActivity";for (int i = 0; i < IconNames.Length; i++){XmlElement alias = doc.CreateElement("activity-alias");alias.SetAttribute("name", AndroidNs, packageName + ".IconAlias." + IconNames[i]);alias.SetAttribute("targetActivity", AndroidNs, launcherActivity);alias.SetAttribute("icon", AndroidNs, "@mipmap/ic_launcher_" + IconNames[i]);// 默认图标(i==0)启用,其余禁用;这是硬性要求,不能全部启用alias.SetAttribute("enabled", AndroidNs, i == 0 ? "true" : "false");alias.SetAttribute("exported", AndroidNs, "true");XmlElement filter = doc.CreateElement("intent-filter");XmlElement action = doc.CreateElement("action");action.SetAttribute("name", AndroidNs, "android.intent.action.MAIN");XmlElement category = doc.CreateElement("category");category.SetAttribute("name", AndroidNs, "android.intent.category.LAUNCHER");filter.AppendChild(action);filter.AppendChild(category);alias.AppendChild(filter);application.AppendChild(alias);}doc.Save(manifestPath);}}
同时确保 MainActivity 自身的 MAIN/LAUNCHER filter 被移除(若模板里有),并设置 launchMode="singleTask"。可在同一脚本里处理。
3.6.2 Android 原生切换逻辑(Kotlin)
package com.example.game.iconimport android.content.ComponentNameimport android.content.Contextimport android.content.Intentimport android.content.pm.PackageManagerimport android.os.Buildobject IconSwitcher {private const val PREFS = "icon_switcher"private const val KEY_PENDING = "pending_icon"private const val KEY_CURRENT = "current_icon"/** 用户点击时只记录,不立即执行 */@JvmStaticfun requestIcon(context: Context, iconName: String) {context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_PENDING, iconName).apply()}/** 由 Unity 在 OnApplicationPause(true) 时调用,或由原生生命周期钩子调用 */@JvmStaticfun applyPendingIfAny(context: Context) {val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)val pending = prefs.getString(KEY_PENDING, null) ?: returnval current = prefs.getString(KEY_CURRENT, null)if(pending == current) returnval pm = context.packageManagerval all = queryAliases(context)val target = all.firstOrNull { it.contains(".IconAlias."[iOSIconInjector] 缺失图标集: {src}");continue;}CopyDirectory(src, dst);}// 2) 声明替代图标集(空格分隔)。Xcode 会据此自动生成 CFBundleAlternateIcons,// 不要再去手写 Info.plist 的图标键。proj.SetBuildProperty(mainTarget,"ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES",string.Join(" ", AlternateIconNames));// 3) 已知坑:Images.xcassets 可能被拷贝但没进 Xcode 工程,导致图标不生效string xcassetsGuid = proj.FindFileGuidByProjectPath("Unity-iPhone/Images.xcassets");if (string.IsNullOrEmpty(xcassetsGuid)){xcassetsGuid = proj.AddFile("Unity-iPhone/Images.xcassets", "Unity-iPhone/Images.xcassets");proj.AddFileToBuild(mainTarget, xcassetsGuid);}File.WriteAllText(projPath, proj.WriteToString());// 4) 若走 §5.2 的传统 Info.plist 方案,在此用 PlistDocument 写入 CFBundleAlternateIcons。// 走资产目录方案时,这一步必须省略。}private static void CopyDirectory(string src, string dst){Directory.CreateDirectory(dst);foreach (string dir in Directory.GetDirectories(src, "*", SearchOption.AllDirectories))Directory.CreateDirectory(dir.Replace(src, dst));foreach (string file in Directory.GetFiles(src, "*", SearchOption.AllDirectories))File.Copy(file, file.Replace(src, dst), true);}}
appiconset 参考配置(Assets/Editor/AppIcons/AppIconSpring.appiconset/Contents.json):
{"images" : [{ "idiom" : "iphone", "scale" : "2x", "size" : "20x20" },{ "idiom" : "iphone", "scale" : "3x", "size" : "20x20" },{ "idiom" : "iphone", "scale" : "2x", "size" : "29x29" },{ "idiom" : "iphone", "scale" : "3x", "size" : "29x29" },{ "idiom" : "iphone", "scale" : "2x", "size" : "40x40" },{ "idiom" : "iphone", "scale" : "3x", "size" : "40x40" },{ "idiom" : "iphone", "scale" : "2x", "size" : "60x60" },{ "idiom" : "iphone", "scale" : "3x", "size" : "60x60" },{ "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },{ "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },{ "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },{ "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },{ "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },{ "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },{ "idiom" : "ipad", "scale" : "1x", "size" : "76x76" },{ "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },{ "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }],"info" : { "author" : "xcode", "version" : 1 }}
产出时按 size + scale 计算实际像素(如 60x60@3x = 180×180),文件名填入各条目的 "filename" 字段。
6. 双端差异速查

会DONT_KILL_APP 已失效) | ||
| 有,无法关闭 | ||
nil | ||
CFBundleAlternateIcons | ||
7. 上线检查清单
双端通用
图标全部随包预置,产品需求已确认为"N 选 1 预设"而非动态下发 提供"恢复默认图标"入口 换图标入口有明确的知情提示文案 做了包体回归(新增 N 个图标后的安装包增量)
Android
MainActivity已移除 MAIN/LAUNCHERfilter,launchMode="singleTask",未设taskAffinity=""有且仅有一个 alias 默认 enabled="true"切换逻辑在 App 进入后台时执行,不在前台执行 正式版本永不删除/重命名已发布的 alias(写进团队规范) 目标机型(尤其小米/华为)真机验证图标刷新延迟与"应用已更新"提示 出海包已按 Google Play 条款自查:用户主动触发 + 易于撤销 + 非广告用途
iOS
选定资产目录方案或传统 Info.plist 方案,未混用手写图标键 appiconset 尺寸齐全(含 1024×1024),PNG 无透明通道 若走资产目录方案, ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES名字与 appiconset 目录名逐字符一致(写错会被静默忽略)构建 Xcode 版本已锁定(建议 26.2+,避开 26.1 的新旧混用失效问题) 真机验证(模拟器可能不生效),且覆盖 iOS 18 与 iOS 26 两端 completion handler 中未直接操作 UI _iconGetAlternateIconName的返回指针在 C# 侧正确释放