ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity手游动态更换App图标:Android与iOS原理、代码与排坑

Unity手游动态更换App图标:Android与iOS原理、代码与排坑 接到这个需求的第一反应我猜你和我会一样哈App图标不是打包时候就定死的吗还能动态换后来在Unity手游项目里调研并落地之后我确认了两件事。第一这个需求在Android和iOS上确实都能做但两端的实现路径完全是两码事。第二真正麻烦的不是技术本身而是方案设计阶段没有把双端差异和运营预期对齐导致后期反复改。这篇文章我把完整方案拆开讲从系统机制原理到Manifest、Info.plist、原生代码、Unity侧的封装和构建脚本再到真机联调踩坑都基于我实际跑通的结果来写尽量让拿到文章的同事能直接照着落地。1. 双端底层的“换图标”能力差异1.1 Android凭什么能“随便换”Activity Alias与PackageManager开关Android的桌面图标不是一个独立文件而是系统解析出来的一个“启动入口组件”。桌面Launcher会去问PackageManager这个应用有哪些组件声明了MAIN LAUNCHER然后把它们显示成图标。既然桌面图标只是组件入口的一种映射那我们只要在运行时切换“哪个组件是可用的入口”就能让桌面图标发生变化。这里核心机制就是activity-alias。它允许你给同一个真实Activity起多个别名每个别名可以配置独立的icon、label并且都可以声明LAUNCHER过滤器。举个例子真实入口是MainActivity我给它建三个别名icon_alias_default、icon_alias_festival、icon_alias_anniversary每个别名指向同一个MainActivity但Icon各不相同。切换时用PackageManager的setComponentEnabledSetting方法把目标alias启用、把其他alias全部禁用。系统组件状态一变Launcher收到通知后会重新解析入口桌面上显示的图标就换了。整个过程不需要任何特殊权限也不需要弹窗App自己就能完成所以Android这边体验非常顺滑。不过要注意“随便换”是打引号的alias的icon是android:icon引用的是编译期打进去的静态资源不能直接引用网络图片或者运行时生成的Bitmap。也就是说所有可能用到的图标必须提前随包打进去切换只是换“入口对应的资源引用”。如果你真想用任意网络图片当桌面图标那就走ShortcutManager生成动态快捷方式或者pin Shortcut到桌面但那不是真正意义上的App主图标本文不展开。1.2 iOS为什么“这也不行那也不行”Alternate App Icons协议iOS这边从iOS 10.3开始苹果提供了官方接口UIApplication.setAlternateIconName(_:completionHandler:)支持应用在运行过程中切换到“包内预先声明的备用图标”。听着和Android差不多但限制非常死备用图标必须打包在应用资源目录中不能从网络下载后动态设置。必须提前在Info.plist里用CFBundleAlternateIcons声明所有可用备用图标。调用切换接口时系统会弹一个确认框用户必须手动确认桌面图标才会变。一次只能有一个备用图标处于激活状态切回默认图标时传入nil。这个API的设计初衷是给“用户自己选择App外观”这类场景用的本来就不打算让开发者疯狂改桌面图标。所以iOS侧的“动态换图标”本质上变成了“在游戏内引导用户去点确认切换预置图标”与Android那种后台悄悄切换完全不同。这个差异必须第一时间让策划和运营知道不然后面验收时候很容易撕扯。1.3 双端能力对照维度AndroidiOS支持方式activity-alias PackageManagerUIApplication.setAlternateIconName图标来源随包预置的mipmap资源随包预置的PNG资源运行时任意图片不支持只能预置不支持只能预置用户确认无感知后台直接切换系统弹窗必须用户确认切换成功与否本端完全可控用户不点确认就失败桌面刷新大部分ROM需要广播触发或等待系统自动处理恢复默认启用默认alias即可传入nil切换回主图标先把这个对照表吃透后续所有设计和沟通都围绕这张表展开。Android端可以做“运营指令到达即切换”iOS端只能做“运营指令到达后引导用户完成一次确认”这是天然的能力边界不是技术问题。2. 方案选型与整体架构设计2.1 为什么选“预置多套运行时开关”而不是“动态生成”刚开始接触这个需求时团队里有同事提出“那干脆让后端下发一张图片客户端动态生成图标不就好了”。这个想法很诱人但两个平台都不支持原因我在第一章提过Android的alias需要静态资源引用iOS的Alternate App Icons需要包内文件。所以唯一稳妥的路线就是预置多套图标运行时开关生效。预置方案也有明显缺点包体积会涨。一套完整的移动端图标Android要覆盖mdpi到xxxhdpiiOS要覆盖60pt、120pt、180pt再加上1024px的商店图不需要打包。一套下来大概200-400KB如果预置5套就是1-2MB。对于动辄几个G的手游来说这点体积可以接受一来静态资源吃的是安装包和磁盘空间不是运行时内存二来可以通过构建脚本按需打进对应平台的资源节省上传市场的包体。我觉得比体积更要紧的是回退策略。图标一共就几套运营配置下发后客户端要能判断“这个iconKey是否在白名单里”不在就切回默认免得活动下线后客户端还停留在活动图标上。2.2 Unity到原生层的通信路径怎么选Unity C#层要和原生层通信常规有三条路子AndroidJavaClass / AndroidJavaObject 直接调用Java静态方法这是Android最常见的方式。iOS用[DllImport(__Internal)]绑定C函数或Objective-C函数IL2CPP构建下能直接调。原生层通过UnitySendMessage反向调用C#方法一般用于原生回调通知Unity。换图标是一次低频、单方向的命令操作不需要原生层频繁回调所以我在Android和iOS两侧都选了前两种单向调用方式。C#层只负责把业务参数传下去不等待原生返回结果原生侧出错只打日志。这样代码简单也不容易把Unity主线程卡住。2.3 服务端配置下发与运营节奏运营的典型诉求是“明天0点上活动图标同步换成限定Logo”。客户端如果是按版本写死图标索引必须等发版那就黄了。所以需要一个轻量配置接口类似{ icon_key: festival_v1, start_time: 1735689600, end_time: 1736294400 }客户端在App启动时、从后台回到前台时各拉取一次配置。本地缓存一份网络失败时用缓存。如果icon_key不在本地白名单直接切回默认。切记判断时间范围活动结束后自动回退默认图标不能等下一次发版。这块有个我被坑过的细节很多玩家玩手游是长时间挂机的他们可能一天一夜不重启App。如果只在启动时检查配置有些玩家当天根本看不到新图标。一定还要监听OnApplicationPause(false)的时机从后台切回来也检查一次运营活动命中率才能上去。3. Android端完整实操从Manifest到真机验证3.1 修改AndroidManifest主Activity去掉LAUNCHERAlias默认开启这是整个Android实现里最容易犯错的地方。很多教程会直接在主Activity上保留MAIN LAUNCHER同时又添加多个activity-alias结果安装后桌面出现两个图标因为系统认为主Activity和默认alias都是合法入口。我在项目里的做法是主Activity只声明intent-filter的MAIN不声明LAUNCHER把所有桌面入口全部交给alias。Unity工程里修改Assets/Plugins/Android/AndroidManifest.xml核心结构如下application android:iconmipmap/ic_launcher_default android:labelstring/app_name activity android:name.MainActivity android:exportedtrue /activity activity-alias android:name.icon_alias_default android:enabledtrue android:exportedtrue android:targetActivity.MainActivity android:iconmipmap/ic_launcher_default intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias activity-alias android:name.icon_alias_festival android:enabledfalse android:exportedtrue android:targetActivity.MainActivity android:iconmipmap/ic_launcher_festival intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias /application关键点有三个第一保证安装后icon_alias_default是唯一enabled的LAUNCHER组件否则桌面要么双图标要么无图标。第二android:exportedtrue是必加的Android 12及以上target部件如果没写exported会直接安装失败。第三application的icon最好也设成默认图标因为系统设置页、通知栏、最近任务列表里显示的图标走的是application的icon它不跟随alias变化。3.2 原生Java类切换组件状态并触发桌面刷新写一个干净的Java类放在Unity工程里时路径通常为Assets/Plugins/Android/com/example/game/IconChanger.java或者直接作为Android Library打包。核心代码就是遍历所有alias启用目标位、禁用其他位。package com.example.game; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; public class IconChanger { private static final String[] ICON_ALIASES { com.example.game.icon_alias_default, com.example.game.icon_alias_festival, com.example.game.icon_alias_anniversary }; public static void changeIcon(Context context, int index) { if (index 0 || index ICON_ALIASES.length) { index 0; } PackageManager pm context.getPackageManager(); for (int i 0; i ICON_ALIASES.length; i) { ComponentName componentName new ComponentName(context, ICON_ALIASES[i]); pm.setComponentEnabledSetting( componentName, (i index) ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ); } Intent refreshIntent new Intent(Intent.ACTION_MANAGER_PACKAGE_CHANGED); refreshIntent.setPackage(context.getPackageName()); context.sendBroadcast(refreshIntent); } }DONT_KILL_APP这个标志很关键切换组件状态不能把自己App杀掉不然玩家正在游戏里时图标一变、进程被kill体验非常糟糕。广播那行是为了主动提醒Launcher重新扫描组件。实测下来部分ROM收不到这个广播或者收到了也不刷新但广播本身是安全的发一下总比不发好。如果你在国产品牌ROM上发现切完图标半天不变那大概率是ROM桌面策略的问题不是我们代码没生效可以通过检查PackageManager.getComponentEnabledSetting的返回值来确认组件状态确实已经被改了。3.3 图标资源尺寸与自适应图标的坑Unity工程里Android的图标资源需要直接放到Plugins/Android/res/mipmap-*目录下构建时AAPT才会把它们打包进APK。常规要求是密度尺寸pxmdpi48x48hdpi72x72xhdpi96x96xxhdpi144x144xxxhdpi192x192我实际项目里一般只打xxhdpi和xxxhdpi两档因为手游用户主流机型基本都在这个区间低密度机型系统会自动降采样。但这里有个更隐蔽的坑Android 8.0引入自适应图标后系统会把android:icon放在自适应容器里裁切成圆形、圆角方形等形状。如果你提供的是传统方形带大背景的图标在Android 13上很可能被系统套一层背景色再遮罩观感变成“白底彩色小图标”和预期严重不符。所以我的做法是设计侧出图时自适应图标格式要求前景图标有效区域在安全区内即直径占图标整体约66%的圆形范围内背景图单独出。Unity侧如果要完整支持自适应效果需要准备mipmap-anydpi-v26下的adaptive-icon XML或者退一步接受系统遮罩。这个取舍要提前和美术沟通不要等联调时才说“为什么图标被裁圆了”。4. iOS端完整实操Info.plist、资源与原生代码4.1 Info.plist的CFBundleAlternateIcons配置iOS的备用图标声明在Info.plist里。Unity构建Xcode工程后默认的Info.plist是Unity生成的可以手动在Xcode里改也可以写PostProcessBuild后处理脚本注入我个人建议后者因为每次构建手改太容易漏。配置结构如下keyCFBundleIcons/key dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon60x60/string /array /dict keyCFBundleAlternateIcons/key dict keyfestival_icon/key dict keyCFBundleIconFiles/key array stringfestival_icon/string /array /dict keyanniversary_icon/key dict keyCFBundleIconFiles/key array stringanniversary_icon/string /array /dict /dict /dictCFBundleAlternateIcons字典的key就是后续代码里setAlternateIconName穿入的名字比如festival_icon不要带扩展名。CFBundleIconFiles数组里的字符串是资源文件名的主名不带2x、3x后缀。系统会自动按当前屏幕scale去找festival_icon.png、festival_icon2x.png、festival_icon3x.png。iOS这里没有自适应图标那一套优点是简单缺点是图标必须整张出好系统不会帮你裁。4.2 在Unity工程里安放备用图标文件与自动注入备用图标必须以真实文件形式存在于.app包根目录Unity里最省事的做法是把PNG放到Assets/Plugins/iOS/目录下。构建iOS工程时Unity会把Plugins/iOS下的资源文件自动加入Xcode工程的Copy Bundle Resources阶段最终打进.app包里。后缀命名要严格festival_icon.png、festival_icon2x.png、festival_icon3x.png三张分别为60x60、120x120、180x180像素。如果漏了某个scale系统在某分辨率设备上切换图标时会报错或者直接失败这个在联调时要特别检查。Info.plist的后处理注入可以用UnityEditor.iOS.Xcode命名空间下的PlistDocument类[PostProcessBuild(1)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target ! BuildTarget.iOS) return; string plistPath Path.Combine(path, Info.plist); PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementDict root plist.root.AsDict(); PlistElementDict bundleIcons root.CreateDict(CFBundleIcons); // 写入主图标与备用图标key对应资源文件名主名 PlistElementDict alternateIcons bundleIcons.CreateDict(CFBundleAlternateIcons); PlistElementDict festival alternateIcons.CreateDict(festival_icon); festival.CreateArray(CFBundleIconFiles).AddString(festival_icon); plist.WriteToFile(plistPath); }这段脚本在每次构建iOS包后自动执行比手动在Xcode里改不容易出错也方便多版本维护。注意这个脚本只会注入你要生效的图标如果项目里没有对应的PNG编译没有问题但真机上切换必失败所以后处理脚本最好加一个文件存在性检查。4.3 Objective-C原生代码与系统弹窗处理Unity侧DllImport会调用一个extern C的C函数函数内部再调UIKit的setAlternateIconName。我写了一个Objective-C的桥接文件放在Assets/Plugins/iOS/下#import UIKit/UIKit.h #ifdef __cplusplus extern C { #endif void _ChangeIOSIcon(const char *iconName) { NSString *name nil; if (iconName ! NULL strlen(iconName) 0) { name [NSString stringWithUTF8String:iconName]; } dispatch_async(dispatch_get_main_queue(), ^{ UIApplication *app [UIApplication sharedApplication]; if (![app supportsAlternateIcons]) { NSLog([IconSwitcher] alternate icons not supported); return; } [app setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog([IconSwitcher] change icon failed: %, error.localizedDescription); } }]; }); } #ifdef __cplusplus } #endif注意几个细节。setAlternateIconName必须在主线程调用所以我用dispatch_async(dispatch_get_main_queue())包了一层。传入nil表示恢复主图标C#层无法把null字符串安全地传给非托管代码所以C#侧约定传空字符串OC侧把空串转成nil。系统弹窗出现后用户点击确认或取消都会走completionHandler但是App会短暂进入后台再回来这一步是系统行为无法阻止。如果游戏内正在播放战斗或者加载要避免在关键流程节点触发切换最好放在登录成功回到大厅等安全时机。4.4 App Store审核与体验注意点Alternate App Icons是官方公开API审核层面不会被拒前提是图标内容本身不违规、没有诱导用户点击之类的问题。需要注意的是商店页面展示的图标仍然是开发者后台配置的那个1024px大图标和桌面图标是否一致无关。所以运营如果以为“商店里的图标也一起换了”那是不可能的商店图标更新只能靠开发者后台审核。iOS端体验上最大的问题就是那个系统弹窗。很多普通玩家看到弹窗会懵“为什么换个游戏图标还要我同意”我的处理方式是在游戏内做一个活动页面明确说明接下来系统会弹一个确认框请点允许图标就会焕新。文案要给足预期这样成功率会非常高。如果直接闷头调用API用户随手点取消后台统计里图标切换成功率可能只有一半运营会觉得你做的东西是坏的。5. Unity侧的封装、资源管理与构建脚本5.1 C#统一接口一套代码管两端Unity侧只暴露一个静态方法内部按平台分发。这样业务层写起来很舒服不用在意哪端怎么实现。我在项目里封装成AppIconSwitcherusing System; using System.Runtime.InteropServices; using UnityEngine; public static class AppIconSwitcher { [DllImport(__Internal)] private static extern void _ChangeIOSIcon(string iconName); public static void SwitchTo(int androidIndex, string iosIconKey ) { #if UNITY_ANDROID !UNITY_EDITOR try { using (AndroidJavaClass player new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (AndroidJavaObject activity player.GetStaticAndroidJavaObject(currentActivity)) using (AndroidJavaClass changer new AndroidJavaClass(com.example.game.IconChanger)) { changer.CallStatic(changeIcon, activity, androidIndex); } } catch (Exception e) { Debug.LogError($[AppIconSwitcher] Android switch failed: {e}); } #elif UNITY_IOS !UNITY_EDITOR _ChangeIOSIcon(iosIconKey); #endif } }Android侧用using包裹AndroidJavaObject和AndroidJavaClass确保调用完成后释放底层引用避免频繁切换时JNI全局引用泄漏。真机联调时如果你发现切换几十次后App开始卡顿、崩溃优先查这里有没有Release。Editor环境里我直接什么都不做因为Editor里调的JNI是没有意义的不如在Android平台上跑真机验证。5.2 资源清单与包体积控制技巧动态换图标从资源管理角度来说本质是“同一套入口多种视觉皮肤”。建议在项目里维护一张图标清单平台文件格式分辨率存储位置AndroidPNG48~192px多密度Assets/Plugins/Android/res/mipmap-*/Android 8自适应XMLPNG前景/背景分离mipmap-anydpi-v26iOSPNG60/120/180pxAssets/Plugins/iOS/包体积上一套完整双端图标大约增加0.3-0.5MB五套就是1.5-2.5MB。如果项目对包体敏感可以这样做把图标作为构建期参数通过BuildProfile在不同渠道包只打入需要的图标套数。比如普通渠道只打默认下个活动预告两套商店包多打几套。用ScriptableObject配置每个渠道的图标白名单构建脚本根据白名单裁剪Plugins/Android/res和Plugins/iOS下的文件。5.3 构建脚本自动化的关键环节自动化主要做两件事Android侧确保Manifest里的alias、mipmap资源齐全iOS侧确保Info.plist注入正确。iOS的注入代码我在第四章给过关键实现Android这边我的后处理脚本会读取一个图标清单JSON校验Manifest里每个alias的name和icon引用是否匹配不匹配直接报构建错误。这种前置校验看起来很笨但能省下真机上“怎么图标没变”的排查时间。还有一个自动化相关的细节如果同一个Unity工程出多个包名比如国内渠道包、Google包activity-alias的完整类名必须跟着applicationId走。Java层代码里我写的是com.example.game.icon_alias_default如果包名变了这个字符串也要变。最稳妥的做法是在构建脚本里把包名动态拼进Java的常量或者通过BuildConfig传入千万别在代码里硬编码两套包名来回改。6. 真机联调的坑与排查6.1 图标不刷新Launcher有自己的想法Android切完组件状态桌面图标没变这是遇到频率最高的问题。先别急着改代码按顺序排查PackageManager.getComponentEnabledSetting(alias)返回值是不是COMPONENT_ENABLED_STATE_ENABLED不是说明组件状态没改对去查manifest的alias name是否完整、包名是否一致。状态改对了但桌面图标还没变试试锁屏再解锁、等待30秒或者长按桌面点击“重新布局”。不少国产ROM的Launcher只在特定时机重扫组件广播只是建议不是命令。如果实在不行用adb命令模拟一次组件切换adb shell pm enable com.example.game/.icon_alias_festival adb shell am force-stop com.example.game如果adb切换后桌面正常变化说明拉Launcher刷新逻辑没问题问题在App内广播如果adb切换也没变化那就是ROM桌面缓存策略的问题只能接受延迟或者引导用户手动刷新。6.2 安装后出现双图标或者没有图标双图标几乎都是manifest配置问题主Activity保留了LAUNCHER同时又有一个alias是enabled状态桌面把所有可用入口全列出来了。无图标则是所有alias默认都禁用了或者targetActivity写错成不存在的类。我的经验是在manifest里把主Activity的LAUNCHER彻底拿掉默认只留一个alias的enabled为true其他都false然后安装后第一时间检查桌面图标确认只有一个再去测运行时切换。这个初始状态如果错了往下所有测试都是白费。6.3 切换失败到底怎么定位iOS侧切换失败日志里通常有Thread 1: signal SIGABRT或NSError描述常见原因依次是iconName传错带了扩展名、对应文件不在.app包里、CFBundleAlternateIcons里没声明这个key、supportsAlternateIcons返回NO。如果确认文件在Resource bundle里但名字和plist里的key不一致也会失败。Android侧崩溃更多是找不到ComponentName或者包名拼错异常信息会直接抛在Java层日志里。我在联调时习惯在C#和原生层各打一条日志关键字分别是[AppIconSwitcher]和[IconChanger]崩溃时一眼能看出是Unity侧没调通还是原生侧执行失败。6.4 我在实际项目里踩过的几个教训第一个教训是iOS弹窗问题。早期我天真地以为iOS和Android一样能无感切换结果真机上一弹窗用户不点允许图标就没变。后来我在活动页面里加了明确引导把“系统会请求确认”这句话直接做成弹窗文案切换成功率才从不到一半拉到九成。这个不是代码问题是用户引导问题。第二个教训是运营时间对齐。策划把活动开始时间定在0点但我第一版只在启动时拉配置导致很多挂机玩家当天根本看不到新图标。后来改成了OnApplicationPause(false)和启动时都检查才彻底解决。需求方那边验收时根本不管你是不是只在启动时查只看“活动期间图标到底变没变”这锅得客户端背。第三个教训是Android 13的自适应图标问题。美术做了一套四边都贴着边界的高饱和图标在Android 13上被系统当成前景素材套进遮罩边缘被硬生生裁掉了。后来所有图标出稿时都统一加安全区规范前景有效内容控制在中心66%范围内这个问题才算根治。如果你也准备接动态换图标这个需求我的建议是先拿这张双端能力对照表去和运营对齐预期确认“Android可以自动切、iOS需要用户确认”再动手。技术实现本身不难难的是把系统限制翻译成业务语言让所有人知道哪些能做到、哪些做不到。把预期管理好后面整个开发流程会顺畅很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进