尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity手游动态更换App图标全攻略:Android与iOS双端实现原理与热更接入

Unity手游动态更换App图标全攻略:Android与iOS双端实现原理与热更接入 1. 为什么需要动态更换 App 图标做过手游发行的同学应该都有这种感觉App 图标和商店宣传图一样属于「包一层皮就上线」的运营资产。平时一个图标可以用一年但遇到版本周年庆、春节活动、IP 联动这类节点策划大概率会提一个需求——能不能让玩家打开 App 的时候自动把桌面图标换成本次活动的限定图标单看需求本身很简单切图、换图标、发版。但真正落在 Unity 手游上这个需求会瞬间变成三件事Android 端怎么换、iOS 端怎么换、Unity 层怎么统一封装。而且双端的系统机制完全不同一个靠android:alias一个靠setAlternateIconName写起来是两个思路。我最早接触这个需求是在做一款中度休闲游戏的时候发行提了个相当扎心的要求「图标要能跟着线上活动走不能每次活动都强制发版更新」。当时我第一反应是「这玩意儿能不能做」后来查了一圈资料结论是能做而且海外不少游戏已经这么干了只是国内团队做得少相关的整包方案也不多。这篇内容我就把完整的技术方案拆开讲覆盖 Android 和 iOS 双端的系统原理、Unity 侧的 C# 封装、动态图标的热更接入流程以及在真机调试中必然会踩到的坑。适合已经有一定 Unity 开发经验、正在做发行向手游的客户端同学参考也适合想评估这个需求可行性的技术负责人快速建立认知。2. 双端系统机制与方案选型2.1 核心思路不是「替换文件」而是「切换入口」很多人第一次接触这个需求直觉反应是「把图标文件替换掉」。但 Android 和 iOS 的系统机制都不允许直接覆盖已安装 App 的资源文件签名校验和资源保护机制在那里摆着你也没法绕过。真正可行的思路是在包内预置多套图标通过系统机制切换「当前生效的图标」。Android 端靠的是activity-alias它允许你创建多个指向同一个 Activity 的「别名入口」每个入口可以有自己的icon和label。系统桌面上显示的是某个入口的图标正常情况下你启动主 Activity桌面图标对应的就是主入口那个图标。但如果把另一个 alias 设为 enabled、同时把当前入口 disable桌面图标就会变成 alias 指向的那个图标。iOS 端靠的是Info.plist里的CFBundleAlternateIcons。你在工程里预置多套图标资源并注册到 plist 里运行时调用UIApplication.sharedApplication setAlternateIconName:系统就会自动把当前 App 显示在桌面上的图标切换成指定的那份。iOS 的这个能力是系统级的连刷新都不需要你管。所以双端虽然代码路径完全不同但本质是同一件事预制资源 系统级入口切换。这也是 Unity 侧能够统一封装的基础。2.2 为什么不用运行时下载图标动态加载既然要做活动图标那是否可以在运行时从 CDN 拉一张图直接设置成桌面图标理论上可以看看思路但实际操作完全行不通。下面分开说Android 端activity-alias的icon指向的是 res 资源引用但资源 ID 是在打包时固定生成的。你没法在运行时把一个下载好的 PNG 路径填进 manifest 里。就算你用根目录路径去更新资源系统桌面显示用的图标缓存也会直接失效搞不好还触发安全异常。iOS 端CFBundleAlternateIcons必须在Info.plist注册且对应的图片文件必须在 main bundle 里。就算你下载到沙盒目录系统也不会读它。所以这个功能的资源层必须走预制方案动态的内容只是「选哪一套」而不是「下载一张新图」。2.3 方案对比预制多套图标 vs 动态替换文件我在调研时列过一个简单的表格方便判断不同方案的取舍方案Android 可行性iOS 可行性热更新支持发版频率预制多套图标 系统切换可行alias 机制可行AlternateIcons可配合热更下发配置一个版本周期预埋后续活动图标运行时下载图标不可行不可行无不适用发版换图标可行但低效可行但需审核无每次活动都要发版最后我选的方案非常传统预置 2-3 套备用图标在包里通过服务端下发的配置决定切到哪一套。这样既满足运营需求又把客户端改动压到了最低。后面所有实现细节也都是围绕这个方案展开的。3. Android 端实现细节拆解3.1 AndroidManifest 配置中的 alias 机制Android 端是整个需求里最绕的一部分核心在于activity-alias的写法。主 Activity 的声明本身不需要大改你只需要在 manifest 里多写几个 alias 节点每个 alias 都targetActivity指向同一个主 Activity但icon引用的资源不同。举个例子正常的入口是这个activity android:name.MainActivity 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如果你切了两套活动图标就要加两个 aliasactivity-alias android:name.MainActivity_Event_A android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_event_a android:labelstring/app_name_event_a android:targetActivity.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias这里有几个关键点必须注意。alias 节点里必须也有 MAIN/LAUNCHER 的 intent-filter否则桌面不会把它当成一个可启动入口自然也不会显示图标。其次默认生效的那个入口必须是enabledtrue备用入口全部enabledfalse否则系统会不知道你到底该显示哪个图标。关于 alias 的enabled切换系统真正做的是修改PackageManager.COMPONENT_ENABLED_STATE也就是组件的启用状态。你把一个 alias 启用、同时把另一个 disableLauncher 就会刷新图标。这里有个细节旧版本的 Android 系统对「入口 Activity 全部被禁用」这种情况非常敏感如果主 Activity 和所有 alias 全都变成 disabled系统可能直接判定应用不可启动桌面图标直接消失。所以至少要保证有一个入口始终是 enabled 状态。3.2 动态切换的 C# 侧封装Unity 侧不能直接写 Java 代码改 manifest所以我们选择把「切换动作」封装成 AndroidJavaClass / AndroidJavaObject 的静态调用。伪代码逻辑我用 C# 写在下面。#if UNITY_ANDROID using UnityEngine; public class AndroidIconChanger { private const string BridgeClass com.yourgame.iconbridge.IconBridge; public static void SwitchIcon(string aliasName) { using (var bridge new AndroidJavaClass(BridgeClass)) { bridge.CallStatic(switchToAlias, aliasName); } } } #endif对应的 Java 桥接类长这样核心调的是PackageManager.setComponentEnabledSettingpackage com.yourgame.iconbridge; import android.content.ComponentName; import android.content.pm.PackageManager; import android.content.Context; import android.util.Log; public class IconBridge { private static final String DEFAULT_ALIAS .MainActivity; private static final String EVENT_A_ALIAS .MainActivity_Event_A; private static final String EVENT_B_ALIAS .MainActivity_Event_B; public static void switchToAlias(Context context, String aliasName) { PackageManager pm context.getPackageManager(); String packageName context.getPackageName(); try { // 先启用目标 alias ComponentName target new ComponentName(packageName, packageName aliasName); pm.setComponentEnabledSetting( target, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); // 再禁用其他入口 disableIfNot(context, packageName, DEFAULT_ALIAS, aliasName); disableIfNot(context, packageName, EVENT_A_ALIAS, aliasName); disableIfNot(context, packageName, EVENT_B_ALIAS, aliasName); } catch (Exception e) { Log.e(IconBridge, switchToAlias failed, e); } } private static void disableIfNot(Context context, String pkg, String alias, String exclude) { if (alias.equals(exclude)) return; ComponentName cn new ComponentName(pkg, pkg alias); context.getPackageManager().setComponentEnabledSetting( cn, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); } }为什么先启用目标再禁用其他因为要避免出现瞬间全部禁用导致桌面图标消失的情况。其次DONT_KILL_APP这个 flag 很关键它告诉系统不要杀进程否则切换图标期间 App 可能直接被系统回收体验很差。3.3 Android 端的生效条件冷启动与 PMS 缓存Android 端从代码执行到桌面真正换图标中间有一个很让人头疼的延迟问题。setComponentEnabledSetting只是改了系统的包管理状态桌面的 Launcher 什么时候刷新图标由系统决定。多数情况下需要重启 Launcher 或者等待一段时间才会更新。实际测试中我发现一个更可靠的办法是切换图标后让 App 进程做一次冷启动也就是彻底杀掉进程再重新拉起。冷启动会触发系统对组件状态的重新检查Launcher 刷新图标的概率会大很多。另外Android 10API 29之后系统对包管理器的权限和缓存机制越来越严格alias 切换在部分机型上甚至要等待更久才能看到桌面变化。我的经验是不要依赖即时生效要在产品层设计「下次启动时生效」的逻辑而不是「点击按钮立刻生效」。否则测试同学会在真机上反复抓狂。4. iOS 端实现细节拆解4.1 Info.plist 与 CFBundleAlternateIcons 注册iOS 端的实现比 Android 直观很多核心就是Info.plist里声明备用图标然后调用系统的替换接口。先展示一下 plist 的结构keyCFBundleIcons/key dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon60x60/string /array /dict keyCFBundleAlternateIcons/key dict keyEventAIcon/key dict keyCFBundleIconFiles/key array stringAppIconEventA/string /array /dict keyEventBIcon/key dict keyCFBundleIconFiles/key array stringAppIconEventB/string /array /dict /dict /dict这里CFBundleAlternateIcons下面的 dict 中每个 key 就是备用图标的标识名后面调用系统接口时要用到这个标识名。标识名建议用英文不要用中文或特殊字符避免 Xcode 或 App Store 证书校验时出幺蛾子。同时工程目录里必须有对应的图片资源命名要完全一致比如AppIconEventA.png必须要有多尺寸版本包含 20pt、29pt、40pt、60pt 这些 iOS 图标规范要求的尺寸。如果漏了一档尺寸系统在切换的时候可能直接拒绝更换。4.2 Unity 调用 iOS 原生接口的实现方式Unity 工程里调用 iOS 原生接口最省事的方式是写一个 Objective-C 的桥接类然后通过DllImport(__Internal)导入。如果你用的是 Unity 2020 之后的版本也可以用 Unity 官方的UnitySendMessage来做回调不过单纯切图标并不需要回调直接同步调用即可。桥接类实现如下#import UIKit/UIKit.h void switchToAlternateIcon(const char* iconName) { if (available(iOS 10.3, *)) { NSString *name [NSString stringWithUTF8String:iconName]; if (name.length 0) { name nil; // nil 表示恢复主图标 } [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(switch icon error: %, error.localizedDescription); } }]; } }C# 侧写法#if UNITY_IOS !UNITY_EDITOR using System.Runtime.InteropServices; public class IosIconChanger { [DllImport(__Internal)] private static extern void switchToAlternateIcon(string iconName); public static void SwitchIcon(string iconName) { switchToAlternateIcon(iconName); } } #endif调用时传null或空字符串则setAlternateIconName会切回主图标。4.3 iOS 审核与系统限制必须知道的红线iOS 端真正的门槛不在技术而在审核。Apple 对AlternateIcons的使用有一条明确的限制备用图标不能用于隐藏 App 功能、规避审核或误导用户。而且这个接口最早只对 Enterprise 或内部测试开放后来才在 iOS 10.3 开放给所有 App但 App Store 审核时依然会审查你的备用图标数量和内容。实际踩过的坑是如果你在提审时把备用图标配置得非常花哨甚至和副标题暗示的内容不一致审核团队有概率打回理由一般是「图标与实际功能不符」。我的建议是第一版只做 2 套备用图标一套主图标、一套活动图标并且活动图标的内容和截图里的玩法保持强关联不要做无边界的创意。另外一个必须留意的限制是这个接口在 iOS 8.0 到 10.3 之间不存在。如果游戏的 deployment target 设成了 iOS 9就要做好版本判断在低版本上静默降级为「不切图标」或「下拉提示等下次升级」。5. Unity 侧封装与整体热更流程接入5.1 平台抽象与运行时判断到这一步你已经有了双端的原生桥接。真正落到 Unity 工程时还要做一层「平台抽象」避免业务层到处写平台判断。我习惯的做法是定义一个AppIconManager类内部根据RuntimePlatform自动选择调用 Android 还是 iOS 的桥接对外暴露一个统一接口public static class AppIconManager { public static void SwitchIcon(string iconKey) { if (string.IsNullOrEmpty(iconKey)) { iconKey default; } #if UNITY_ANDROID !UNITY_EDITOR AndroidIconChanger.SwitchIcon(iconKey); #elif UNITY_IOS !UNITY_EDITOR IosIconChanger.SwitchIcon(iconKey); #else Debug.Log([AppIconManager] current platform not support, iconKey iconKey); #endif } }注意 Android 和 iOS 的 iconKey 含义不同。Android 端传的是 alias 的「后半段」比如MainActivity_Event_A对应iconKey MainActivity_Event_AiOS 端传的是CFBundleAlternateIcons里 dict 的 key比如EventAIcon。这个映射关系你可以在配置层做一张表不要让上层业务感知。5.2 服务端下发的配置结构切图标的业务逻辑是游戏启动后拉取远程配置如果配置里指定了要切换的活动图标就调SwitchIcon。这个功能本质上就是一个热更能力所以配置结构要设计得轻量、可追溯。我用的 JSON 结构大致如下{ icon_config: { version: 12, icon_key: EventA, start_ts: 1717000000, end_ts: 1717600000 } }字段含义version配置版本方便排查「是不是缓存了旧配置」。icon_key要切换的图标 keydefault表示恢复默认图标。start_ts/end_ts活动起止时间客户端在启动时判断当前时间是否落在区间内。拿到配置后的执行顺序是先判断当前是否在有效期内如果在再判断当前图标是不是目标图标如果不是才调SwitchIcon。不要在每次启动都调原生切换那会反复触发系统组件状态检查浪费性能。5.3 从「切图标」到「重启生效」的产品链路设计最后一个大问题什么时候真正生效iOS 端setAlternateIconName调用后系统会立刻刷新桌面图标很快生效。Android 端则不一定尤其是在一些国产 ROM 上Launcher 不一定响应组件切换事件。所以我最终在产品链路里加入了「重启生效」机制具体流程是这样启动时拉取远程配置判断是否有新的 icon_key。如果与当前图标不一致弹一个提示框「为庆祝本次活动App 图标即将更新点击重启立即生效」。用户点击后客户端标记「待重启」状态然后调用 Android 的PackageManager切换 alias。调用成功后用Application.Quit()退出进程桌面图标刷新任务交给系统。iOS 端因为即时生效不需要重启但流程统一走一遍也无妨只要不弹提示框即可。这里有一个很实用的细节不要试图在切换后立刻用Application.Restart()Unity 没有原地重启进程的官方 API。我试过用System.Diagnostics.Process.Start在新进程里重新拉起自己Android 上会有各种兼容性问题后来还是老老实实引导用户手动重开或者用冷启动方案。6. 常见问题与真机调试实录6.1 Android 上切完图标没反应这是排障记录里出现频率最高的问题。原因一般有这么几类alias 没有配置 intent-filter没有 MAIN/LAUNCHER 的 alias 只是普通组件桌面不会把它当入口自然没有图标切换。两个 alias 同时 enabled如果不小心保留了旧的 enabled 入口系统可能显示的还是旧的图标因为存在多个入口时 Launcher 会选择其中一个。必须保证任意时刻只有一个 enabled。系统 Launcher 缓存Samsung、小米、OPPO 都有各自的桌面缓存机制。切完图标后等几十秒或者重启 Launcher 进程才会刷新这是正常的。我实际调试时最有效的套路是先看adb shell dumpsys package的输出确认目标 Component 的enabled状态是不是真的切了再看dumpsys activity里有没有被 Launcher 记录为入口。如果 Component 状态没切换那问题在代码如果切换了但图标没变那问题在系统缓存。6.2 Android 低版本冷启动与 PMS 的坑Android 8.0 之前setComponentEnabledSetting切换组件状态有时候不会刷新快捷方式需要等 Launcher 重载。这说明要想让桌面可靠更新冷启动或重启 Launcher 几乎不可避免。还有一个隐藏坑如果将DONT_KILL_APP换成0也就是允许杀进程系统可能在切状态时直接把你的 App 干掉。这在调试时不算大事但线上切换图标后如果用户发现 App 突然退到后台产品观感很差。所以务必用DONT_KILL_APP。另外如果游戏本身接入了统计 SDK 或热更 SDK切组件状态触发进程重建可能会导致部分 SDK 的启动时序错乱比如广告 SDK 重新初始化。我碰到过一次聚合广告在图标切换后 load 失败的情况后来查下来是进程被杀导致 SDK 断连。由此可见保活进程比切图标本身更优先。6.3 iOS 切换失败或图标不变的处理iOS 端切图标失败常见原因有三类图标资源尺寸不全。CFBundleIconFiles里指定的图片必须同时满足所有 iPhone 图标尺寸要求漏掉某档系统直接拒绝。标识名不匹配。调用setAlternateIconName:传入的字符串必须和CFBundleAlternateIcons里的 key 完全一致。注意大小写eventa和EventAIcon是两回事。切回主图标传参错误。如果只是想恢复默认图标setAlternateIconName:nil即可不能传空字符串否则会报错。调试时有个技巧completionHandler的 error 对象会带一个NSError看error.code和localizedDescription基本能定位问题。最常见的是UIApplicationErrorAlternateIconName相关错误码说明你传的标识名在 plist 里找不到。6.4 桌面图标显示为系统默认图标的兜底机制无论 Android 还是 iOS动态切换图标都可能遇到极端情况图标变成系统默认的空白图标或机器人。Android 上出现这种情况的原因通常是你把所有入口包括主 Activity 全部 disabled或者 alias 的 targetActivity 路径写错。iOS 上则大概率是图片名字和 plist 里的字符串不一致系统加载不到图片就开始摆烂。我为此在客户端里加了一个**「定时自愈」逻辑**每次启动后读取当前配置中的 icon_key再跑一次「当前图标是否和配置一致」的检查如果不一致就主动切一次。相当于把同步逻辑幂等了即使上次切换失败下次启动也能自动修复。这个兜底逻辑虽然简单但对线上排障很有帮助。因为动态图标最怕「卡在中间态」用户看到的是一个异常图标这对品牌形象和商店转化率都有负面影响。7. 双端能力差异与选型总结把 Android 和 iOS 放在一起做对比差异很明显维度AndroidiOS系统机制activity-alias Component 状态切换CFBundleAlternateIconssetAlternateIconName:最低系统版本无特殊限制iOS 10.3 及以上生效速度慢依赖 Launcher 刷新快系统立刻刷新是否需要重启多数场景需要冷启动或等待不需要审核风险无明显风险需注意图标内容和审核预期一致资源预置多张 mipmap 资源多套多尺寸资源所以如果你的项目最低版本锁定在 Android 7 以下且使用低端机较多我建议慎重采用动态图标方案。因为 Android 低版本机型上 Launcher 行为五花八门很可能会在某些定制系统上怎么切都不生效最后只能给用户弹提示「重启手机试试」这体验太差了。从执行效率和获客角度看方案本身的价值是显而易见的。iOS 因为 Apple 对桌面图标的展示规则更统一切完图标后用户会立刻看到变化运营可以做「限时图标」的稀缺感营销Android 上虽然生效慢一点但胜在机制开放自由度更高。如果你只做单端优先做 iOS成本最低效果最好。如果要双端一起上一定要在项目管理上预留至少一周的机型适配和回归测试时间。8. 实际操作中的最后几点建议动态换图标这个功能写代码本身难度不高真正考验的是对整个链路的理解尤其是 Android 生效机制、iOS 审核红线、以及和热更配置的配合。我个人在做完这个功能后留下几个很深的体会第一不要试图在 Icon 上做「实时性」很强的运营动作。比如中午 12 点活动开始12 点 01 分才启动游戏的用户看到的图标是否还是旧的完全有可能。所以给运营的建议是图标切换是「延迟生效的广告位」适合提前一天预热切换而不是卡点切换。第二备用图标最好控制在 3 套以内。Android 的每一个 alias 都意味着一个入口状态额外维护成本不高但 iOS 的每套图标都要准备至少 5-6 个尺寸的图片资源设计同学出图成本很高。控制好数量不要因为系统支持就无限加。第三一定要埋点。图标每次切换成功或失败都要上报数据。运营要判断活动图标到底覆盖了多少用户没有埋点就只能靠肉眼猜这个需求如果上线前不做埋点后面复盘的时候会非常吃亏。第四做好「恢复默认」的一键入口。活动结束不要只靠服务端下配置切回来客户端要内置一个「美术检查模式」方便测试同学在测试包内手动切任意图标不然每次回归都要伪造服务端返回值效率太低。最后一个实用小技巧如果你在 Android 端遇到切了 alias 但 Launcher 不刷新可以试试在切换后调用一次context.getPackageManager().getLaunchIntentForPackage(packageName)。有时候 Launcher 会因为查询入口而触发一次图标刷新虽然不是官方保证但我实测在部分小米和原生 Android 机型上有效至少比干等强。
返回列表