本文为 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. 结论先行
| 维度 | Android | iOS |
|---|---|---|
| 可行吗 | ✅ 可行,官方机制 | ✅ 可行,官方机制 |
| 核心 API | PackageManager.setComponentEnabledSetting(s) + 预埋 <activity-alias> |
UIApplication.setAlternateIconName(_:completionHandler:) |
| 图标来源 | 必须随包预置,不能运行时下载 | 必须随包预置,不能运行时下载 |
| 切换时会不会杀进程 | 会。DONT_KILL_APP 自 Android 10 起已失效 |
不会杀进程,但 App 会被系统切到后台并强制弹窗 |
| 能否静默切换 | 能(用户无感,仅桌面图标延迟刷新) | 不能。系统强制弹「您已更改图标」Alert |
| 主要风险 | 组件全禁用 → 应用崩溃 / 图标消失 | 商店政策与 App Store Connect 校验 |
一句话选型:双端都能做,但**都是"预置图标集合 + 用户主动选择"**这一种形态。任何"服务端下发新图标""无人值守自动换"的方案在两端都不成立------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 上 |
会出现两个入口,或多个图标同时出现 |
有且仅有 一个 alias android:enabled="true",其余全部 false |
桌面出现多个图标;Android 只取第一个匹配的,目标图标可能永远不显示 |
MainActivity 设 launchMode="singleTask",且不设 taskAffinity="" |
点新图标会新起一个 App 实例,而不是回到已有任务栈 |
| alias 上的属性不会从 target activity 继承 | 只在 MainActivity 上设 android:icon 对 alias 无效,必须逐个 alias 显式声明 |
| 上线后永远不要删除或重命名已有 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 侧最大的设计约束
这是全网教程里最普遍的一个过时结论。大量示例代码(包括官方文档的引用片段)仍写着:
kotlin
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 上表现显著劣化,以下为社区汇总的实测数据,上线前务必在自己的目标机型上复验:
| 机型 / Launcher | 表现 |
|---|---|
小米 MIUI(com.miui.home) |
图标切换延迟约 2.1--4.7s;约 65% 概率触发"应用已更新"Toast;桌面快捷方式变灰;部分情况需重启系统才显示新图标 |
华为 EMUI(com.huawei.android.launcher) |
延迟 ≥5s,且需手动下拉刷新;强制弹"应用已更新";桌面小组件重置为默认 |
共性原因:Launcher 图标不是应用内 UI,切换涉及 AMS → Launcher 的跨进程通信且无同步回调;OEM Launcher 普遍加了"图标变更防抖""版本校验""静默更新拦截"逻辑。
应对:
- 用
Build.MANUFACTURER做机型判定,对 MIUI/EMUI 在切换后给一句 Toast 引导("图标将在桌面稍后更新"),把预期管理做在前面; - 快捷方式残留用
ShortcutManager清理; - 不要在 App 前台切换------部分 Launcher 会因此把用户直接弹回桌面(这也是 §3.3 结论的第二个独立理由)。
3.6 Android 实现代码
3.6.1 注入 Manifest 的 Editor 脚本
csharp
// Assets/Editor/AndroidIconAliasInjector.cs
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;
using UnityEditor.Android;
public class AndroidIconAliasInjector : IPostGenerateGradleAndroidProject
{
public int callbackOrder => 100;
// 索引 0 视为默认图标;名称将用于生成 alias
private static readonly string[] IconNames = { "default", "spring_festival", "anniversary" };
private const string AndroidNs = "http://schemas.android.com/apk/res/android";
public void OnPostGenerateGradleAndroidProject(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;
// 幂等:重复构建时先清掉旧的 alias
foreach (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 的 applicationId
packageName = 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)
kotlin
package com.example.game.icon
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
object IconSwitcher {
private const val PREFS = "icon_switcher"
private const val KEY_PENDING = "pending_icon"
private const val KEY_CURRENT = "current_icon"
/** 用户点击时只记录,不立即执行 */
@JvmStatic
fun requestIcon(context: Context, iconName: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_PENDING, iconName).apply()
}
/** 由 Unity 在 OnApplicationPause(true) 时调用,或由原生生命周期钩子调用 */
@JvmStatic
fun applyPendingIfAny(context: Context) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val pending = prefs.getString(KEY_PENDING, null) ?: return
val current = prefs.getString(KEY_CURRENT, null)
if (pending == current) return
val pm = context.packageManager
val all = queryAliases(context)
val target = all.firstOrNull { it.contains(".IconAlias.$pending") } ?: return
// 先启用新的,再禁用旧的:避免出现"零 launcher 组件"窗口
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val settings = ArrayList<PackageManager.ComponentEnabledSetting>()
settings.add(PackageManager.ComponentEnabledSetting(
target, PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP))
all.filter { it != target }.forEach {
settings.add(PackageManager.ComponentEnabledSetting(
it, PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP))
}
pm.setComponentEnabledSettings(settings) // API 33+,原子生效
} else {
pm.setComponentEnabledSetting(target,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
all.filter { it != target }.forEach {
pm.setComponentEnabledSetting(it,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
}
}
prefs.edit().putString(KEY_CURRENT, pending).remove(KEY_PENDING).apply()
}
private fun queryAliases(context: Context): List<ComponentName> {
val pm = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
@Suppress("DEPRECATION")
val resolved = pm.queryIntentActivities(
intent, PackageManager.MATCH_DISABLED_COMPONENTS)
return resolved
.asSequence()
.filter { it.activityInfo.packageName == context.packageName }
.filter { it.activityInfo.name.contains(".IconAlias.") }
.map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) }
.toList()
}
@JvmStatic
fun getCurrentIcon(context: Context): String =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CURRENT, "default") ?: "default"
}
queryIntentActivities必须带MATCH_DISABLED_COMPONENTS,否则查不到已禁用的 alias(而绝大多数 alias 恰好处于禁用态)。
4. Unity 侧:统一 C# 桥接层
两端对上层暴露同一套 API,游戏逻辑不关心平台差异。
csharp
// Assets/Scripts/AppIcon.cs
using System;
using System.Runtime.InteropServices;
using UnityEngine;
public static class AppIcon
{
#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")] private static extern bool _iconSupportsAlternateIcons();
[DllImport("__Internal")] private static extern void _iconSetAlternateIcon(string name);
[DllImport("__Internal")] private static extern string _iconGetAlternateIconName();
#endif
/// <summary>当前平台是否支持换图标(iOS 需 supportsAlternateIcons 为真)</summary>
public static bool IsSupported()
{
#if UNITY_IOS && !UNITY_EDITOR
return _iconSupportsAlternateIcons();
#elif UNITY_ANDROID && !UNITY_EDITOR
using (var up = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = up.GetStatic<AndroidJavaObject>("currentActivity"))
return new AndroidJavaClass("com.example.game.icon.IconSwitcher") != null && activity != null;
#else
return false;
#endif
}
/// <summary>name 传 null 表示恢复默认图标</summary>
public static void SetIcon(string name)
{
if (string.IsNullOrEmpty(name)) name = null;
#if UNITY_IOS && !UNITY_EDITOR
_iconSetAlternateIcon(name);
#elif UNITY_ANDROID && !UNITY_EDITOR
using (var up = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = up.GetStatic<AndroidJavaObject>("currentActivity"))
using (var switcher = new AndroidJavaClass("com.example.game.icon.IconSwitcher"))
{
// Android 只登记意图,真正生效延迟到进入后台(见 §3.3)
switcher.CallStatic("requestIcon", activity, name ?? "default");
}
#endif
}
public static string GetCurrentIcon()
{
#if UNITY_IOS && !UNITY_EDITOR
return _iconGetAlternateIconName();
#elif UNITY_ANDROID && !UNITY_EDITOR
using (var up = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = up.GetStatic<AndroidJavaObject>("currentActivity"))
using (var switcher = new AndroidJavaClass("com.example.game.icon.IconSwitcher"))
return switcher.CallStatic<string>("getCurrentIcon", activity);
#else
return null;
#endif
}
/// <summary>Android 专用:进入后台时提交待生效的图标变更</summary>
private static void FlushPendingOnAndroid()
{
#if UNITY_ANDROID && !UNITY_EDITOR
using (var up = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = up.GetStatic<AndroidJavaObject>("currentActivity"))
using (var switcher = new AndroidJavaClass("com.example.game.icon.IconSwitcher"))
switcher.CallStatic("applyPendingIfAny", activity);
#endif
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Hook()
{
Application.focusChanged += focused =>
{
if (!focused) FlushPendingOnAndroid();
};
}
}
要点:
SetIcon在 Android 上只是登记,不立即生效;真正的组件启停发生在 App 失去焦点(退到后台)时,从而避开"切图标必杀进程"带来的体验事故。- iOS 上
SetIcon会立即触发系统弹窗,所以 iOS 侧应在用户点击的那一刻调用,而不是延迟------弹窗本身就是"用户知情确认"的证据,对政策合规有利。 - 两端的时序差异是有意为之,封装在桥接层里,上层调用方不需要感知。
5. iOS 端
5.1 机制与硬约束
核心 API 为 UIApplication.setAlternateIconName(_:completionHandler:),可用性 iOS 10.3+ / iPadOS 10.3+ / Mac Catalyst 13.1+ / tvOS 10.2+ / visionOS 1.0+。
- 传名称 → 切到该替代图标;传
nil→ 恢复主图标(CFBundlePrimaryIcon声明的那个)。 - 前提:
supportsAlternateIcons必须为true。 - 当前名称可从
UIApplication.alternateIconName读取。 - completion handler 在 UIKit 提供的队列上执行,不保证是主队列------回调里碰 UI 必须自己切回主线程。
系统强制弹窗 :图标变更后系统会自动展示「您已更改图标」提示,无法关闭(与 Android 完全不同)。好处是天然满足"用户知情"的合规要求;坏处是无法做到无感,产品上要接受这一点。
5.2 传统方案:CFBundleAlternateIcons(iOS 10.3 ~ 18)
在 Info.plist 中声明:
objectivec
CFBundleIcons
├─ CFBundlePrimaryIcon
│ └─ CFBundleIconFiles: [ "AppIcon60x60" ]
└─ CFBundleAlternateIcons ← 字典:key = 传给 setAlternateIconName 的名字
├─ "AppIconSpring"
│ └─ CFBundleIconFiles: [ "AppIconSpring60x60" ]
└─ "AppIconAnniversary"
└─ CFBundleIconFiles: [ "AppIconAnniversary60x60" ]
iPad 需要额外一份 CFBundleIcons~ipad(同样结构),否则 iPad 上替代图标不生效。
图标文件为 AlternateAppIcons/ 目录下的 PNG,按惯例需要覆盖:@2x 120×120、@3x 180×180、@2x~ipad 152×152、@3x~ipad 167×167,以及 1024×1024 的营销图标。必须是 PNG、不含透明通道。
重要 :如果改用 §5.3 的资产目录方案,不要再手写这些 Info.plist 键 ------ Xcode 会在构建时自动生成
CFBundleAlternateIcons,手工添加的键会与之冲突。两种方案二选一。
5.3 现代方案:资产目录 appiconset(Xcode 13+)
Xcode 13 起,替代图标走资产目录,由两个 build setting 驱动:
| Xcode 界面项 | Build Setting | 作用 |
|---|---|---|
| Include all app icon assets | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS |
YES 时把资产目录里所有 appiconset 都编入产物,并自动写入 CFBundleAlternateIcons。为 YES 时忽略 下一项(底层等价于给 actool 传 --include-all-app-icons) |
| Alternate App Icon Sets | ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES |
空格分隔 的图标集名称列表,仅编入指定的替代图标(底层对每个名字给 actool 传 --alternate-app-icon)。名字写错会被静默忽略,不报错 |
| Primary App Icon Set Name | ASSETCATALOG_COMPILER_APPICON_NAME |
主图标集名,通常是 AppIcon |
推荐 :用 ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES 精确列出,而不是开 INCLUDE_ALL_APPICON_ASSETS。理由:后者会把测试期遗留的图标、废弃图标一并打进包,且每个图标集都会显著增大包体 ------社区实测有 App 因为加了 12 个图标从 20MB 膨胀到 304MB,务必控制图标集数量并做包体回归。
5.4 ⚠️ iOS 26 新格式:Icon Composer .icon(当前最大的不确定性来源)
iOS 26 引入 Liquid Glass 图标,用 Icon Composer 生成 .icon 文件(File → Save 产出,无需 Export,也不要 放进 Images.xcassets,直接拖进项目导航器)。系统从 Info.plist 顶层的 CFBundleIcons 读取图标信息,Xcode 依据 Alternate App Icon Sets build setting 自动写入 CFBundleAlternateIcons。
兼容新旧两套格式的命名技巧(社区总结,非官方文档)
若 App 需同时支持 iOS 18 及更早 + iOS 26,做法是:
- 让
.icon文件名与资产目录中对应的 appiconset 名字完全一致 。资产目录里叫AppIcon/AppIcon2/AppIcon3,.icon文件就命名AppIcon.icon/AppIcon2.icon/AppIcon3.icon。 - Target 的 General 页 App Icon 设置保持不动,勾选 Include all app icon assets。
- 不要手工往
Info.plist加任何图标键 ;如果之前加过CFBundleIcons/CFBundleIcons~ipad,删掉。
这样系统在 iOS 18 及更早走资产目录的 PNG,在 iOS 26 走对应的 .icon,setAlternateIconName 在两端都能正常工作。
⚠️ Xcode 26 各小版本行为差异(社区实测汇总)
这一段是全篇最不稳定、上线前必须实测的部分:
| 版本 | 状态 |
|---|---|
| Xcode 26 beta 3 | 修复了 "Unable to set Icon Composer icon as alternate iOS icon";此前 setAlternateIconName 失败 / supportsAlternateIcons 返回 false 是已知回归 |
| Xcode 26 beta 4 | 再次出问题:只往 Assets.car 填 Icon Composer 变体而忽略资产里的 App Icon,"Include all app icon assets" 选项一度消失 |
| Xcode 26.0 | 新旧混用的命名技巧可用 |
| Xcode 26.1 | ⚠️ 有报告称新旧混用的 workaround 不再生效 ,需停留在 26.0 才能同时保留两套图标。社区曾用 Assets Catalog Other Flags = --enable-icon-stack-fallback-generation=disabled 部分绕过,但会伴随 "Failed to generate flattened icon stack" 警告 |
| Xcode 26.2 | 有报告称替代图标问题已修复;仍有个别开发者遇到 "Resource temporarily unavailable",重启设备后解决 |
行动建议:
- 锁定 Xcode 版本写进团队构建规范,不要盲目跟进小版本升级;
- 若只支持 iOS 26+,可以直接用
.icon+ 手工补CFBundleIcons→CFBundleAlternateIcons(含CFBundleIcons~ipad)绕过静默失败; - 务必真机验证 ------有报告称 iOS 26.2 上真机正常但模拟器上不生效。
5.5 ⚠️ 与 App Store Connect A/B 测试的关系
App Store 的 Product Page Optimization(产品页优化) 支持对商店列表图标做 A/B 测试,其配置方式与本文的替代图标共用同一套资产与 build setting。这意味着:
- 如果你同时要做"商店图标 A/B"和"App 内换图标",两者会争抢同一组 appiconset 命名,需要提前规划命名空间;
- 所有用于 A/B 的图标都需要齐备的尺寸变体(含 1024×1024),缺尺寸会导致 App Store Connect 校验失败。
5.6 Unity iOS 桥接(Objective-C)
objc
// Assets/Plugins/iOS/AppIconBridge.mm
#import <UIKit/UIKit.h>
extern "C" {
bool _iconSupportsAlternateIcons()
{
if (@available(iOS 10.3, *)) {
return [UIApplication sharedApplication].supportsAlternateIcons;
}
return false;
}
const char* _iconGetAlternateIconName()
{
if (@available(iOS 10.3, *)) {
NSString* name = [UIApplication sharedApplication].alternateIconName;
if (name == nil) return NULL;
// 必须返回 malloc 拷贝:Unity 侧 marshal 后会释放,返回栈/常量指针会悬空
return strdup([name UTF8String]);
}
return NULL;
}
void _iconSetAlternateIcon(const char* name)
{
if (@available(iOS 10.3, *)) {
NSString* iconName = (name == NULL) ? nil : [NSString stringWithUTF8String:name];
// completion handler 不保证在主队列,此处不碰 UI,保持原样即可
[[UIApplication sharedApplication] setAlternateIconName:iconName
completionHandler:^(NSError* error) {
if (error != nil) {
NSLog(@"[AppIcon] setAlternateIconName failed: %@", error);
}
}];
}
}
}
C# 侧注意 :
_iconGetAlternateIconName返回的指针在 C# 侧应声明为IntPtr并用Marshal.PtrToStringAnsi读取后Marshal.FreeHGlobal释放。上面桥接层为可读性简化为直接返回string,实际工程中请改成IntPtr,否则存在内存泄漏与悬空指针风险。
5.7 Unity 侧自动注入替代图标(Editor 脚本)
把美术产出的 .appiconset 目录预置在 Assets/Editor/AppIcons/ 下,构建后拷进 Xcode 工程的 Images.xcassets 并设置 build setting:
csharp
// Assets/Editor/iOSIconInjector.cs
using System.IO;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEditor.iOS.Xcode;
public class iOSIconInjector : IPostprocessBuildWithReport
{
public int callbackOrder => 100;
private static readonly string[] AlternateIconNames = { "AppIconSpring", "AppIconAnniversary" };
public void OnPostprocessBuild(BuildReport report)
{
if (report.summary.platform != BuildTarget.iOS) return;
string projPath = PBXProject.GetPBXProjectPath(report.summary.outputPath);
var proj = new PBXProject();
proj.ReadFromString(File.ReadAllText(projPath));
string mainTarget = proj.GetUnityMainTargetGuid();
// 1) 把预置的 appiconset 拷进 Images.xcassets
string xcassets = Path.Combine(report.summary.outputPath,
"Unity-iPhone", "Images.xcassets");
string sourceDir = "Assets/Editor/AppIcons";
foreach (string name in AlternateIconNames)
{
string src = Path.Combine(sourceDir, name + ".appiconset");
string dst = Path.Combine(xcassets, name + ".appiconset");
if (!Directory.Exists(src))
{
UnityEngine.Debug.LogError($"[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):
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. 双端差异速查

| 维度 | Android | iOS |
|---|---|---|
| 切换是否杀进程 | 会 (DONT_KILL_APP 已失效) |
不会,但退到后台 |
| 是否有系统弹窗 | 无 | 有,无法关闭 |
| 生效时机建议 | 延迟到进后台 | 用户点击时立即 |
| 图标启用顺序 | 先启新、再禁旧(或 API 33+ 原子接口) | 无所谓 |
| 恢复默认 | 启用 index 0 的 alias | 传 nil |
| 图标命名空间 | alias 名 + mipmap 资源名 | CFBundleAlternateIcons 的 key / appiconset 名 |
| 包体影响 | 每个 mipmap 多套密度资源 | 每个 appiconset 全套尺寸(实测有 20MB→304MB 的案例) |
| 主要政策风险 | Google Play 设备设置条款(§3.2) | 与商店图标 A/B 争夺资产命名(§5.5) |
| 主要技术风险 | 组件全禁用 → 崩溃 | Xcode 26 小版本行为漂移(§5.4) |
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# 侧正确释放
参考来源
- UIApplication.setAlternateIconName(_:completionHandler:)
- Configuring your app to use alternate app icons
- CFBundleAlternateIcons
- <activity-alias> | Android Developers
- Behavior changes: Apps targeting Android 16 or higher
- Google Play Policy --- Deceptive Device Settings Changes
- Unity Scripting API: IPostGenerateGradleAndroidProject
- kyubuns/AppIconChangerUnity --- Unity iOS 换图标插件(仅 iOS)
- jinglikeblue/UnityIconSwitcher --- Unity 双端换图标插件
- Apple Developer Forums 关于 Xcode 26 替代图标的讨论帖(788466、788925、787583)