刚开始做Android全局桌面宠物这个需求时,我心里盘算得特别美:用Unity做一只小狐狸,骨骼动画、待机随机动作、摸一下给点反应,然后让它浮在所有App上面。这个设想一听就很吸引人,但真正动手之后发现,90%的时间不是在调Unity的动画,而是在和Android的系统窗口、生命周期、触摸分发、电池优化较劲。这篇文章想把整套Unity方案的落地路径写清楚:从Unity工程怎么配、怎么导出,到Android原生侧怎么把UnityPlayer挂进WindowManager,再到生命周期、触摸穿透和常驻性能处理。适合正在规划桌面宠物、桌面挂件、悬浮助手这类项目的开发者参考,尤其是第一次把Unity往系统级悬浮窗上搬的朋友。
1. 先看问题的本质:Unity为什么不能单干“全局”这件事
1.1 全局桌面宠物的本质:一个系统级悬浮窗
先说结论:Android上所谓的“全局桌面宠物”,技术本质上就是一个系统级悬浮窗,而不是桌面Launcher插件。
系统允许你的应用通过WindowManager.addView()把一个View添加到全局窗口层,只要申请到SYSTEM_ALERT_WINDOW悬浮窗权限,这个View就能覆盖在所有应用上方。你对“桌面宠物”的所有期待——一直显示、可以拖动、被别的App盖住也能继续动,都是靠这个机制实现的。
所以问题就变成了:Unity怎么把自己的渲染画面变成一个能被WindowManager添加的View。
如果你直接用原生Android画一个卡通角色,那很简单,一个ImageView加逐帧动画或者Lottie就搞定了。但如果你要的是一个3D骨骼动画宠物、需要物理模拟、粒子特效,甚至要跟用户做复杂互动,原生的成本会高得离谱。这时候才轮到Unity上场。问题在于,Unity本身从设计上就不是给“悬浮窗”场景用的,它默认的输出主体是Activity全屏渲染。
1.2 UnityPlayer:连接Unity引擎与WindowManager的桥
Unity在Android上的所有版本,都会在导出工程里带一个核心类UnityPlayer。这个类继承自FrameLayout,内部包含两个关键东西:
- 一个
SurfaceView(或TextureView)用来承接Unity渲染的结果; - 一组引擎生命周期方法,用来把Android的
Activity生命周期事件转发给Unity。
换句话说,UnityPlayer这个对象本身就是一个Android View。你可以像使用任何View一样把它addView到WindowManager里,这一步就是“Unity画面全局化”的关键。
但这里有一个非常容易踩的坑:UnityPlayer的初始化需要持有一个Activity上下文。它内部很多模块,比如输入法回调、权限回调、对话框、Activity生命周期监听,都强依赖一个存活的Activity引用。你如果在Service里直接new UnityPlayer(context),短时间看好像没问题,但一旦遇到系统回收、输入法弹出、DNS解析权限弹窗这类边缘情况,会成批地崩。
1.3 选型对比:Unity方案并不是桌面宠物的唯一解
我在立项前其实认真对比过三条路线,这里直接放一张自己的对比表:
| 方案 | 表现力 | 常驻内存 | 交互复杂度上限 | 开发成本 | 适配成本 |
|---|---|---|---|---|---|
| 逐帧动画 | 低,只有2D帧切换 | 低,约30-60MB | 低,只能播放 | 低 | 低 |
| Lottie | 中,2D矢量动画 | 低,约30-60MB | 中,可加点击区域 | 低 | 低 |
| Unity渲染 | 高,3D骨骼/物理/粒子 | 高,约150-250MB | 高,可做手势互动 | 较高 | 较高 |
如果你是做一个简单的“飞行的小宠物”挂件,用Lottie绝对是更合适的方案,省电、包体小、也不会被系统杀。但如果你要做“宠物会走路、会被摸头、会饿、会睡觉、能跟手指互动”这种有沉浸感的桌面伴侣,Unity基本是唯一的可选路径。
所以建议先明确产品边界:真正需要Unity的那些能力,才值得付出常驻内存和电量成本。
2. Unity侧的正确配置:所有细节都为悬浮窗服务
2.1 相机与背景:透明背景的正确打开方式
桌面宠物必须允许悬浮窗“只显示宠物自己”,背景要完全透明。Unity默认的相机清屏是一个纯色背景,而且很多时候是纯蓝色或灰色,这直接会导致悬浮窗变成一块大色块。
透明背景的做法分两步:
第一步,相机设置。选中相机后,把Clear Flags从Skybox改成Solid Color,然后把Background颜色的Alpha通道设成0,也就是RGBA(0,0,0,0)。注意不是仅仅把颜色设成黑色,而是这四个值都归零。我见过很多同事只改了RGB没改Alpha,结果Unity编辑器和真机上一片黑底,排查半天。
第二步,Player Settings。在Player设置窗口里,找到Android平台的Resolution and Presentation面板,必须勾选Use 32-bit Display Buffer。不开启这个选项时,系统帧缓冲是16位色深,Alpha通道会被丢弃或压缩,透明背景在部分机型上会变成半透明或者黑块。
如果你用的是URP管线,透明背景还有两个额外注意点:一是相机背景类型要选Solid Color并把Alpha设0;二是要确认没有开Bloom这类后处理,因为后处理需要先渲染一个不透明中间缓冲区,会把你的Alpha信息吃掉。
2.2 Quality与渲染设置:别让宠物吃掉手机电池
桌面宠物的特点是常驻,它不像游戏那样只有几分钟到几小时的游玩时间,而是可能从早到晚挂在那里。Unity默认的Quality等级、抗锯齿、阴影、后处理全开的话,中端机型帧率能掉到十几帧,同时手机发烫。
我的建议是直接用代码在启动时压设置,而不是依赖Unity编辑器里的Quality面板,因为打包后Quality面板里的配置不一定生效:
private void ApplyQualitySettings() { QualitySettings.vSyncCount = 0; QualitySettings.antiAliasing = 0; QualitySettings.shadows = ShadowQuality.Disable; QualitySettings.softParticles = false; Application.targetFrameRate = 30; }阴影在这个场景下基本是纯浪费。宠物尺寸不大,悬浮窗本身也就300多dp宽,阴影细节用户根本注意不到,但开销却不少。抗锯齿同理,宠物如果是像素风或者模型面数不高,根本不需要MSAA。
还有一个细节:如果你发现宠物边缘有半透明噪点,不是贴图问题,大概率是Color Space配了Linear但透明混合没做sRGB转换。简单处理方式是用Gamma空间,配合透明背景时边缘会更干净。
2.3 C#侧消息接口设计:把控制权交给Android层
Unity渲染画面和动画是一回事,但“谁来告诉Unity播放什么动画”是另一回事。Android原生悬浮窗希望控制Unity的宠物状态,比如点击宠物时播放某个互动动画、双击让它睡觉、从通知栏让它吃饭。这个控制的通信通道就是UnitySendMessage。
C#侧做好一个稳定的控制入口是关键。我一般会在场景里放一个常驻的GameObject,名字固定叫PetRoot,挂一个PetController脚本:
using UnityEngine; public class PetController : MonoBehaviour { [SerializeField] private Animator animator; private void Awake() { DontDestroyOnLoad(gameObject); } public void SetState(string state) { if (!string.IsNullOrEmpty(state)) { animator.SetTrigger(state); } } public void OnPetClicked(float x, float y) { // 这里可以做点按反馈,比如播放被摸头的动画 animator.SetTrigger("Touch"); } }Android侧调用Unity方法:
unityPlayer.UnitySendMessage("PetRoot", "SetState", "Sleep");反过来,Unity要通知Android侧“我准备好了”或者“用户点了透明区域外的位置”,用AndroidJavaClass:
using UnityEngine; public static class PetBridge { public static void NotifyUnityReady() { #if UNITY_ANDROID && !UNITY_EDITOR using (var cls = new AndroidJavaClass("com.example.pet.PetNativeBridge")) { cls.CallStatic("onUnityReady"); } #endif } }因为UnitySendMessage是字符串指令,函数名写错了只会在运行时刷Error而不会报编译错误,所以建议所有对外接口集中在一个脚本里,写清楚的注释,别散落到各个MonoBehaviour上。
2.4 导出Android工程时的Gradle与包体细节
Unity导出时建议直接选Android Project(Gradle工程),而不是只生成APK。因为你要在Android Studio里改原生代码、加悬浮窗服务,Unity导出的APK不方便二次编辑。
导出时两个版本相关的点需要提前决定:
- 脚本后端选IL2CPP还是Mono?Unity新版默认IL2CPP,我建议保持默认。IL2CPP包体比Mono大一些,但运行时性能和64位兼容性更好。桌面宠物是常驻型应用,稳定性优先,IL2CPP更合适。如果你极度在意包体大小且确定只做32位老设备,才考虑Mono。
- 目标架构ARM64必须选上。Unity默认会选ARMv7+ARM64,但新版Android Studio工程的ABI过滤要注意,如果一个不小心只保留ARMv7,新机型上会直接安装失败或运行崩溃。
导出完成后用Android Studio打开工程,你会看到unityLibrary模块和launcher模块。之后我们的原生悬浮窗代码全部写在launcher里,方便单独管理,不需要再动Unity导出的模块。
3. Android原生侧的核心承载:把UnityPlayer挂进WindowManager
3.1 为什么需要一个透明宿主Activity而不是直接Service
网上很多帖子说可以在Service里直接初始化UnityPlayer,确实是能跑,但只适合Demo。
我实际测试下来的结论是:UnityPlayer内部对Activity上下文的依赖比想象中深。它会在引擎初始化时尝试获取Activity的引用,后续如果Activity为null的部分代码会在特定事件触发时直接NPE,比如设备方向变化、输入法弹起、权限回调回来后。这些边缘崩溃难以复现,一旦出现就会被用户打低分。
更稳妥的架构是:用Activity作为UnityPlayer的宿主,但把这个Activity做成透明的、没有UI的,并且通过moveTaskToBack让它退到后台。它的角色更像一个“Unity引擎的靠山”,真正显示在用户面前的是被添加到WindowManager里的悬浮窗。
整体启动链路是这样的:
- 应用入口检查悬浮窗权限,没有则引导授权;
- 启动前台
Service,保活进程; - 启动透明
PetHostActivity; PetHostActivity的onCreate里初始化UnityPlayer并addView到WindowManager;- 调用
moveTaskToBack(true),用户不会看到透明Activity的切换动画; - Unity场景加载完成后通过回调通知Android侧,悬浮窗才真正显示。
3.2 WindowManager.LayoutParams逐项说明
WindowManager.LayoutParams是这个方案最核心的配置对象,每个字段都会影响后续体验。这里直接放一张我最终使用的配置表:
| 字段 | 推荐值 | 原因 |
|---|---|---|
| type | TYPE_APPLICATION_OVERLAY | Android 8.0后正确的悬浮窗类型,旧版TYPE_PHONE已被废弃 |
| width / height | 按dp转px,320x360左右 | 悬浮窗不要做全屏,否则遮挡所有App操作 |
| flags | FLAG_NOT_FOCUSABLE或FLAG_LAYOUT_IN_SCREEN | 不获取焦点,避免抢占输入;全屏布局适配刘海屏 |
| format | PixelFormat.TRANSLUCENT | 告诉WindowManager这是一个半透明窗口,Unity透明背景才正常 |
| gravity | Gravity.TOP或Gravity.START | 配合x/y坐标定位宠物在屏幕上的位置 |
需要注意FLAG_NOT_FOCUSABLE这个flag是一把双刃剑:加了它,悬浮窗不会抢键盘焦点,但Unity内部的一些输入事件也需要做额外处理;不加它,宠物点一下就会让当前App失去焦点,严重影响体验。所以保留这个flag,Unity侧的触摸交互后面单独处理。
3.3 悬浮窗授权与国产ROM兼容
申请权限的代码比较固定:
fun checkOverlayPermission(context: Context): Boolean { return Settings.canDrawOverlays(context) } fun requestOverlayPermission(activity: Activity, requestCode: Int) { val intent = Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${activity.packageName}") ) activity.startActivityForResult(intent, requestCode) }但真正麻烦的是国产ROM。小米、华为、vivo、OPPO这些系统,除了SYSTEM_ALERT_WINDOW之外,通常还有“后台弹出界面”“锁屏显示”“自启动”“省电策略”等多个独立开关。哪怕你在Manifest里声明了权限,用户没有在权限中心手动打开其中一个开关,悬浮窗就会莫名其妙地被系统拦截。
排查这类问题的一个高效办法是用adb直接看权限状态:
adb shell appops get <包名> SYSTEM_ALERT_WINDOW返回allow说明授权正常,返回ignore或deny就说明被系统拦截了。但注意这个命令只对原生的AppOps有效,国产ROM的自定义权限开关不一定反映在这里,所以还是得在代码里加一个“悬浮窗是否真正显示”的兜底判断,显示失败后引导用户打开对应品牌的权限页。
3.4 组装起来:主流程代码示例
下面这段代码是宿主Activity的核心结构,我实际项目里差不多就是这个形态:
class PetHostActivity : Activity() { private lateinit var unityPlayer: UnityPlayer private lateinit var windowManager: WindowManager private lateinit var overlayParams: WindowManager.LayoutParams override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) windowManager = getSystemService(WINDOW_SERVICE) as WindowManager unityPlayer = UnityPlayer(this) unityPlayer.windowFocusChanged(true) overlayParams = buildOverlayParams() windowManager.addView(unityPlayer, overlayParams) startForegroundService(Intent(this, PetService::class.java)) moveTaskToBack(true) } private fun buildOverlayParams(): WindowManager.LayoutParams { val width = dp(320f) val height = dp(360f) return WindowManager.LayoutParams( width, height, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, PixelFormat.TRANSLUCENT ).apply { gravity = Gravity.TOP or Gravity.START x = dp(72f) y = dp(240f) } } override fun onDestroy() { if (::unityPlayer.isInitialized) { windowManager.removeView(unityPlayer) unityPlayer.quit() } super.onDestroy() } }这里有个细节:moveTaskToBack(true)调用时机很重要,必须在addView之后执行,否则它会先把整个任务退到后台,导致Activity还没创建完就被暂停,Unity初始化可能被打断。
4. 生命周期协调:两个“世界”如何同步不崩
4.1 启动时序:addView之后为什么容易白屏
Unity场景加载是异步的,不像原生View那样同步创建。你把UnityPlayer加到WindowManager的那一刻,Unity引擎可能才刚开始初始化Shader、加载模型、编译资源,此时画面要么是白的,要么是黑的。
我一开始没处理这个问题,每次启动宠物都会闪一下白屏,特别廉价。后来改成两层启动判断:
- Unity侧的
Start回调里调用PetBridge.NotifyUnityReady(); - Android侧收到
onUnityReady后再把UnityPlayer的Alpha从0渐变回1。
void Start() { animator.Play("Idle"); PetBridge.NotifyUnityReady(); }Android侧收到回调:
object PetNativeBridge { @JvmStatic fun onUnityReady() { // 从透明渐变为可见 } }还要注意一个问题:Unity情况复杂,Start回调在某些场景下可能晚于Android侧的超时判断。建议Android侧同时做一个兜底,比如3秒后无论如何都显示,避免万一Unity侧发消息失败导致宠物永远隐藏。
4.2 后台态处理:runInBackground、onPause与焦点丢失
桌面宠物应用一个最大的矛盾点在于:宠物要从后台一直动,但宿主Activity必然会在用户打开其他应用时退到后台。
Unity默认在Activity退到后台后会暂停渲染循环,这对游戏没问题,但对桌面宠物是致命的。必须在Unity启动早期就设置:
Application.runInBackground = true;这个设置可以用代码强制开启。但runInBackground只是解决“Player Loop是否继续跑”的问题,Activity本身的onPause、onStop回调还是会被系统触发。
如果你的宿主Activity是写自定义的,建议这样处理:
- 重写
onPause时,不要调用unityPlayer.pause(),让UnityPlayer本身去处理,或者干脆不处理; - 重写
onDestroy时才做真正的暂停和清理。
因为很多网上模板是继承UnityPlayerActivity,它里面默认的onPause会调用mUnityPlayer.pause(),这个行为用于全屏游戏没问题,用于悬浮宠物就会导致桌面宠物一打开别的App就僵住。
另外,OnApplicationFocus这个Unity回调在悬浮场景下会非常频繁地触发,因为宿主Activity几乎永远处于“无焦点”状态。不要在OnApplicationFocus里做暂停逻辑,否则你的宠物大部分时间都在沉睡。
4.3 退出与二次启动:UnityPlayer的单实例约束
UnityPlayer在同一个Android进程里只能有一个实例。你如果removeView之后再次new UnityPlayer(this),大概率会遇到黑屏、崩溃或者“Unable to create player” 这类问题。
正确的退出流程是:
private fun destroyPet() { if (::unityPlayer.isInitialized) { windowManager.removeView(unityPlayer) unityPlayer.quit() unityPlayer = null } }quit()是UnityPlayer最彻底但最重的退出方式。如果你只是要“暂停一下”,可以调用pause(),它不会销毁引擎,重新恢复也更快。
还有一个容易被忽略的坑:如果用户按了系统返回键或者从最近任务里划掉了宿主Activity,UnityPlayer可能在onDestroy里被quit(),但悬浮窗View还挂在WindowManager上。你需要在onDestroy里先removeView再quit(),顺序反了会导致View在已销毁的Surface上绘制,直接崩。
4.4 内存压力与进程被杀保护
Unity的内存占用本身不低,系统在内存压力大时喜欢优先杀后台大内存进程。桌面宠物是一个常驻型进程,不保护的话,用户玩一下大型游戏回来,宠物就不见了。
保护手段有两层:
第一层是前台Service。startForeground后进程优先级提升到前台级别,普通内存清理不会动它。注意Android 13/14对前台服务类型有更细的要求,需要在Manifest里声明合适的foregroundServiceType,并确保用户没有在系统设置里关闭通知。通知一旦被关掉,某些系统会暗中降级你的进程优先级。
第二层是在onTrimMemory回调里做轻量操作。比如系统级别TRIM_MEMORY_RUNNING_CRITICAL时,主动把待机动画切成低帧率版本,释放一部分加载但未使用的动画资源,降低被清理的概率。
5. 交互体验:触摸、拖动与透明点击穿透
5.1 拖动与Unity内部触摸的分工
悬浮宠物首先要能拖动。用户按住宠物移动,宠物跟着手指走;如果只是点了一下,那就把这次触摸当成互动,让宠物做出反应。
Android侧需要给unityPlayer设置OnTouchListener,自己管理拖动逻辑:
var downX = 0f var downY = 0f var startLpX = 0 var startLpY = 0 var isDragging = false unityPlayer.setOnTouchListener { _, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { downX = event.rawX downY = event.rawY startLpX = overlayParams.x startLpY = overlayParams.y isDragging = false } MotionEvent.ACTION_MOVE -> { val dx = event.rawX - downX val dy = event.rawY - downY val slop = ViewConfiguration.get(applicationContext).scaledTouchSlop if (Math.abs(dx) > slop || Math.abs(dy) > slop) { isDragging = true overlayParams.x = startLpX + dx.toInt() overlayParams.y = startLpY + dy.toInt() windowManager.updateViewLayout(unityPlayer, overlayParams) } } MotionEvent.ACTION_UP -> { if (!isDragging) { // 点击,通知Unity做互动反馈 unityPlayer.UnitySendMessage("PetRoot", "OnPetClicked", "") } } } true }这里我没有把原始的MotionEvent转发给Unity,而是通过UnitySendMessage发了一个简单的点击通知。原因很简单:桌面宠物的交互通常只需要“点一下”“摸一下”这种精度,没必要把Android完整的触摸序列透传给Unity内部,透传反而容易触发复杂的焦点处理。
5.2 多点触控与手势冲突
如果你想让宠物支持捏合缩放、双指旋转这类手势,单靠上面的逻辑是不够的,因为你在原生层已经消费了所有触摸事件。
我的做法是维护一个手指计数器,检测到双指下落时,切换到“手势模式”,暂停拖动逻辑,并把后续的MotionEvent直接通过unityPlayer.dispatchTouchEvent(event)透传给Unity:
private val activePointers = HashSet<Int>() override fun onTouch(v: View, event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_POINTER_DOWN -> { activePointers.add(event.actionIndex) if (activePointers.size >= 2) { isInteracting = true } } MotionEvent.ACTION_MOVE -> { if (isInteracting) { unityPlayer.dispatchTouchEvent(event) } else { // 单指拖动逻辑 } } MotionEvent.ACTION_POINTER_UP -> { activePointers.remove(event.actionIndex) if (activePointers.size < 2) { isInteracting = false } } } return true }这个方案不算完美,因为Unity侧的Input系统接收到的触摸事件只有后半段,缺少完整的DOWN事件,Unity内部的多点手势可能不完整。要彻底解耦,需要都转发原始事件并自己写手势识别,但这个复杂度对桌面宠物来说有点过度设计了。
如果你确实要做完整的双指交互,我建议直接放弃原生层的拖动逻辑,把整个触摸分发给Unity处理,由Unity在C#侧完成拖动手势和旋转缩放的识别,再通过updateViewLayout回传位置给Android侧。这才是功能最完整但也是最复杂的方案,一般产品用不到。
5.3 透明区域的点击穿透:现实与妥协
桌面宠物和普通悬浮球最大的区别在于:宠物是“抠图”出来的,周围一圈都是透明背景。从视觉上看,用户点的是宠物旁边的空白区域,但实际上点的是悬浮窗的矩形区域,底下的App按钮会被挡住。
真正逐像素级别的触摸穿透是极难做到的。它需要Unity每帧把触摸点对应的像素内容读回Android侧,再判断该像素Alpha是否大于0。这个方案有两个致命问题:
- 每帧读取像素性能开销极大,不适合常驻应用;
- SurfaceView和View的触摸事件处理机制并不保证能准确定位到像素层级。
工程上的妥协方案是“压缩可触摸区域 + 命中测试”。
我的实现思路是:在Unity侧维护一个“宠物有效交互范围”的碰撞列表,把宠物的身体、头部、小气泡、按钮这些物理碰撞体挂上标记。触摸坐标通过UnitySendMessage传进C#,C#用碰撞检测判断这个点是否在宠物有效区域内,然后把结果返回给Android侧。
public void OnTouchAt(float x, float y) { Vector2 worldPoint = Camera.main.ScreenToWorldPoint(new Vector3(x, y, 0)); bool hitPet = Physics2D.OverlapPoint(worldPoint) != null; // 通过回调通知Android侧是否命中宠物 PetBridge.NotifyTouchHit(hitPet); }Android侧根据返回值决定是否要拦截这轮触摸。这个方案不是真正的“透明像素穿透”,但视觉体验已经不错了,因为宠物主体周围的透明边距被裁剪掉之后,用户误触概率大大降低。
如果想让透明区域真正做到点击穿透,更彻底的办法是不要用Unity,而是用原生动画方案,原生View的TouchDelegate和窗口配置可以比较精确地控制可触摸范围。这是一开始选型就要考虑清楚的事。
5.4 容易被忽略的体验细节
这里有四个细节,虽然不起眼,但对体验影响非常大。
第一个是输入法弹出。当用户在某个App里打字时,悬浮宠物如果正好在键盘区域内,会跟着键盘向上弹或者被挡住。我通常会在悬浮窗的根View上设置OnGlobalLayoutListener,检测到窗口高度明显变小,就把宠物临时移动到屏幕顶部,避免和输入法抢位置。
第二个是边缘吸附。用户把宠物拖到屏幕边缘时,让它自动吸附到左边缘或右边缘,只露出一小半身子,会让桌面更清爽。实现起来就是对ACTION_UP后的坐标做一次判断,然后用动画平滑移动到位。
第三个是避免抢焦点。悬浮窗一定要保持FLAG_NOT_FOCUSABLE,否则用户在某App里打字时,点了一下宠物,输入法焦点就被抢走了,当前输入状态丢失,这是用户最反感的场景。
第四个是低功耗模式的响应。Android的省电模式(Battery Saver)开启后,系统对后台动画的限制会变严格。建议在宠物端监听一下省电模式变化,进入省电模式时自动降帧,甚至切换到静置睡觉动画,既省电又贴合场景,用户不会因为宠物“睡着了”而困惑,反而觉得产品很懂系统。
6. 性能调优与常驻续航:让宠物长期住在桌面上
6.1 内存账本:Unity空场景和带模型宠物的实际开销
很多人对Unity方案的顾虑是内存,我也不例外。实测下来,Unity空场景挂到悬浮窗常驻,内存占用大概在150-200MB之间,这还不包括你的宠物模型、贴图和动画资源。我做过一个中等面数的小恐龙角色,加上几套动画、压缩贴图,整体内存就去到了250MB左右。
这个数值放到桌面宠物场景里,确实偏高。但如果你确认要用3D交互,这个成本只能接受。能做的事是尽量压缩:
- 纹理格式用ASTC或ETC2,不要用RGBA32;
- 贴图不开MipMap,悬浮窗显示尺寸小,MipMap没有意义;
- 动画尽量用Animator而不是Animation Clip直接播放,方便做内存复用;
- 切换宠物前调用一次
Resources.UnloadUnusedAssets()。
在Unity的Profiler里盯一段时间,重点关注Graphics Memory。我见过有项目在反复切换宠物后,贴图越积越多,最后被系统判死刑,就是这个原因。
6.2 帧率控制与空闲渲染暂停
宠物常驻最怕的不是内存,是持续高帧率渲染导致CPU/GPU一直满载。Unity默认的targetFrameRate如果不设,很多机型会跑到60帧,白白浪费电量。
我的做法是分档控制:
// 待机状态:低帧率 OnDemandRendering.effectiveRenderFrameRate = 15; // 用户交互状态:高帧率 OnDemandRendering.effectiveRenderFrameRate = 30;OnDemandRendering是Unity专门做按需渲染的模块,比直接用targetFrameRate更精细。你可以理解成,它允许你告诉Unity“当前只需要每秒渲染15帧就够了”,Unity会同时降低更新频率,而不是仅仅跳过绘制。
如果你的宠物待机动画本身不需要一直动,还可以进一步做成“按需渲染”模式,没有事件时一帧都不渲染,有事件时才渲染一帧。比如宠物睡觉时,动画本身只有一个呼吸循环,用8-10帧足够;被用户摸一下时再临时把帧率拉高到30,播放完交互动作后再降回来。
我在项目里实测过,同样一只宠物,60帧全速跑和15帧待机切换,功耗差距大约在3-4倍。这个差距对常驻应用是决定性的。
6.3 资源侧优化:模型、纹理、音效与动画
3D宠物模型的面数不需要太高。桌面悬浮窗显示尺寸通常只有两三百dp,一个四五千面、带PBR贴图的模型和一千面、纯色材质卡通模型,视觉差异极小,但渲染开销相差很大。
我建议美术侧能走卡通平涂就走平涂,用简单材质,少用高光法线贴图。骨骼数量也要控制,一只宠物几十根骨头就够,别加一堆只为“以后可能有用”的骨骼。
音效方面,桌面宠物如果要做“语音”或“动作音效”,建议用短小的AudioClip,压缩格式选择Vorbis或ADPCM,音量别太大。同时要避免多个音效同时播放,用AudioMixer做一下通道限制,不然用户打游戏时宠物突然“喵”一声,会被骂。
还有一个容易踩的坑:Unity在启动时会把所有Resources目录下的资源预加载,所以尽量别把大资源放Resources里,改成Addressables或AssetBundle按需加载。桌面宠物切换动画或皮肤时再动态加载,能显著降低启动和常驻内存。
6.4 进程保活与系统电池策略
前台Service已经提过,这里补充一些实测经验。
通知栏常驻通知不能关。很多用户会清理掉通知,从Android 8.0开始,前台服务如果通知被移除,系统会在几秒内杀进程。如果你确实不想展示脏乱的通知,可以用低优先级的通知渠道,但绝对不能完全没有通知。
系统电池优化策略也是一个大变量。某些厂商系统会默认把第三方应用加入“耗电应用”名单,即使你的前台Service活着,也会在熄屏后强制冻结CPU。技术上通过ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS可以引导用户把应用加入电池优化白名单,但这个弹窗在很多ROM上被厂商屏蔽了,最后还是得引导用户去系统设置里手动允许自启动和忽略电池优化。
还有一点:Unity的Application.targetFrameRate在某些机型上会被系统“帧率调度策略”覆盖,尤其是一加、小米这种有高刷策略的系统。如果发现宠物动起来比预期卡,先检查是不是被系统限帧了,而不是盲目优化渲染线程。
这个项目做下来,我最大的感受是:Unity方案能不能用在桌面宠物上,不在Unity而在外围系统。3D引擎本身没有任何障碍,真正的难点全在于你要把一个为“全屏游戏”设计的引擎,驯化成系统级悬浮窗的一部分。这里面的坑,主要集中在生命周期同步、触摸穿透和长期运行的资源消耗上。
最后分享一个特别有用的调试技巧:开发阶段不要只盯着旗舰机,打开开发者选项里的“不保留活动”,多模拟几次Activity被系统回收的场景。这个开关能把你生命周期里所有藏着的崩溃提前暴露出来。我在项目正式上线前靠这个开关排查出至少三个只在用户长时间使用后才会触发的崩溃点,省了很多在线上擦屁股的时间。