ARTICLE DETAIL

资讯详情

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

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

Unity 动态更换 App 图标:Android 与 iOS 双端实现方案 动态更换 App 图标这件事说穿了就是把图标资源从编译期固化变成运行期可切换。听起来简单但真到 Unity 项目里落地Android 和 iOS 两端的实现路径完全不同坑也完全不在一个维度上。Android 靠activity-alias做组件级别的开关iOS 则依赖系统提供的setAlternateIconName接口而 Unity 作为跨平台引擎既没有现成的统一 API又要在 IL2CPP 和 Mono 两种后端下都能跑通。我在两个上线项目里分别踩过 Android 的activity-alias被厂商 ROM 拦截、iOS 的图标切换弹窗被审核质疑这些坑下面把整套方案从原理到代码完整拆一遍适合已经有 Unity 基础、正在做运营活动或节日换肤需求的同学参考。1. 先搞清楚两端图标切换的底层机制差异很多人一上来就找Unity 换图标插件结果发现要么只支持一端要么在真机上直接崩。根本原因在于 Android 和 iOS 对应用图标这个概念的管理方式从设计哲学上就不一样不理解这一点后面所有代码都是碰运气。1.1 Android 的 activity-alias 到底在做什么Android 里一个应用可以声明多个activity-alias每个 alias 指向同一个入口 Activity但可以拥有独立的android:icon、android:label和android:enabled状态。系统桌面Launcher读取的是当前处于 enabled 状态的那个 alias 的图标信息。所以换图标的本质是禁用当前 alias启用目标 alias然后通知 Launcher 刷新。这里有个关键点容易被忽略activity-alias的android:enabled属性在AndroidManifest.xml里是静态声明的运行时修改必须通过PackageManager.setComponentEnabledSetting()。而且这个操作是异步生效的部分 Launcher 不会立即刷新需要配合ACTION_MAINCATEGORY_LAUNCHER的广播或者直接杀掉 Launcher 进程不推荐来触发重绘。!-- AndroidManifest.xml 中的 alias 声明示例 -- activity-alias android:name.icon_default android:enabledtrue android:exportedtrue android:iconmipmap/ic_launcher_default android:labelstring/app_name android:targetActivity.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias activity-alias android:name.icon_festival android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_festival android:labelstring/app_name android:targetActivity.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias注意targetActivity必须指向真实的入口 Activity且这个 Activity 本身不要再声明LAUNCHER的 intent-filter否则会出现两个图标。这是新手最容易犯的错——原来的MainActivity里那个LAUNCHER声明必须删掉全部交给 alias 来承载。1.2 iOS 的 setAlternateIconName 限制在哪iOS 10.3 之后开放了UIApplication.shared.setAlternateIconName(_:completionHandler:)允许在运行时切换图标。但它的限制比 Android 多得多所有备选图标必须在Info.plist的CFBundleIcons→CFBundleAlternateIcons中预先声明不能动态添加。图标文件必须打包进主 Bundle不能从网络下载后直接使用除非你走 Asset Catalog 的按需资源但图标不支持。切换时会弹出系统级提示框您已更改XX的图标这个提示无法去除只能通过一些取巧方式比如在切换瞬间弹一个自己的 UI 覆盖来弱化但审核有风险。图标必须是完整的正方形 PNG不能有圆角、不能有透明通道否则系统会自动加黑底尺寸建议 180x180、120x120、87x87 等多套。Info.plist的配置结构长这样keyCFBundleIcons/key dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon60x60/string /array /dict keyCFBundleAlternateIcons/key dict keyfestival/key dict keyCFBundleIconFiles/key array stringicon_festival_60x60/string stringicon_festival_120x120/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dict这里的 key比如festival就是后面代码里传给setAlternateIconName的名字。传nil表示恢复主图标。1.3 Unity 层为什么不能直接调Unity 的 C# 层没有暴露任何图标切换 API因为图标属于平台原生概念Unity 的 Player 构建流程只负责把图标打进包体不负责运行时管理。所以必须走平台原生代码 C# 桥接的路线Android 用AndroidJavaObject调PackageManageriOS 用DllImport调 Objective-C 的运行时函数。两端桥接的代码风格差异很大Android 侧是 JNI 调用iOS 侧是 P/Invoke这也是为什么很多一套代码通吃的插件实际上内部是两套完全独立的实现。2. Android 侧从 Manifest 配置到运行时切换的完整链路Android 这块的坑主要集中在 Manifest 配置和厂商 ROM 兼容性上。我见过太多项目在模拟器上跑得好好的一到真机尤其是某些定制系统就失效。2.1 Manifest 配置的三个致命细节第一个细节是android:enabled的初始状态。默认图标对应的 alias 必须enabledtrue其他全部false。如果你把所有 alias 都设成true桌面上会出现多个图标用户直接卸载。第二个细节是 alias 的android:name命名。建议用包名全路径比如com.yourcompany.yourgame.icon_festival不要用相对路径.icon_festival因为某些构建工具在处理相对路径时会有歧义尤其是在多渠道打包比如接入某些 SDK 后自动改包名的场景下。第三个细节是android:exported。Android 12API 31之后所有带 intent-filter 的组件必须显式声明android:exportedalias 也不例外。设成true是安全的因为它需要被 Launcher 外部调用。注意如果你的项目用了自定义的AndroidManifest.xml模板在Assets/Plugins/Android/下要确保 alias 声明在application标签内且不要被其他构建脚本覆盖。Unity 的 Manifest 合并机制有时会保留旧版本建议每次改完都反编译 APK 确认。2.2 运行时切换的 Java 代码实现在Assets/Plugins/Android/下建一个 Java 文件或者直接用 C# 的AndroidJavaObject写。我倾向于写一个独立的 Java 工具类因为逻辑比较长放在 C# 里可读性差。package com.yourcompany.iconutil; import android.content.ComponentName; import android.content.Context; import android.content.pm.PackageManager; public class IconChanger { public static void changeIcon(Context context, String aliasName) { PackageManager pm context.getPackageManager(); String packageName context.getPackageName(); // 先禁用所有 alias String[] allAliases { packageName .icon_default, packageName .icon_festival, packageName .icon_holiday }; for (String alias : allAliases) { pm.setComponentEnabledSetting( new ComponentName(packageName, alias), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ); } // 启用目标 alias pm.setComponentEnabledSetting( new ComponentName(packageName, packageName . aliasName), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ); } }DONT_KILL_APP这个 flag 很关键。如果不加系统会在切换组件状态时杀掉应用进程用户体验极差。加上之后进程保留但图标刷新可能延迟需要额外处理。2.3 图标刷新延迟与 Launcher 兼容处理即使加了DONT_KILL_APP很多 Launcher 也不会立即刷新图标。实测下来小米、华为、OPPO 的部分机型需要几秒到几十秒才刷新三星和原生 Android 基本是秒刷。解决方案有两个一是发送一个自定义广播部分 Launcher 会监听com.android.launcher.action.INSTALL_SHORTCUT之类的动作但这个不是标准接口兼容性有限。二是更暴力的做法切换完成后延迟 500ms 再调用一次pm.setComponentEnabledSetting把目标 alias 先禁用再启用强制触发一次状态变更通知。这个技巧在多个项目里验证有效但要注意不要频繁调用否则可能触发系统的组件状态异常。// C# 侧调用示例 public static void SwitchIcon(string aliasName) { using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (var iconChanger new AndroidJavaClass(com.yourcompany.iconutil.IconChanger)) { iconChanger.CallStatic(changeIcon, activity, aliasName); } }调用时机建议放在游戏启动后的 Loading 阶段或者设置界面里不要在Awake里立刻调因为此时 Activity 可能还没完全初始化。3. iOS 侧Info.plist 预声明与原生桥接的实操细节iOS 这块的复杂度不在代码量而在于资源准备和审核合规。代码本身很短但配置错了就是白屏或者崩溃。3.1 备选图标的资源规范与打包方式每个备选图标需要准备多套尺寸至少包括 60x602x120x120和 60x603x180x180iPad 还需要 76x76 和 83.5x83.5 系列。文件命名建议统一前缀比如icon_festival_120x120.png、icon_festival_180x180.png放在Assets/Plugins/iOS/下Unity 构建时会自动拷贝到 Xcode 工程的根目录。这里有个大坑图标文件不能放在 Asset Catalog 里必须作为独立文件放在 Bundle 根目录然后在Info.plist里用文件名不带扩展名引用。如果你放在.xcassets里setAlternateIconName会找不到资源直接报错。另外图标不能有 alpha 通道。iOS 对备选图标的 alpha 检查比主图标更严格带透明的 PNG 会导致切换后图标显示为黑底或者直接失败。用 Photoshop 或 ImageMagick 批量去掉 alpha 通道# 用 ImageMagick 批量去除 alpha 通道 for f in icon_festival_*.png; do convert $f -background black -alpha remove -alpha off $f done3.2 Objective-C 桥接代码与 C# 调用在Assets/Plugins/iOS/下建一个.mm文件用 Objective-C 以便和 Unity 的 C 接口兼容// IconChanger.mm #import UIKit/UIKit.h extern C { void _ChangeIcon(const char* iconName) { NSString *name [NSString stringWithUTF8String:iconName]; if ([name isEqualToString:]) { name nil; // 传空字符串表示恢复主图标 } dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError *error) { if (error) { NSLog(Change icon failed: %, error.localizedDescription); } }]; }); } }C# 侧用DllImport声明#if UNITY_IOS !UNITY_EDITOR using System.Runtime.InteropServices; public static class iOSIconChanger { [DllImport(__Internal)] private static extern void _ChangeIcon(string iconName); public static void SwitchIcon(string iconName) { _ChangeIcon(iconName ?? ); } } #endif注意dispatch_async到主线程这一步不能省setAlternateIconName必须在主线程调用否则行为未定义。3.3 系统弹窗的规避思路与审核风险iOS 切换图标时那个您已更改图标的弹窗是系统强制的无法通过公开 API 去除。市面上有些做法是在调用setAlternateIconName的瞬间弹一个自己的全屏 UI把系统弹窗盖住等切换完成后再关掉。这个做法在技术上可行但审核时如果被人工发现可能被判定为误导用户或绕过系统提示有被拒风险。我的建议是如果只是节日活动换图标直接接受这个弹窗在 UI 上提前告知用户切换后系统会弹出提示点击确认即可。如果是付费换图标比如用户花钻石买一个图标那更要在购买前明确说明会有系统弹窗避免用户以为没生效而重复购买。4. Unity 层的统一封装与状态持久化两端原生代码写完之后Unity 层需要一个统一的入口同时要处理状态记录、异常兜底和编辑器下的模拟。4.1 跨平台接口设计与条件编译public static class AppIconManager { public static void SetIcon(string iconKey) { #if UNITY_ANDROID !UNITY_EDITOR AndroidIconChanger.SwitchIcon(icon_ iconKey); #elif UNITY_IOS !UNITY_EDITOR iOSIconChanger.SwitchIcon(iconKey default ? : iconKey); #else Debug.Log($[Editor] Switch icon to: {iconKey}); #endif PlayerPrefs.SetString(CurrentAppIcon, iconKey); PlayerPrefs.Save(); } public static string GetCurrentIcon() { return PlayerPrefs.GetString(CurrentAppIcon, default); } }iconKey用语义化的名字比如default、festival、holidayAndroid 侧拼成icon_festivaliOS 侧直接用festival。这样两端的命名规则解耦后期加图标只需要改映射表。4.2 状态记录与异常兜底状态记录用PlayerPrefs就够了但要注意Android 上如果用户在系统设置里清了应用数据PlayerPrefs会丢失但 alias 的 enabled 状态是存在系统里的不会丢。这就会导致状态不一致——游戏以为还是默认图标实际系统里已经是节日图标了。解决办法是在启动时做一次校验Android 侧读取当前 enabled 的 alias反推当前图标 key和PlayerPrefs对比不一致就以系统为准。iOS 侧更简单直接读UIApplication.shared.alternateIconName为nil就是默认图标。// Android 侧读取当前 alias 状态 public static string GetCurrentAlias() { using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (var pm activity.CallAndroidJavaObject(getPackageManager)) { string packageName activity.Callstring(getPackageName); string[] aliases { icon_default, icon_festival, icon_holiday }; foreach (var alias in aliases) { var component new AndroidJavaObject(android.content.ComponentName, packageName, packageName . alias); int state pm.Callint(getComponentEnabledSetting, component); if (state 1) return alias; // COMPONENT_ENABLED_STATE_ENABLED } } return icon_default; }异常兜底方面Android 的setComponentEnabledSetting在某些 ROM 上会抛SecurityException必须 try-catch 包住失败时回退到默认图标并给用户一个提示。iOS 的setAlternateIconName失败会走 completionHandler 的 error同样要处理。4.3 编辑器下的模拟与真机测试清单编辑器下没法真正换图标但可以在 Game 视图里用一个 UI 元素模拟图标预览方便策划确认效果。真机测试必须覆盖以下场景测试项AndroidiOS首次安装默认图标确认只有一个图标确认主图标正确切换到备选图标确认桌面图标变化确认弹窗后图标变化切回默认图标确认恢复确认恢复且无弹窗异常杀进程后重启确认图标保持确认图标保持清除应用数据确认状态校验生效不适用覆盖安装新版本确认 alias 状态不丢确认 Info.plist 合并正确覆盖安装这一项特别容易出问题。Android 覆盖安装时如果新版本的 Manifest 里 alias 声明有变化比如删掉了某个 alias系统可能会重置 enabled 状态。iOS 覆盖安装时如果Info.plist里的备选图标列表变了已切换的图标可能会失效变回默认。所以每次发版前都要回归测试这一项。5. 那些文档里不会写的踩坑记录这部分是我在两个项目里实际踩过的坑按严重程度排序。5.1 Android 厂商 ROM 的 alias 拦截某些定制 ROM 会对setComponentEnabledSetting做限制尤其是当 alias 的android:exportedtrue且带LAUNCHERintent-filter 时系统可能认为这是创建快捷方式行为而拦截。表现是代码执行成功没抛异常但桌面图标不变。排查方法是打印getComponentEnabledSetting的返回值确认状态确实变了如果变了但图标没变那就是 Launcher 没刷新不是权限问题。应对策略是准备一个降级方案如果检测到切换后 3 秒内图标未刷新通过读取当前 alias 状态判断提示用户请手动重启桌面或稍等片刻。虽然体验不好但比默默失败强。5.2 iOS 备选图标在 Xcode 工程中的路径问题Unity 构建出的 Xcode 工程Assets/Plugins/iOS/下的文件会被拷贝到Libraries/或者根目录具体位置取决于 Unity 版本。如果Info.plist里引用的图标文件名和实际拷贝后的路径不匹配切换就会失败。最稳妥的做法是在 Xcode 工程里手动确认图标文件的位置然后在Info.plist里用相对路径引用。如果用了 PostProcessBuild 脚本自动改Info.plist一定要在脚本里同时处理图标文件的拷贝不要只改 plist。5.3 图标切换与热更新/资源加载的冲突如果项目用了热更新框架比如 HybridCLR 或 ILRuntime图标切换的代码如果放在热更程序集里Android 侧的 JNI 调用可能因为程序集卸载而失效。建议把图标切换的原生桥接代码放在主工程程序集AOT 部分热更层只负责调用接口。iOS 侧的DllImport同理必须放在 AOT 程序集。5.4 审核被拒的真实案例有个项目在 iOS 审核时被拒理由是应用图标在未告知用户的情况下发生变化。后来我们改成切换图标前弹一个确认框明确写切换后系统会提示图标已更改这是正常现象并在设置界面里提供恢复默认图标的入口。改完之后顺利过审。所以合规的关键不是技术而是用户知情和可逆。6. 性能、包体与运营策略的权衡换图标这个功能本身性能开销极小但它对包体和运营策略的影响不小。6.1 图标资源对包体的影响每个备选图标在 Android 上需要准备 5 套密度mdpi 到 xxxhdpiiOS 上需要 3 套1x 到 3x如果做 5 个备选图标光图标资源就可能增加 2-3MB。对于包体敏感的项目建议Android 侧只保留 xhdpi 和 xxhdpi 两套低密度设备让系统缩放。iOS 侧只保留 2x 和 3x1x 设备已经很少了。图标用 PNG-8 而不是 PNG-24颜色数控制在 256 以内体积能减半。6.2 运营节奏与图标切换时机图标切换最好和运营活动绑定比如春节、周年庆、联动活动。切换时机建议放在活动开始前 1-2 天通过服务端下发开关控制避免客户端发版。但要注意Android 的 alias 是静态声明的新增图标必须发版所以运营侧要提前规划好未来半年的图标需求一次性把 alias 都声明进去用服务端开关控制启用哪个。iOS 同理Info.plist里的备选图标列表也是静态的新增必须发版。所以两端都要预留坑位比如一次性声明 8 个 alias实际只用其中几个。6.3 用户留存与图标切换的关系从数据上看主动切换图标的用户占比通常不到 5%但这部分用户的留存和付费率明显高于平均值。所以这个功能的价值不在于覆盖率而在于给核心用户提供个性化的满足感。建议把切换入口放在设置界面的显眼位置配合活动赠送限定图标来提升参与度。最后分享一个实操中的小技巧Android 侧切换图标后如果想让 Launcher 尽快刷新可以在切换完成后调用一次activity.finish()再重启 Activity虽然会闪一下但刷新成功率接近 100%。这个做法在部分 ROM 上比延迟重试更可靠代价是用户体验有轻微中断适合在设置界面里由用户主动触发切换时使用。
返回列表