
做Unity开发的朋友应该都遇到过这么一个问题项目切到后台之后Unity的脚本逻辑基本就停了。Unity自己没法在后台继续跑定时任务、网络请求或者音频播放这时候就得靠Android系统的Service机制来接管。这篇文章我会从Unity调用Android原生服务开始聊清楚怎么用Android Service在后台执行代码顺便把Android 8.0之后的系统限制和替代方案也讲透。我最早接触这个需求是给一个陪伴类App加“后台心跳上报”用户把App切后台后服务器还是要每10秒收到一次在线信号。原以为在Unity里开个线程、写个循环就行了结果切后台没几分钟Unity引擎直接挂起所有C#逻辑全部停止。后来才明白Unity在移动端的生命周期和普通App完全不同后台执行只能交给系统层的Service来做。这篇文章不分引擎还是原生直接把Unity接Android Service这条路踩透。不管是做游戏、工具类App还是数字孪生项目只要你遇到“App退到后台之后还要干活”的需求下面这套方案都能用。1. Unity后台执行的痛点在哪里1.1 Unity脚本在退后台时究竟发生了什么很多初次接触Unity移动端开发的人会有一个误解只要手机没锁屏、App还在内存里代码就应该继续跑。实际上Unity移动端在Activity进入onPause之后引擎就会停掉主循环。Update、FixedUpdate、协程、Invoke等全部暂停只有极少数底层原生操作还能执行。这里要特别说说协程。我最开始也天真地认为协程是跑在子线程里的。其实协程只是利用迭代器和帧循环来分段执行本质还在主线程。App退后台主循环停止协程就卡死在yield那一步。即使你用了WaitForSecondsRealtime这种不受TimeScale影响的方法只要引擎主循环不跑它照样不会继续。还有一个常见误导是Application.runInBackground。这个开关确实能让Unity在离开焦点时继续跑但它在多数Android设备上的效果非常不稳定电池优化、厂商后台清理、系统省电模式都会打断它。而且就算能跑渲染管线、物理系统、资源加载都还占着功耗完全不可控。所以正规做法就是不依赖Unity引擎把后台任务下沉到Android系统组件里。1.2 Service在Android系统里到底是什么角色Android的Service是四大组件之一专门用来处理“没有界面但需要长期执行”的任务。它和Activity的生命周期是独立的。你在Unity里启动一个Service之后就算Unity的Activity退到后台甚至Activity被系统回收销毁只要Service进程还活着任务就能一直跑。Service分两种启动型ServicestartService和绑定型ServicebindService。启动型一旦通过startService启动就会在后台无限期执行直到调用stopService或者任务自己停。绑定型Service通过Binder和调用方绑定调用方销毁后服务也会跟着结束。做Unity后台任务绝大多数情况下用启动型Service就够了因为Unity端可能随时被销毁服务不能依赖于Unity那一侧。要注意这里说的“后台执行”并不等于“独立进程”。默认Service和App在同一个进程内。只不过Service是系统级别的组件生命周期不受Activity影响所以能脱离Unity继续执行。1.3 哪些场景适合用Service哪些不适合用Service解决后台执行并不是所有“切后台继续跑”的需求都合适。我按自己的实战经验分三类适合用Service的定时上报心跳、后台音乐播放、消息推送到达后的逻辑处理、传感器数据采集比如计步、需要持续连接网络的长连接。不适合硬套Service的只需要在App被杀死后定时启动任务的应该用AlarmManager或者WorkManager只是想在几秒内等网络请求完成再走的直接加前台等待就行没必要折腾Service重度渲染或依赖Unity场景资源的逻辑Service里没有Unity环境根本没法跑C#逻辑。记住一个判断标准你的后台任务是否需要Unity引擎对象。如果需要大概率不应该走Service如果只是纯Java/纯逻辑跟C#里的数据状态没关系那Service就是最优解。2. 准备工作Unity工程与Android插件2.1 方案选型直接用Unity导出工程 vs 预编译AAR自己动手之前先确定工程结构。我在实际项目中用过两种方式各有优劣。第一种用Unity导出Android工程然后在Android Studio里打开工程直接修改Java代码。优点是调试方便Unity的lib工程已经帮你配好了依赖关系Service类可以直接放在unityLibrary里改。缺点是每次Unity做修改都要重新导出手动迁移文件比较多多人协作时还容易冲突。第二种用Android Studio单独创建Android Library模块编译出AAR文件放到Unity项目的Assets/Plugins/Android目录下。这种方式是目前我在正式项目里最常用的Android代码和Unity代码完全解耦版本管理也干净Unity侧只需要调AAR提供好的接口。缺点是第一次配置AAR有点门槛但一次性投入很值得。如果你的项目只是临时验证想法用第一种。如果是正式产品直接用第二种。下面所有步骤我都会按第二种方式讲。2.2 创建Android插件工程并配置依赖在Android Studio里新建一个项目模块类型选“Android Library”。模块名字可以叫unity-service-lib包名用com.yourcompany.unityservice。Unity导出AAR时会自动带上libs/unity-classes.jar但我们在Android Studio里编译模块时并没有Unity相关类所以需要手动引入Unity的classes.jar。classes.jar在哪在你的Unity安装目录下Editor/Data/PlaybackEngines/AndroidPlayer/Variations/mono/Release/Classes/classes.jar。我用的是Unity 2021长期支持版路径基本一致。把它复制到模块的libs目录然后在build.gradle里加上implementation files(libs/classes.jar)注意AAR里面最终会不会带上这个classes.jar取决于你的打包配置。如果不希望最终包体冲突可以在模块的packagingOptions里排除或者直接在编译期用compileOnly引依赖。我的习惯是compileOnly这样Unity主工程里已经有自己的UnityPlayer类了不必重复打包。还要保证模块的minSdkVersion不低于Unity工程要求的版本一般是21或22我通常设为24。targetSdkVersion建议和Unity里保持一致不然后面遇到后台限制会很头疼。2.3 在AndroidManifest里注册Service与权限Android Library模块可以有自己的AndroidManifest.xml最终合并到Unity工程的manifest里。我们先在模块的manifest里声明Service和必要权限。manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.yourcompany.unityservice uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_SPECIAL_USE / uses-permission android:nameandroid.permission.WAKE_LOCK / application service android:name.UnityBackgroundService android:enabledtrue android:exportedfalse / /application /manifest这里有个点要提醒android:exportedfalse很关键。这个Service只是给自己的Unity App用的不需要被外部App唤起。如果漏掉这个属性在Android 12上可能会因为安全性要求直接报错或者安装失败。FOREGROUND_SERVICE权限是为了后面把Service转成前台服务准备的。如果只是做简单的startService后台任务普通场景可以不用但Android 9以后很多设备会在短时间内杀掉后台服务所以最好还是提前把前台服务的路子铺好。3. 手把手写一个“后台心跳”Service3.1 Service的完整生命周期与回调方法现在开始写核心的Service类。先看一个最基础的骨架package com.yourcompany.unityservice; import android.app.Service; import android.content.Intent; import android.os.IBinder; public class UnityBackgroundService extends Service { Override public void onCreate() { super.onCreate(); } Override public int onStartCommand(Intent intent, int flags, int startId) { return START_STICKY; } Override public void onDestroy() { super.onDestroy(); } Override public IBinder onBind(Intent intent) { return null; } }Service的启动入口是onStartCommand。每次你调用startService这个方法就会被触发。返回值START_STICKY表示如果Service进程被系统杀掉系统会尝试重新创建这个Service适合需要后台持续运行的场景。如果任务没做完就不期望重启可以用START_NOT_STICKY。onCreate只在Service第一次创建时执行一次后面重复startService不会再触发。资源初始化、线程池创建都放在这里。onDestroy是清理资源的最后机会线程、广播、网络连接都在这里释放否则会内存泄漏。3.2 在Service里创建后台线程执行任务Service本身跑在Android主线程直接在onStartCommand里写循环或者网络请求会卡住UI。所以必须自己开子线程。下面这段代码实现了一个简单的每10秒打印一次心跳日志的后台任务public class UnityBackgroundService extends Service { private static final String TAG UnityBgService; private HandlerThread workThread; private Handler workHandler; private boolean running false; Override public void onCreate() { super.onCreate(); workThread new HandlerThread(UnityBgServiceThread); workThread.start(); workHandler new Handler(workThread.getLooper()); } Override public int onStartCommand(Intent intent, int flags, int startId) { if (!running) { running true; workHandler.post(heartbeatRunnable); } return START_STICKY; } private Runnable heartbeatRunnable new Runnable() { Override public void run() { if (!running) return; Log.d(TAG, heartbeat tick); workHandler.postDelayed(this, 10_000L); } }; Override public void onDestroy() { running false; workHandler.removeCallbacksAndMessages(null); workThread.quitSafely(); super.onDestroy(); } Override public IBinder onBind(Intent intent) { return null; } }这里用HandlerThread加Handler而不是直接new Thread好处是方便做延时任务和消息队列管理。postDelayed(this, 10000)把任务本身再次压队就形成了一个定时循环。这个循环不会受Unity主循环影响因为它是纯粹的Android线程机制。如果你要在这个后台任务里更新Unity侧的数据或UI可以通过UnitySendMessage把消息传回去后面我会专门讲通信方式。3.3 Unity端启动和停止Service的C#封装Android侧准备好了Unity这边用C#调用AndroidJavaObject来启动和停止Service。核心代码如下using UnityEngine; public static class AndroidServiceBridge { private const string PackageName com.yourcompany.unityservice; private const string ServiceClass PackageName .UnityBackgroundService; public static void StartBackgroundService() { #if UNITY_ANDROID !UNITY_EDITOR using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (AndroidJavaObject currentActivity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (AndroidJavaClass serviceClass new AndroidJavaClass(ServiceClass)) using (AndroidJavaObject intent new AndroidJavaObject(android.content.Intent, currentActivity, serviceClass)) { currentActivity.Call(startService, intent); } #endif } public static void StopBackgroundService() { #if UNITY_ANDROID !UNITY_EDITOR using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) using (AndroidJavaObject currentActivity unityPlayer.GetStaticAndroidJavaObject(currentActivity)) using (AndroidJavaClass serviceClass new AndroidJavaClass(ServiceClass)) using (AndroidJavaObject intent new AndroidJavaObject(android.content.Intent, currentActivity, serviceClass)) { currentActivity.Call(stopService, intent); } #endif } }注意几个关键点第一currentActivity可能为空。如果Unity的Activity在后台被系统回收了再次进入应用时Unity会重建Activity但那一刻如果代码直接尝试获取Activity可能拿到null。实际项目中我会在调用前加空判断或者提前缓存activity引用。第二new AndroidJavaObject(android.content.Intent, currentActivity, serviceClass)的第三个参数是Java Class对象AndroidJavaClass会映射成java.lang.Class所以这里正好能用来构建Intent。第三这个封装只在Android真机上有效所以必须用#if UNITY_ANDROID !UNITY_EDITOR包起来否则编辑器里会报错。调用时机也很重要。我一般建议在用户进入App主页时启动Service或者点了“开启后台同步”按钮时启动。不要每次切后台都启动一次因为Service本身会常驻重复启动没有意义只会在onStartCommand里反复触发心跳重置。3.4 Unity与Service通信的4种常用方式Service在后台干活Unity界面怎么知道结果我整理了自己用过的4种方式按推荐程度从高到低说。第一种UnitySendMessage。Android端可以直接调用Unity提供的静态方法往Unity场景里指定GameObject发送消息。在Service的任意线程里都可以调用底层会把消息排到Unity主线程。UnityPlayer.UnitySendMessage(MainCanvas, OnServiceMessage, heartbeat_tick);Unity端对应脚本public void OnServiceMessage(string message) { Debug.Log(收到Service消息: message); }第二种轮询静态变量。在Java类里定义一个static的计数或者状态字段Unity端每个帧都通过AndroidJavaObject读取这个字段。优点是实现简单不用考虑消息同步问题缺点是每帧做JNI调用有开销不适合高频数据。第三种广播BroadcastReceiver。Service发出自定义广播Unity侧在Java层注册Receiver再通过UnitySendMessage转发给C#。适合需要多处监听、多个页面接收消息的场景。但配置比前两种复杂广播也是全局的注意安全问题。第四种LocalBroadcast或者LiveData。这属于现代Android推荐方案但在Unity环境里集成略重。如果Unity侧还需要和原生Activity、原生Fragment交互可以上。纯Unity场景没必要。我个人最常用的是UnitySendMessage处理80%的场景都够了。它天然线程安全不需要自己切线程还自带消息串行化两个线程同时调用也不容易乱。但要注意GameObject在场景里必须存在而且名字要一致。如果场景切换导致该物体被销毁消息会直接丢失。4. 高版本Android的适配前台服务与系统限制4.1 Android 8.0的后台服务限制到底限制了什么Android 8.0API 26是一个分水岭。官方明确规定应用处于后台时不能随便调用startService。这里的“后台”定义是应用没有可见Activity或者Activity处于stopped状态。这意味着如果你的Unity App已经退到后台然后再尝试用上面的C#代码启动Service系统会直接抛IllegalStateException后台执行代码这事就做不成了。所以在Android 8.0以上启动Service的时机必须是在应用仍然处于前台的时候。那问题来了用户先在前台启动Service然后切到后台Service能一直跑吗可以但同样有时间限制。普通后台Service在系统资源紧张时会被优先清理而且Google的电池优化策略会越来越激进。想让任务稳定存活就得把Service升级成“前台服务”。4.2 升级为前台Service并创建通知渠道前台服务就是通过startForeground()让Service变成用户可见的任务系统会要求它在通知栏显示一条常驻通知。用户能看到“XX应用正在后台运行”同时它被系统杀掉的优先级会大幅降低。Android 8.0以上通知必须指定渠道NotificationChannel否则通知不会显示。Android 13API 33以后通知本身的显示还需要动态申请POST_NOTIFICATIONS权限。我写了一个带前台服务改造的Service示例import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.Build; private static final String CHANNEL_ID unity_bg_service_channel; private void startAsForegroundService() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { NotificationChannel channel new NotificationChannel( CHANNEL_ID, Unity后台服务, NotificationManager.IMPORTANCE_LOW ); NotificationManager manager (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); manager.createNotificationChannel(channel); } Notification.Builder builder; if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { builder new Notification.Builder(this, CHANNEL_ID); } else { builder new Notification.Builder(this); } Intent notificationIntent new Intent(this, UnityPlayerActivity.class); PendingIntent pendingIntent PendingIntent.getActivity( this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE ); builder.setContentTitle(后台服务运行中) .setContentText(正在执行后台任务) .setSmallIcon(android.R.drawable.ic_popup_sync) .setContentIntent(pendingIntent) .setOngoing(true); Notification notification builder.build(); startForeground(1, notification); }然后在onCreate()或者onStartCommand()里调用startAsForegroundService()即可。注意只要调用了startForegroundService就变成了前台服务必须在不需要时显式调stopForeground()或者stopSelf()否则通知会一直挂着。很多新手会踩一个坑忘记了调用startForeground()却又在AndroidManifest里声明了FOREGROUND_SERVICE权限结果onStartCommand跑起来后系统一直警告“Context.startForegroundService() did not then call Service.startForeground()”。注意如果你在Unity里调用的是startService并没有问题但如果在Android 12上换用了startForegroundService方式启动就必须在5秒内调用startForeground否则系统会直接ANR或崩溃。4.3 与其硬扛不如换个思路WorkManager/JobScheduler如果你的后台任务并不需要“一直、实时、长期”地跑我更推荐用WorkManager或者JobScheduler而不是Service硬扛。WorkManager适合带条件的延迟任务比如“只在WiFi下上传日志”“每天早上8点同步数据”。系统会合并任务、选择最佳执行时间还能保证App重启之后任务不丢。JobScheduler是更底层的调度器WorkManager底层就是依赖它实现的但如果Unity里不想引入额外依赖直接用JobScheduler也可以。从Unity调WorkManager可以通过自定义UnityPlayerActivity回调或者直接在AAR里写一个JobService/Worker类。这里不展开代码核心思路仍然一样Unity层只负责把参数传过去原生层负责调度。这套方案的收益是系统能严格控制后台功耗你的Service不会因为电池优化而被盯上。代价是任务的实时性没法保证也许延迟几分钟甚至几小时。如果你能接受那就别折腾Service了。5. 我踩过的坑问题排查与优化实录5.1 Service总是被系统杀掉怎么办最常见的情况是App在前台启动Service退后台后能跑一会几分钟后Service被系统清理掉。这个问题的根源多半不是Service本身写错了而是进程进入了缓存状态被LMKLow Memory Killer回收。首先确认你有没有调用startForeground。如果任务很重要直接按4.2改成前台服务。其次国产ROM的后台清理和Google原生生态完全不同。很多国产手机即使你开了前台服务也会在用户一键清理时把App彻底杀掉通知栏服务也会被一并清除。这种情况下除了引导用户把应用加入电池优化白名单没有更好的办法。还有一个小技巧在onTaskRemoved()里处理一下。当用户从最近任务列表滑掉App时系统会回调这个方法默认Service也会随之销毁。如果你希望Service继续存活可以在onTaskRemoved里重新startService或者给出提示Override public void onTaskRemoved(Intent rootIntent) { // 提示用户后台任务已停止或者尝试恢复 super.onTaskRemoved(rootIntent); }但要清醒认识到这种“复活”手段在Android 10受到严格限制国内厂商也可能直接拦截后台启动。把它当成一个“尽力而为”的优化不要作为核心保障。5.2 启动Service却找不到类、闪退要怎么查这个坑我踩过不止一次。最常见的原因是包名写错了或者AAR里的类没有真正打进APK。如果Unity构建时没有把AAR里的代码合并进去运行时就会报ClassNotFoundExceptionService根本创建不起来。排查步骤可以按这个顺序来第一步在Unity里检查Assets/Plugins/Android目录的AAR文件是否存在且文件名、后缀名都是小写。Android资源文件路径对大小写敏感我曾经把MyService.aar改成myservice.aar后问题自动消失了。第二步用解压工具打开AAR文件确认class.jar里真的有UnityBackgroundService.class。如果没有说明Android工程编译失败或者打包漏了代码。第三步在AndroidManifest里查看合并后的manifest。Unity导出工程后路径是Temp/StagingArea/AndroidManifest.xml直接在导出包里搜Service的包名看是否被合并进去。第四步如果以上都没问题在Unity设备日志里看具体异常。用adb logcat -s Unity过滤Unity日志或者adb logcat | grep AndroidRuntime找Java崩溃栈。5.3 Unity场景里的数据在后台回来后不更新场景的问题是Service后台跑得稳稳当当也用UnitySendMessage发消息给场景了但切回App后发现UI还是旧数据看不到最新状态。原因一般是消息发到了一个不存在的GameObject或者目标GameObject在切后台后被销毁了。UnitySendMessage是强引用的目标对象查不到时API不会报错只会静默丢消息你根本没机会发现。另外一个原因Unity主线程恢复后并不一定会立即处理所有消息。消息队列要等当前帧结束、脚本循环恢复后才执行。如果Service发消息频率很高比如每秒一次而Unity切回前台的第一帧还在做资源恢复部分消息可能被合并或丢弃。解决办法有三个第一保证接收消息的物体是常驻物体最好放在DontDestroyOnLoad里第二Unity端用协程做一个兜底同步比如每3秒主动读取Service端的静态状态第三服务端把关键数据存在本地SharePreferences或文件Unity启动时直接读取不依赖实时推送。我现在做项目基本是消息加状态轮询双通道既保证实时性又有兜底。5.4 内存与电量的血泪教训最后聊一聊性能。Service跑在Android主进程如果里面直接写死循环CPU占用会飙升手机发烫、掉电用户分分钟卸载你的应用。这也是系统限制后台Service的根本原因。我的建议是能用延时任务就不要while(true)循环能用WorkManager就不要Service硬扛如果一定要常驻服务记得在Service里维护一个线程池不要每个任务都new Thread。另外Unity侧也要配合。不要每帧都去查Service状态、做JNI调用那会让Unity主线程频繁跨语言调用卡顿明显。最好是Service主动推消息Unity端只在收到消息时更新UI或者在固定间隔用协程拉取一次。还有一个容易忽略的内存问题Service里通过UnitySendMessage传字符串时如果消息体是一个很大的JSON每次都会在Java层和C#层之间做字符串拷贝频繁调用会积累不少GC压力。消息内容能精简就精简传状态码而不是传整串内容。最后提醒一句MaterialDesign里那个android.R.drawable.ic_popup_sync小图标只是方便测试正式项目一定要用自己的图标否则通知栏风格会很突兀。而且通知渠道的IMPORTANCE_LOW表示不响铃、不震动如果用户要求有提示音还要再调高通知importance并处理运行时通知权限。我在实际项目中把Service封装成一个独立的Unity插件后团队其他人再也不用关心Android原生代码C#侧只需要调用一行AndroidServiceBridge.StartBackgroundService()。等到项目里又出现“定时同步”“长连接保持”这类需求时直接在Service里加模块就行复用性很高。如果你现在正在被Unity后台执行折腾建议先按这个流程跑通一个最小心跳Demo然后再往里面填充你真正的业务逻辑。