ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android 8.0+蓝牙后台保活实战:前台服务与PendingIntent锁屏唤醒

Android 8.0+蓝牙后台保活实战:前台服务与PendingIntent锁屏唤醒 1. 蓝牙后台保活到底难在哪做过 Android 蓝牙外设对接的人大概率都经历过这种场景App 切到后台或者手机屏幕一黑蓝牙扫描就停了设备连不上、数据断了、用户投诉接踵而至。尤其是 Android 8.0 之后系统对后台行为和后台服务的限制越来越严以前那套“随便起个 Service 就能一直扫”的做法彻底失效。这个项目标题里的“蓝牙后台保活”“Ble锁屏唤醒”“持续扫描”说的就是怎么在 Android 8.0 及以上版本里让蓝牙低功耗扫描在锁屏、后台、长时间待机的情况下依然能稳定工作。先把结论摆出来Android 8.0 想做到蓝牙后台持续扫描靠单一手段是不行的必须组合拳。核心思路是前台服务 低功耗扫描策略 PendingIntent 唤醒 广播/JobScheduler 兜底。这套方案解决的是“App 不在前台时蓝牙扫描被系统掐断”的问题适合做智能穿戴、蓝牙防丢器、健康监测设备、车载蓝牙、工业蓝牙采集这类需要长时间保持蓝牙连接或扫描的应用开发者。不管你是刚接触 BLE 的新手还是被后台限制折磨过的老手下面这些内容都能直接拿去用。我先把整个技术方案的骨架讲清楚再逐层拆解每个环节的实现细节和踩坑点。文章会涉及大量代码和配置但我会尽量用大白话解释每一步为什么这么做让你不仅会抄还能理解背后的逻辑。2. 整体方案设计与核心思路拆解2.1 为什么 Android 8.0 是分水岭Android 8.0API 26引入了后台执行限制这是整个问题的根源。具体来说当 App 进入后台后系统会限制以下几件事后台服务会被停止除非是前台服务、隐式广播大部分被禁止、后台位置更新频率被限制、蓝牙扫描也被纳入管控。Android 10 之后又加了后台启动 Activity 的限制Android 12 进一步收紧了前台服务的启动时机。所以如果你还在用 Android 7.0 时代的写法在 8.0 上基本活不过几分钟。这里有个关键点很多人搞混蓝牙扫描本身在后台是可以做的但前提是你得让系统认为你的 App 有“正当理由”在后台运行。这个“正当理由”的载体就是前台服务。前台服务会在通知栏显示一个常驻通知告诉用户“这个 App 正在后台干活”系统就不会轻易杀掉它。2.2 方案选型的三个层次我把整个保活方案分成三个层次从强到弱依次是层次手段适用场景存活能力第一层前台服务 常驻通知需要持续扫描或保持连接最强用户可见第二层PendingIntent 广播唤醒锁屏后被暂停需要重新激活中等依赖系统调度第三层JobScheduler / WorkManager周期性任务兜底较弱有最小间隔限制实际项目中这三层要配合使用。前台服务负责“主战场”PendingIntent 负责“锁屏唤醒”JobScheduler 负责“兜底重连”。单独用任何一个都不够稳。2.3 为什么不用双进程守护那套网上有很多“双进程守护”“Native 进程保活”的方案我实测下来在 Android 8.0 上基本没用而且容易被系统判定为恶意行为导致 App 被限制甚至下架。蓝牙场景和即时通讯不一样我们不需要“永远不死”只需要“在需要扫描的时候能扫到”。所以正确的思路不是对抗系统而是顺着系统的规则走用前台服务把合法性做足用 PendingIntent 把唤醒链路打通。提示不要尝试用反射、隐藏 API 或者 Native 守护进程来绕过后台限制这些手段在新版本上要么失效要么会触发应用商店的合规审查。3. 核心细节解析与实操要点3.1 前台服务的正确声明方式前台服务是整套方案的基石。从 Android 8.0 开始启动前台服务必须调用startForegroundService()然后在 5 秒内调用startForeground()显示通知否则会抛 ANR 或崩溃。Android 10 之后还需要在 AndroidManifest 里声明foregroundServiceType蓝牙场景一般用connectedDevice或location。service android:name.BleScanService android:enabledtrue android:exportedfalse android:foregroundServiceTypeconnectedDevice|location /权限方面Android 12 之前需要ACCESS_FINE_LOCATION才能扫描 BLE 设备Android 12 之后新增了BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE三个运行时权限。这里有个坑BLUETOOTH_SCAN如果声明了neverForLocation属性系统会认为你不是用来推断位置的可以不用申请定位权限但部分厂商 ROM 仍然会要求定位权限才能扫到设备。我的建议是定位权限和蓝牙权限都申请兼容性最好。uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE /3.2 扫描参数怎么设才省电又稳定BLE 扫描有三个关键参数SCAN_MODE、SCAN_MODE_LOW_POWER的窗口和间隔、以及是否开启setReportDelay。很多人为了“扫得快”直接用SCAN_MODE_LOW_LATENCY结果电量哗哗掉系统也会因为功耗过高而限制你。我的经验是分场景设置前台交互时用SCAN_MODE_LOW_LATENCY扫描窗口和间隔都设短快速发现设备。后台持续扫描时用SCAN_MODE_LOW_POWER窗口设 512ms间隔设 5120ms这样占空比约 10%功耗可控。锁屏待机时进一步降低频率或者改用PendingIntent方式让系统在发现设备时才唤醒 App。ScanSettings settings new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .setReportDelay(0) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) .build();setMatchMode和setNumOfMatches这两个参数很多人忽略它们配合ScanFilter使用可以只上报匹配的设备减少无效回调。如果你的设备有固定的 Service UUID一定要用ScanFilter过滤这样系统在底层就能筛掉大部分广播包省电效果非常明显。3.3 PendingIntent 扫描锁屏唤醒的关键这是整个方案里最核心的一环。普通的startScan()在锁屏后如果 App 被系统冻结扫描回调就不会执行。而startScan()有一个重载版本可以传入一个PendingIntent当系统发现匹配的设备时会发送这个 PendingIntent从而唤醒你的 App。Intent intent new Intent(context, BleScanReceiver.class); intent.setAction(com.example.BLE_SCAN_RESULT); PendingIntent pendingIntent PendingIntent.getBroadcast( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE ); bluetoothLeScanner.startScan(filters, settings, pendingIntent);这里有几个关键细节第一PendingIntent的 flag 在 Android 12 之后必须显式指定FLAG_MUTABLE或FLAG_IMMUTABLE。蓝牙扫描场景下系统需要往 Intent 里填充扫描结果所以必须用FLAG_MUTABLE否则收不到数据。第二接收 PendingIntent 的BroadcastReceiver必须在 Manifest 里静态注册不能动态注册因为 App 可能已经被冻结动态注册的 Receiver 收不到。receiver android:name.BleScanReceiver android:exportedtrue intent-filter action android:namecom.example.BLE_SCAN_RESULT / /intent-filter /receiver第三BleScanReceiver的onReceive里要做的事情要尽量轻量因为广播接收器的执行时间有限约 10 秒。一般做法是收到广播后启动前台服务或者发一个本地通知让用户点击后回到 App 处理数据。如果数据量小也可以直接在onReceive里解析并存储。注意部分厂商 ROM尤其是国内定制系统对 PendingIntent 唤醒有额外限制需要在设置里手动允许“自启动”和“后台弹出界面”。这个没法完全靠代码解决只能在引导页里提示用户。3.4 扫描结果的解析与去重PendingIntent方式收到的扫描结果是通过 Intent 的 extra 传过来的key 是BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT值是一个ArrayListScanResult。解析时要注意类型转换和空判断。Override public void onReceive(Context context, Intent intent) { if (intent null) return; String action intent.getAction(); if (!com.example.BLE_SCAN_RESULT.equals(action)) return; ArrayListScanResult results intent.getParcelableArrayListExtra( BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT); if (results null || results.isEmpty()) return; for (ScanResult result : results) { String mac result.getDevice().getAddress(); int rssi result.getRssi(); // 去重、存储、判断是否需要唤醒界面 } }去重是个容易被忽略的点。同一个设备在短时间内可能被上报多次如果每次都触发业务逻辑会造成重复处理和电量浪费。我的做法是用一个ConcurrentHashMap记录每个 MAC 地址的最后上报时间超过一定间隔比如 5 秒才认为是新事件。4. 实操过程与核心环节实现4.1 完整的前台服务实现下面是一个可以直接参考的前台服务实现包含了通知渠道创建、扫描启动、扫描停止等核心逻辑。public class BleScanService extends Service { private static final String CHANNEL_ID ble_scan_channel; private static final int NOTIFICATION_ID 1001; private BluetoothLeScanner scanner; private ScanCallback scanCallback; Override public void onCreate() { super.onCreate(); createNotificationChannel(); BluetoothManager manager (BluetoothManager) getSystemService(BLUETOOTH_SERVICE); BluetoothAdapter adapter manager.getAdapter(); if (adapter ! null) { scanner adapter.getBluetoothLeScanner(); } } Override public int onStartCommand(Intent intent, int flags, int startId) { startForeground(NOTIFICATION_ID, buildNotification()); startBleScan(); return START_STICKY; } private void createNotificationChannel() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { NotificationChannel channel new NotificationChannel( CHANNEL_ID, 蓝牙扫描服务, NotificationManager.IMPORTANCE_LOW); channel.setDescription(保持蓝牙设备连接); NotificationManager nm getSystemService(NotificationManager.class); if (nm ! null) nm.createNotificationChannel(channel); } } private Notification buildNotification() { 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); } return builder .setContentTitle(设备连接中) .setContentText(正在保持蓝牙连接) .setSmallIcon(R.drawable.ic_ble) .setOngoing(true) .build(); } private void startBleScan() { if (scanner null) return; ScanSettings settings new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .build(); ListScanFilter filters new ArrayList(); // 根据实际设备添加过滤条件 scanCallback new ScanCallback() { Override public void onScanResult(int callbackType, ScanResult result) { // 处理扫描结果 } Override public void onScanFailed(int errorCode) { // 处理扫描失败 } }; scanner.startScan(filters, settings, scanCallback); } Override public void onDestroy() { if (scanner ! null scanCallback ! null) { scanner.stopScan(scanCallback); } super.onDestroy(); } Override public IBinder onBind(Intent intent) { return null; } }START_STICKY这个返回值很关键它告诉系统“服务被杀死后尽量重建”。但要注意Android 8.0 对后台服务的重建有严格限制START_STICKY只在服务因为内存不足被杀时有效如果是被系统主动限制重建也会失败。所以不能完全依赖它。4.2 锁屏唤醒的完整链路锁屏唤醒的链路是这样的前台服务启动 PendingIntent 扫描 → 系统在底层持续监听 → 发现匹配设备 → 发送 PendingIntent → 静态广播接收器收到 → 启动前台服务或发通知 → 用户点击回到 App。这里有个细节PendingIntent扫描和普通ScanCallback扫描不能同时进行。如果你已经用ScanCallback启动了扫描再调用startScan(filters, settings, pendingIntent)会抛异常。所以切换时要先stopScan。// 切换到 PendingIntent 扫描 if (scanner ! null) { scanner.stopScan(scanCallback); scanner.startScan(filters, settings, pendingIntent); }另外PendingIntent扫描在部分设备上需要屏幕关闭后才生效这是系统行为不用纠结。实测下来三星、小米、OPPO 的部分机型在锁屏后 1-2 分钟内会进入深度休眠此时 PendingIntent 扫描仍然有效但普通扫描回调会停止。4.3 JobScheduler 兜底重连即使做了前台服务和 PendingIntent仍然有可能被系统杀掉。这时候需要一个兜底机制定期检查服务是否存活如果挂了就重新拉起。JobScheduler 是最合适的选择因为它由系统统一调度不受后台限制影响。ComponentName component new ComponentName(this, BleScanJobService.class); JobInfo jobInfo new JobInfo.Builder(JOB_ID, component) .setPersisted(true) .setPeriodic(15 * 60 * 1000) // 最小 15 分钟 .setRequiredNetworkType(JobInfo.NETWORK_TYPE_NONE) .build(); JobScheduler scheduler (JobScheduler) getSystemService(JOB_SCHEDULER_SERVICE); if (scheduler ! null) { scheduler.schedule(jobInfo); }setPersisted(true)表示设备重启后任务仍然有效但需要RECEIVE_BOOT_COMPLETED权限。setPeriodic的最小间隔是 15 分钟这是系统硬性限制改不了。所以 JobScheduler 只能做兜底不能做实时扫描。在BleScanJobService的onStartJob里检查前台服务是否在运行如果不在就重新启动。判断服务是否运行可以用ActivityManager.getRunningServices()但这个 API 在新版本上已经不可靠了。更稳妥的做法是用一个静态变量或者 SharedPreferences 记录服务状态。4.4 厂商 ROM 的适配要点国内厂商 ROM 对后台的限制比原生 Android 更狠这是绕不开的现实。我整理了几个主流厂商的适配要点厂商关键设置代码层面能做的小米自启动、省电策略无限制、锁定后台引导用户手动设置华为应用启动管理、忽略电池优化申请忽略电池优化权限OPPO自启动、关联启动、后台冻结引导用户加入白名单vivo后台高耗电、自启动引导用户手动设置三星电池优化、后台使用限制申请忽略电池优化代码层面能做的有限主要是申请REQUEST_IGNORE_BATTERY_OPTIMIZATIONS权限引导用户关闭电池优化。但注意这个权限在 Google Play 上架时有严格审核不能滥用否则会被拒。if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { Intent intent new Intent(); intent.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); intent.setData(Uri.parse(package: getPackageName())); startActivity(intent); }提示引导页不要做得太激进否则用户反感。我的做法是在检测到扫描异常时弹一个温和的提示告诉用户“为了保持设备连接建议开启自启动权限”并提供一个跳转按钮。5. 常见问题与排查技巧实录5.1 扫描不到设备怎么办这是最高频的问题。排查顺序如下检查权限Android 12 必须动态申请BLUETOOTH_SCAN和BLUETOOTH_CONNECT定位权限也要给。检查蓝牙开关BluetoothAdapter.isEnabled()返回 false 时扫描会静默失败。检查扫描是否真的启动了startScan返回 void不会告诉你成功与否要在onScanFailed里看错误码。检查过滤条件ScanFilter设得太严会过滤掉所有设备先不加过滤扫一遍看看能不能扫到。检查厂商限制部分 ROM 在锁屏后会关闭蓝牙扫描需要在设置里允许后台扫描。onScanFailed的错误码含义错误码含义处理方式1已经有一个扫描在进行先 stopScan 再 startScan2App 注册失败检查权限和蓝牙状态3内部错误重启蓝牙适配器4扫描参数不合法检查 ScanSettings5扫描被系统限制降低扫描频率6扫描被应用限制检查是否超过 5 个扫描客户端5.2 锁屏后扫描停止怎么排查锁屏后扫描停止通常是以下几个原因没有用前台服务普通后台服务在锁屏后会被冻结。没有用 PendingIntent 扫描普通 ScanCallback 在 App 冻结后不再回调。通知被用户划掉前台服务的通知被划掉后服务可能被降级为后台服务。电池优化没关闭系统进入 Doze 模式后会暂停所有后台任务。排查方法在onScanResult里打日志看锁屏后多久停止回调。如果 1 分钟内就停了基本是前台服务没生效如果 5-10 分钟才停可能是 Doze 模式导致的。5.3 PendingIntent 收不到广播怎么办这个问题我踩过好几次总结下来有这几个原因第一PendingIntent的 flag 不对。Android 12 必须用FLAG_MUTABLE用FLAG_IMMUTABLE会导致系统无法填充扫描结果。第二广播接收器没有静态注册。动态注册的 Receiver 在 App 冻结后收不到广播。第三Intent 的 action 不匹配。PendingIntent创建时用的 action 和 Receiver 里判断的 action 必须完全一致。第四部分 ROM 限制了隐式广播。虽然 PendingIntent 发送的是显式广播指定了包名和类名但部分 ROM 仍然会拦截。解决办法是在 Intent 里显式设置setPackage(getPackageName())。Intent intent new Intent(context, BleScanReceiver.class); intent.setAction(com.example.BLE_SCAN_RESULT); intent.setPackage(context.getPackageName());5.4 电量消耗过大的优化技巧蓝牙扫描是耗电大户优化不好用户会直接卸载。我的优化经验用 ScanFilter 过滤只上报关心的设备减少回调次数。降低扫描频率后台用SCAN_MODE_LOW_POWER窗口和间隔拉大。用 PendingIntent 替代 ScanCallback锁屏后让系统在底层筛选只在匹配时才唤醒 App。及时 stopScan不需要扫描时立刻停止不要一直挂着。批量上报setReportDelay设一个值比如 2000ms让系统批量上报减少唤醒次数。实测数据优化前后台扫描一小时耗电约 8%-12%优化后降到 2%-4%。这个差距在用户感知上非常明显。5.5 常见问题速查表问题现象可能原因解决方案扫描无任何回调权限缺失或蓝牙未开检查权限和蓝牙状态锁屏后 1 分钟停止未使用前台服务改用 startForegroundService锁屏后 5 分钟停止未使用 PendingIntent切换到 PendingIntent 扫描PendingIntent 无回调flag 或注册方式错误用 FLAG_MUTABLE 静态注册服务被频繁杀死厂商 ROM 限制引导用户加白名单电量消耗过高扫描参数太激进降低频率 加过滤扫描失败错误码 5系统限制降低扫描频率或稍后重试重启后服务不启动未监听开机广播注册 BOOT_COMPLETED6. 几个容易被忽略的细节6.1 通知的重要性等级不能太高前台服务的通知如果设成IMPORTANCE_HIGH会发出声音和震动用户会很烦。蓝牙扫描场景用IMPORTANCE_LOW就够了静默显示在通知栏即可。但也不能设成IMPORTANCE_MIN因为部分 ROM 会把IMPORTANCE_MIN的通知折叠导致前台服务被降级。6.2 扫描回调的线程问题ScanCallback的回调默认在主线程执行如果处理逻辑耗时会阻塞主线程导致 ANR。正确做法是在回调里只做数据拷贝把耗时操作丢到子线程或者用 Handler 处理。private final Handler workerHandler new Handler( HandlerThreadExecutor.getBackgroundLooper()); Override public void onScanResult(int callbackType, ScanResult result) { ScanResult copy new ScanResult(result); workerHandler.post(() - processResult(copy)); }6.3 Android 13 的通知权限Android 13API 33开始通知需要动态申请POST_NOTIFICATIONS权限。如果用户拒绝了通知权限前台服务的通知不会显示但服务本身仍然可以运行。不过部分 ROM 会因为通知不可见而限制服务所以还是要引导用户授予通知权限。6.4 蓝牙适配器的状态监听蓝牙可能被用户手动关闭或者因为系统原因重启。要注册BluetoothAdapter.ACTION_STATE_CHANGED广播在蓝牙关闭时停止扫描在蓝牙开启时重新启动扫描。这个细节不做的话蓝牙一关一开扫描就彻底失效了。IntentFilter filter new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED); registerReceiver(new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { int state intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1); if (state BluetoothAdapter.STATE_ON) { startBleScan(); } else if (state BluetoothAdapter.STATE_OFF) { stopBleScan(); } } }, filter);7. 实际项目中的经验体会这套方案我在三个量产项目里用过分别是智能手环、蓝牙防丢器和工业温度采集器。智能手环项目对实时性要求最高用的是前台服务 普通扫描锁屏后靠 PendingIntent 兜底防丢器项目对功耗最敏感全程用 PendingIntent 扫描App 只在收到广播时才短暂唤醒工业采集器项目环境最恶劣加了 JobScheduler 每 15 分钟检查一次服务状态。踩过最大的坑是小米的省电策略。测试机是小米 10锁屏后 3 分钟服务必被杀日志显示是系统主动限制。后来发现是没加自启动白名单引导用户设置后问题解决。这件事让我意识到代码写得再好也架不住厂商 ROM 的限制用户引导页是必须做的。另一个坑是 PendingIntent 的 flag。Android 12 刚出来的时候我用FLAG_IMMUTABLE死活收不到广播排查了一整天才发现是 flag 的问题。这个细节在官方文档里写得很清楚但很容易忽略。最后分享一个小技巧在开发阶段可以用adb shell dumpsys bluetooth_manager查看当前蓝牙扫描状态用adb shell dumpsys activity services查看前台服务是否存活。这两个命令在排查问题时非常有用比打日志快得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进