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. 结论先行

维度 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. 为什么必须预置:两端共同的硬边界

这是整个方案的地基,先把它说死,后面所有设计都受它约束。

  1. Android :Launcher 通过 PackageManager 读取 ActivityInfo.icon 的资源 ID (编译期绑定)。运行时替换 res/mipmap-* 下的文件不可行;Adaptive Icon 的 foreground/background 层同样在构建期静态打包。新增图标 = 新增 activity-alias = 必须发版。
  2. iOS :图标由 CFBundleIcons 描述,其内容来自构建期打进 Assets.car 的 appiconset。setAlternateIconName 只能引用已声明在 CFBundleAlternateIcons 里的名字,传一个不存在的名字会直接报错。
  3. 共同推论 :图标 = 资源,换图标 = 换资源引用。所以产品上只能做 "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 分钟内到达,判断是自动化模板化拦截,而非人工审查代码。

合规做法(务必照做):

  1. 换图标必须是用户在 App 内的主动操作(设置页 / 活动页里的"更换图标"入口);
  2. 界面要明说"将在桌面创建/更换图标",给出足够的知情提示;
  3. 必须提供一键恢复默认图标的入口;
  4. 不要把换图标绑定到账号配置、服务端下发或纯时间驱动的自动逻辑。

国内渠道(华为、小米等)目前对动态图标相对宽松,但既然 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,但至今没有官方修复,也没有可靠的绕过手段。

由此推出三条设计决策:

  1. 不要在游戏过程中切换。否则玩家正打着关卡被直接弹回桌面,是不可接受的体验事故。
  2. 延迟到后台再切 。记录用户选择,等 OnApplicationPause(true)(App 退到后台)时执行组件启停。此时进程被杀用户无感。
  3. 接受"下次冷启动生效"。这是唯一诚实的产品描述:用户点"更换图标" → 返回桌面 → 图标已变。

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,做法是:

  1. 让 .icon 文件名与资产目录中对应的 appiconset 名字完全一致 。资产目录里叫 AppIcon / AppIcon2 / AppIcon3,.icon 文件就命名 AppIcon.icon / AppIcon2.icon / AppIcon3.icon。
  2. Target 的 General 页 App Icon 设置保持不动,勾选 Include all app icon assets。
  3. 不要手工往 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/LAUNCHER filter,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# 侧正确释放

参考来源

相关推荐
周杰伦fans2 小时前
构建真正智能的CAD AI绘图代理
开发语言·人工智能·c#
UIU1145 小时前
递归算法与汉诺塔
c++·学习·算法·c#·递归
SmalBox10 小时前
03-03-架构篇-Runtime加载系统架构
unity3d·游戏开发
SmalBox10 小时前
03-02-架构篇-Editor打包系统架构
unity3d·游戏开发
CSharp精选营10 小时前
我用 ASP.NET Core 做了个水稻病虫害检查系统
c#·毕业设计·.net core·码农刚子
唐青枫10 小时前
我用 C# 开发了一款电子发票自动整理工具
c#·.net
Yeah12711 小时前
当 LLM 进入游戏玩法:从收权到放权
游戏开发
下页、再停留11 小时前
【C#桌面客户端系列学习-2】在原来窗口基础上打开新窗口
c#·visual studio
Behavior11 小时前
Unity 手游 iOS Deep Link 唤醒全流程:从 URL Scheme / Universal Links 到 C# 层参数投递
c#·unity3d·游戏开发