ARTICLE DETAIL

资讯详情

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

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

Unity手游动态更换App图标:Android与iOS双端方案详解 做手游项目的人应该都遇到过这种场景运营大促前三天丢来一个需求——把桌面上的App图标换成活动皮肤最好还能挂个角标。你打开排期一看iOS审核慢的时候要一周各安卓渠道审核节奏又参差不齐等图标过审活动差不多也凉了。于是能不能不重新提审、不改包体就直接更换桌面图标就变成了客户端团队绕不开的硬需求。Unity做双端手游时这个问题尤其典型。很多团队连独立原生工程都没有全部业务都在Unity里所以这里说的动态更换App图标本质上是用Unity作为业务层通过原生桥接分别调用Android和iOS的系统能力在不重新发布版本的情况下切换桌面图标。这篇文章就把我这边实际落地的一套双端方案完整拆开讲。Android以activity-alias入口切换为核心iOS走CFBundleAlternateIcons加setAlternateIconName中间层用一套C#封装包住平台差异。看完你不仅能跑通功能还能避开我在真机测试、渠道审核、桌面兼容性上踩过的那些坑。1. 一张图标背后的运营刚需为什么手游都在做动态换肤先说清楚为什么不然很容易把技术方案做偏。动态换图标不是客户端工程师自嗨的功能它背后是运营活动的真实痛点。1.1 旧方案换一次图标就像经历一次灾难传统情况下想换App图标就得打一个新包提交审核。iOS这边App Store审核慢的时候三到五天起步遇到节假日更没谱。安卓渠道虽然普遍比iOS快但国内分发市场少说也有十来个主流渠道每个渠道的审核节奏、素材规范、包体要求都不完全一样。结果就是一个简单的图标替换硬生生变成跨平台、跨渠道的发布工程。更要命的是活动图标往往是有时效性的。比如春节、周年庆、版本大节点图标只在特定周期内有意义。如果审核周期覆盖不了活动窗口这个图标换上去就是白换甚至会出现活动都结束了图标才换上的尴尬局面。1.2 动态方案真正解决的三个核心场景在我接触的项目里动态换图标主要覆盖三类需求。第一种是运营活动皮肤。春节、周年庆、联动活动预先在包里埋好对应图标服务端按时间段下发切换指令。活动前自动换上活动后自动切回默认图标全程不需要任何审核流程。第二种是AB测试与点击率实验。同样的游戏主题不同的图标视觉对新增转化率影响很大。有些团队会准备两套或三套完全不同的图标风格按用户分组下发直接对比桌面点击率。这个玩法只有动态方案能支撑因为静态提审做不到快速切换。第三种是用户主动定制。比如给玩家提供经典版/高对比版/暗色版多种图标选择让用户自己在设置页选。这个场景对用户好感度有帮助尤其在玩家看重个性化的品类里。1.3 双端能力概览Android和iOS根本不是一回事这里要提前打个预防针双端动态更换App图标的实现路径完全不同。Android端从系统设计上就没有一个官方动态修改应用图标的接口通常的通用做法是利用PackageManager.setComponentEnabledSetting在多个预埋的入口组件之间切换——也就是标题里提到的activity-alias方案。这个方案能改默认桌面主图标不依赖版本号Android 4.x到14实测都能用但对桌面Launcher的刷新机制有依赖。iOS端从10.3开始苹果官方开放了UIApplication.setAlternateIconName接口允许开发者在预置的备选图标之间切换。这个方案是系统级支持的切换效果非常干净但限制也很多后面会展开讲。所以整体上Android方案更暴力一些iOS方案更官方一些。但不管哪一端都要通过Unity的桥接层才能让C#业务代码调用到原生能力。2. Android端方案拆解activity-alias入口切换与ShortcutManager桌面捷径Android这边我实现了两种技术路径适用场景不一样先直接给结论运营侧自动换图标认准activity-alias方案用户手动个性化选图标可以额外考虑ShortcutManager的快捷方式方案。下面详细拆。2.1 原理组件开关切换的出入口魔法Android的App安装包在桌面上的图标入口本质上是Manifest里的一个组件声明——通常是主Activity带着MAIN和LAUNCHER两个action。系统Launcher通过PackageManager查询到当前启用状态的入口组件然后把它的icon和label渲染到桌面上。activity-alias的思路就是在Manifest里给同一个目标Activity声明多个别名入口这些别名一个个都是独立的组件可以分别设置自己的icon和label而且默认情况下同一时刻只启用其中一个。切换时用setComponentEnabledSetting把当前的入口disable把另一个enable。Launcher监听到组件变化后会刷新桌面图标。这个方案之所以手游通用是因为它不要求手机系统版本也不需要用户授权整个过程对用户是静默的。2.2 完整落地Manifest预埋、Java桥接类、Unity侧调用先看Manifest。正常情况下你的Unity游戏主Activity是com.unity3d.player.UnityPlayerActivity——当然很多项目会做自己的壳Activity继承它。这里以Unity默认壳为例application android:iconmipmap/ic_launcher android:labelstring/app_name !-- 默认入口 -- activity android:namecom.unity3d.player.UnityPlayerActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity !-- 活动入口1春节皮肤 -- activity-alias android:name.SpringFestivalIcon android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_spring android:labelstring/app_name android:targetActivitycom.unity3d.player.UnityPlayerActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias !-- 活动入口2周年庆皮肤 -- activity-alias android:name.AnniversaryIcon android:enabledfalse android:exportedtrue android:iconmipmap/ic_launcher_anniv android:labelstring/app_name android:targetActivitycom.unity3d.player.UnityPlayerActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity-alias /application有几个细节提醒。android:exportedtrue是必须的别漏因为桌面Launcher本身就是外部组件入口不允许外部访问的话切换会失灵。还有一点android:icon资源必须真实存在于对应的mipmap目录否则Launcher解析不到资源桌面可能短暂出现茶杯小人占位图标。然后写一个Java桥接类放在Assets/Plugins/Android/下Unity构建时会自动打包进去。package com.yourgame.plugin; import android.content.ComponentName; import android.content.Context; import android.content.pm.PackageManager; public class IconSwitcher { private static Context sContext; public static void init(Context context) { sContext context.getApplicationContext(); } public static void switchIcon(String aliasName, boolean enable) { if (sContext null) return; PackageManager pm sContext.getPackageManager(); // aliasName 传 default 时代表默认主Activity ComponentName target; if (default.equals(aliasName)) { target new ComponentName(sContext, com.unity3d.player.UnityPlayerActivity); } else { target new ComponentName(sContext, sContext.getPackageName() . aliasName); } int state enable ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; pm.setComponentEnabledSetting( target, state, PackageManager.DONT_KILL_APP ); } public static boolean isEnabled(String aliasName) { if (sContext null) return false; ComponentName target; if (default.equals(aliasName)) { target new ComponentName(sContext, com.unity3d.player.UnityPlayerActivity); } else { target new ComponentName(sContext, sContext.getPackageName() . aliasName); } return pm.getComponentEnabledSetting(target) PackageManager.COMPONENT_ENABLED_STATE_ENABLED; } }注意切换逻辑要成对调用。比如从默认图标切到春节皮肤switchIcon(SpringFestivalIcon, true)switchIcon(default, false)顺序上建议先enable新的入口再disable旧的入口避免出现某个瞬间桌面上没有可用入口。实测先disable后enable也是可以的但偶尔在个别ROM上会遇到launcher重新加载后短暂黑屏所以还是先开新后关旧。Unity侧封装调用using UnityEngine; public class AndroidIconBridge { private static AndroidJavaClass _iconSwitcher; public static void Init() { if (Application.platform ! RuntimePlatform.Android) return; using var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer); var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); _iconSwitcher new AndroidJavaClass(com.yourgame.plugin.IconSwitcher); _iconSwitcher.CallStatic(init, activity); } public static void EnableAlias(string aliasName, bool enable) { _iconSwitcher?.CallStatic(switchIcon, aliasName, enable); } public static bool IsAliasEnabled(string aliasName) { return _iconSwitcher ! null _iconSwitcher.CallStaticbool(isEnabled, aliasName); } }到这里Android上的主路径就通了。2.3 ShortcutManager方法只适合做用户主动换图标再补充另一条技术路线。Android 7.1API 25开始系统提供了ShortcutManager8.0之后变成了ShortcutManagerCompat推荐用法。它的思路是把一个动态快捷方式放到桌面上这个快捷方式可以自定义图标和名称。if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { ShortcutManager shortcutManager sContext.getSystemService(ShortcutManager.class); ShortcutInfo shortcut new ShortcutInfo.Builder(sContext, player_selected_icon) .setShortLabel(我的游戏) .setIcon(Icon.createWithResource(sContext, R.mipmap.ic_launcher_user_style)) .setIntent(new Intent(sContext, UnityPlayerActivity.class) .setAction(Intent.ACTION_MAIN) .addCategory(Intent.CATEGORY_LAUNCHER)) .build(); shortcutManager.setDynamicShortcuts(Collections.singletonList(shortcut)); }但这里有个致命限制动态快捷方式默认不会自动出现在桌面主屏上。用户必须手动长按App图标在弹出菜单里选择添加快捷方式才会被固定在桌面。也就是说它靠的是用户自己操作没办法作为运营侧静默换图标的手段。那它适合什么适合做用户个性化设置。比如游戏设置页提供一个自定义图标功能用户可以选几套预置皮肤选完后桌面多一个带新图标的快捷方式入口。原App图标还在用户想删掉也不影响App本体。算是一个补充玩法不至于和activity-alias形成冲突。实际项目里我是两种都保留的运营活动走activity-alias用户在设置页自行选择走ShortcutManager二者互不干扰。2.4 adaptive icon与国产ROM的兼容性实测备注Android 8.0开始引入adaptive icon规范系统会在运行时对icon做蒙板裁剪。如果你的App已经按照adaptive icon来做预埋的mipmap资源就不能只提供一张1024x1024的png建议直接按照官方规范为每个图标资源在mipmap-anydpi-v26目录下提供对应的adaptive icon XML里面引用前景层、背景层和单色层。adaptive-icon xmlns:androidhttp://schemas.android.com/apk/res/android background android:drawabledrawable/ic_launcher_background / foreground android:drawabledrawable/ic_launcher_foreground / monochrome android:drawabledrawable/ic_launcher_monochrome / /adaptive-icon兼容性上我测过小米、华为、OPPO、vivo、三星和原生Android模拟器。整体结论是原生Android和三星最干净切换后桌面三五秒就刷新了部分国产ROM会表现得慢一些这是因为Launcher本身对组件变化的事件监听不及时不是方案本身的bug。处理办法是切换成功后给一个小小延迟再次调用pm.getComponentEnabledSetting确认状态同时用DONT_KILL_APP防止进程被杀。还有个坑部分ROM的桌面在切换后可能暂时显示上一个入口残留的透明图标点一下能进游戏但视觉上是残缺的。这种情况一般会在几分钟内自愈但如果用户急着看到效果可以在切换成功后用Unity的Handheld.Vibrate()做一个极短震动反馈让用户感到操作已生效缓解等待焦虑。这是运营体验层面的细节实际测试中很受用。3. iOS端方案拆解CFBundleAlternateIcons与setAlternateIconName相比Android的野路子iOS从10.3开始官方支持动态更换App图标但限制也多。开发者可以在App包内预置多套图标运行时通过setAlternateIconName切换。整个交互由苹果自己处理切换很流畅甚至App都不用退出。3.1 Info.plist预置不声明就白搭iOS能切换的图标必须是打包时就已经躺在App bundle里的在运行时动态往bundle里塞文件这个路径走不通别想。而要在Info.plist里声明可用图标需要配置CFBundleIcons下的CFBundleAlternateIcons字典。每一套备选图标对应一个keykey下面的CFBundleIconFiles数组指定图标文件。keyCFBundleIcons/key dict keyCFBundleAlternateIcons/key dict keySpringFestival/key dict keyCFBundleIconFiles/key array stringSpringFestivalIcon/string /array keyUIPrerenderedIcon/key false/ /dict keyAnniversary/key dict keyCFBundleIconFiles/key array stringAnniversaryIcon/string /array keyUIPrerenderedIcon/key false/ /dict /dict /dict这里的图标文件名字是不带扩展名的资源名不是文件名带2x、3x后缀。建议在Xcode工程里用Asset Catalog管理直接把多套图标加到AppIcon的备选栏位里Xcode会自动生成对应的plist内容。如果团队里Unity侧发版要注意确认最终Xcode工程里这些资源确实被合入而不是只存在于Unity工程里。3.2 Objective-C桥接与Unity侧DllImportUnity工程里要调用iOS原生能力一般是通过__Internal动态符号链接的DllImport方式。Unity iOS构建时会生成一个Xcode工程插件源码会被编进去C#侧声明extern方法即可调用。先在Assets/Plugins/iOS/下放一个Objective-C文件比如IconSwitcher.mm#import UIKit/UIKit.h #import Foundation/Foundation.h extern C { void _switchAppIcon(const char* iconName) { NSString *nsName nil; if (iconName ! NULL) { nsName [NSString stringWithUTF8String:iconName]; } dispatch_async(dispatch_get_main_queue(), ^{ if ([[UIApplication sharedApplication] supportsAlternateIcons]) { [[UIApplication sharedApplication] setAlternateIconName:nsName completionHandler:^(NSError * _Nullable error) { if (error) { NSLog([IconSwitcher] change error: %, error.localizedDescription); } }]; } else { NSLog([IconSwitcher] supportsAlternateIcons NO); } }); } bool _supportsAlternateIcons() { return [[UIApplication sharedApplication] supportsAlternateIcons]; } }setAlternateIconName传入nil就是恢复默认图标。所有切换操作必须在主线程执行所以代码里用了dispatch_async这个细节别省否则偶尔会触发系统层面警告。C#侧封装using System.Runtime.InteropServices; using UnityEngine; public class IosIconBridge { #if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _switchAppIcon(string iconName); [DllImport(__Internal)] private static extern bool _supportsAlternateIcons(); #endif public static void SwitchTo(string iconName) // null 表示恢复默认 { #if UNITY_IOS !UNITY_EDITOR _switchAppIcon(iconName); #else Debug.Log($[IosIconBridge] Editor mock switch to: {iconName ?? default}); #endif } public static bool SupportsAlternateIcons() { #if UNITY_IOS !UNITY_EDITOR return _supportsAlternateIcons(); #else return true; #endif } }有个点要注意Unity编辑器里跑iOS代码是走不到真机分支的所以测试时要么上真机要么在编辑器分支打log模拟。我这边通常会维护一套mock逻辑保证编辑器环境下UI按钮能点击看效果方便在开发期快速验证切换逻辑本身。3.3 审核注意三句话讲清楚你的使用场景App Store审核对动态换图标是有要求的。如果你的App只是常规活动皮肤切换通常只需要在审核备注里主动说明涉及内购或引导下载场景要重点解释。根据苹果审核指南凡是涉及购买、订阅、内购提示、引导下载这类诱导用途的图标动态切换需要额外向苹果说明并且不能有误导性。换句话说你可以做但要把目的和使用场景交代清楚。实际经验是iOS审核员看到CFBundleAlternateIcons配置后有时候会问一句这个功能是干嘛的。我的建议是在审核备注里写清楚三件事一是切换图标的触发条件二是备选图标素材仅限预置资源三是切换不涉及任何外部链接下载合规风险很低。写清楚之后我手头的项目没有因为这个功能被拒过。4. Unity侧统一封装状态持久化、回调与配置驱动真正落到Unity项目里不能只写两个平台的桥接类就完事必须有一层统一的业务管理层。既要把Android和iOS的差异吞掉又要保证App重启之后图标状态是对的。4.1 对外API设计与平台分发我这边对外暴露的API尽可能简单public enum AppIconPreset { Default, SpringFestival, Anniversary, Halloween } public static class AppIconManager { public static void ApplyIcon(AppIconPreset preset, System.Actionbool onCompleted null) { switch (Application.platform) { case RuntimePlatform.Android: ApplyAndroid(preset); onCompleted?.Invoke(true); break; case RuntimePlatform.IPhonePlayer: ApplyIos(preset, onCompleted); break; default: Debug.Log($[AppIconManager] Editor simulate: {preset}); onCompleted?.Invoke(true); break; } } private static void ApplyAndroid(AppIconPreset preset) { AndroidIconBridge.Init(); // 先把所有非当前入口disable掉 AndroidIconBridge.EnableAlias(SpringFestivalIcon, preset AppIconPreset.SpringFestival); AndroidIconBridge.EnableAlias(AnniversaryIcon, preset AppIconPreset.Anniversary); AndroidIconBridge.EnableAlias(HalloweenIcon, preset AppIconPreset.Halloween); // 关键default主入口的启用状态和其他alias相反 bool defaultEnabled preset AppIconPreset.Default; AndroidIconBridge.EnableAlias(default, defaultEnabled); } private static void ApplyIos(AppIconPreset preset, System.Actionbool onCompleted) { string nativeName preset AppIconPreset.Default ? null : preset.ToString(); IosIconBridge.SwitchTo(nativeName); // 真机上确实拿不到切换后的即时回调状态 // 这里可以做一个主动查询延迟1.5s再次读取 supportsAlternateIcons 当前图标名 // 但 iOS 没有公开查询当前图标名的接口一般默认成功即可 onCompleted?.Invoke(true); } }这里特意用了enum枚举而不是裸字符串是因为在客户端代码里枚举能防止拼错服务端下发时再映射成字符串。Unity侧最终会把枚举转成平台需要的字符串。4.2 状态持久化重启后图标要还原Android和iOS都有一个共同问题切换图标是无状态的系统只负责当前图标生效但App进程被杀、重启之后当前生效的入口组件状态会被系统记住这个倒是不用担心。但你的业务层必须知道自己当前处于哪个preset状态不然服务端下发指令时会出现重复切换或切错图标。解决方案简单粗暴——用PlayerPrefs存一份public static void SaveCurrentPreset(AppIconPreset preset) { PlayerPrefs.SetInt(AppIconPreset, (int)preset); PlayerPrefs.Save(); } public static AppIconPreset LoadCurrentPreset() { return (AppIconPreset)PlayerPrefs.GetInt(AppIconPreset, (int)AppIconPreset.Default); }启动流程里加一步校验public static void RestoreIconState() { var current LoadCurrentPreset(); // 如果当前图标已经是目标状态跳过切换 if (IsAlreadyApplied(current)) return; ApplyIcon(current, null); }这里要提个细节PlayerPrefs本身在Android上就是一个xml文件写入时机不当可能会丢。一定要在切换成功后调用PlayerPrefs.Save()别依赖自动落盘时机。极端情况可以在切换前先存目标状态切换成功后改存实际状态双保险。我遇到过两次因为App异常被杀导致PlayerPrefs没写进去重启后图标和配置对不上的情况加了双状态标记之后就解决了。4.3 服务端配置驱动运营同学只改一个字段整个系统接入服务端配置之后运营只需要后台配置三个信息preset名称、生效开始时间、生效结束时间。客户端可以在冷启动、前后台切换时拉取配置然后判断当前时间是否在活动区间内自动调用ApplyIcon。这里有一个坑就是时间边界。运营配置的结束时间到了客户端可能需要一两次启动才会拉到新配置图标下岗会有延迟。所以我在服务端下发的数据结构里预置了nextPreset字段让客户端可以在活动结束后提前切换到下一个预设避免到时请求不到。这是个容易被忽视但实际运营中很关键的设计。5. 双端实测踩坑记录文档里不会告诉你的那些事方案搭起来简单真正麻烦的是各种真机上的异常表现。我把这一路踩过的坑按平台整理了一份当作避坑指南看你至少能少走两步弯路。5.1 Android桌面刷新延迟与图标消失的处理第一次用activity-alias方案在小米手机上测试时切换完图标后桌面直接空白了十几秒差点以为把入口disable坏了。后来复盘发现这不是方案挂了而是Launcher没有及时检测到组件状态变化。要给这个坑做个防御性设计我总结下来有三个办法切换后不要立刻杀进程。setComponentEnabledSetting时务必用DONT_KILL_APP这是官方明确的安全做法。在所有alias切换完成后延迟1到2秒再检查一次入口状态。校验逻辑就是前面写的isEnabled方法确认目标入口确实是ENABLED状态。运营侧给足心理预期。比如活动效果页写图标将在几秒内更新避免用户一看桌面没变就反复重启桌面。另外某些ROM上如果原入口已经被用户从桌面上删掉切换后新入口会被自动补回来但图标位置可能会跑。这个属于ROM行为差异没有统一解法一般不需要管。5.2 iOS图标变黑透明通道的锅iOS的App图标是不允许带Alpha通道的。你从设计手里拿到的活动素材如果PNG还保留透明区域直接塞进CFBundleIconFiles后真机切换时会发现整个图标变成纯黑底。这个在Xcode的模拟器上有时候看不出来真机上必现。处理方式是在素材导出时提前把图标文件压平到不透明的背景上。不要试图在Unity侧做运行时处理iOS的图标切换根本走不到运行时合成一切都是静态资源。这个检查要写进资源规范里在美术出图那一刻就卡住。5.3 静默切换的边界别过度骚扰用户技术上能做到静默切换但产品上要克制。iOS端好一点系统会自己处理切换动画用户感知是正常的。Android端如果频繁切换用户可能会觉得桌面App图标总在变产生不信任感。我现在的策略是运营活动类切换只在版本大节点、节日节点使用一年控制在4到6次个性化选择必须用户主动点击应用按钮才会触发。服务端下发配置时也会带一个silent标记位true时不做任何提示false时在游戏内弹一个小气泡提示图标已更新让用户明确感知变化来源。5.4 回归测试清单发版前按这个跑一遍最后给一份实测下来的回归清单照着跑基本能覆盖大部分风险测试内容Android做法iOS做法默认图标完整性安装后确认桌面主图标是默认icon安装后确认桌面主图标是主icon切换到备选图标调用EnableAlias后等待5-10秒刷新桌面调用setAlternateIconName立即切换切回默认图标EnableAlias(default, true)传入nil恢复默认重启后状态保持切换后杀掉进程再进桌面切换后杀掉App再打开资源缺失异常故意删某套mipmap看是否出现占位图标故意删某套icon文件看在Xcode是否报错极端快速连续切换连续切换10次确认最终状态连续切换10次确认无崩溃低内存场景切换后立刻大内存加载战斗场景同样操作确认App不闪退部分ROM桌面小米/华为/OPPO/vivo/三星各跑一轮iPhone X及以上各iOS版本跑一轮这套清单每轮发版前跑一遍基本能保证动态图标功能不会成为发版事故点。动态更换App图标这套能力说难不算难说简单也真不简单。难点主要不在代码量而在双端机制差异、ROM兼容性、审核合规这几层。用Unity做手游天然就需要在C#业务层把这些问题统一掉。我这边踩了一圈坑之后把方案收敛成一套配置驱动的模块运营改后台、客户端自动拉配置切换整个流程对研发侧的干扰降到了最低。如果你也要在Unity项目里落地类似功能建议先把Android的activity-alias方案跑通再补iOS的官方接口中间层架构复用起来后面再加节日皮肤就只是资源和配置的事了。
返回列表