ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity手游动态换图标方案:Android与iOS双端实现与避坑指南

Unity手游动态换图标方案:Android与iOS双端实现与避坑指南 手游上线后想换个图标做活动结果发现应用商店里的图标是打包时写死的改一次就得重新提审、重新发版等审核通过活动热度都过去了。这个痛点做发行的朋友应该都懂。动态换图标这个需求最早是运营那边提过来的——春节要换喜庆图标、周年庆要换纪念图标、跟品牌联动要换联名图标每次都要走一遍完整发版流程成本高得离谱。后来我们决定在客户端层面把这件事解决掉让图标跟着活动配置走服务端下发指令客户端自己切换完全不用惊动应用商店。这套方案的核心思路其实不复杂Android 端利用activity-alias机制预埋多个图标入口通过PackageManager动态启用和禁用对应的别名组件iOS 端则依赖系统提供的setAlternateIconName接口在 Info.plist 里预先声明所有候选图标运行时按需切换。Unity 层负责统一封装两端差异对外暴露一套简单的 C# 接口业务侧只需要传一个图标标识就能完成切换。下面我把整个方案从原理到落地完整拆一遍包括我们踩过的坑和最终稳定运行的配置。1. 先搞清楚两端系统到底允许你做什么在动手写代码之前必须先把 Android 和 iOS 各自的能力边界摸清楚。这两个平台对动态换图标的态度完全不同如果不了解底层机制很容易写出在 Android 上跑得好好的代码到 iOS 上直接崩掉或者反过来。1.1 Android 的 activity-alias 到底是个什么东西Android 实现动态换图标的核心是activity-alias。这个东西在 AndroidManifest.xml 里声明它本身不是一个真正的 Activity而是某个 Activity 的一个别名入口。系统桌面在读取应用图标时会把所有android.intent.category.LAUNCHER的组件都列出来包括主 Activity 和所有 activity-alias。默认情况下只有主 Activity 的图标会显示其他的 alias 处于 disabled 状态。当你通过PackageManager.setComponentEnabledSetting把某个 alias 设为COMPONENT_ENABLED_STATE_ENABLED同时把主 Activity 设为COMPONENT_ENABLED_STATE_DISABLED桌面上的图标就会切换成这个 alias 对应的图标。这里有个关键点同一时间只能有一个 LAUNCHER 组件处于启用状态否则桌面上会出现多个图标。这个约束是硬性的系统不会帮你处理冲突。activity android:name.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity activity-alias android:name.IconAliasDefault android:targetActivity.MainActivity android:enabledtrue 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-alias activity-alias android:name.IconAliasSpring android:targetActivity.MainActivity android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_spring android:labelstring/app_name intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias上面这段配置里主 Activity 没有设置android:enabled默认是 true所以初始状态下桌面显示的是主 Activity 的图标。每个 alias 都指向同一个MainActivity区别只在于android:icon不同。注意android:enabled的初始值要仔细设计——如果你希望默认图标由某个 alias 来承载就要把主 Activity 设为android:enabledfalse同时把默认 alias 设为 true。这里有个很多人会忽略的细节activity-alias的android:name必须是完整的类路径形式即使它不是一个真实的类。比如你的包名是com.example.game那 alias 的 name 就应该是com.example.game.IconAliasSpring在 Manifest 里可以简写成.IconAliasSpring但代码里操作时必须用全名。1.2 iOS 的 setAlternateIconName 有哪些硬性限制iOS 这边的机制和 Android 完全不同。系统提供了UIApplication.shared.setAlternateIconName(_:completionHandler:)这个 API允许你在运行时切换应用图标。但前提是所有候选图标必须提前在Info.plist的CFBundleIcons和CFBundleAlternateIcons字典里声明好不能运行时动态添加。keyCFBundleIcons/key dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon/string /array /dict keyCFBundleAlternateIcons/key dict keySpringIcon/key dict keyCFBundleIconFiles/key array stringicon_spring/string /array keyUIPrerenderedIcon/key false/ /dict keyAnniversaryIcon/key dict keyCFBundleIconFiles/key array stringicon_anniversary/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dict几个必须记住的限制第一图标文件必须是打包进 App Bundle 的 PNG 资源不能从网络下载后直接使用第二每个候选图标需要提供多个尺寸的版本比如 60x60、120x120、180x180 等系统会根据设备自动选择第三切换图标时系统会弹出一个确认弹窗提示您已更改XX的图标这个弹窗无法绕过是系统行为第四setAlternateIconName传入nil表示恢复默认图标。还有一个容易被忽视的点iOS 切换图标后如果用户把 App 从后台彻底杀掉再重新打开图标状态是保持的系统会记住你上次设置的 alternate icon name。但如果你在切换后立即调用setAlternateIconName(nil)图标会恢复成主图标这个逻辑要跟业务需求对齐。1.3 两端能力对比与方案选型依据把两端的差异列成表格会更清楚对比维度Android (activity-alias)iOS (setAlternateIconName)图标来源打包进 APK 的资源打包进 Bundle 的 PNG候选数量理论上无限制建议不超过 10 个切换时机运行时任意时刻运行时任意时刻用户确认无弹窗静默切换有系统弹窗无法绕过生效速度立即生效桌面刷新立即生效恢复默认启用主 Activity传 nil审核风险低属于系统标准能力低属于系统标准能力从选型角度看Android 的方案更灵活没有弹窗干扰适合做频繁切换的场景iOS 因为有系统弹窗用户体验上会有打断感所以更适合在特定节点比如活动开启时做一次性切换而不是频繁变动。我们在实际项目里的策略是Android 端可以跟随活动配置随时切换iOS 端则控制在活动周期内最多切换一到两次避免频繁弹窗惹用户烦。2. Unity 层如何封装一套统一接口Unity 作为跨平台引擎C# 层不能直接调用 Android 的PackageManager或 iOS 的UIApplication必须通过平台通道桥接。我们的做法是在 C# 层定义一套统一接口Android 用 JNI 调用 Java 层封装好的方法iOS 用DllImport调用 Objective-C 导出的 C 函数。2.1 C# 接口设计与平台分发逻辑接口设计要尽量简单业务侧只关心换成哪个图标不关心底层怎么实现。我们定义的接口长这样public static class AppIconChanger { public static void ChangeIcon(string iconKey) { #if UNITY_ANDROID !UNITY_EDITOR AndroidIconBridge.ChangeIcon(iconKey); #elif UNITY_IOS !UNITY_EDITOR IosIconBridge.ChangeIcon(iconKey); #else Debug.Log($[Editor] Mock change icon to: {iconKey}); #endif } public static string GetCurrentIcon() { #if UNITY_ANDROID !UNITY_EDITOR return AndroidIconBridge.GetCurrentIcon(); #elif UNITY_IOS !UNITY_EDITOR return IosIconBridge.GetCurrentIcon(); #else return default; #endif } }iconKey是一个字符串标识比如default、spring、anniversary。Android 端把它映射到对应的 alias 类名iOS 端把它映射到CFBundleAlternateIcons里的 key。编辑器环境下直接打日志模拟方便在 Unity 里调试业务逻辑不用每次都出包。这里有个设计上的取舍要不要把当前图标状态持久化到本地我们的做法是持久化用PlayerPrefs存一个 key因为 Android 的组件启用状态是系统层面持久化的但 iOS 的alternateIconName虽然系统也记但为了两端逻辑一致还是自己存一份更稳妥。启动时读本地记录跟系统实际状态做一次校验不一致就以系统为准并修正本地记录。2.2 Android 侧 JNI 桥接的完整实现Android 侧的 Java 封装类需要处理 alias 的启用禁用逻辑。核心方法是先禁用所有 alias 和主 Activity再启用目标 alias。注意顺序很重要如果先启用目标再禁用其他的中间会有一个短暂的双图标状态。public class AppIconHelper { private static final String PKG com.example.game; private static final String MAIN_ACTIVITY PKG .MainActivity; private static final String[] ALL_ALIASES { PKG .IconAliasDefault, PKG .IconAliasSpring, PKG .IconAliasAnniversary }; public static void changeIcon(Activity activity, String iconKey) { String targetAlias mapKeyToAlias(iconKey); PackageManager pm activity.getPackageManager(); // 先禁用主 Activity 和所有 alias pm.setComponentEnabledSetting( new ComponentName(activity, MAIN_ACTIVITY), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); for (String alias : ALL_ALIASES) { int state alias.equals(targetAlias) ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; pm.setComponentEnabledSetting( new ComponentName(activity, alias), state, PackageManager.DONT_KILL_APP); } } private static String mapKeyToAlias(String iconKey) { switch (iconKey) { case spring: return PKG .IconAliasSpring; case anniversary: return PKG .IconAliasAnniversary; default: return PKG .IconAliasDefault; } } }DONT_KILL_APP这个 flag 非常关键。如果不加每次切换组件状态系统都会杀掉当前进程App 会直接闪退重启用户体验极差。加上这个 flag 后进程不会被杀但桌面图标会立即刷新。实测在大部分主流机型上切换后大约 1 到 2 秒桌面图标就会更新个别定制 ROM 可能需要手动下拉刷新一下桌面。C# 侧通过AndroidJavaClass和AndroidJavaObject调用这个静态方法public static class AndroidIconBridge { public static void ChangeIcon(string iconKey) { using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (var helper new AndroidJavaClass(com.example.game.AppIconHelper)) { helper.CallStatic(changeIcon, activity, iconKey); } } }2.3 iOS 侧 Objective-C 导出与 C# 调用iOS 侧需要写一个 Objective-C 的.mm文件导出 C 函数供 Unity 的DllImport调用。注意函数要用extern C包裹避免 C 名称修饰导致找不到符号。extern C { void _ChangeAppIcon(const char* iconKey) { NSString *key [NSString stringWithUTF8String:iconKey]; NSString *iconName nil; if ([key isEqualToString:spring]) { iconName SpringIcon; } else if ([key isEqualToString:anniversary]) { iconName AnniversaryIcon; } // default 时 iconName 保持 nil表示恢复主图标 dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setAlternateIconName:iconName completionHandler:^(NSError *error) { if (error) { NSLog([AppIcon] change failed: %, error); } }]; }); } }C# 侧声明public static class IosIconBridge { [DllImport(__Internal)] private static extern void _ChangeAppIcon(string iconKey); public static void ChangeIcon(string iconKey) { _ChangeAppIcon(iconKey); } }这里必须注意setAlternateIconName必须在主线程调用所以 Objective-C 里用dispatch_async(dispatch_get_main_queue(), ...)包了一层。另外这个.mm文件需要放到Assets/Plugins/iOS/目录下Unity 打包时会自动合并进 Xcode 工程。3. 图标资源准备与打包配置的坑代码写完了不代表就能跑通图标资源的准备和打包配置才是真正耗时间的地方。我们在这个环节踩的坑比写代码多得多。3.1 Android 多密度图标与 alias 资源引用Android 的图标资源要放在res/mipmap-*目录下按密度分mipmap-mdpi、mipmap-hdpi、mipmap-xhdpi、mipmap-xxhdpi、mipmap-xxxhdpi。每个 alias 引用的图标名不能重复比如默认图标叫ic_launcher_default春节图标叫ic_launcher_spring各自都要有一套完整的密度版本。如果你用的是 Unity 自动打包这些资源需要放在Assets/Plugins/Android/res/下Unity 会合并进最终的 APK。但这里有个坑Unity 对res目录的合并规则比较严格如果多个插件都有res目录可能会出现资源冲突。我们的做法是把所有图标资源统一放在一个res目录下避免分散。还有一个细节activity-alias的android:icon引用的是 mipmap 资源但如果你在 alias 上同时设置了android:roundIcon那圆形图标也会跟着切换。如果你的应用有圆形图标需求比如 Pixel 设备记得每个 alias 都要配roundIcon否则切换后圆形图标可能显示成默认的。3.2 iOS 图标尺寸规范与 Info.plist 声明iOS 的图标尺寸要求比 Android 更细。对于CFBundleAlternateIcons里的每个图标你需要提供以下尺寸以 iPhone 为主尺寸用途文件名示例60x602x 通知/设置icon_spring2x.png60x603x 通知/设置icon_spring3x.png120x1202x 主屏icon_spring2x.png180x1803x 主屏icon_spring3x.png1024x1024App Storeicon_spring_1024.png实际上CFBundleIconFiles数组里只需要写基础文件名不带2x、3x后缀系统会自动匹配对应的倍率文件。比如写icon_spring系统会去找icon_spring2x.png和icon_spring3x.png。如果你只提供了icon_spring.png没有倍率后缀在高清屏上会显示模糊。我们踩过的一个坑是iOS 的 alternate icon 不支持 Asset Catalog 里的 AppIcon 集必须用独立的 PNG 文件放在 Bundle 根目录或指定目录下。如果你把图标放在.xcassets里setAlternateIconName会找不到资源切换直接失败。这个限制在苹果官方文档里写得比较隐晦我们当时排查了很久才发现。3.3 打包脚本自动化处理图标资源手动管理这么多图标资源很容易出错我们写了一个 Editor 脚本在打包前自动校验图标资源是否齐全[MenuItem(Build/Validate Icon Resources)] public static void ValidateIconResources() { string[] requiredKeys { default, spring, anniversary }; string androidResPath Assets/Plugins/Android/res; string iosIconPath Assets/Plugins/iOS/Icons; foreach (var key in requiredKeys) { // 校验 Android 各密度目录 string[] densities { mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi }; foreach (var d in densities) { string path ${androidResPath}/mipmap-{d}/ic_launcher_{key}.png; if (!File.Exists(path)) Debug.LogError($Missing Android icon: {path}); } // 校验 iOS 各尺寸 string[] iosFiles { ${key}2x.png, ${key}3x.png }; foreach (var f in iosFiles) { string path ${iosIconPath}/{f}; if (!File.Exists(path)) Debug.LogError($Missing iOS icon: {path}); } } Debug.Log(Icon resource validation done.); }这个脚本在 CI 流程里跑每次打包前自动执行缺资源直接报错阻断打包避免出包后才发现图标缺失。4. 实测中的异常情况与排查链路方案上线后我们在不同机型上遇到了一些异常这里把排查过程完整记录下来方便遇到类似问题的朋友参考。4.1 Android 切换后桌面图标不刷新这是遇到最多的问题。表现是代码执行成功setComponentEnabledSetting没有抛异常但桌面图标还是旧的。排查下来有几个原因第一个原因是DONT_KILL_APP没加或者加错了位置。这个 flag 要加在每次setComponentEnabledSetting调用上漏掉任何一次都可能导致进程被杀。第二个原因是部分定制 ROM比如某些国产系统对组件状态变更的响应有延迟需要等几秒或者手动触发桌面刷新。第三个原因是如果 App 正在前台运行某些 ROM 会延迟刷新桌面图标等 App 退到后台才更新。针对这些情况我们的处理策略是切换后给一个短暂的延迟比如 500ms然后提示用户图标将在桌面更新请稍候查看。如果用户反馈没更新引导他们下拉刷新桌面或者重启桌面。实测在主流机型上退到后台再回前台图标基本都会刷新。4.2 iOS 切换弹窗导致业务中断iOS 的系统弹窗是绕不过去的但我们可以控制弹窗出现的时机。最初我们在活动开启时自动切换结果用户正在操作时突然弹窗体验很割裂。后来改成在特定场景下触发比如用户进入活动页面时、或者从后台回到前台时这样弹窗出现得相对自然。还有一个细节如果连续调用setAlternateIconName系统会排队处理但弹窗可能会叠加或者被忽略。我们的做法是加一个状态锁切换进行中不允许再次调用等 completionHandler 回调后再解锁。private static bool _isChanging false; public static void ChangeIconSafe(string iconKey) { if (_isChanging) return; _isChanging true; // 调用平台接口在回调里把 _isChanging 置回 false }4.3 两端状态不一致的同步问题有个场景容易出问题用户在 Android 上切换了图标然后换到 iOS 设备登录同一个账号期望图标也跟着变。但图标状态是设备本地的不会跨设备同步。我们的方案是服务端记录用户当前的活动图标配置客户端启动时拉取配置如果本地图标跟配置不一致就自动切换。但这里又引出一个问题iOS 启动时自动切换会弹窗用户一打开 App 就弹窗体验不好。所以我们的策略是 iOS 端启动时不自动切换而是在用户进入活动页面时再切换并且给一个明确的提示切换活动图标。Android 端则可以启动时静默切换无感知。5. 上线后的稳定性数据与经验沉淀这套方案在我们项目上线运行了大半年覆盖了春节、周年庆、两次品牌联动共四次图标切换整体稳定性符合预期。Android 端切换成功率在 98% 以上失败的基本都是极个别定制 ROM 的兼容问题iOS 端切换成功率接近 100%没有遇到过切换失败的情况主要成本还是在图标资源的准备上。从经验角度看有几个点值得后来者注意。第一候选图标不要贪多Android 端建议控制在 5 个以内iOS 端控制在 3 到 5 个太多会增加包体和维护成本。第二图标资源一定要用脚本校验人工检查迟早会漏。第三iOS 的弹窗体验要提前跟产品对齐不要等上线了才发现用户投诉。第四服务端配置要有兜底如果下发的图标 key 客户端不认识要能回退到默认图标而不是崩溃。最后分享一个我们内部的小工具在 Unity 编辑器里做了一个图标切换的调试面板可以在 Play 模式下模拟切换查看业务逻辑是否正确响应不用每次都出真机包。这个面板帮我们省了大量调试时间尤其是验证切换后 UI 状态同步这类逻辑时特别方便。
返回列表