ARTICLE DETAIL

资讯详情

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

Unity手游动态换图标实战:Android activity-alias与iOS setAlternateIconName双端方案

Unity手游动态换图标实战:Android activity-alias与iOS setAlternateIconName双端方案 手游运营做久了总会碰到一个绕不开的需求逢年过节、版本大更、或者配合某个市场活动想把桌面上的 App 图标换成限定款。用户不用重新下载安装包图标自己就变了——这种魔法效果在买量投放和节日运营里转化率提升相当明显。但真动手做的时候你会发现Unity 这边根本没有现成的 API 能直接改图标Android 和 iOS 两套系统的实现思路完全不一样坑还特别多。这篇内容就是把我自己在 Unity 手游项目里落地动态换图标方案的完整过程拆开讲。核心关键词是Unity、Android、iOS、App图标、activity-alias。适合已经有 Unity 打包经验、需要给运营提供换图标能力的开发同学也适合想了解双端原生交互差异的技术负责人。我会把 Android 的activity-alias方案、iOS 的setAlternateIconName方案、Unity 侧的统一调用层、以及实测中踩过的坑全部讲清楚代码可以直接抄。1. 先搞清楚动态换图标到底难在哪1.1 系统层面的限制决定了实现路径很多人第一反应是能不能在运行时把图标文件替换掉。答案是不能。Android 和 iOS 的 App 图标在安装时就被系统记录在桌面启动器Launcher的数据库里App 自身没有权限去修改这个记录。你能做的是提前在安装包里预埋多套图标资源然后通过系统提供的机制去切换当前生效的那一套。这个前提非常关键它直接决定了整个方案的设计图标不是生成出来的而是预置切换。所以如果你的运营同学说能不能让用户上传一张图当图标那得先泼盆冷水——做不到至少在不越狱、不 root 的正常环境下做不到。你能提供的是有限数量的、打包时就确定好的图标选项。Android 和 iOS 虽然都遵循预置切换这个思路但具体机制差异很大。Android 靠的是activity-alias这个清单文件里的组件别名机制iOS 靠的是UIApplication的setAlternateIconName接口。下面分别拆。1.2 两端机制的核心差异对比先上一张表把两端的差异一次性说清楚后面再展开细节。对比维度AndroidiOS核心机制activity-alias组件切换setAlternateIconNameAPI图标预置方式每个别名对应一套iconInfo.plist 声明CFBundleAlternateIcons是否需要重启不需要但桌面有短暂刷新不需要系统弹提示框系统弹窗无有无法去除生效范围桌面图标最近任务桌面图标设置页最低版本无特殊要求iOS 10.3审核风险低需说明用途可能被问这张表里最容易被忽略的是 iOS 的系统弹窗。setAlternateIconName调用后系统会强制弹出一个您已更改XX的图标的提示这个提示无法通过任何公开 API 去掉。很多团队第一次做的时候以为是自己代码写错了其实是系统行为。这一点在需求评审阶段就要跟产品说清楚否则上线后被用户投诉为什么换个图标还弹窗就很被动。Android 这边相对安静切换别名后桌面图标会刷新但不会有任何系统提示。不过 Android 的坑在于不同厂商的 Launcher 行为不一致这个后面细讲。2. Android 端activity-alias 方案的完整落地2.1 activity-alias 到底是什么为什么能用它换图标activity-alias是 AndroidManifest.xml 里的一个组件声明它可以给一个已存在的 Activity 起一个别名。这个别名在系统看来是一个独立的组件入口可以拥有自己的android:icon、android:label、android:enabled等属性。关键点在于桌面上的图标本质上是一个组件入口的快捷方式。当你的 App 安装了多个activity-alias每个别名都指向同一个主 Activity但各自带不同的图标那么系统桌面上就存在多个入口。你通过PackageManager.setComponentEnabledSetting来启用其中一个、禁用其他的桌面图标就会切换成被启用那个别名对应的图标。这里有个容易混淆的地方不是一个图标变成另一个而是多个入口之间切换显示。用户看到的始终是一个图标是因为你同时只启用了一个别名。如果操作不当同时启用了多个桌面上就会出现多个图标——这是新手最常踩的坑之一。2.2 AndroidManifest 的正确写法假设我们的主 Activity 叫MainActivity要预置三套图标默认、春节限定、周年庆限定。Manifest 大概长这样activity android:name.MainActivity android:exportedtrue android:enabledfalse intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity activity-alias android:name.AliasDefault 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.AliasSpring android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_spring 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.AliasAnniversary android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_anniversary 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 的android:enabled要设为false并且它的 intent-filter 可以保留也可以去掉。因为真正对外暴露入口的是别名主 Activity 本身不需要作为启动入口。如果主 Activity 也带 LAUNCHER 的 intent-filter 且 enabled 为 true那桌面上会多出一个图标。第二每个别名都要带完整的 intent-filterMAINLAUNCHER两个都不能少。少了任何一个这个别名就不会在桌面生成图标切换过去之后用户会发现图标消失了。第三别名的android:name建议用固定字符串不要用相对路径简写虽然.AliasDefault这种简写是合法的会补全成包名。因为后面在代码里要用完整的组件名去调用setComponentEnabledSetting用简写容易在拼接时出错。第四图标资源要放在mipmap目录下不要放drawable。Android 8.0 之后自适应图标Adaptive Icon要求放在mipmap-anydpi-v26里用drawable会导致部分机型显示异常。2.3 切换逻辑的代码实现Unity 侧通过 AndroidJavaObject 调用原生接口。核心逻辑是先禁用所有别名再启用目标别名。注意顺序——先禁用再启用不要反过来。public static void SwitchIcon(string aliasName) { #if UNITY_ANDROID !UNITY_EDITOR using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (AndroidJavaObject activity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (AndroidJavaObject packageManager activity.CallAndroidJavaObject(getPackageManager)) { string packageName activity.Callstring(getPackageName); string[] allAliases { AliasDefault, AliasSpring, AliasAnniversary }; foreach (string alias in allAliases) { string component packageName /. alias; int newState alias aliasName ? 1 // COMPONENT_ENABLED_STATE_ENABLED : 2; // COMPONENT_ENABLED_STATE_DISABLED packageManager.Call(setComponentEnabledSetting, new AndroidJavaObject(android.content.ComponentName, packageName, component), newState, 1); // DONT_KILL_APP } } #endif }这里setComponentEnabledSetting的第三个参数DONT_KILL_APP值为 1非常重要。如果不传这个标志切换组件状态时系统会杀掉当前 App 进程用户会看到 App 突然闪退。传了之后进程保留体验平滑。还有一个细节ComponentName的构造。用new ComponentName(packageName, component)时第二个参数如果是包名/.别名这种形式系统能正确解析。但更稳妥的写法是直接传完整的类名比如com.example.game.AliasSpring。我实测下来用包名 /. 别名这种拼接在绝大多数机型上没问题但个别定制 ROM 会解析失败建议直接用完整类名。2.4 切换后桌面不刷新的问题排查代码写对了但用户反馈图标没变这是 Android 端最高频的问题。排查链路是这样的先确认setComponentEnabledSetting的返回值有没有异常。这个方法本身不返回结果但可以通过getComponentEnabledSetting反查当前状态确认设置是否生效。如果状态确实变了但桌面没刷新大概率是 Launcher 的缓存问题。不同厂商的 Launcher 刷新策略不一样原生 Android 和大部分主流机型会立即刷新部分定制 ROM 需要等几秒极少数机型需要用户手动重启桌面或者锁屏再解锁。实测下来可以在切换后主动发一个广播触发 Launcher 刷新但这个方法不是官方推荐的兼容性也一般。更稳妥的做法是切换后给用户一个提示图标将在几秒内更新如未更新请稍等把预期管理好。别指望所有机型都秒变。另外要注意在 Application 启动阶段调用切换可能不生效因为此时 PackageManager 还没完全就绪。建议在第一个场景加载完成后、或者用户主动点击更换图标按钮时再调用。3. iOS 端setAlternateIconName 的接入细节3.1 Info.plist 的图标声明结构iOS 这边不需要改 Xcode 工程里的 AppIcon 资源而是要在 Info.plist 里额外声明一组备选图标。结构是这样的keyCFBundleIcons/key dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon60x60/string /array /dict keyCFBundleAlternateIcons/key dict keySpringIcon/key dict keyCFBundleIconFiles/key array stringSpringIcon60x60/string /array keyUIPrerenderedIcon/key false/ /dict keyAnniversaryIcon/key dict keyCFBundleIconFiles/key array stringAnniversaryIcon60x60/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dict几个关键约束备选图标的文件名必须遵循名称尺寸的命名规范比如SpringIcon60x60、SpringIcon120x120、SpringIcon180x180。系统会根据设备屏幕密度自动选择对应尺寸。如果你只放了一个尺寸在某些设备上会显示模糊或者干脆不显示。图标文件要直接拖进 Xcode 工程的根目录和 Info.plist 同级不要放进 Assets.xcassets 里。这是 iOS 备选图标的一个反直觉要求——常规 AppIcon 是放 Assets 里的但备选图标必须作为独立文件存在 bundle 根目录。我第一次做的时候放进了 Assets结果setAlternateIconName一直返回错误排查了半天才发现是这个原因。CFBundleAlternateIcons的 key 就是后面代码里要传的图标名称比如SpringIcon。这个名字和文件名是两回事别搞混。3.2 Unity 调用 iOS 原生接口的桥接写法iOS 侧需要写一个 Objective-C 的桥接文件暴露给 Unity 调用。核心代码// IconSwitcher.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([IconSwitcher] 当前设备不支持备选图标); return; } NSString *targetName [name isEqualToString:Default] ? nil : name; [app setAlternateIconName:targetName completionHandler:^(NSError * _Nullable error) { if (error) { NSLog([IconSwitcher] 切换失败: %, error.localizedDescription); } else { NSLog([IconSwitcher] 切换成功); } }]; } }注意setAlternateIconName传nil表示恢复默认图标传具体名称表示切换到对应备选图标。这个nil的处理很容易漏导致切不回默认图标。Unity 侧用DllImport声明#if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _SwitchIcon(string iconName); #endif public static void SwitchIcon(string iconName) { #if UNITY_IOS !UNITY_EDITOR _SwitchIcon(iconName); #endif }3.3 那个无法去除的系统弹窗前面提过iOS 调用setAlternateIconName后系统会弹窗。这个弹窗的文案是系统固定的比如您已更改游戏名的图标。它由 SpringBoard 弹出App 层完全无法拦截或隐藏。网上有一些黑科技声称能绕过比如通过 Method Swizzling 替换presentViewController的实现。这类做法我强烈不建议一是违反平台规则审核有被拒风险二是不同系统版本行为不一致维护成本极高三是可能引发其他 UI 异常。正确的做法是在产品层面接受这个弹窗并在交互设计上做引导。比如用户点击更换图标后先弹一个自己的确认框说明接下来系统会弹出确认提示点击确认即可让用户有心理预期。这样比用户突然看到一个系统弹窗要好得多。4. Unity 侧统一调用层的设计4.1 用条件编译隔离双端差异Unity 项目里最忌讳的就是把平台相关代码散落在业务逻辑里。正确的做法是抽一个统一的AppIconManager对外只暴露SwitchIcon(AppIconType type)一个接口内部用条件编译分发到 Android 或 iOS 实现。public enum AppIconType { Default, Spring, Anniversary } public static class AppIconManager { public static void SwitchIcon(AppIconType type) { #if UNITY_ANDROID !UNITY_EDITOR SwitchAndroid(type); #elif UNITY_IOS !UNITY_EDITOR SwitchIOS(type); #else Debug.Log($[Editor] 模拟切换图标: {type}); #endif } private static void SwitchAndroid(AppIconType type) { string alias type switch { AppIconType.Spring AliasSpring, AppIconType.Anniversary AliasAnniversary, _ AliasDefault }; // 调用前面写的 AndroidJavaObject 逻辑 } private static void SwitchIOS(AppIconType type) { string name type switch { AppIconType.Spring SpringIcon, AppIconType.Anniversary AnniversaryIcon, _ Default }; _SwitchIcon(name); } }这样业务层只需要AppIconManager.SwitchIcon(AppIconType.Spring)一行完全不用关心平台差异。编辑器下走Debug.Log分支方便调试流程而不报错。4.2 状态持久化与启动时恢复切换图标后这个状态是系统层面记住的App 重启后图标不会变回去。但你的业务逻辑可能需要知道当前用的是哪个图标比如在设置页高亮显示当前选中的图标。Android 侧可以通过getComponentEnabledSetting反查当前启用的别名iOS 侧可以通过alternateIconName属性读取当前图标名。但更简单可靠的做法是自己在本地存一份状态用PlayerPrefs记录用户选择的图标类型启动时读取。public static void SwitchIcon(AppIconType type) { // ... 平台切换逻辑 PlayerPrefs.SetInt(CurrentAppIcon, (int)type); PlayerPrefs.Save(); } public static AppIconType GetCurrentIcon() { return (AppIconType)PlayerPrefs.GetInt(CurrentAppIcon, 0); }这里有个坑如果用户在系统层面手动改了图标iOS 支持在设置里改你的本地记录就和实际不一致了。所以更严谨的做法是启动时用系统 API 反查一次以系统状态为准同步更新本地记录。iOS 的alternateIconName和 Android 的getComponentEnabledSetting都能做到这一点。4.3 编辑器下的模拟与测试Unity 编辑器里没法真正切换图标但你需要保证调用AppIconManager.SwitchIcon不报错否则开发阶段测试其他功能时会一直被异常打断。用条件编译把编辑器分支隔离出来走一个空实现或者日志输出就够了。另外建议做一个简单的测试面板列出所有图标选项点击后调用切换接口。这样在真机测试时不用每次都改代码重新打包直接在面板上点就能验证。这个面板只在 Development Build 里显示正式包剔除。5. 打包配置与上线前的检查清单5.1 Android 打包时的资源与清单校验Unity 打包 Android 时AndroidManifest.xml会被 Unity 的构建流程处理。如果你是在Assets/Plugins/Android/AndroidManifest.xml里手动写的别名声明要确保它和 Unity 自动生成的清单正确合并。合并冲突是常见问题尤其是MainActivity的声明。建议的做法是主 Activity 的声明交给 Unity 自动生成你只手动添加activity-alias部分。如果 Unity 生成的清单里主 Activity 带了 LAUNCHER intent-filter你需要通过android:enabledfalse把它关掉或者用 manifest merger 的tools:noderemove移除它的 intent-filter。图标资源方面每个别名对应的图标都要准备完整的密度适配mipmap-mdpi、mipmap-hdpi、mipmap-xhdpi、mipmap-xxhdpi、mipmap-xxxhdpi以及mipmap-anydpi-v26下的自适应图标 XML。少一个密度对应机型上就可能显示模糊。5.2 iOS 打包时的文件引用检查iOS 这边最容易出问题的是备选图标文件没有被正确打进 bundle。Xcode 工程里图标文件必须出现在 Copy Bundle Resources 构建阶段。如果你是用 Unity 导出 Xcode 工程后再手动添加文件记得检查这一步。另外CFBundleAlternateIcons的声明要写在 Info.plist 里。Unity 导出的工程里 Info.plist 是自动生成的你需要用 PostProcessBuild 脚本在导出后自动注入这段配置否则每次重新导出都要手动改很容易漏。// PostProcessBuild 示例简化 [PostProcessBuild(1000)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target BuildTarget.iOS) { string plistPath Path.Combine(path, Info.plist); // 用 PlistDocument 读取并注入 CFBundleIcons 配置 } }5.3 上线前的双端检查清单检查项AndroidiOS图标资源完整性5 种密度 自适应各尺寸齐全清单/plist 配置别名 enabled 状态正确CFBundleAlternateIcons 完整默认图标可恢复是是传 nil切换后进程存活DONT_KILL_APP系统行为系统弹窗无有需产品确认审核说明一般无需建议准备用途说明低版本兼容无特殊要求iOS 10.3iOS 审核这块要特别提一句。备选图标功能本身是官方支持的正常使用不会违规。但如果你的 App 用图标切换来做一些伪装用途比如切换成其他 App 的图标那就有被拒的风险。正常的节日运营、主题切换用途在审核时如果被问到说明清楚即可。6. 实测中踩过的那些坑6.1 Android 别名切换后出现双图标这是最经典的问题。原因通常是主 Activity 的android:enabled没有设为false或者主 Activity 也带了 LAUNCHER intent-filter。系统认为主 Activity 和别名都是独立入口桌面上就出现了两个图标。排查方法用adb shell dumpsys package 你的包名查看所有组件的 enabled 状态确认同一时间只有一个带 LAUNCHER 的组件是 enabled 的。如果发现多个回到 Manifest 检查。还有一种情况是切换过程中先启用新别名、后禁用旧别名中间有一个极短的时间窗口两个都 enabled。虽然用户一般感知不到但个别 Launcher 会在这个窗口内生成第二个图标并缓存下来。所以一定要先禁用所有再启用目标。6.2 iOS 切换后图标模糊备选图标只放了一个尺寸系统在大屏设备上拉伸显示导致模糊。解决办法是每个备选图标都准备 60x60、120x120、180x180 三个尺寸对应 1x、2x、3x。命名要严格遵循名称尺寸的格式比如SpringIcon60x602x.png对应 120x120 像素。这里有个命名细节容易错文件名里的60x60是逻辑尺寸实际像素由 2x/3x 后缀决定。SpringIcon60x602x.png的实际像素是 120x120SpringIcon60x603x.png是 180x180。Info.plist 里只写SpringIcon60x60系统自动匹配后缀。6.3 切换图标后 App 冷启动异常有反馈说切换图标后下次冷启动时 App 崩溃或者白屏。排查下来多数是 Android 侧的问题切换别名时把主 Activity 也禁用了导致系统找不到启动入口。记住主 Activity 本身可以 enabledfalse但别名必须至少有一个是 enabledtrue。如果所有别名都被禁用App 就没有启动入口了点击图标会提示应用未安装。防御性写法在切换逻辑里加一个校验确保目标别名存在且切换后至少有一个别名是 enabled 的。切换失败时回滚到默认别名。6.4 部分机型切换不生效的兼容处理前面提过 Launcher 刷新问题。实测下来主流机型小米、华为、OPPO、vivo 的主流版本基本都能正常刷新但一些老旧机型或者定制 ROM 需要更长时间。可以在切换后延迟 1-2 秒再查询一次状态如果没生效就重试一次。另外在 App 处于后台时切换图标部分机型不会立即刷新桌面需要等用户回到桌面才刷新。所以建议在用户主动操作点击按钮且 App 在前台时执行切换成功率最高。7. 一些延伸思考与经验总结动态换图标这个功能技术本身不算复杂但它的价值在于运营灵活性。我自己的项目里春节、周年庆、联动活动都用过配合买量投放时点击率确实有提升。但有几个经验值得分享。第一图标数量不要贪多。Android 每多一个别名Manifest 就多一段声明包体也会因为多套图标资源而增大。iOS 每多一个备选图标bundle 也会变大。一般 3-5 套足够了太多反而增加维护成本和审核风险。第二切换入口要藏好。不要在主界面放一个显眼的换图标按钮那会干扰正常用户。放在设置页的二级菜单里或者通过活动页面引导进入。运营活动结束后入口可以下线但已经切换的图标不会自动恢复需要用户手动切回或者你通过版本更新强制恢复。第三测试要覆盖冷启动场景。切换图标后杀掉进程重新打开确认图标保持、App 正常启动。这个场景在开发阶段容易被忽略但线上出问题就是大问题。第四iOS 的系统弹窗要在需求阶段就同步给产品。我见过有团队上线后才发现这个弹窗被产品质疑为什么没提前说。技术方案评审时就把这个限制摆出来避免后期扯皮。这套方案我在两个项目里落地过Android 和 iOS 双端都跑通了。核心代码不复杂难的是对各种边界情况的处理和厂商兼容性。如果你正准备做这个功能建议先在真机上把双端的切换、恢复、冷启动三个场景都验证一遍再接入业务逻辑。
返回列表