ARTICLE DETAIL

资讯详情

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

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

Unity手游动态更换App图标:Android与iOS双端实现方案详解 1. 动态图标这件事到底在解决什么问题做过手游运营的人大概都遇到过这种场景春节要换喜庆图标情人节要换粉色图标跟某个品牌联名要换联名款图标甚至某些渠道要求首发期间用特定图标。如果每次都要重新打包提审那运营节奏基本就废了——安卓渠道几十个包要重新出iOS还要等审核等活动上线黄花菜都凉了。动态更换App图标这个需求本质上就是让App在不重新安装、不重新提审的前提下把桌面上那个图标换成另一张图。听起来简单但Android和iOS两端的实现机制完全不同坑也完全不一样。Android靠的是activity-alias这个组件的启用与禁用iOS靠的是系统提供的setAlternateIconName接口。Unity作为跨平台引擎本身并没有封装这套能力需要我们自己写原生插件桥接。这篇文章适合三类人看一是正在做手游运营活动、需要动态换图标的一线开发二是想了解Unity与原生平台交互方式的工程师三是技术负责人需要评估这个方案的成本和风险。我会把Android和iOS两端的完整实现路径、参数配置、踩坑记录都摊开讲代码可以直接抄。先说结论Android端灵活度极高可以做到秒级切换、无需重启iOS端受系统限制较多切换时会有系统弹窗提示且必须提前在Info.plist里声明所有备选图标。两端都需要在Unity侧做一层统一封装让业务层调用起来无感知。2. 整体方案设计与技术选型思路2.1 为什么不用替换资源文件这种土办法我见过有人尝试在运行时直接替换APK里的图标资源或者用反射去改PackageManager里的信息。这条路在Android上理论可行但极其危险一是需要root权限普通用户根本用不了二是会破坏应用签名校验导致应用被系统判定为篡改三是不同ROM行为不一致兼容性灾难。所以正规做法一定是走系统提供的官方接口。Android官方给的方案就是activity-alias。它的原理是你可以在AndroidManifest里为同一个Activity声明多个别名每个别名可以有自己的android:icon和android:label。系统桌面显示的图标实际上是这些别名中当前处于启用状态的那一个。你只要动态地启用目标别名、禁用其他别名桌面图标就会跟着变。这个机制从Android 1.0时代就存在稳定性和兼容性都经过了十几年验证。iOS的方案是UIApplication的setAlternateIconName:completionHandler:方法从iOS 10.3开始提供。它的原理是你在Info.plist的CFBundleIcons字典里预先声明所有备选图标运行时通过这个接口告诉系统我要用第几个。系统会自己处理图标切换但会弹一个您已更改XX的图标的提示框这个提示无法去掉是系统行为。2.2 Unity侧的统一封装思路Unity本身不提供任何图标切换API所以我们必须写原生插件。整体架构分三层Unity业务层调用一个统一的AppIconChanger.SetIcon(string iconKey)方法传入图标标识。C#桥接层通过AndroidJavaObject调用Android的Java方法通过DllImport调用iOS的Objective-C方法。原生实现层Android用Java写一个工具类操作PackageManageriOS用Objective-C写一个类调用setAlternateIconName。这样设计的好处是业务层完全不用关心平台差异换图标就像调一个普通方法一样简单。下面这张表对比了两端的核心差异先有个整体印象对比项AndroidiOS核心机制activity-alias启用/禁用setAlternateIconName接口是否需要预声明是在Manifest中声明别名是在Info.plist中声明切换时是否弹窗否是系统强制弹窗切换是否需要重启否即时生效否但桌面刷新有延迟备选图标数量限制无硬性限制无硬性限制但plist体积会增大最低支持版本全版本iOS 10.32.3 方案选型中容易忽略的考量点第一个考量点是图标资源的存放位置。Android的别名图标必须放在res/mipmap目录下作为编译期资源打包进APK不能从网络下载后动态设置。这意味着每增加一个备选图标APK体积就会增加。一个中等复杂度的图标各密度加起来大概50到200KB如果准备20个活动图标APK可能膨胀2到4MB。这个成本要在方案设计阶段就评估清楚。第二个考量点是iOS的弹窗体验。很多产品经理第一次听到切换图标会弹系统提示时是拒绝的觉得破坏用户体验。但这个弹窗无法绕过只能接受。实际做法是在弹窗出现前先给用户一个自定义的确认弹窗用户点确认后再调用系统接口这样系统弹窗就变成了二次确认心理上更容易接受。第三个考量点是状态持久化。用户切换图标后如果App被卸载重装图标会恢复默认。如果用户换了设备也不会同步。所以图标状态需要存在本地App启动时读取并确保当前图标与记录一致。Android端尤其要注意如果用户在系统设置里手动改了图标部分ROM支持你的记录就和实际不一致了启动时需要做一次校正。3. Android端核心实现细节拆解3.1 AndroidManifest中的activity-alias配置这是整个Android方案的基石。假设你的主Activity是com.example.game.MainActivity你想准备三个图标默认、春节、情人节。配置大概长这样activity android:namecom.example.game.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity activity-alias android:namecom.example.game.MainActivity.default android:targetActivitycom.example.game.MainActivity android:iconmipmap/ic_launcher_default android:labelstring/app_name android:enabledtrue android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias activity-alias android:namecom.example.game.MainActivity.spring android:targetActivitycom.example.game.MainActivity android:iconmipmap/ic_launcher_spring android:labelstring/app_name_spring android:enabledfalse android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias这里有几个关键点必须注意。第一主Activity本身不要再带LAUNCHER的intent-filter否则会出现两个图标。正确做法是把LAUNCHER的intent-filter只放在alias上主Activity只保留其他必要的filter。第二每个alias的name必须唯一建议用包名加后缀的格式。第三enabled属性默认图标对应的alias设为true其他设为false。第四targetActivity必须指向真实存在的Activity且该Activity的exported要为true。注意如果你的项目用了Android App BundleAAB发布activity-alias的配置同样有效但要注意Google Play对图标资源的处理。AAB会根据设备密度分发资源但alias的启用状态是运行时的不受影响。3.2 Java侧切换逻辑的完整实现在Unity的Android插件目录下通常是Assets/Plugins/Android新建一个Java文件比如AppIconChanger.javapackage com.example.game; import android.content.ComponentName; import android.content.Context; import android.content.pm.PackageManager; public class AppIconChanger { private static final String PKG com.example.game; private static final String[] ALL_ALIASES { com.example.game.MainActivity.default, com.example.game.MainActivity.spring, com.example.game.MainActivity.valentine }; public static void setIcon(Context context, String aliasSuffix) { String targetAlias PKG .MainActivity. aliasSuffix; PackageManager pm context.getPackageManager(); for (String alias : ALL_ALIASES) { int newState alias.equals(targetAlias) ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; pm.setComponentEnabledSetting( new ComponentName(PKG, alias), newState, PackageManager.DONT_KILL_APP ); } } }这段代码的核心是setComponentEnabledSetting。第三个参数DONT_KILL_APP非常关键——如果不加这个标志系统会在切换组件状态时杀掉应用进程用户体验就是点一下图标App闪退了。加上之后切换是静默完成的App继续运行。但这里有个Android系统的经典坑切换alias后桌面图标的刷新有延迟。有些设备上会立即刷新有些设备要等几秒还有些设备需要用户回到桌面才刷新。这是系统Launcher的行为无法从应用侧强制。实测下来大部分主流机型在1到3秒内会刷新个别ROM可能需要更久。如果产品要求点了立刻变那要提前跟产品沟通这个限制。3.3 Unity C#侧调用Android的桥接代码Unity侧通过AndroidJavaClass和AndroidJavaObject来调用上面的Java方法public static void SetIconAndroid(string aliasSuffix) { using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (var changer new AndroidJavaClass(com.example.game.AppIconChanger)) { changer.CallStatic(setIcon, activity, aliasSuffix); } }这段代码看起来简单但有两个容易出错的地方。第一currentActivity必须在主线程获取如果在子线程调用会拿到null。第二AndroidJavaClass和AndroidJavaObject都实现了IDisposable用using包起来可以及时释放JNI引用避免内存泄漏。我见过项目里因为没释放导致JNI引用表溢出的案例排查了很久。另外如果你的项目开启了ProGuard或R8混淆要确保AppIconChanger类不被混淆在proguard-rules里加一行-keep class com.example.game.AppIconChanger { *; }。否则打包后反射调用会失败报ClassNotFoundException。3.4 图标资源命名与密度适配Android的图标资源要放在Assets/Plugins/Android/res/mipmap-*目录下按密度分文件夹mipmap-mdpi48x48mipmap-hdpi72x72mipmap-xhdpi96x96mipmap-xxhdpi144x144mipmap-xxxhdpi192x192如果偷懒只放一个mipmap-xxhdpi在低密度设备上系统会缩放图标会糊。实测下来至少要把mdpi、hdpi、xhdpi、xxhdpi、xxxhdpi五个密度都放齐才能保证所有设备上清晰。还有一个细节自适应图标Adaptive Icon。从Android 8.0开始图标支持前景层和背景层分离。如果你的备选图标要做自适应需要在mipmap-anydpi-v26目录下放XML描述文件引用前景和背景资源。这个配置比普通图标复杂但效果更好——系统会根据不同Launcher的形状圆形、方形、圆角方形自动裁剪。如果活动图标对视觉效果要求高建议做自适应版本。4. iOS端核心实现细节拆解4.1 Info.plist中备选图标的声明方式iOS的备选图标必须在Info.plist里预先声明格式如下keyCFBundleIcons/key dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon60x60/string /array /dict keyCFBundleAlternateIcons/key dict keyspring/key dict keyCFBundleIconFiles/key array stringspring60x60/string /array keyUIPrerenderedIcon/key false/ /dict keyvalentine/key dict keyCFBundleIconFiles/key array stringvalentine60x60/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dict这里的spring和valentine就是备选图标的key后面调用接口时要用到。CFBundleIconFiles数组里填的是图标文件名不带扩展名系统会自动匹配2x和3x版本。比如你放了spring60x602x.png和spring60x603x.png系统会自动选。注意iOS的备选图标必须是PNG格式且不能有alpha通道透明通道。如果图标有透明区域提交App Store时会被拒。这个坑很多人踩过做图的时候一定要让设计导出不带透明的版本。4.2 Objective-C切换接口的封装在iOS插件目录下新建AppIconChanger.mm用.mm后缀支持Objective-C方便和Unity交互#import UIKit/UIKit.h extern C { void _SetAppIcon(const char* iconName) { NSString *name [NSString stringWithUTF8String:iconName]; if ([name isEqualToString:default]) { name nil; } if (![[UIApplication sharedApplication] supportsAlternateIcons]) { return; } [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog(Set icon failed: %, error.localizedDescription); } }]; } }这段代码有几个关键处理。第一supportsAlternateIcons要先判断虽然iOS 10.3以上都支持但保险起见还是检查一下。第二传nil表示恢复默认图标所以当传入default时要转成nil。第三completionHandler里的错误要打日志方便排查。4.3 Unity C#侧调用iOS的桥接代码#if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _SetAppIcon(string iconName); #endif public static void SetIconiOS(string iconKey) { #if UNITY_IOS !UNITY_EDITOR _SetAppIcon(iconKey); #endif }DllImport(__Internal)是Unity调用iOS静态库的标准写法。注意#if UNITY_IOS !UNITY_EDITOR这个条件编译因为编辑器下没有__Internal这个库不加条件会在编辑器里报错。4.4 iOS弹窗问题的处理策略前面提到iOS切换图标时系统会弹一个提示框。这个弹窗的文案是系统固定的无法自定义。实际产品中通常的做法是用户点击切换图标按钮。弹出自定义确认框确定要更换图标吗系统会弹出确认提示请点击使用。用户点确认后调用setAlternateIconName。系统弹窗出现用户点使用。图标切换完成。这样处理的好处是用户对系统弹窗有心理预期不会觉得突兀。另外系统弹窗出现时App的界面会被短暂遮挡如果此时有动画或音效在播放要注意暂停和恢复。还有一个细节iOS切换图标后App不会重启但桌面图标刷新可能有延迟。实测下来大部分设备在1秒内刷新个别情况需要用户手动回到桌面。这个和Android类似属于系统行为。5. Unity统一封装与业务层调用5.1 统一接口设计把两端的实现包在一个静态类里业务层只需要调一个方法public static class AppIconChanger { public static void SetIcon(string iconKey) { #if UNITY_ANDROID !UNITY_EDITOR SetIconAndroid(iconKey); #elif UNITY_IOS !UNITY_EDITOR SetIconiOS(iconKey); #else Debug.Log($[Editor] Set icon to: {iconKey}); #endif PlayerPrefs.SetString(current_icon, iconKey); PlayerPrefs.Save(); } public static string GetCurrentIcon() { return PlayerPrefs.GetString(current_icon, default); } }iconKey的命名要两端统一。比如Android的alias后缀是springiOS的plist key也是spring这样业务层传spring就能两端通用。默认图标统一用default。5.2 启动时的状态校正App启动时要做一次校正确保实际图标和记录一致。Android端可以查询当前启用的aliaspublic static String getCurrentAlias(Context context) { PackageManager pm context.getPackageManager(); for (String alias : ALL_ALIASES) { int state pm.getComponentEnabledSetting(new ComponentName(PKG, alias)); if (state PackageManager.COMPONENT_ENABLED_STATE_ENABLED) { return alias.substring(alias.lastIndexOf(.) 1); } } return default; }iOS端可以查询alternateIconNameextern C { const char* _GetCurrentAppIcon() { NSString *name [[UIApplication sharedApplication] alternateIconName]; if (name nil) { return strdup(default); } return strdup([name UTF8String]); } }启动时对比查询结果和PlayerPrefs记录如果不一致以实际为准更新记录。这样即使用户在系统设置里手动改了图标App也能正确识别。5.3 业务层的调用时机换图标的调用时机通常有三个用户主动触发在设置页面放一个更换图标入口用户点击后选择。活动自动触发比如检测到当前是春节期间App启动时自动切换到春节图标。运营后台下发通过配置接口下发当前应该使用的图标keyApp启动时读取并切换。第一种最可控第二种要注意用户是否已经手动改过图标不要覆盖用户的选择。第三种最灵活但要注意配置接口的容错——如果下发的key在本地不存在要回退到默认图标不能崩溃。6. 常见问题与排查技巧实录6.1 Android端典型问题速查问题现象可能原因排查方法解决方案切换后出现两个图标主Activity也带了LAUNCHER filter检查Manifest移除主Activity的LAUNCHER filter切换后App闪退没加DONT_KILL_APP标志查看logcat加上DONT_KILL_APP切换无效图标不变alias的enabled状态没改对用adb shell dumpsys package查检查setComponentEnabledSetting调用打包后报ClassNotFoundException被ProGuard混淆了查看混淆日志加keep规则图标模糊缺少高密度资源检查mipmap目录补齐各密度资源部分机型不刷新Launcher行为差异多机型测试提示用户回桌面查看6.2 iOS端典型问题速查问题现象可能原因排查方法解决方案切换无反应plist里没声明该key检查Info.plist补上CFBundleAlternateIcons提交App Store被拒图标有alpha通道用预览工具检查导出不带透明的PNG切换后弹窗不消失completionHandler没回调打断点检查error信息图标显示为白色方块图标文件名不匹配检查资源名确保文件名和plist一致模拟器上无效模拟器不支持用真机测试换真机验证6.3 我踩过的几个坑第一个坑是Android的alias命名。我一开始用了MainActivity_spring这种下划线格式结果在某些ROM上解析异常。后来改成点号分隔的MainActivity.spring就正常了。虽然官方文档没说下划线不行但实测下来点号更稳。第二个坑是iOS的图标缓存。有次测试时切换图标后桌面显示的还是旧图标以为是代码问题排查了半天才发现是iOS的图标缓存。解决办法是卸载重装或者等一段时间。这个缓存机制在开发阶段很烦但线上用户基本感知不到。第三个坑是Unity的Android插件目录结构。Java文件必须放在Assets/Plugins/Android下且包名要和Java文件里的package声明一致。我有次把文件放错了目录打包后一直报找不到类查了很久才发现是目录问题。第四个坑是iOS的plist合并。如果项目用了多个插件每个插件都有自己的Info.plist片段Unity在打包时会合并。如果两个插件都声明了CFBundleIcons会冲突。解决办法是只在一个地方声明或者用PostProcessBuild脚本动态合并。7. 性能与体积影响的实测数据动态图标方案对包体的影响主要来自图标资源。我拿一个实际项目做了测试准备了10个备选图标每个图标5个密度Android端APK增大了约3.2MBiOS端IPA增大了约2.8MB。如果图标数量增加到20个增量大概翻倍。对运行时性能的影响几乎可以忽略。Android端切换alias是一次PackageManager调用耗时在10毫秒以内。iOS端切换图标稍慢因为系统要处理图标缓存实测在200到500毫秒之间但这是异步的不阻塞主线程。内存方面图标资源是编译期打包的运行时不会全部加载到内存只有当前显示的图标会被系统加载。所以不用担心内存问题。如果包体实在紧张可以考虑用图标压缩。PNG图标用TinyPNG之类的工具压缩通常能减少30%到50%的体积。但要注意压缩后不能有肉眼可见的失真尤其是图标边缘。8. 一些延伸玩法和注意事项动态图标除了换节日图标还有几个延伸玩法。一是用户个性化让用户自己选喜欢的图标增加归属感。二是成就解锁比如玩家达到某个等级后解锁专属图标。三是联名活动和品牌方合作时用联名图标活动结束后切回默认。但有几个注意事项必须强调。第一不要频繁切换。虽然技术上可以做到每次启动都切但频繁切换会让用户困惑也增加系统负担。建议一天最多切一次且要有明确的触发条件。第二要给用户选择权。自动切换图标前最好征得用户同意或者在设置里提供关闭自动切换的选项。第三测试要覆盖多机型。Android碎片化严重不同ROM对alias的支持有差异至少要覆盖主流品牌的主力机型。最后分享一个小技巧如果产品要求切换图标后立即生效可以在切换后调用一次Launcher的刷新。Android端可以发送一个Intent.ACTION_MAIN的广播部分Launcher会响应并刷新。但这个不是标准API效果因Launcher而异只能作为锦上添花不能作为主要依赖。我在实际项目中的体会是动态图标这个功能技术难度不算高但细节特别多两端加起来大概需要3到5个工作日完成开发和测试。最大的成本不在写代码而在准备图标资源和多机型测试。如果团队里没有熟悉原生开发的同学建议预留充足的时间或者考虑用现成的插件市场方案虽然要花钱但能省不少事。
返回列表