ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity Android后台执行实战:借助Service实现常驻任务与双向通信

Unity Android后台执行实战:借助Service实现常驻任务与双向通信 接到一个Unity的Android项目需求本身挺普通App退到桌面甚至锁屏之后每隔几秒采集一次传感器数据然后把数据发到服务端。开发到一半我就发现Unity的Update()一退到后台就彻底不干活了不管怎么勾选Player Settings里的Run In Background应用一进入后台主循环基本处于半停摆状态。折腾一圈后真正管用的做法是把后台逻辑搬到一个Android系统级的Service里让原生代码在Unity界面不可见之后继续跑。下面我就把整个思路、踩过的坑、完整可复现的代码都整理出来希望能帮到正在做Unity Android混合开发、或者想把定位、下载、传感器采集这类任务抽到后台执行的朋友。1. 为什么Unity的后台执行绕不开Android Service1.1 Unity退到后台以后到底发生了什么要理解为什么必须请出Service先得知道Unity在Android上的运行机制。Unity引擎的渲染、物理、Update和协程都挂在一个叫UnityPlayerActivity的Android Activity上。Activity一旦进入onPause或onStopUnity就会根据策略暂停主循环默认情况下画面不再渲染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 或 前台ServiceWorkManager由系统统一调度更省电倒计时/定时任务AlarmManager也可ServiceHandlerAlarmManager在系统休眠时也能触发需要实时回调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到UnityUnitySendMessage的用法和坑从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 14API 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:androidhttp://schemas.android.com/apk/res/android uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.WAKE_LOCK / application service android:namecom.yourcompany.unitybridge.BackgroundService android:exportedfalse android:stopWithTaskfalse / /application /manifestandroid:stopWithTaskfalse的作用是当用户把最近任务卡片划掉时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 Actionlong OnTick; public static event Actionstring OnTaskStarted; public static event Action OnServiceDestroyed; public static void Initialize() { if (_initialized) return; GameObject go new GameObject(BackgroundServiceBridge); DontDestroyOnLoad(go); go.AddComponentBackgroundServiceBridge(); _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.GetStaticAndroidJavaObject(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.GetStaticAndroidJavaObject(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以后为什么不让随便启动ServiceAndroid对后台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声明需要的权限典型场景locationforegroundServiceTypelocationFOREGROUND_SERVICE_LOCATION后台定位dataSyncforegroundServiceTypedataSyncFOREGROUND_SERVICE_DATA_SYNC后台上传/下载connectedDeviceforegroundServiceTypeconnectedDeviceFOREGROUND_SERVICE_CONNECTED_DEVICE蓝牙/串口specialUseforegroundServiceTypespecialUseFOREGROUND_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。排查思路很简单确认所有启动入口都用了startForegroundServiceService的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_STICKYManifest里有没有设置android:stopWithTaskfalse。还有个小细节Service里Handler如果一直高频执行也可能是“耗电过高”被杀的原因。5.4 通知栏没有正确显示通知不显示Service往往还在跑但用户看不到常驻通知后台状态很难判断。原因通常是Android 8.0以上没有创建NotificationChannel或者渠道被用户手动关闭。解决方法是像示例代码那样在Service的onCreate里创建一个固定渠道IDstartForeground时传入同样的渠道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一退后台连接就断了。把设备通信逻辑全部放进ServiceUnity只负责接收解析后的数据整体稳定性会高很多。注意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 logcatJava层的打印比Unity层可靠得多。希望这篇实践能帮你少踩几个我踩过的坑。
返回列表