ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android桌面宠物实战:用UnityPlayer构建系统级悬浮窗的完整指南

2026/9/12 9:49:31 拓冰建站 浏览量
Android桌面宠物实战:用UnityPlayer构建系统级悬浮窗的完整指南 刚开始做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侧“我准备好了”或者“用户点了透明区域外的位置”用AndroidJavaClassusing 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 ProjectGradle工程而不是只生成APK。因为你要在Android Studio里改原生代码、加悬浮窗服务Unity导出的APK不方便二次编辑。导出时两个版本相关的点需要提前决定脚本后端选IL2CPP还是MonoUnity新版默认IL2CPP我建议保持默认。IL2CPP包体比Mono大一些但运行时性能和64位兼容性更好。桌面宠物是常驻型应用稳定性优先IL2CPP更合适。如果你极度在意包体大小且确定只做32位老设备才考虑Mono。目标架构ARM64必须选上。Unity默认会选ARMv7ARM64但新版Android Studio工程的ABI过滤要注意如果一个不小心只保留ARMv7新机型上会直接安装失败或运行崩溃。导出完成后用Android Studio打开工程你会看到unityLibrary模块和launcher模块。之后我们的原生悬浮窗代码全部写在launcher里方便单独管理不需要再动Unity导出的模块。3. Android原生侧的核心承载把UnityPlayer挂进WindowManager3.1 为什么需要一个透明宿主Activity而不是直接Service网上很多帖子说可以在Service里直接初始化UnityPlayer确实是能跑但只适合Demo。我实际测试下来的结论是UnityPlayer内部对Activity上下文的依赖比想象中深。它会在引擎初始化时尝试获取Activity的引用后续如果Activity为null的部分代码会在特定事件触发时直接NPE比如设备方向变化、输入法弹起、权限回调回来后。这些边缘崩溃难以复现一旦出现就会被用户打低分。更稳妥的架构是用Activity作为UnityPlayer的宿主但把这个Activity做成透明的、没有UI的并且通过moveTaskToBack让它退到后台。它的角色更像一个“Unity引擎的靠山”真正显示在用户面前的是被添加到WindowManager里的悬浮窗。整体启动链路是这样的应用入口检查悬浮窗权限没有则引导授权启动前台Service保活进程启动透明PetHostActivityPetHostActivity的onCreate里初始化UnityPlayer并addView到WindowManager调用moveTaskToBack(true)用户不会看到透明Activity的切换动画Unity场景加载完成后通过回调通知Android侧悬浮窗才真正显示。3.2 WindowManager.LayoutParams逐项说明WindowManager.LayoutParams是这个方案最核心的配置对象每个字段都会影响后续体验。这里直接放一张我最终使用的配置表字段推荐值原因typeTYPE_APPLICATION_OVERLAYAndroid 8.0后正确的悬浮窗类型旧版TYPE_PHONE已被废弃width / height按dp转px320x360左右悬浮窗不要做全屏否则遮挡所有App操作flagsFLAG_NOT_FOCUSABLE或FLAG_LAYOUT_IN_SCREEN不获取焦点避免抢占输入全屏布局适配刘海屏formatPixelFormat.TRANSLUCENT告诉WindowManager这是一个半透明窗口Unity透明背景才正常gravityGravity.TOP或Gravity.START配合x/y坐标定位宠物在屏幕上的位置需要注意FLAG_NOT_FOCUSABLE这个flag是一把双刃剑加了它悬浮窗不会抢键盘焦点但Unity内部的一些输入事件也需要做额外处理不加它宠物点一下就会让当前App失去焦点严重影响体验。所以保留这个flagUnity侧的触摸交互后面单独处理。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()它不会销毁引擎重新恢复也更快。还有一个容易被忽略的坑如果用户按了系统返回键或者从最近任务里划掉了宿主ActivityUnityPlayer可能在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)透传给Unityprivate val activePointers HashSetInt() 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被系统回收的场景。这个开关能把你生命周期里所有藏着的崩溃提前暴露出来。我在项目正式上线前靠这个开关排查出至少三个只在用户长时间使用后才会触发的崩溃点省了很多在线上擦屁股的时间。