接到一个Unity的Android项目,需求本身挺普通:App退到桌面甚至锁屏之后,每隔几秒采集一次传感器数据,然后把数据发到服务端。开发到一半我就发现,Unity的Update()一退到后台就彻底不干活了,不管怎么勾选Player Settings里的Run In Background,应用一进入后台,主循环基本处于半停摆状态。折腾一圈后,真正管用的做法是把后台逻辑搬到一个Android系统级的Service里,让原生代码在Unity界面不可见之后继续跑。下面我就把整个思路、踩过的坑、完整可复现的代码都整理出来,希望能帮到正在做Unity Android混合开发、或者想把定位、下载、传感器采集这类任务抽到后台执行的朋友。
1. 为什么Unity的后台执行绕不开Android Service
1.1 Unity退到后台以后到底发生了什么
要理解为什么必须请出Service,先得知道Unity在Android上的运行机制。Unity引擎的渲染、物理、Update和协程,都挂在一个叫UnityPlayerActivity的Android Activity上。Activity一旦进入onPause或onStop,Unity就会根据策略暂停主循环:默认情况下画面不再渲染,Update不再每帧执行,协程的MoveNext也基本停住。即使你把Run In Background打开,让Unity主循环继续跑,Android系统层面仍然认为这是一个后台应用,在内存紧张或用户清理任务时,整个进程随时可能被杀掉。
更现实的问题是,现在的Android手机后台管理都特别激进,尤其是国产ROM。用户一按Home键,五分钟内就能把不活跃的进程清理掉。Unity进程没了,你在C#里开的Thread、Task、协程全部跟着陪葬。而Service是Android的四大组件之一,它的存在能提高进程优先级。如果把Service再升级成前台Service,通知栏常驻一条通知,进程优先级会高很多,被系统杀掉的概率会小很多。
1.2 Service、Thread、协程三者的定位区别
有人可能问,我在C#里用UniTask或者直接开一个Thread,不也能做后台执行吗?确实能,但前提是Unity进程还活着。Thread和协程不是Android组件,它们没有资格影响系统对进程的判定。协程依赖Unity主循环,主循环一停,协程就断供;Thread虽然能继续跑,但你不能在子线程里碰Unity的API,更拦不住系统杀进程。所以Thread和协程适合做“App还活着的时候的短时异步操作”,比如加载资源、请求网络,但不适合做“App退到后台后还要稳定执行好几个小时”的长期任务。
Service是系统级的存活方案。启动Service之后,Android会认为进程里有活跃组件,优先级明显高于纯后台Activity进程。如果你再把Service转成前台Service,进程优先级基本和用户正在看的应用一个级别。简单说,Unity的线程是“进程内存里的执行体”,Service是“系统进程调度里的保命符”,两者解决的问题完全不同。
1.3 不同后台场景下的方案选型
“后台执行代码”说起来一句话,落到具体场景,选型差别很大。我整理了一个常用的选型表,方便大家直接对照:
| 后台场景 | 推荐方案 | 选型原因 |
|---|---|---|
| 后台播放音频 | MediaSession + 媒体播放Service | 系统媒体服务有独立通道 |
| 后台连续定位 | 前台Service + LocationManager | 需要常驻进程和高优先级 |
| 后台网络轮询 | WorkManager 或 前台Service | WorkManager由系统统一调度更省电 |
| 倒计时/定时任务 | AlarmManager,也可Service+Handler | AlarmManager在系统休眠时也能触发 |
| 需要实时回调Unity更新UI | 前台Service + UnitySendMessage | 消息能精确推给Unity侧刷新界面 |
所以不是所有后台需求都得用Service。但如果你需要长时间、实时、并且要跟Unity侧保持通信,Service是最直接的选择,这也是本文后面重点展开的内容。
2. 核心设计:Unity和Android Service怎么通信
2.1 Unity和Android原生互相调用的两条链路
Unity和Android原生之间的通信,本质上就是两条链路。Unity侧通过AndroidJavaClass和AndroidJavaObject反射调用Java的静态方法或实例方法;Android侧通过UnityPlayer.UnitySendMessage向Unity场景里指定名字的GameObject发一条消息,让挂在它身上的MonoBehaviour方法被调用。两条链路合在一起,就是一个双向的RPC通道。
结合后台需求来看:Unity需要告诉Service“开始干活”或者“停止干活”,这走第一条链;Service需要告诉Unity“我每秒执行了一次”或者“某个任务完成了”,这走第二条链。把这两条路想清楚,后面写代码就是套模板的事。这里有个容易绕晕的点:Unity调用Android是主动拉,Android回调Unity是被动推,两个方向用到的API完全不同,千万不要搞混。
2.2 Unity调用Android:选Java插件还是直接反射
很多第一次接触Unity混合开发的人会以为,直接在C#里反射调用Android的Service类就行。这个想法有个硬伤:Service必须是一个继承Android框架类的Java类,C#反射只能调用现成的类方法,没法凭空创建一个Service子类。所以我们必须写一段Java代码交给Android系统。常见做法有两种:一种是直接把.java文件丢进Assets/Plugins/Android目录,让Unity打包时一起编译;另一种是用Android Studio做一个AAR插件,再放进Assets/Plugins/Android。我强烈推荐第二种。
原因很简单:AAR方式对Android Gradle插件版本、Manifest合并、SDK版本控制都更可控,遇到问题也好排查。而且做一次AAR工程之后,后面所有原生能力都可以往同一个插件里堆,比如蓝牙、串口、定位、下载,共用一套桥接框架。刚开始确实要多花半天搭建环境,但后续收益很大。
2.3 Android到Unity:UnitySendMessage的用法和坑
从Android端回调Unity,标准写法是UnityPlayer.UnitySendMessage(gameObjectName, methodName, param)。第一个参数是场景中GameObject的名字,不是脚本类型名;第二个参数必须和C#里的某个public方法名一致;第三个参数最多只能传一个字符串。这个方法最大的坑是:如果目标GameObject不存在,调用会安静地失败,不抛异常也没日志,看起来就像Unity侧毫无反应。
所以我的习惯是在App启动时创建一个不销毁的桥接GameObject,名字固定为BackgroundServiceBridge,并挂上对应的MonoBehaviour组件,然后在Java层统一向这个名字发消息。如果需要传复杂数据,就在Java侧先把JSON拼成字符串,Unity收到后再反序列化。千万不要试图传一个Object过去,UnitySendMessage只认字符串。
2.4 生命周期和内存管理的平衡
Service和Unity的生命周期是完全独立的。Unity的Activity可以销毁,Service可以继续存活;但Service活着的时候,如果Unity还没初始化好,UnitySendMessage喊破喉咙也没人接。因此启动Service的时机必须放在Unity场景就绪之后。我一般会在主场景的Awake里做初始化,然后再通过按钮或逻辑去启动Service。停止Service的时机则可以放在应用彻底退出时,或者用户主动关闭后台任务。
另外要提醒一点:Unity侧不要每帧去newAndroidJavaObject或反射Java方法,反射调用有开销,低端机上每帧执行会造成明显卡顿。正确做法是把调用封装成静态方法,只在状态切换、按钮点击、关键事件时调用。
3. 实操:从零构建一个会在后台执行的Unity-Android插件
3.1 前置准备:Android SDK和Unity版本
开始动手前先把环境理清楚。你需要一个可以导出Android包的Unity项目、一台Android 8.0以上的真机、一个Android Studio。Unity版本建议2019.4 LTS以上,2019.4开始对Android Gradle插件和AAR的支持比较完整。Android Studio版本选2022.3或更新,SDK Platform装到Android 14(API 34)附近,Build Tools不要低于30.0.0。
这里有个比较容易忽略的点:Unity导出APK时,会先生成一份Gradle工程再编译。你AAR里的compileSdkVersion和targetSdkVersion最好和Unity Player Settings里的设置保持一致或接近,否则可能出现Gradle依赖版本冲突,报错信息又臭又长,很难定位。
3.2 Android Studio工程结构
打开Android Studio,新建一个普通的Android项目,包名建议用com.yourcompany.unitybridge。项目建好之后,再通过File > New > New Module > Android Library新建一个库模块,把实际的插件代码放在这个Library模块里。为什么不让app模块直接支持?因为Unity只需要AAR产物,app模块最终打出来是APK,和Unity的Android工程结构不搭。
如果插件代码里需要引用UnityPlayer类,还得把Unity安装目录下的classes.jar复制到库模块的libs目录,并且在build.gradle里添加compileOnly依赖。classes.jar一般在Unity安装目录的PlaybackEngines/AndroidPlayer/Variations/mono/Release/Classes/classes.jar下。这一步是最容易卡住新手的地方,漏掉它编译时会直接报找不到com.unity3d.player.UnityPlayer。
3.3 写一个能在后台定时执行的BackgroundService
下面是核心的Service类。这个服务会在启动后每隔一秒执行一次后台逻辑,并通过UnityBridge把当前时间戳回传给Unity。代码里我用Handler的sendEmptyMessageDelayed做循环,避免依赖外部线程池,保证逻辑简单可控。
package com.yourcompany.unitybridge; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.util.Log; public class BackgroundService extends Service { private static final String TAG = "UnityBackgroundService"; private static final int MSG_RUN = 1; private static final long INTERVAL_MS = 1000L; private Handler mHandler = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(Message msg) { if (msg.what == MSG_RUN) { onTick(); sendEmptyMessageDelayed(MSG_RUN, INTERVAL_MS); } } }; @Override public void onCreate() { super.onCreate(); Log.d(TAG, "onCreate"); startForegroundWithNotification(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { Log.d(TAG, "onStartCommand"); String params = ""; if (intent != null && intent.getStringExtra("params") != null) { params = intent.getStringExtra("params"); } onTaskStart(params); mHandler.removeMessages(MSG_RUN); mHandler.sendEmptyMessageDelayed(MSG_RUN, INTERVAL_MS); return START_STICKY; } private void onTaskStart(String params) { notifyUnityOnMainThread("OnBackgroundTaskStart", params); } private void onTick() { Log.d(TAG, "tick " + System.currentTimeMillis()); // 这里只做轻量逻辑,耗时任务请丢到子线程 notifyUnityOnMainThread("OnBackgroundTick", System.currentTimeMillis() + ""); } private void startForegroundWithNotification() { if (Build.VERSION.SDK_INT >= 26) { NotificationChannel channel = new NotificationChannel( "unity_bg_channel", "Unity Background Service", NotificationManager.IMPORTANCE_LOW); NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (manager != null) { manager.createNotificationChannel(channel); } Notification notification = new Notification.Builder(this, "unity_bg_channel") .setContentTitle("Unity服务运行中") .setContentText("正在后台执行任务") .setSmallIcon(android.R.drawable.ic_dialog_info) .build(); startForeground(1001, notification); } else { Notification notification = new Notification.Builder(this) .setContentTitle("Unity服务运行中") .setContentText("正在后台执行任务") .setSmallIcon(android.R.drawable.ic_dialog_info) .build(); startForeground(1001, notification); } } private void notifyUnityOnMainThread(String methodName, String param) { Handler mainHandler = new Handler(Looper.getMainLooper()); mainHandler.post(new Runnable() { @Override public void run() { UnityBridge.notifyUnity(methodName, param); } }); } @Override public void onDestroy() { mHandler.removeMessages(MSG_RUN); UnityBridge.notifyUnity("OnBackgroundServiceDestroy", ""); Log.d(TAG, "onDestroy"); super.onDestroy(); } @Override public IBinder onBind(Intent intent) { return null; } }这里有个关键点:notifyUnityOnMainThread把消息切到主线程再发给Unity。虽然UnitySendMessage在部分版本里能从子线程调用,但为了稳定,我习惯先切回主线程,避免偶发的线程安全问题。onStartCommand返回START_STICKY,表示Service如果被系统异常杀掉,系统会尝试重建它,并再走一次onStartCommand。这算是提高后台存活率的第一道防线。
3.4 在Manifest中声明Service和权限
Service类写好后,还需要在Manifest里注册。如果是AAR方式,库模块的AndroidManifest会自动合并到最终的Unity工程里。但我建议在Unity工程侧也显式放一份AndroidManifest,优先保证权限齐全。下面这段XML可以直接放到Unity工程的Assets/Plugins/Android/AndroidManifest.xml里。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <application> <service android:name="com.yourcompany.unitybridge.BackgroundService" android:exported="false" android:stopWithTask="false" /> </application> </manifest>android:stopWithTask="false"的作用是当用户把最近任务卡片划掉时,Service不会跟着被停止。如果你的产品逻辑要求App退出就停掉后台任务,那这个属性就不要加。要注意,国内ROM对stopWithTask的处理并不统一,很多机型还是会杀,所以不能完全指望这个属性,后面第4节会讲更多适配手段。
3.5 导出AAR并引入Unity工程
库模块写好之后,在Android Studio右侧的Gradle面板里,找到库模块的Tasks分组,双击assembleRelease,等构建完成后,AAR产物会出现在模块目录的build/outputs/aar/下。再把AAR文件拷贝到Unity项目的Assets/Plugins/Android目录,Unity会自动识别并在打包时将其合并进最终APK。
打包时如果遇到Manifest merger failed,多数情况下是权限声明或application节点冲突。按报错信息去AndroidManifest里删除重复项,或者在Unity侧修改包名保持唯一即可。如果Unity版本比较老,可能还需要在Player Settings里把Gradle插件版本调到和AAR一致,这个要根据具体报错信息决定。
3.6 Unity侧C#封装代码
Unity侧的核心是创建一个常驻的桥接GameObject,然后在C#里封装启动和停止Service的静态方法。下面是一段可以直接复用的C#代码。
using System; using UnityEngine; public class BackgroundServiceBridge : MonoBehaviour { private static bool _initialized = false; public static event Action<long> OnTick; public static event Action<string> OnTaskStarted; public static event Action OnServiceDestroyed; public static void Initialize() { if (_initialized) return; GameObject go = new GameObject("BackgroundServiceBridge"); DontDestroyOnLoad(go); go.AddComponent<BackgroundServiceBridge>(); _initialized = true; } public static void StartService(string paramsText) { Initialize(); #if UNITY_ANDROID && !UNITY_EDITOR using (AndroidJavaClass player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) { AndroidJavaObject activityContext = player.GetStatic<AndroidJavaObject>("currentActivity"); using (AndroidJavaClass launcher = new AndroidJavaClass("com.yourcompany.unitybridge.ServiceLauncher")) { launcher.CallStatic("startBackgroundService", activityContext, paramsText); } } #else Debug.Log("BackgroundService only works on Android"); #endif } public static void StopService() { #if UNITY_ANDROID && !UNITY_EDITOR using (AndroidJavaClass player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) { AndroidJavaObject activityContext = player.GetStatic<AndroidJavaObject>("currentActivity"); using (AndroidJavaClass launcher = new AndroidJavaClass("com.yourcompany.unitybridge.ServiceLauncher")) { launcher.CallStatic("stopBackgroundService", activityContext); } } #endif } public void OnBackgroundTick(string message) { Debug.Log("[Unity] tick from service: " + message); OnTick?.Invoke(Convert.ToInt64(message)); } public void OnBackgroundTaskStart(string message) { Debug.Log("[Unity] task start: " + message); OnTaskStarted?.Invoke(message); } public void OnBackgroundServiceDestroy(string message) { Debug.Log("[Unity] service destroyed"); OnServiceDestroyed?.Invoke(); } }上面的C#代码里,Initialize()会在场景中创建一个名字为BackgroundServiceBridge的GameObject并挂上组件。这里有个细节容易踩坑:UnitySendMessage的第一个参数匹配的是GameObject的名字,并不是脚本类型名。所以我在创建GameObject时,名字故意和组件类名保持一致,Java层统一往BackgroundServiceBridge这个名字发消息,就可以保证发到正确的物体上。
3.7 真机验证是否在后台执行
打包安装后,先在Unity场景里放两个按钮,一个调用StartService,一个调用StopService。点击启动后,按Home键退到桌面,再用USB连电脑执行adb logcat -s UnityBackgroundService,你会看到日志里每秒跳出一条tick 时间戳。同时通知栏会出现“Unity服务运行中”的常驻通知,说明Service在后台真实跑起来了。
接下来验证Unity侧回调:保持Service运行,打开App回到Unity界面,看Console窗口有没有打出[Unity] tick from service这条日志。如果Java层日志一直在输出,但Unity侧没有,基本可以断定是GameObject名字或方法名对不上,回头检查UnitySendMessage的三个参数。
4. 高版本Android的后台限制与适配
4.1 Android 8.0以后为什么不让随便启动Service
Android对后台Service的收紧是循序渐进的。Android 8.0开始,后台应用不能随意调用startService,必须改用startForegroundService,并且在5秒内调用startForeground把自己转成前台服务。这里的“后台应用”指的是没有可见Activity、没有前台Service、也没有被系统认定为活跃状态的应用。
所以如果你是从最近任务列表里退到后台之后再调startService,基本都会抛IllegalStateException。解决办法就是统一走startForegroundService启动,并在Service创建后立刻带上通知调用startForeground。这也是为什么前面的代码里Service一启动就创建通知的原因,这既是功能需要,也是系统强制要求。
4.2 Android 12/13/14 前台服务类型
Android 10引入了前台服务类型foregroundServiceType,要求在Manifest里声明Service是干什么用的。Android 14则更加严格,startForeground时必须传入对应的类型,类型和权限对不上,后台启动可能会直接崩溃。常见类型有dataSync、location、connectedDevice、specialUse等。如果我们的后台任务主要是同步数据,那声明成dataSync就行。
下面是一个快速对照表,方便选择正确的类型:
| 类型 | Manifest声明 | 需要的权限 | 典型场景 |
|---|---|---|---|
| location | foregroundServiceType="location" | FOREGROUND_SERVICE_LOCATION | 后台定位 |
| dataSync | foregroundServiceType="dataSync" | FOREGROUND_SERVICE_DATA_SYNC | 后台上传/下载 |
| connectedDevice | foregroundServiceType="connectedDevice" | FOREGROUND_SERVICE_CONNECTED_DEVICE | 蓝牙/串口 |
| specialUse | foregroundServiceType="specialUse" | FOREGROUND_SERVICE_SPECIAL_USE | 无匹配类型的场景 |
如果你的targetSdkVersion还比较低,这些限制会宽松些,但应用市场都会逐步要求targetSdk提到高位,所以不如一开始就按新规范写。上面的示例Service如果要上Android 14,可以在startForeground时传入FOREGROUND_SERVICE_TYPE_DATA_SYNC,并在Manifest里补上FOREGROUND_SERVICE_DATA_SYNC权限。
4.3 如何尽量保证Service不被系统杀掉
Service能在后台跑多久,很大程度取决于你怎么管理资源。首先要保证它是前台Service,也就是必须有常驻通知。通知创建时建议把渠道的IMPORTANCE设为LOW,既能看到又不打扰用户,用户也更不容易手动关掉通知渠道。
其次是控制执行频率。不要为了“看起来实时”就把Handler周期设到几百毫秒,高频率唤醒CPU会让系统判定为高耗电应用,更容易进清理名单。后台定位采样一秒一次改成三到五秒一次,网络轮询合并成批处理,系统的容忍度会高很多。如果任务确实需要长时间CPU运行,比如后台下载,应该在任务期间申请PARTIAL_WAKE_LOCK,但任务一结束必须立刻释放,长时间持有会引发耗电异常。
还有一点,START_STICKY能让Service在被系统异常杀掉后尝试重建,但重建后进程内的数据会丢失。如果有恢复需求,建议把任务参数持久化到SharedPreferences,在onStartCommand里重新读取再恢复任务。
4.4 测试环境与国产ROM的特殊性
这个主题下,Android原生模拟器基本没有参考价值,因为模拟器的后台策略非常宽松。我强烈建议准备几台真机,至少覆盖一台Android 13/14的Pixel或一加,一台小米或Redmi,一台华为或荣耀,有条件再覆盖OPPO和vivo。你会发现同一份代码在原生Android上能跑五个小时,在国产ROM上十分钟就被清掉。
这不是Service代码写得有问题,而是各厂商自己的省电策略在起作用。遇到十分钟被杀的情况,先看Logcat里有没有onDestroy,如果连onDestroy都没有,说明进程是被系统直接杀掉,根本没走正常销毁流程。接下来要把各厂商的“一键优化”“自启动”“省电策略”等设置逐项放行,并把对应的设置路径写进项目交付文档里,方便测试和运营。
5. 常见问题与排查技巧实录
5.1 启动Service报IllegalStateException
这是最常见的报错。原因一般是Android 8.0以上还在用startService,或者前台Service启动后5秒内没有调用startForeground。排查思路很简单:确认所有启动入口都用了startForegroundService,Service的onCreate或onStartCommand里尽早调用startForeground,并保证通知渠道在Android 8.0以上已经建好。
5.2 UnitySendMessage没有触发
Unity侧毫无反应,先查三件事:GameObject名字是不是全对,大小写是否一致;方法名是不是和UnitySendMessage第二个参数完全一致;目标GameObject在调用发生时是否已经创建并激活。如果这三项都满足,再看Java层有没有抛出异常,Logcat里通常能看到UnitySendMessage相关的错误信息。找不到错误就加日志,从Java的UnityBridge开始打,逐层确认消息是否提交到了Unity引擎。
5.3 退到后台后Service很快被杀
这种情况绝大多数是厂商省电策略。先看Service是不是已经通过startForeground变成前台服务;再看通知是不是被用户划掉或关掉了通知渠道;接着把应用加入电池白名单。如果在原生Android上也秒死,那就检查onStartCommand是否返回START_STICKY,Manifest里有没有设置android:stopWithTask="false"。还有个小细节:Service里Handler如果一直高频执行,也可能是“耗电过高”被杀的原因。
5.4 通知栏没有正确显示
通知不显示,Service往往还在跑,但用户看不到常驻通知,后台状态很难判断。原因通常是Android 8.0以上没有创建NotificationChannel,或者渠道被用户手动关闭。解决方法是像示例代码那样,在Service的onCreate里创建一个固定渠道ID,startForeground时传入同样的渠道ID。通知不要用IMPORTANCE_HIGH,否则会频繁打扰,用户会更容易把整个渠道关掉。
5.5 混淆导致类名丢失
如果发布的是Release包,ProGuard/R8很可能把Java侧类名和方法名混淆掉,导致Unity反射调用失效。需要在混淆规则里保留插件包名下的所有类。在ProGuard配置中添加下面这段即可:
-keep class com.yourcompany.unitybridge.** { *; }尤其是ServiceLauncher和BackgroundService这两个类名,绝对不能被混淆。Unity侧其实也建议在ProjectSettings里把Managed 代码 Striping调到禁用,或者为桥接脚本加[RuntimeInitializeOnLoadMethod]之类的处理,否则IL2CPP裁剪可能把反射用到的类剪掉。
6. 这套方案还能怎么玩
6.1 后台定位跟踪
把BackgroundService里的定时Handler逻辑换成定位请求,每隔几秒从LocationManager拿一次经纬度,再通过UnitySendMessage推给Unity。这样App退到后台后,Unity界面虽然不可见,但定位数据还在持续采集。需要注意前台Service类型要声明成location,并动态申请定位权限,涉及Android 6.0以上的运行时权限和Android 12以上的精确定位权限。
6.2 后台下载/上传
Service里集成OkHttp或DownloadManager,可以让大文件下载在App退到后台时继续跑。下载进度的更新不必太频繁,每1%或每500毫秒回传一次就够,Unity侧收到后更新进度条组件。这样用户在通知栏看到前台服务,回到App又能看到进度条,体验比单纯的Activity下载好很多。下载完成或失败时,再通过UnitySendMessage通知Unity侧刷新状态。
6.3 后台串口通信
工业项目里,Unity做可视化HMI,串口那边用USB转串口或者蓝牙模块,主机需要一直采集设备数据。如果数据接收写在Activity里,Activity一退后台连接就断了。把设备通信逻辑全部放进Service,Unity只负责接收解析后的数据,整体稳定性会高很多。注意USB Host和蓝牙的权限都需要在Service启动前申请,不要等Service运行后再弹权限窗。
6.4 数字孪生与IoT场景
数字孪生项目大多是Unity做渲染,后台需要实时接收MQTT或WebSocket推送的传感器数据。把网络长连接放在Service里,Unity退到后台就专心当“显示层”,数据一有变化再回调进来刷新模型。这种架构能避免网络连接因为UI生命周期被反复重建,特别适合设备端一体机或者展馆大屏这类需要长时间稳定在线的场景。
不过无论怎么扩展,都要控制Service里的常驻线程数量。一个Service最好只负责一件事,处理不完不要阻塞,如果后台任务越来越多,考虑按业务拆成多个Service,或者用同一个通知渠道关联多个业务模块。
这块东西我前前后后折腾了将近一周,最大的感受是:代码本身不难,难的是Android版本碎片化带来的各种“门禁”。同一个Service,在原生Android上稳如老狗,到国产ROM上被砍得七零八落。所以我现在做Unity后台任务的第一原则是:能用前台Service绝不用裸Service,能少跑绝不多跑,所有关键日志全部写文件,方便线上排查。最后再分享一个小细节:调试Unity Android后台问题时,别只盯着Unity Console,一定要学会看adb logcat,Java层的打印比Unity层可靠得多。希望这篇实践能帮你少踩几个我踩过的坑。