ARTICLE DETAIL

资讯详情

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

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

Unity手游动态换图标:Android与iOS双端实现方案 1. 动态图标这件事到底在解决什么问题做过手游运营的人大概都遇到过这种场景版本更新赶上春节想换个喜庆图标或者跟某个IP联动图标上得带联名角色的头像再或者渠道包需要区分图标做A/B测试。如果每次都要重新打包提审等审核通过黄花菜都凉了。动态更换App图标这个需求说白了就是让App在安装之后不经过应用商店更新直接把桌面上的图标换成另一张图。这个能力在Android和iOS上的实现路径完全不同。Android靠的是activity-alias配合PackageManager的组件启用/禁用机制iOS则是UIApplication的setAlternateIconName方法。Unity作为跨平台引擎本身并没有提供统一的图标切换API所以需要针对两个平台分别写原生桥接代码再通过C#层做统一封装。这篇文章适合谁看如果你正在做Unity手游的运营功能开发或者需要给项目加一套“节日换图标”“联动换图标”的机制那这篇内容基本可以当作一个完整的落地方案来参考。我会把Android和iOS两端的实现细节、Unity侧的桥接方式、以及实际踩过的坑都摊开讲清楚。代码层面以C#和平台原生代码为主假设读者对Unity的基本开发流程和Android/iOS原生开发有初步了解但不需要特别深的原生功底。先说一个前提认知动态换图标这件事两个平台都有系统层面的限制不是想怎么换就怎么换。Android相对宽松可以做到“静默切换”用户无感知iOS则一定会弹出一个系统提示框告诉用户“图标已更改”。这个差异直接决定了两端的产品策略和交互设计不可能完全一致。理解这一点后面的技术选型才不会走偏。2. Android端用activity-alias玩转图标切换2.1 activity-alias的工作原理Android实现动态图标的核心思路是在AndroidManifest.xml里为同一个Activity注册多个activity-alias每个alias对应一个不同的图标和名称。系统桌面上显示的图标实际上就是当前处于启用状态的那个alias。切换图标的过程就是禁用当前alias、启用目标alias的过程。为什么用activity-alias而不是直接改AndroidManifest因为已安装的APK的Manifest是只读的不可能在运行时修改。activity-alias是Android系统提供的一种机制允许你为同一个组件注册多个入口每个入口可以有不同的icon、label、enabled状态等属性。PackageManager提供了setComponentEnabledSetting方法可以在运行时动态改变这些组件的启用状态。这里有一个关键点主Activity本身必须保持启用状态否则App会直接崩溃或者无法启动。我们操作的是alias的启用/禁用而不是主Activity。通常的做法是主Activity的enabled设为true但不在桌面上显示图标通过android:exported和intent-filter的配置来控制然后所有alias都指向主Activity每个alias有自己的图标资源。2.2 Manifest配置的完整写法先看一个典型的Manifest配置结构。假设我们的主Activity叫MainActivity需要支持默认图标、春节图标、联动图标三种状态activity android:name.MainActivity android:exportedtrue android:launchModesingleTask android:configChangesorientation|screenSize|keyboardHidden intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity activity-alias android:name.DefaultIcon 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.SpringIcon android:targetActivity.MainActivity android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_spring android:labelstring/app_name_spring intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias activity-alias android:name.CollabIcon android:targetActivity.MainActivity android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_collab android:labelstring/app_name_collab intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias注意几个细节。第一主Activity的intent-filter里虽然写了LAUNCHER但实际上桌面上不会显示它因为所有alias都启用了LAUNCHER category系统会选择当前启用的alias来显示。第二每个alias的targetActivity都指向同一个MainActivity这样无论用户从哪个图标启动最终进入的都是同一个Activity。第三android:enabled的初始状态很重要默认图标对应的alias必须是true其他都是false否则安装后桌面上会出现多个图标。注意部分国产ROM如某些定制系统对activity-alias的支持存在差异尤其是在图标缓存方面。实测发现切换alias后部分设备需要几秒钟才会刷新桌面图标这是系统Launcher的缓存机制导致的不是代码问题。2.3 C#侧调用Android原生接口Unity侧需要通过AndroidJavaObject来调用PackageManager的相关方法。核心逻辑是先禁用当前alias再启用目标alias。这里有一个顺序问题——如果先启用目标再禁用当前中间会有一个短暂的时间窗口桌面上可能出现两个图标。所以正确的顺序是“先禁用旧的再启用新的”。#if UNITY_ANDROID public static void SwitchIcon(string aliasName) { using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (AndroidJavaObject currentActivity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (AndroidJavaObject packageManager currentActivity.CallAndroidJavaObject(getPackageManager)) { string packageName currentActivity.Callstring(getPackageName); // 先禁用所有alias string[] allAliases { .DefaultIcon, .SpringIcon, .CollabIcon }; foreach (string alias in allAliases) { ComponentName component new ComponentName(packageName, packageName alias); packageManager.Call(setComponentEnabledSetting, component, 2, // COMPONENT_ENABLED_STATE_DISABLED 1 // DONT_KILL_APP ); } // 再启用目标alias ComponentName targetComponent new ComponentName(packageName, packageName aliasName); packageManager.Call(setComponentEnabledSetting, targetComponent, 1, // COMPONENT_ENABLED_STATE_ENABLED 1 // DONT_KILL_APP ); } } #endifsetComponentEnabledSetting的第三个参数DONT_KILL_APP非常关键。如果不加这个标志切换组件状态时系统会杀掉App进程用户体验就是“点一下换图标App直接闪退了”。加上之后App进程不会被杀死但桌面Launcher会收到通知并刷新图标。这里还有一个隐藏的坑ComponentName的构造函数需要完整的类名。在Unity中packageName是应用的包名而alias的完整类名是packageName aliasName。比如包名是com.example.gamealias是.SpringIcon那么完整类名就是com.example.game.SpringIcon。如果alias定义时没有用相对路径比如直接写了com.example.game.SpringIcon那拼接方式就要相应调整。2.4 状态持久化与恢复切换图标之后App需要记住当前用的是哪个图标否则下次启动时无法判断当前状态。通常的做法是用SharedPreferences存一个字符串标记。但这里有个问题如果用户卸载重装SharedPreferences会丢失图标会恢复成默认的。这其实是符合预期的行为因为重装后所有alias都回到Manifest里定义的初始状态。另一个需要注意的场景是App升级。当APK被覆盖安装时activity-alias的启用状态会被保留吗实测结果是在大多数Android版本上覆盖安装会保留组件的启用状态。但这并不是一个强保证不同ROM可能有不同行为。所以更稳妥的做法是在App启动时检查当前启用的alias是否与SharedPreferences里记录的一致如果不一致以SharedPreferences为准重新切换一次。public static string GetCurrentAlias() { using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (AndroidJavaObject currentActivity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (AndroidJavaObject packageManager currentActivity.CallAndroidJavaObject(getPackageManager)) { string packageName currentActivity.Callstring(getPackageName); string[] allAliases { .DefaultIcon, .SpringIcon, .CollabIcon }; foreach (string alias in allAliases) { ComponentName component new ComponentName(packageName, packageName alias); int state packageManager.Callint(getComponentEnabledSetting, component); if (state 1) // ENABLED { return alias; } } return .DefaultIcon; } }这段代码在App启动时调用可以准确获取当前桌面上显示的是哪个图标。结合SharedPreferences的记录就能实现状态的可靠恢复。3. iOS端setAlternateIconName的能与不能3.1 iOS动态图标的基本约束iOS从10.3开始提供了setAlternateIconNameAPI允许App在运行时切换图标。但这个API有几个硬性约束必须在动手之前就搞清楚。第一所有备选图标必须在Info.plist的CFBundleIcons字典中预先声明。你不能在运行时动态添加一个之前没有声明过的图标。这意味着备选图标的数量是有限的而且每增加一个图标包体就会增大因为图标资源要打进包里。第二切换图标时系统一定会弹出一个模态提示框内容大致是“您已更改‘XXX’的图标”。这个提示框无法绕过无法自定义无法延迟。用户必须手动点击“确定”才能关闭。这是iOS系统的隐私保护机制任何App都无法规避。第三setAlternateIconName在主线程调用且切换过程是异步的。调用之后不能立即假设图标已经切换完成需要通过回调来确认结果。第四iPadOS和iOS的行为基本一致但某些旧版本iOS10.3之前的版本不支持这个API需要做版本判断。3.2 Info.plist的配置细节在Unity项目中iOS的Info.plist通常是通过PostProcessBuild脚本自动生成的也可以手动在Xcode工程中修改。核心配置是在CFBundleIcons下添加CFBundleAlternateIcons字典keyCFBundleIcons/key dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon60x60/string /array keyCFBundleIconName/key stringAppIcon/string /dict keyCFBundleAlternateIcons/key dict keySpringIcon/key dict keyCFBundleIconFiles/key array stringSpringIcon60x60/string /array keyUIPrerenderedIcon/key false/ /dict keyCollabIcon/key dict keyCFBundleIconFiles/key array stringCollabIcon60x60/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dict这里有几个容易出错的地方。CFBundleIconFiles里填的是图标文件的名称不带扩展名。iOS会自动根据设备分辨率选择对应的尺寸比如SpringIcon60x602x.png、SpringIcon60x603x.png。如果只提供了一张图系统会尝试缩放但效果可能不理想。建议至少提供60x60、120x120、180x180三个尺寸。另外CFBundleAlternateIcons的key就是setAlternateIconName方法中传入的图标名称。这个名称是大小写敏感的必须完全匹配。如果传入一个不存在的名称系统会直接报错。3.3 Unity与iOS原生代码的桥接Unity调用iOS原生代码有两种方式一种是使用DllImport直接调用C函数另一种是通过.mm文件写Objective-C桥接。对于setAlternateIconName这种需要访问UIApplication的方法必须用Objective-C来写。首先在Unity的Plugins/iOS目录下创建一个.mm文件#import UIKit/UIKit.h extern C { void _SwitchIcon(const char* iconName) { NSString *name [NSString stringWithUTF8String:iconName]; UIApplication *app [UIApplication sharedApplication]; if (![app supportsAlternateIcons]) { NSLog(Alternate icons not supported on this device.); return; } NSString *iconNameStr nil; if (strlen(iconName) 0) { iconNameStr name; } [app setAlternateIconName:iconNameStr completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(Failed to switch icon: %, error.localizedDescription); } else { NSLog(Icon switched successfully.); } }]; } bool _SupportsAlternateIcons() { return [[UIApplication sharedApplication] supportsAlternateIcons]; } const char* _GetCurrentIconName() { NSString *current [[UIApplication sharedApplication] alternateIconName]; if (current nil) { return strdup(); } return strdup([current UTF8String]); } }然后在C#侧声明对应的DllImport#if UNITY_IOS using System.Runtime.InteropServices; public static class iOSIconSwitcher { [DllImport(__Internal)] private static extern void _SwitchIcon(string iconName); [DllImport(__Internal)] private static extern bool _SupportsAlternateIcons(); [DllImport(__Internal)] private static extern string _GetCurrentIconName(); public static void SwitchIcon(string iconName) { if (_SupportsAlternateIcons()) { _SwitchIcon(iconName); } } public static string GetCurrentIconName() { return _GetCurrentIconName(); } } #endif注意_SwitchIcon的参数传入空字符串表示恢复默认图标。在Objective-C侧setAlternateIconName:nil就是恢复主图标但C#的string不能直接传nil所以用空字符串做约定在Objective-C侧判断strlen(iconName) 0来决定是否传nil。3.4 iOS端的用户体验处理前面说了iOS切换图标一定会弹系统提示框。这个提示框的文案是系统固定的无法修改。但我们可以控制的是什么时候触发切换。如果是在游戏启动时自动切换用户会突然看到一个系统弹窗体验很突兀。更好的做法是在设置界面里提供一个“更换图标”的按钮用户主动点击后才触发切换这样弹窗的出现就是用户预期之内的。另外iOS的图标切换是异步的completionHandler回调可能在主线程的下一个RunLoop才执行。如果切换后需要更新UI比如显示“当前图标春节版”必须在回调里处理不能调用完setAlternateIconName就立即更新UI。还有一个细节当App处于后台时调用setAlternateIconName系统可能会延迟到App回到前台时才弹出提示框。所以最好确保调用时App处于活跃状态。4. Unity统一封装层让业务代码不碰原生4.1 接口设计与平台判断业务层不应该关心当前是Android还是iOS也不应该直接调用AndroidJavaObject或DllImport。我们需要一个统一的接口把平台差异封装在内部。接口设计大概是这样public static class AppIconManager { public enum IconType { Default, Spring, Collab } public static void SwitchIcon(IconType iconType, Actionbool onComplete null) { #if UNITY_ANDROID SwitchIconAndroid(iconType, onComplete); #elif UNITY_IOS SwitchIconiOS(iconType, onComplete); #else onComplete?.Invoke(false); #endif } public static IconType GetCurrentIcon() { #if UNITY_ANDROID return GetCurrentIconAndroid(); #elif UNITY_IOS return GetCurrentIconiOS(); #else return IconType.Default; #endif } public static bool IsSupported() { #if UNITY_ANDROID return true; #elif UNITY_IOS return iOSIconSwitcher.SupportsAlternateIcons(); #else return false; #endif } }这个接口有三个方法SwitchIcon负责切换GetCurrentIcon负责查询当前状态IsSupported负责判断设备是否支持。业务层只需要调用这三个方法不需要知道底层是activity-alias还是setAlternateIconName。4.2 图标资源的组织方式Android和iOS的图标资源存放位置不同。Android的图标放在Assets/Plugins/Android/res/mipmap-xxx/目录下每个alias对应一组不同分辨率的图标。iOS的图标则需要在Xcode工程中配置或者通过PostProcessBuild脚本自动拷贝到正确位置。对于Unity项目推荐的做法是在Assets/AppIcons/目录下按图标类型分文件夹存放原始图标然后通过Editor脚本在打包时自动拷贝到对应平台的目标目录。这样美术只需要维护一份图标源文件打包流程自动处理平台差异。Android的图标命名需要遵循ic_launcher_xxx的规范iOS的图标命名需要遵循xxx60x602x的规范。这些命名规则可以在Editor脚本里自动生成避免手动改名出错。4.3 切换流程的完整时序一次完整的图标切换在Android上的时序是这样的业务层调用AppIconManager.SwitchIcon(IconType.Spring)封装层判断当前平台是Android通过SharedPreferences读取当前alias如果已经是目标alias直接返回成功调用PackageManager.setComponentEnabledSetting禁用当前alias调用PackageManager.setComponentEnabledSetting启用目标alias将目标alias写入SharedPreferences回调通知业务层切换完成在iOS上的时序业务层调用AppIconManager.SwitchIcon(IconType.Spring)封装层判断当前平台是iOS检查supportsAlternateIcons如果不支持直接返回失败调用setAlternateIconName传入SpringIcon等待completionHandler回调根据回调结果通知业务层成功或失败注意iOS上没有“查询当前图标”的同步方法alternateIconName属性是同步的但它的值在切换过程中可能不会立即更新。所以iOS侧的GetCurrentIcon最好也结合本地存储来判断而不是完全依赖系统API。5. 实操中踩过的坑与排查技巧5.1 Android图标不刷新或出现双图标这是最常见的问题。表现是调用切换代码后桌面图标没有变化或者同时出现了两个图标。原因通常有三个一是setComponentEnabledSetting的调用顺序不对先启用后禁用导致中间态出现双图标二是DONT_KILL_APP标志没有加系统杀进程后状态没有正确保存三是Launcher缓存没有刷新需要等待几秒或手动重启Launcher。排查方法先用adb shell dumpsys package com.example.game | grep -A 5 Enabled Components查看当前启用的组件列表确认alias的启用状态是否符合预期。如果状态正确但图标没刷新那就是Launcher缓存问题可以尝试adb shell am broadcast -a android.intent.action.PACKAGE_CHANGED -d package:com.example.game强制通知Launcher刷新。还有一个隐蔽的坑如果主Activity的android:enabled被设为false整个App都无法启动。有些开发者为了隐藏主Activity的图标把主Activity的enabled设为false只保留alias这是错误的做法。主Activity必须保持enabledtrue隐藏图标应该通过不添加LAUNCHER category来实现。5.2 iOS切换图标后弹窗不出现或报错iOS上最常见的问题是setAlternateIconName返回错误提示“The requested icon is not present in the bundle”。这通常是因为Info.plist里的图标名称和代码里传入的名称不一致或者图标文件没有正确打包进Bundle。排查方法在Xcode中打开Build Phases检查Copy Bundle Resources里是否包含了所有备选图标文件。然后在代码里打印[[NSBundle mainBundle] infoDictionary]确认CFBundleIcons字典的内容是否正确。另一个问题是弹窗不出现。如果App在后台调用setAlternateIconName弹窗会延迟到App回到前台时才出现。如果App在前台但弹窗仍然不出现可能是completionHandler被提前释放了需要确保Block被正确捕获。注意iOS模拟器不支持动态图标切换supportsAlternateIcons在模拟器上返回false。必须在真机上测试。5.3 Unity打包后的资源路径问题Unity打包Android时Assets/Plugins/Android/res/目录下的资源会被合并到APK的res目录中。但如果图标文件放在其他目录比如Assets/Resources/打包后不会被识别为Android资源。必须确保图标文件放在正确的Plugins目录下。iOS侧Unity默认不会把自定义的图标文件拷贝到Xcode工程中。需要通过PostProcessBuild脚本在Xcode工程生成后手动将图标文件拷贝到工程目录并在Info.plist中添加对应的键值。这个过程比较繁琐建议写一个自动化脚本在每次打包时自动执行。5.4 常见问题速查表问题现象可能原因排查方法解决方案Android切换后图标不变Launcher缓存adb查看组件状态发送PACKAGE_CHANGED广播Android出现双图标启用/禁用顺序错误检查代码调用顺序先禁用所有再启用目标Android App闪退主Activity被禁用检查Manifest主Activity保持enabledtrueiOS报图标不存在Info.plist配置错误打印infoDictionary检查CFBundleAlternateIconsiOS弹窗不出现App在后台检查App状态确保在前台调用iOS模拟器不支持模拟器限制supportsAlternateIcons使用真机测试Unity打包后图标丢失资源路径错误解压APK检查res目录放到Plugins/Android/res5.5 几个实操心得第一Android的图标切换建议加一个防抖逻辑。如果用户在短时间内连续点击切换按钮可能会触发多次setComponentEnabledSetting调用导致状态混乱。简单的做法是加一个bool isSwitching标志切换过程中忽略新的请求。第二iOS的图标切换回调是在主线程执行的但setAlternateIconName本身也是主线程调用。如果在回调里做耗时操作会阻塞UI。建议在回调里只做状态更新耗时逻辑放到其他线程。第三测试阶段建议在设置界面加一个“当前图标”的显示方便确认切换是否生效。Android可以通过getComponentEnabledSetting查询iOS可以通过alternateIconName查询。这个信息在排查问题时非常有用。第四如果项目使用了热更新框架需要注意图标切换的状态存储不能放在热更新代码里因为热更新代码可能被回滚。建议把状态存在PlayerPrefs或原生SharedPreferences中这些存储不受热更新影响。6. 双端方案的差异对比与选型建议6.1 能力差异的客观现实把Android和iOS的动态图标能力放在一起对比差异非常明显对比维度AndroidiOS切换方式activity-aliassetAlternateIconName用户感知静默切换无弹窗必弹系统提示框图标数量限制理论上无限制受包体大小限制切换速度即时生效异步有延迟状态查询同步查询同步查询但可能滞后模拟器支持支持不支持最低版本无特殊要求iOS 10.3这个差异决定了产品策略Android可以做“自动换图标”比如检测到节日自动切换iOS则更适合“用户主动换图标”在设置里提供选项。如果强行在iOS上做自动切换用户会突然看到一个系统弹窗体验很差。6.2 包体影响的评估每增加一个备选图标Android端会增加大约50-200KB取决于图标数量和分辨率iOS端会增加大约100-300KB因为需要提供多个尺寸。如果备选图标数量控制在5个以内对包体的影响基本可以忽略。但如果超过10个就需要考虑是否值得了。一个优化思路是Android端可以只提供一套中等分辨率的图标让系统自动缩放。iOS端则必须提供完整尺寸否则审核可能被拒。所以iOS端的图标数量要更谨慎地控制。6.3 什么场景适合用动态图标根据实际项目经验以下几个场景比较适合节日运营春节、中秋、圣诞等节日切换对应图标提升节日氛围IP联动与知名IP合作时图标换成联名角色增加曝光版本里程碑比如周年庆、重大版本更新时换图标做纪念A/B测试不同渠道包用不同图标测试图标对点击率的影响不太适合的场景频繁切换比如每天换一次因为iOS的弹窗会严重打扰用户图标数量过多超过10个包体和管理成本都会上升。6.4 后续扩展方向这套方案落地之后还可以往几个方向扩展。一是结合服务端配置由运营后台控制图标切换的时机和内容不需要发版就能调整。二是结合用户行为比如用户完成某个成就后解锁专属图标增加趣味性。三是结合时间维度比如春节期间自动切换节后自动恢复完全自动化。不过要注意iOS的自动切换受限于系统弹窗如果要做定时自动切换最好在App启动时检查并提示用户而不是在后台静默调用。Android则没有这个限制可以做得更灵活。我个人在实际项目中的体会是动态图标这个功能技术难度不算高但细节特别多尤其是双端差异和ROM兼容性。建议在项目早期就把这套机制搭好不要等到运营提需求了才临时加那样很容易因为打包流程、资源路径等问题卡住。另外测试阶段一定要覆盖主流机型特别是国产ROM的Launcher行为差异比较大提前发现问题比上线后救火要轻松得多。
返回列表