ARTICLE DETAIL

资讯详情

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

Unity双端动态切换App图标:Android与iOS完整实现指南

Unity双端动态切换App图标:Android与iOS完整实现指南 我写这篇文的背景是把动态换图标这个需求从“接到运营需求”一直到“双端上线”完整跑了一遍。先说结论这个玩法在 Android 和 iOS 上能实现但两条路的原理完全不同、坑也完全不一样。很多帖子只讲其中一端或者只给结论不给原因导致 Unity 项目接的时候处处碰壁。这篇文章我按自己的落地经验把双端方案、C# 封装、服务端配置和踩坑记录都拆开讲清楚打算接这个需求的同学可以直接抄作业。1. 先搞清楚需求我们到底在谈哪种“换图标”1.1 营销向的图标运营是什么手游圈子里“换图标”通常不是给用户一个设置入口而是运营在特定节点把桌面图标换成活动视觉。比如周年庆换个庆典背景、新版本上线换一张主视觉、节假日改成应景风格。目的很直接已经下载过的玩家每天看桌面图标一变就等于一次免费的曝光比推送更柔和也比“打开 App 才能看到活动”的触达效率高。这类需求在立项时经常被描述得很简单“后台配一下客户端启动时读配置把图标换掉就行。”但我实际做下来发现这里藏着一个非常关键的认知差异双端都只能切换“预置在安装包里的图标”不能像拉取公告图片一样从服务器拉一张 PNG 直接换上。这和很多运营同学的第一直觉不一样也是后面所有方案设计的前提。1.2 一个容易被误解的前提图标必须随包体预置为什么不能动态下载图片当图标因为系统的安全机制不允许普通 App 在运行时修改自己的安装信息。Android 的 launcher 图标本质上是 Manifest 里声明的 Activity 元数据iOS 的图标则由 Info.plist 声明并由系统统一管理都不是直接指向一个本地文件的“快捷方式”。所以“动态”两个字准确说是“包内预置多套图标运行时通过系统能力切换当前生效的那一套。” 运营想要改图只能从我们预先做好的几套里选一套或者在下次发版时把新图打进去。这也决定了项目结构从一开始就要规划好图标素材、别名配置、服务端开关、回滚策略缺一不可。下面分别说两端怎么做。2. Android 侧用 Activity-alias PackageManager 做双 launcher 切换2.1 Activity-alias 方案的核心原理Android 上最常见、也最稳定的动态换图标方案不是去改图标资源而是利用activity-alias这个 Manifest 机制。原理一句话你可以在 Manifest 里声明多个指向同一个 Activity 的“快捷入口别名”每个别名可以拥有独立的android:icon和android:label。Launcher 看到的不是 Activity 本身而是那个带着MAINLAUNCHER过滤器的组件。我们通过 PackageManager 在运行期动态启用其中一个别名、禁用其他别名相当于让系统认为“当前可用的启动入口变成了另一个”Launcher 刷新后就会展示新的图标。这个方案之所以稳是因为它不修改任何资源文件只是改动了 PackageManager 里的组件启用状态。这个状态是持久的用户重启手机、杀掉桌面进程后依然有效。2.2 需要你写进工程的 Manifest 配置以 Unity 项目为例首先得拿到 Android 的最终 Manifest 控制权。我采用的是在Assets/Plugins/Android/AndroidManifest.xml里维护一份主 ManifestUnity 构建时会合并它。下面是一份关键配置的骨架manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.game application android:iconmipmap/ic_launcher android:labelstring/app_name activity android:namecom.unity3d.player.UnityPlayerActivity android:exportedfalse /activity activity-alias android:name.LauncherDefault android:targetActivitycom.unity3d.player.UnityPlayerActivity android:iconmipmap/ic_launcher 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.LauncherFestival android:targetActivitycom.unity3d.player.UnityPlayerActivity android:iconmipmap/ic_launcher_festival android:labelstring/app_name_festival android:enabledfalse intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias /application /manifest注意几个细节真实的UnityPlayerActivity不要加MAIN/LAUNCHER过滤器否则桌面上会出现两个图标。默认别名LauncherDefault不写android:enabledfalse保证新装机用户一定有图标。其他活动图标别名默认都是enabledfalse需要切换时再从代码里启用。android:exportedfalse是写给 alias 还是 targetActivity 要看具体 gradle 版本要求Unity 较新版本构建时如果不写会在编译期报警建议补上并统一使用显式包名形式。2.3 Unity C# 侧如何调用 Java 插件Manifest 配置好后运行时的切换逻辑就要从 C# 调到 Java 侧。我在项目里用一个极简的 Java 静态类封装 PackageManager 调用放在Assets/Plugins/Android/下的 AAR 或 jar 中C# 通过AndroidJavaClass调用。Java 侧核心代码package com.example.game.iconswitch; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; public class IconSwitcher { public static void switchTo(Context context, String enableAlias, String[] disableAliases) { PackageManager pm context.getPackageManager(); // 先启用目标组件避免出现“没有任何 launcher 组件可用”的空窗期 pm.setComponentEnabledSetting( new ComponentName(context.getPackageName(), enableAlias), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ); for (String alias : disableAliases) { pm.setComponentEnabledSetting( new ComponentName(context.getPackageName(), alias), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ); } // 额外广播一次包信息变化给部分桌面 Launcher 刷新提示 Intent refresh new Intent(Intent.ACTION_PACKAGE_CHANGED); refresh.setData(Uri.parse(package: context.getPackageName())); context.sendBroadcast(refresh); } }注意我这里的顺序先ENABLE再DISABLE。很多人习惯先禁用原来再启用新的这在部分 ROM 上会导致一瞬间系统找不到可用的 launcher 组件桌面可能弹“没有可用的桌面程序”或短暂白屏。先启用再禁用能最大限度避免竞态。C# 侧调用#if UNITY_ANDROID !UNITY_EDITOR using UnityEngine; public static class AndroidIconSwitcher { private static AndroidJavaClass _helperClass; public static void SwitchTo(string enableAlias, string[] disableAliases) { if (_helperClass null) _helperClass new AndroidJavaClass(com.example.game.iconswitch.IconSwitcher); using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); // Java 方法接收 String 数组C# 传 string[] 即可 _helperClass.CallStatic(switchTo, activity, enableAlias, disableAliases); } } } #endif这里有个 Unity 老版本兼容问题部分 Unity 版本对AndroidJavaObject传参string[]时偶尔出现参数不匹配稳妥做法是在 Java 方法里只接收一个String逗号分隔例如LauncherDefault,LauncherFestival然后 Java 内部用split(,)解析。虽然多一步解析但跨 Unity 版本的稳定性更好。2.4 三个绕不开的 Android 坑缓存、杀进程、自适应图标第一图标不是“立刻刷新”的。setComponentEnabledSetting调用成功后SystemUI 和桌面 Launcher 不一定会马上重新读取组件状态。部分原生 Android 设备上回到桌面几秒内就能看到图标变化但国内一些 ROM 的桌面图标缓存可能持续几分钟甚至要手动重启桌面才会刷新。这不是你的代码错了而是 Launcher 的缓存策略导致。测试时的取巧办法是adb shell am force-stop com.android.launcher3或直接重启手机判断最终状态。第二DONT_KILL_APP不能漏。如果漏掉这个 flag系统在禁用组件的同时可能连进程一起杀掉。对玩家来说图标是变了但游戏进程直接被终止体验非常差。尤其是切完图标玩家还想立刻继续游戏时这个坑会非常明显。第三Android 8.0 之后自适应图标Adaptive Icon是主流。如果你只给了普通 PNG系统会自动裁剪到一个圆形遮罩里容易切边。正确做法是用 mipmap 目录下的ic_launcher.xml这种 adaptive icon 描述文件里面分别定义背景层和前景层。多套图标就准备多套 XML运行时替换的是这个 XML 资源的引用。Android 12 以后如果启动器开启了“主题图标”系统还可能拿你提供的 monochrome 单色层去统一着色颜色表现跟设计稿不一定一样需要在设计阶段提前说明。3. iOS 侧CFBundleAlternateIcons 官方备用图标方案3.1 iOS 10.3 之后才有的 setAlternateIconNameiOS 上能做的“动态换图标”准确说是从 iOS 10.3 开始提供的setAlternateIconName(_:completionHandler:)API。这个 API 是苹果官方给的运行在 UIKit 层面能力范围很清楚你只能切换到项目里已经声明好的其他图标不能把任意一张图片塞进去。前提条件有三个系统版本 iOS 10.3现在要上线的基本都满足在 Info.plist 里通过CFBundleAlternateIcons声明备用图标所有备用图标文件都打包进 App Bundle满足这三个条件后调用setAlternateIconName会触发系统的图标切换completionHandler会告诉你成功还是失败。返回系统默认图标也很简单调用时传nil即可。3.2 Info.plist 声明备用图标声明位置在CFBundleIcons字典下的CFBundleAlternateIcons。每个备用图标项用一段字典描述里面通过CFBundleIconFiles数组引用包里的图片资源。我在 Unity 项目里是用构建后处理脚本修改 Xcode 工程的 Info.plist避免每次打完 Xcode 工程再手动点选。脚本大致结构#if UNITY_IOS using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; public class IconPostProcess { [PostProcessBuild(400)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target ! BuildTarget.iOS) return; string plistPath path /Info.plist; PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementDict root plist.root; PlistElementDict bundleIcons root.CreateDict(CFBundleIcons); PlistElementDict alternate bundleIcons.CreateDict(CFBundleAlternateIcons); PlistElementDict festival alternate.CreateDict(FestivalIcon); festival.CreateArray(CFBundleIconFiles).AddString(FestivalIcon); plist.WriteToFile(plistPath); // 同步把图片资源添加到 Xcode 工程 // 这里用 PBXProject 将 Assets/Plugins/iOS/FestivalIcon.png 等文件加入 Build Phase } } #endif实际生成的 Info.plist 内容会长这样keyCFBundleIcons/key dict keyCFBundleAlternateIcons/key dict keyFestivalIcon/key dict keyCFBundleIconFiles/key array stringFestivalIcon/string /array /dict /dict /dict图片资源的命名需要注意iOS 会查找FestivalIcon.png、FestivalIcon2x.png、FestivalIcon3x.png这样的文件无需带后缀名。图标尺寸方面虽然没有强制校验每个备用文件的具体像素但实际会按 App Store 对主图标的同等要求来对待推荐直接给 1024x1024 的原图并由 Xcode 在构建时自动生成各尺寸。这里建议做成构建后处理脚本自动拷贝如果手抖漏了文件运行时setAlternateIconName会直接报错。3.3 Objective-C 插件与 Unity 回调Unity 没法直接调 iOS 的UIApplication所以需要一层 Objective-C 插件。我在Assets/Plugins/iOS里放一个精简的.mm文件#import UIKit/UIKit.h #import UnityAppController.h extern C { void UnityIconSwitch_Apply(const char *iconName) { NSString *name iconName ? [NSString stringWithUTF8String:iconName] : nil; if (![[UIApplication sharedApplication] supportsAlternateIcons]) { UnitySendMessage(AppIconManager, OnIOSResult, 0); return; } dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { if (error) { NSLog([IconSwitch] %, error.localizedDescription); UnitySendMessage(AppIconManager, OnIOSResult, 0); } else { UnitySendMessage(AppIconManager, OnIOSResult, 1); } }]; }); } }C# 侧声明DllImport#if UNITY_IOS !UNITY_EDITOR using System.Runtime.InteropServices; public static class iOSIconSwitcher { [DllImport(__Internal)] private static extern void UnityIconSwitch_Apply(string iconName); public static void SwitchTo(string iconName) { UnityIconSwitch_Apply(iconName); } public static void ResetToDefault() { UnityIconSwitch_Apply(null); } } #endif回调是通过UnitySendMessage发给场景里名为AppIconManager的 GameObject。脚本上需要挂一个接收方法OnIOSResult(string result)这一步很容易漏最小 Demo 里可以只做日志输出但上线版本建议把回调结果转发给更上层的管理器。3.4 审核和尺寸方面的隐性限制备用图标是官方 API大多数情况下不会被 App Store 拒但有几个隐性风险图标内容需要符合审核规范不能把广告位、二维码、诱导信息直接放图标上。尤其注意切换后的图标依然会出现在桌面和设置中本质上是 App 外观的一部分。苹果允许程序内自动切换但审核时如果发现图标频繁变化或存在混淆用户的行为有被打回的风险。稳妥做法是切换动作和用户行为或明确的运营活动绑定而不是完全随机。应用商店内展示的图标不会随运行时切换变化。用户在主屏看到的图变了商店页面还是上传包时的那张这是正常的提前给运营打个预防针别到时候又来找开发兴师问罪。4. 双端统一封装C# 接口 服务器配置 回滚设计4.1 抽象一个 AppIconManager两端原理完全不同但到了 C# 层我希望业务逻辑只看到一个方法SwitchTo(AppIconType type)。所以我做了一层AppIconManager把平台差异全收进去。大致设计public enum AppIconType { Default, Festival, Anniversary } public static class AppIconManager { public static bool IsSupported { get { #if UNITY_ANDROID !UNITY_EDITOR return true; #elif UNITY_IOS !UNITY_EDITOR return true; #else return false; #endif } } public static void SwitchTo(AppIconType type) { if (!IsSupported) return; #if UNITY_ANDROID !UNITY_EDITOR string current type AppIconType.Default ? .LauncherDefault : .LauncherFestival; string disable type AppIconType.Default ? .LauncherFestival : .LauncherDefault; AndroidIconSwitcher.SwitchTo(current, new[] { disable }); #elif UNITY_IOS !UNITY_EDITOR string name type AppIconType.Default ? null : FestivalIcon; iOSIconSwitcher.SwitchTo(name); #endif } }这样上层运营活动逻辑只需要知道“今天切到 Festival”不需要关心 Android 的 alias 命名还是 iOS 的 plist key。关于别名映射我不建议在代码里散落硬编码。项目里可以配一张表例如“AppIconType - 平台参数”用 ScriptableObject 或 JSON 资源配置都行。多套图标上线后这张表还能跟服务器下发的 key 对上。4.2 服务端只下发“用哪个”不下发图片这是整个方案里最容易被误解的部分。很多运营后台提需求时会说“我们在后台传一张图上去客户端换掉”。但是双端系统都不允许这么玩。正确的做法是服务端返回一个当前生效的 icon key比如festival_2025客户端根据 key 查本地映射表再调用对应的平台切换逻辑。服务器管的是“什么时候切到哪一套”客户端管的是“这套图对应的系统切换”。映射表大概长这样服务端 keyAndroid alias 后缀iOS plist 图标名备注defaultLauncherDefault主图标永久保留festivalLauncherFestivalFestivalIcon活动专用anniversaryLauncherAnniversaryAnniversaryIcon纪念日专用为什么不能下发图片我在第一节已经解释过了。这里再补充一个运营侧容易忽略的成本Android 的 alias 方案每增加一套图标意味着 Manifest 里要加一个activity-alias节点并且要保证图标资源被 mipmap 引用iOS 每增加一套要在 Info.plist 声明加图片文件。这些都必须随版本发布不是后台配置能省掉的。4.3 定时回滚与降级策略换图标最尴尬的场景是活动结束了图标还留在桌面上一周。运营同学忘配回滚或者活动结束时间表格配错了都会让“新版本图标”变成“旧图标事故”。所以我把回滚逻辑做成两个层级第一层是“服务器参数回滚”。客户端每次启动或者切后台回前台时请求一次配置。如果endTs已经小于当前时间就把 key 强制置为default。这个方案不依赖系统闹钟也不依赖客户端是否有常驻进程只要玩家打开游戏就会自动纠正。第二层是“本地定时兜底”。在 Android 端切换成活动图标时顺便注册一个AlarmManager到endTs触发广播再调一次IconSwitcher切回默认。这个兜底能覆盖“活动期间玩家一直在玩但没有重新走服务端配置”的情况。iOS 端没有通用的定时执行能力所以只能靠服务端状态判断不建议在 iOS 侧强行搞本地定时器。降级策略同样要考虑如果调用平台切换方法抛异常比如 iOS 的备用图标声明缺失AppIconManager 需要捕获并静默回退到默认不能因为这个功能导致游戏启动白屏或崩溃。我遇到过的案例是打了测试包没走构建后处理脚本Info.plist 没声明备用图标iOS 运行时supportsAlternateIcons返回 NO插件没做保护直接崩了。后来才在 Objective-C 侧加了保护分支。5. 真机实测复盘与当前推荐做法5.1 安卓设备上最常翻车的一瞬间安卓端我在本地测试时最常踩的坑不是代码逻辑而是“桌面图标不刷新”。方法调用成功后有时要等十几秒有时怎么等都不变重启手机才变。一开始我以为是方案不生效后来用adb shell dumpsys package查launcher的 enabled 状态发现状态已经正确切换了纯粹是桌面缓存问题。做真机验收时建议按这个流程走调用切换后先检查dumpsys package里对应组件的 enabled 状态。状态正确但桌面没变就切到后台再切回来或者重启桌面进程。部分 ROM 上图标资源如果通过 adaptive icon 的 XML 引用背景层的动态取色会让“看起来没变化”一定要确认前景图设计差异足够大。另外如果同时接入了“桌面快捷方式”功能比如长按 App 图标弹出来的那些 Shortcut它们的缓存策略和 launcher 图标不是同一个有时图标切了快捷方式还显示旧的。这个我没找到能在代码里强制刷新的通用办法只能尽量接受并提醒运营别过度依赖“立刻全量刷新”。5.2 iOS 实测时的意外情况iOS 端的意外主要在信息差上。第一是图标切换不是“瞬时的”completionHandler返回成功主屏上可能还要几秒或者回桌面才能看到。第二是setAlternateIconName的 iconName 必须和 Info.plist 里声明的完全一致大小写差一点都不行。第三是系统如果正处于某个特殊状态比如刚升级系统、正在备份可能返回error这类错误不是我们代码问题不能一失败就重试轰炸。更实用的一点iOS 切换回默认图标时传nil是可靠用法但部分开发者会把“默认图标”也当作一个备用图标声明然后传它的名字。这个方式也能生效但绕了一圈还会导致supportsAlternateIcons的逻辑变复杂。5.3 给 Unity 项目落地的最终建议如果你现在要在 Unity 里接这个功能我会建议按这个优先级推进第一优先把两端官方能力分别做通Android 维护好 manifest 和 Java 插件iOS 维护好 plist 和 Objective-C 插件先脱离 Unity 层把原生端验证完。第二优先再封装 C# 层用 AppIconManager 统一入口把平台判断、回调、异常捕获都收进来不要让业务侧感知双端差异。第三优先最后才接服务端配置。因为服务端下发只解决“什么时候切”的问题如果两端原生能力没验证好服务端配置再正确也白搭。这套方案目前在我参与的项目里已经跑过两个活动周期Android 和 iOS 都没有出现致命问题。如果你准备复用请务必把 Manifest 和 plist 的配置排在开发计划的最前面因为这两个配置一旦遗漏代码写得再漂亮也切不动图标。
返回列表