ARTICLE DETAIL

资讯详情

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

Unity手游动态换App图标:Android与iOS双端原理与工程实践

Unity手游动态换App图标:Android与iOS双端原理与工程实践 Unity 手游在运营活动里换 App 图标是个典型“看着简单、实际坑不少”的需求。尤其是要在 Android 和 iOS 双端实现同一种运营能力Android 这边没有官方换图标 API得靠切换组件启用状态来“骗”桌面 LauncheriOS 却有系统内置的 setAlternateIconName 接口。这篇文章会把两端底层原理、关键代码、接入 Unity 的桥接层以及我踩过的审核和桌面刷新相关的坑一次性讲清楚。适合正在做 Unity 手游的客户端开发、需要接运营活动 SDK 的技术同学参考。1. 需求场景与方案选型1.1 为什么手游要做动态换图标手游对 App 图标的更换频率远高于工具类 App。核心原因有三个第一是节日运营比如春节、周年庆、暑期版本运营希望把包体图标临时换成带活动视觉元素的版本让目标用户在主屏上点开后进入活动页第二是素材 A/B Test同一款游戏主图标用“角色立绘”还是“场景剪影”可能直接影响点击率动态切换可以让服务端下发不同方案不用发新包就做验证第三是冲榜或召回配合买量素材统一视觉减少用户从广告落地页跳转 Game Center 或商店时的“认不出来”问题。正因为这类需求通常伴随着“上线时间不等人”的运营节奏开发者选方案时最怕那种需要重新打包审核的方案。iOS 的动态换图标 API 天然支持内置多套图标运行时切换不额外提审Android 虽然没有对等 API但通过 PackageManager 的组件启用状态机制也能实现同样的效果。理解这条思路你就抓住了整个功能的钥匙本质上不是“换图标”而是“换桌面的启动入口”。1.2 双端方案的本质与选型逻辑Android 的实现原理要绕一个弯。系统没有提供“修改图标文件”的公开权限但桌面启动器Launcher是依靠解析android.intent.action.MAIN和android.intent.category.LAUNCHER组合来确定应用入口的。这个入口可以是 Activity也可以是 activity-alias。于是可行思路就变成让多个 alias 指向同一个真实启动 Activity每个 alias 拥有不同的 icon 资源再通过 PackageManager 同时只启用其中一个 alias桌面图标就会跟着变化。iOS 则完全是另一种思路。iOS 从 10.3 起为 UIApplication 增加了 alternates 机制只要把备选图标文件打进 App Bundle并在 Info.plist 里声明好CFBundleAlternateIcons运行时调用 setAlternateIconName 就能立即切换系统会自动在所有需要图标的位置同步。苹果官方对这个 API 的态度是“允许但不鼓励频繁使用”所以你需要了解它的审核边界而不是单纯看能不能调通。选型时建议直接用“Android 组件切换 iOS 系统 API”这个双轨方案不要在 iOS 上模仿 Android 的组件切换思路。iOS 没有 alias 概念也无法对 App 自身的图标文件做运行时替换试着改主 Info.plist 里的 CFBundleIcons 是无效的因为主图标配置在安装后已经被系统拷贝成快照。维度AndroidiOS系统版本限制Android 全版本通用Android 12 需关注启动画面iOS 10.3 及以上低版本仅能保持默认图标官方 API无使用 PackageManager 组件状态UIApplication.setAlternateIconName是否需要重新提审不需要不需要但审核内容与商店主图标需一致切换生效速度桌面刷新有延迟几百毫秒到数秒基本即时生效包体积影响每套图标约增加几十 KB 到几百 KB每套备用图标需提供全部尺寸体积增加明显表格看完你应该能感觉到技术难度并不算高真正的复杂性集中在系统差异、配置细节、ROM 桌面刷新和审核风险这些“看不见的地方”。接下来分别拆两端实现。2. Android 端核心实现与踩坑2.1 清单文件中的多入口配置Android 端的第一步是改造 AndroidManifest.xml。核心原则是真实启动 Activity 不再直接承担桌面入口职责改为由多个 activity-alias 承担。每个 alias 的 icon 指向一套独立的 mipmap 资源alias 的 targetActivity 全部指向同一个启动 Activity。假设你的包名是com.yourgame.demo启动 Activity 叫SplashActivity默认图标和节日图标分别在mipmap/ic_icon_default和mipmap/ic_icon_holiday清单大致是这样的application android:iconmipmap/ic_icon_default android:labelstring/app_name activity android:name.SplashActivity android:exportedtrue android:themestyle/UnityDefaultTheme / activity-alias android:name.IconDefaultAlias android:targetActivity.SplashActivity android:iconmipmap/ic_icon_default 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:name.IconHolidayAlias android:targetActivity.SplashActivity android:iconmipmap/ic_icon_holiday android:enabledfalse android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias /application这里面有几个关键点很多人第一次写会踩。一是每个 alias 必须带完整的 MAIN LAUNCHER intent-filter否则 Launcher 不会把它识别为应用入口。我曾经把 filter 留在真实 Activity 上alias 不带 filter结果开启 alias 后桌面还是显示原来的 Activity 图标alias 配置完全没生效。二是同一时间只能有一个 alias 处于 enabled 状态。如果切换时直接把旧 alias disable、新 alias enable桌面会出现短暂“无图标”的空白状态如果忘记 disable 旧的桌面就会同时冒出两个同款 App 图标点哪个都能进 App但用户会以为装了两遍。三是 Android 12API 31开始强制要求显式声明 exported 属性。老项目从低版本升级过来时清单里所有 activity、alias、service、receiver 都要补上 android:exported否则编译能过安装或启动时报错。上面示例中我全部写成 true因为 alias 本身需要被系统启动器外部唤起。2.2 Java 层切换逻辑配置好清单后剩下就是通过 PackageManager 启用/禁用组件。核心代码并不复杂public class IconManager { private static final String ALIAS_PREFIX com.yourgame.demo.Icon; public static void changeIcon(Context context, String iconName) { // iconName 取值 DefaultAlias、HolidayAlias 等与清单 alias 后缀对应 String targetAlias ALIAS_PREFIX iconName; String[] allAliases new String[]{ com.yourgame.demo.IconDefaultAlias, com.yourgame.demo.IconHolidayAlias }; PackageManager pm context.getPackageManager(); for (String aliasName : allAliases) { ComponentName componentName new ComponentName(context, aliasName); int state aliasName.equals(targetAlias) ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; pm.setComponentEnabledSetting(componentName, state, PackageManager.DONT_KILL_APP); } } }为什么用DONT_KILL_APP而不是KILL_APP因为手游不想因为换图标被杀掉进程尤其切换动作可能来自游戏内运营弹窗用户还要继续玩。加了这个 flag 后PackageManager 会保留进程继续运行组件状态的变更持久化到系统设置里即使杀进程、重启手机也能记住当前启用的 alias。注意切换顺序先 disable 其他 alias再 enable 目标 alias 比较稳妥。虽然系统会快速重算 Launcher 的 resolve 结果但实测中如果先 enable 新 alias 再 disable 旧的桌面偶尔会保留两个图标。反过来操作最多就是桌面图标在几百毫秒内没有入口随后自动恢复。如果你用的是 Unity 打包的 Android 工程这里还有一个隐藏问题Unity 默认生成的 MainActivity 可能会被 Manifest 合并规则覆盖。建议在 Unity 导出工程后检查一下最终 AndroidManifest.xml确认主 Activity 不是带 LAUNCHER filter 的 UnityPlayerActivity。如果你不想手改 mainTemplate.gradle可以在 Player Settings 里关掉“Custom Main Manifest”然后直接用 Unity 生成的工程修改最终合并后的清单。2.3 Android 实际运行经验图标切换的生效速度在不同 ROM 上表现差异很大。原生 Android 和三星等海外机型通常很快几百毫秒内 Launcher 图标变化但部分国产 ROM 桌面有自己的一套图标缓存机制切完组件后桌面上旧图标可能要等几秒、重启桌面甚至重启手机才会消失。这不是你的代码写错而是桌面没有监听到或者没有及时处理 PackageManager 的组件变更广播。我的排查经验是先用辅助命令验证组件状态。通过adb shell pm get-app-links拿不到这个信息应该用adb shell cmd package query-activities --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER com.yourgame.demo如果查询结果里已经切换成新的 alias说明系统层逻辑没问题桌面显示问题基本可以归纳为 ROM 缓存。在这种情况下可以引导玩家把 App 从最近任务里滑掉再重新进桌面或者告知运营“切换后几分钟内生效”。不过千万不要在代码里主动杀 Launcher 进程市场上很多 ROM 会把这种操作判定为恶意行为。Android 12 之后增加了 Splash Screen启动页面默认会读取 App 图标作为启动画面素材。动态换图标后第一次冷启动时 SplashScreen 可能用的还是旧图标缓存这是系统层面的快照策略不影响功能但如果你对启动画面一致性有要求可以通过windowSplashScreenAnimatedIcon单独指定启动画面图标避免被动态切换影响。Android 13 则引入了“主题图标”THEME_ICON机制。如果用户开启了主题图标系统会根据各 App 的 MONOCHROME 图标做着色没提供 monochrome 资源的 App 会被系统直接从 adaptive icon 里做降级处理效果可能发灰或者变形。做动态换图标时建议为每套变体补一张 monochrome 图标否则在部分 Pixel 和国产旗舰的默认主题下图标观感会和其他 App 明显不一致。3. iOS 端核心实现与注意点3.1 Assets 与 Info.plist 准备iOS 端的准备工作比 Android 要更多环节。首先要在 Assets.xcassets 里为每套备选图标单独建一个 AppIcon 类型的 asset比如AppIcon-Holiday.appiconset。图标命名建议用只含字母和数字的字符串因为 Info.plist 里要用这个字符串作为 key太复杂的命名容易在配置时打错。以 iOS 目前主流尺寸为例一个备用图标至少需要提供 60pt2x120x120 像素和 60pt3x180x180 像素如果你要支持 iPad还要补 76pt2x、83.5pt2x 等。建议直接在 Xcode 的 asset catalog 里拖入对应空位避免手写 Contents.json 时尺寸遗漏。然后打开 Info.plist添加完整的 CFBundleIcons 配置keyCFBundleIcons/key dict keyCFBundleAlternateIcons/key dict keyAppIcon-Holiday/key dict keyCFBundleIconFiles/key array stringAppIcon-Holiday/string /array keyUIPrerenderedIcon/key false/ /dict /dict keyCFBundlePrimaryIcon/key dict keyCFBundleIconFiles/key array stringAppIcon/string /array /dict /dict这里最容易出错的地方是CFBundleIconFiles中的字符串必须和 xcassets 里的 AppIcon 名称完全一致。注意它对应的是 asset 目录名不是图片文件的文件名。如果你建的是AppIcon-Holiday.appiconset这里就写AppIcon-Holiday。还有一点不要在CFBundleAlternateIcons里漏掉主图标对应的 key。主图标对应的配置放在CFBundlePrimaryIcon里不是跟备用图标并列的数组项。如果 Info.plist 里的 CFBundleIcons 结构不对运行时会发现supportsAlternateIcons返回 NO但 Xcode 编译不报错非常坑。3.2 代码切换与 Unity 接入iOS 的切换代码本身很简洁关键是版本判断和错误回调。完整写法- (void)changeAppIcon:(NSString *)iconName completion:(void (^)(NSError *error))completion { if (available(iOS 10.3, *)) { if ([[UIApplication sharedApplication] supportsAlternateIcons]) { [[UIApplication sharedApplication] setAlternateIconName:iconName completionHandler:^(NSError * _Nullable error) { if (completion) { completion(error); } }]; } else { NSError *error [NSError errorWithDomain:AppIconError code:-1 userInfo:{NSLocalizedDescriptionKey: unsupported}]; if (completion) { completion(error); } } } else { if (completion) { completion([NSError errorWithDomain:AppIconError code:-2 userInfo:nil]); } } }注意如果要切换回默认主图标setAlternateIconName 的第一个参数传 nil 即可不是传主图标名。不少同事第一次写都传了主图标名AppIcon系统会报错或者静默失败。在 Unity 工程里iOS 这一侧通常用 Unity framework 的 Objective-C 类和 C# 的__Internal互操作。原生侧做一个 C 函数导出extern C void _ChangeAppIcon(const char* iconName) { NSString *name iconName ? [NSString stringWithUTF8String:iconName] : nil; UIViewController *vc UnityGetGLViewController(); if (available(iOS 10.3, *)) { [[UIApplication sharedApplication] setAlternateIconName:name completionHandler:^(NSError * _Nullable error) { UnitySendMessage(IconManager, OnChangeComplete, error ? fail : success); }]; } }C# 侧声明[DllImport(__Internal)] private static extern void _ChangeAppIcon(string iconName);调用时传入方案标识。这里我特别提醒一点setAlternateIconName 必须在主线程调用。iOS 系统 API 要求 UI 相关操作在主线程如果你从 Unity 的后台线程或原生网络回调里直接调容易出现“unrecognized selector”级别的崩溃或 UI 更新异常。稳妥做法是在完成配置下发后再通过一个调度切换回主队列执行。3.3 审核与运营实践的平衡iOS 动态换图标最容易被忽视的风险不在技术而在审核和运营节奏。第一备用图标必须跟随 App 版本打进包体。苹果没有提供远程下发图标的能力运营想在明天上线一个端午节图标结果新版还在审核今天是无法通过动态接口“凭空加图标”的。所以运营排期里必须把图标素材预留至少一个版本提前打进包内。第二App Store 审核时的展示图标与包内主图标要保持一致。我们曾经遇到过一个问题iTC 后台上传的商店截图是 A 图标但包内 CFBundlePrimaryIcon 对应的 asset 被队友换成了 B 图标审核时被质疑“应用与宣传不符”虽然没直接拒但被要求补充说明。动态换图标本身并不违规但它也不能成为“挂羊头卖狗肉”的手段。所有备选图标都应该是和你的游戏强相关的素材不要用蹭热点、擦边、误导性的图像。第三不要做成极端频繁或隐藏式切换。苹果系统 API 本身没有限制切换次数但审核人员会看到你的应用具备这个能力。如果一个普通的工具类 App 上线后每天自动换一个完全不同风格的图标很容易被怀疑“包体另有用途”或“疑似马甲”。游戏场景下配合节日活动一周内切换一两次是目前比较常见且安全的节奏。最后提一下受控环境如果用户设备安装了限制 App 自定义的描述文件或者系统处于教育、企业设备的监管模式supportsAlternateIcons可能返回 NO。调用层要做降级处理至少保证用户遇到这种情况时不会出现“点了没反应”的困惑可以在 UI 上提示当前设备不支持切换。4. 在 Unity 工程中的工程化落地4.1 原生桥接层设计把两端方案接入 Unity 时最忌讳的是把切换代码直接写在业务逻辑里。我建议在原生工程里封装一个统一的 IconManagerAndroid 暴露静态方法iOS 暴露 C 函数C# 侧只保留一个薄薄的管理器。Android 侧我习惯建一个 Java 单例因为 Unity 侧通过 AndroidJavaClass 调用静态方法比较方便package com.yourgame.android; public class IconManager { public static void changeIcon(String iconName) { Context context UnityPlayer.currentActivity; // 检查是否在主线程 if (Looper.myLooper() ! Looper.getMainLooper()) { final String name iconName; UnityPlayer.currentActivity.runOnUiThread(() - changeIconInternal(context, name)); } else { changeIconInternal(context, iconName); } } }C# 侧不需要关心线程因为 Unity 的 Update 生命周期都是主线程但注意 Remote Config 的回调不一定在主线程所以在回调里再次用UnityMainThreadDispatcher或者SynchronizationContext包裹一下再调原生。iOS 侧同理C# 侧统一接口public enum AppIconVariant { Default, Holiday, Anniversary } public static class AppIconSwitcher { public static void SwitchTo(AppIconVariant variant) { #if UNITY_ANDROID !UNITY_EDITOR SwitchAndroid(variant); #elif UNITY_IOS !UNITY_EDITOR SwitchIOS(variant); #endif } }这种做法的好处是整个项目里只有一处需要感知“当前平台是 Android 还是 iOS”业务层只关心枚举值运营配置下发上来的字符串可以映射到枚举不直接暴露原生 alias 名。后续如果新增一套图标只改原生枚举映射和清单文件不用动业务代码。4.2 远程配置驱动的自动切换流程动态换图标最常见的接入方式是服务端配置驱动。一个比较稳的流程是这样的本地存储当前图标变体 ID默认是default。App 启动进入主菜单后向运营配置中心请求app_icon_variant字段。如果请求到的值等于本地值忽略。如果不同调用 AppIconSwitcher.SwitchTo 执行切换同时更新本地值。切换回调返回后上报一次埋点记录切换结果和耗时。这个流程看起来简单但有几个务实的细节要处理。一是不要在 Splash 阶段阻塞等待配置。很多运营同学希望“用户还没进游戏图标已经换好了”但网络请求不可控万一配置接口超时总不能让玩家卡在启动界面。我建议把切换放到登录成功后的主菜单阶段哪怕图标晚几秒变也不影响正常游戏。二是本地存储的更新要和切换回调联动。iOS 的 setAlternateIconName 有 completionHandlerAndroid 的组件切换虽然理论上立即生效但桌面刷新可能延迟。如果你是“先改本地值、再发切换”万一切换失败本地值已经是新值下次启动就不会再触发重试反过来“先切换成功、再更新本地值”切换失败后下次启动还能自动重试。推荐后者。三是配置下发频率要做节流。动态图标是全局用户可见的变化频繁切换会造成不必要的桌面刷新开销也容易让审核人员看到“一个包内不停换脸”的行为。常规做法是同一变体一天最多切换一次或者只在特殊活动开始/结束时切换一次。4.3 常见问题速查与避坑清单实战中故障大多集中在配置、组件状态和桌面显示这几个层面。列一个排查速查表方便你在接入时对照问题现象可能原因排查方向Android 切换后桌面图标不变Launcher 缓存未刷新等待几秒或重启桌面用 query-activities 验证组件状态Android 桌面出现两个应用图标同时存在两个 enabled 的 alias检查是否先 enable 新 alias 后才 disable 旧 alias遍历统一 disable 再 enableAndroid 冷启动进入 Splash 时用了旧图标Android 12 启动画面快照缓存不用处理或用 windowSplashScreenAnimatedIcon 覆盖iOS 调用 setAlternateIconName 后图标没变Info.plist 中 CFBundleIconFiles 名称不匹配检查备用 asset 目录名与 plist key/数组值是否一致iOS 的 supportsAlternateIcons 返回 NO系统版本低于 10.3 或处于监管模式降级处理异步展示可用性提示审核沟通中提示“图标与展示不一致”iTC 上传的主图标与包内 CFBundlePrimaryIcon 不一致发布前用符号链接或构建脚本对比主图标资源配置下发后图标没切换远程配置没有下发到当前群组或命中了缓存先看本地存储值是否更新再用原生层手动调用验证还有一个容易被忽略的包体积问题。iOS 每套备用图标都要包含所有规格一套完整的 iPhone iPad 图标可能超过 500 KB再加上 Android 端的 adaptive icon 多密度资源三五套素材轻松多出几个 MB。对包体敏感的项目建议用 Icon Composer 或自动化脚本压缩边缘透明通道同时避免放不必要的 PNG 全尺寸大图。记住图标素材是按包体维度常驻内存之外的磁盘资源不是按用户实际使用多少来算的宁可少而精。根据我个人经验动态更换 App 图标这件事真正的技术门槛不在调通接口而在于你要在“运营灵活”和“平台规范”之间找到平衡。Android 端组件切换虽然技巧性很强但只要把 list 里 alias 状态收敛好运行性能几乎没有影响iOS 端则要把所有备选图标视为包内正式资源来管理任何“临时加一个图标”的想法都要提前一个版本去规划。做 Unity 手游项目时我建议先单独验证双端原生 demo确认你使用的 Unity 版本导出的 Android 清单合并结果和 iOS 互操作签名没有冲突再接入游戏主流程这样后面接运营活动时就不会被奇怪的系统行为卡住。
返回列表