夜雨聆风学习资料网

ARTICLE · 1090624

Unity 手游动态更换 App 图标 — Android 与 iOS 双端技术方案

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 侧最大的设计约束

这是全网教程里最普遍的一个过时结论。大量示例代码(包括官方文档的引用片段)仍写着:

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 脚本

// 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 视为默认图标;名称将用于生成 alias    private 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;        // 幂等:重复构建时先清掉旧的 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)

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"    /** 用户点击时只记录,不立即执行 */    @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."[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. 双端差异速查

维度
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# 侧正确释放

相关学习资料