资讯动态

Android蓝牙开发实战:BLE连接、权限适配与数据交互全解析

发布时间:2026/10/14 1:39:58 来源:尧图企业网站定制
1. 项目概述与方案选型1.1 这个项目到底要解决什么问题我最早接触这个需求是在做一款外设联动的工具类App。产品经理丢过来一句话“把我们的硬件设备接到Android手机上能读取状态、能下发指令就行。”听起来简单真正动手时才发现Android蓝牙这块儿的水远比想象中深。从权限适配到设备发现从连接时序到数据分包每一步都有隐藏陷阱等着你。简单说这个项目要解决的核心问题是让Android设备与蓝牙外设建立稳定、可靠的双向通信通道。所谓“交互”拆开来看其实包含四条具体需求能扫描到周围处于可发现状态的蓝牙设备能向目标设备发起配对或直接连接连接建立后能双向收发数据比如读传感器数值、下发控制命令在设备断开、蓝牙关闭、App退到后台等异常场景下能稳定处理并恢复很多初学者会以为第五步是“把数据发出去就行”实际上到了真机调试阶段各种莫名其妙的问题都会冒出来。这个项目之所以值得单独拿出来写一篇总结是因为它几乎覆盖了Android蓝牙开发的所有关键知识点做完一遍基本就能应对绝大多数业务场景。1.2 经典蓝牙还是低功耗蓝牙先做决定再动手开工之前必须先回答一个问题你的外设是经典蓝牙还是低功耗蓝牙BLE这点决定了整个技术方案的走向。我带过的不少开发者在第一步就栽了跟头拿着低功耗蓝牙的设备用经典蓝牙的接口去连折腾半天始终扫描不到设备。对比维度经典蓝牙低功耗蓝牙BLE数据速率较高适合音频、文件传输较低适合小数据量、低频次传输功耗高极低纽扣电池可工作数月连接建立相对慢需要配对快广播即发现Android APIBluetoothSocketBluetoothGatt典型设备蓝牙音箱、蓝牙耳机手环、传感器、智能家电数据特点流式数据可连续收发基于GATT协议按特征值读写绝大多数IOT类外设走的是低功耗蓝牙这条路。如果做的是音频传输或串口透传类设备很多工业级蓝牙模块默认走SPP协议那就是经典蓝牙的范畴。我个人的建议是先看外设的规格书确定蓝牙版本和协议栈类型再决定用哪套API。不要试图写一套代码兼容两种协议虽然Android在底层有部分共享逻辑但上层API几乎完全不同强行封装只会让代码变得臃肿难维护。如果项目确实需要两头兼顾那就抽象出一个统一接口层底层分别实现两套逻辑这是最保险的做法。这个项目我以低功耗蓝牙为主线来讲因为它的应用场景更广、权限适配更复杂、踩坑点多更有代表性。经典蓝牙部分我会在关键差异处做对照说明。1.3 环境准备与版本分布的现实考量开发环境这块其实没有什么特殊要求。Android Studio加上一台真机就足够。之所以强调“真机”是因为蓝牙功能在模拟器上基本不可用很多与系统蓝牙服务的交互只有真机才能完整复现。同时建议手头准备至少两台不同Android版本的测试机。我在项目开发中实测发现Android 10和Android 13在权限模型、后台运行限制上差异很大同一套代码在不同版本上的表现经常不一样。单位里如果有多余的旧手机别嫌麻烦多拿几台测试后期能省下大量排查问题的时间。关于蓝牙版本支持现在Android设备绝大多数都支持BLE 4.0以上低版本设备已经很少见了。编译时把minSdkVersion设到21左右就可以覆盖绝大多数存量设备除非业务要求必须兼容特别老的设备。2. 权限配置与动态申请2.1 Android 12前后的权限模型差异蓝牙权限这块是整个项目里最容易出问题的环节而且问题往往在用户一打开App就爆发。新版Android对蓝牙权限的管理非常严格如果你还停留在旧时期的开发习惯一上来就会遇到闪退或扫描不到设备的情况。在Android 6.0之前蓝牙权限只需要在AndroidManifest.xml里静态声明就能自动授权Android 6.0引入了运行时权限定位权限变成了动态申请到了Android 12API 31蓝牙权限独立出来不再归并到定位权限中。各阶段差异如下Android版本需要声明的关键权限是否需要动态申请Android 6.0 ~ 11BLUETOOTH、BLUETOOTH_ADMIN、ACCESS_FINE_LOCATION定位权限必须动态申请Android 12及以上BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE、ACCESS_FINE_LOCATION蓝牙权限和定位权限都需要动态申请Android 13及以上同上新增Nearby Wifi Devices权限时蓝牙权限独立逻辑不变同上注意一个关键点无论哪个版本只要涉及BLE扫描定位权限都是绕不开的。这是Android系统层面基于隐私保护的强制要求因为广播信号理论上可以用于推断用户位置。所以千万不要以为Android 12有了独立的扫描权限就可以不申请定位权限真机上会直接扫描不到任何设备并且系统日志还会给出比较隐晦的提示。在AndroidManifest.xml里至少应该声明这些权限uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /特别注意那个neverForLocation标志。如果你的App不利用蓝牙信号推导位置信息加了这个标志后在某些版本上系统审批会更宽松也不会强制弹出定位权限选择框。但实际测试中我发现这个标志在部分国产ROM上并不完全生效该申请定位权限时还是得老老实实申请。2.2 动态申请权限的完整封装动态权限申请的处理逻辑比较繁琐尤其是Android 12之后权限数量变多代码很容易写得很散。我建议封装成一个独立的权限工具类统一管理申请流程。下面是基于官方API实现的一个封装示例核心思路是处理“权限分组申请 回调结果”的逻辑class BluetoothPermissionHelper(private val activity: Activity) { private val requestCode 1001 // Android 12及以上需要的权限组 private val permissionsFor12Plus arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.ACCESS_FINE_LOCATION ) // Android 12以下需要的权限组 private val permissionsForLegacy arrayOf( Manifest.permission.ACCESS_FINE_LOCATION ) fun checkAndRequestPermissions() { val neededPermissions if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { permissionsFor12Plus } else { permissionsForLegacy } val notGranted neededPermissions.filter { ContextCompat.checkSelfPermission(activity, it) ! PackageManager.PERMISSION_GRANTED } if (notGranted.isNotEmpty()) { ActivityCompat.requestPermissions(activity, notGranted.toTypedArray(), requestCode) } else { onPermissionGranted?.invoke() } } fun handlePermissionResult(requestCode: Int, grantResults: IntArray) { if (requestCode ! this.requestCode) return val allGranted grantResults.all { it PackageManager.PERMISSION_GRANTED } if (allGranted) { onPermissionGranted?.invoke() } else { onPermissionDenied?.invoke() } } var onPermissionGranted: (() - Unit)? null var onPermissionDenied: (() - Unit)? null }实际使用中这里有个容易忽略的细节用户如果勾选了“不再询问”系统会把拒绝后的权限状态变为永久拒绝状态此时单纯重复申请权限是没有用的必须引导用户到系统设置页手动开启。所以完整的权限处理流程应该是首次申请被拒 - 用shouldShowRequestPermissionRationale判断是否可再次申请 - 如果返回false说明被永久拒绝则跳转设置页。这个过程我做了一个小流程图文字描述一下判断权限状态 - 已授权则直接执行 - 未授权则申请 - 被拒后判断是否可再次申请 - 可申请则弹窗说明缘由并再次申请 - 不可申请则跳转系统设置。2.3 常见权限问题备忘注意部分国产手机在权限管理上做了差异化定制即使你在代码里正确申请了权限系统层面仍可能默认拦截部分权限。比如某些手机在“权限管理”里默认将“附近设备”权限设为询问导致扫描失败。遇到这种问题第一反应不要怀疑代码先去系统设置里手动把权限打开试试。对照排查表现象排查方向扫描不到任何设备定位权限未开启蓝牙未开启扫描时间太短能扫描到但连不上Android 12的CONNECT权限被拒设备已连接其他主机部分设备扫描不到设备未处于广播状态广播间隔过长扫描过滤条件设置错误打开App直接闪退未在Manifest声明权限权限申请代码在未授权前调用了蓝牙API3. 设备发现与扫描过程详解3.1 扫描条件与回调解析权限就绪之后第一步就是扫描设备。BLE的扫描本质上是监听周围设备发出的广播包Android系统的扫描结果通过回调提供给我们。这里要理解一个核心概念BLE扫描结果返回的不是“连接地址”那么简单而是一个包含丰富信息的ScanResult对象。里面最重要的几个字段deviceBluetoothDevice对象封装了MAC地址、名称等信息rssi接收信号强度单位dBm值越大说明信号越好通常-60dBm以上算比较好scanRecord广播数据包包含厂商自定义数据、服务UUID等timestampNanos时间戳我最初做扫描时习惯只看设备名称来过滤目标设备。后来发现一个坑很多工业级设备在未连接状态下不广播设备名称或者广播的只是一个很随意的名字比如“BT-1234”很难从名称上判断是哪台设备。更可靠的做法是通过广播包中的服务UUID来识别设备。很多BLE外设在广播时会把自身提供的服务UUID放在广播数据里我们可以用ScanFilter精确匹配val filter ScanFilter.Builder() .setServiceUuid(ParcelUuid(UUID.fromString(0000ffe0-0000-1000-8000-00805f9b34fb))) .build() val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() val scanner BluetoothLeScannerCompat.getScanner() scanner.startScan(listOf(filter), settings, scanCallback)这里引入了一个第三方库RxAndroidBle或者官方BluetoothLeScanner都可以。个人建议如果项目不大直接用官方API就行没必要为了一个扫描引入重量级库。如果项目里已经用了RxJava那用RxAndroidBle的链式调用确实方便很多这是个人偏好问题。关于SCAN_MODE_LOW_LATENCY这个参数的意思是尽量高频地扫描响应快但功耗高。如果App在后台持续扫描建议用SCAN_MODE_BALANCED或者SCAN_MODE_LOW_POWER否则耗电会比较快。实测下来LOW_LATENCY模式下发现设备的速度明显更快首次连上一台设备大概3~5秒BALANCED模式则需要5~10秒。3.2 扫描生命周期管理扫描不是调用一次startScan就能一直找设备的需要在合适的时机停止扫描。这里有一个很多人踩过的坑扫描回调会持续回调如果不停止扫描会导致严重的电量消耗甚至在部分设备上引发卡顿。通常在以下场景应该停止扫描找到了目标设备并开始连接用户主动取消扫描界面不可见比如App退到后台设置了扫描超时时间我习惯的做法是封装一个扫描管理器内部维护状态确保扫描和停止成对出现class BleScanner(private val context: Context) { private val scanner BluetoothLeScannerCompat.getScanner() private var isScanning false private var scanCallback: ScanCallback? null fun startScan(callback: ScanCallback, filter: ListScanFilter? null) { if (isScanning) return scanCallback callback val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() scanner.startScan(filter, settings, callback) isScanning true } fun stopScan() { if (!isScanning) return scanCallback?.let { scanner.stopScan(it) } scanCallback null isScanning false } }这里的设计理念是让扫描状态的维护对外不可见避免外部在扫描未开始时调用stop导致崩溃。BluetoothLeScanner.stopScan如果传入的回调没有进行过startScan在某些Android版本上会直接抛异常这一点务必警惕。还有一个细节是Android 12之后的扫描回调行为变化。在Android 12以下扫描回调运行在Binder线程不能直接操作UI在Android 12以上情况有所变化但保险起见回调中一律不直接更新UI通过Handler或者协程切回主线程处理。我一般用协程 withContext(Dispatchers.Main)来解决private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { viewModelScope.launch { withContext(Dispatchers.Main) { // 在这里更新UI或处理业务逻辑 processScanResult(result) } } } }3.3 扫描不到设备的处理思路这是被问得最多的问题“为什么我打开App扫了半天一台设备都看不到”排查步骤按优先级排列第一检查定位权限是否已授予。很多国产手机在权限弹窗上做了“仅使用期间允许”和“始终允许”之外的特殊选项即使选了允许也要确认“精确定位”开关是打开的。部分手机默认只有“模糊定位”BLE扫描在某些ROM上就会失败。第二确认目标设备处于可广播状态。很多BLE设备在连接过其他主机后会停止广播或者进入深度休眠此时怎么扫都扫不到。需要按设备的复位键重新进入广播模式。第三确认没有开启过滤条件。如果首次测试先不要设置ScanFilter扫描所有设备看能不能发现其他蓝牙设备比如手机、耳机。如果什么都发现不了说明问题在App或系统层面如果能发现其他设备但就是扫不到你的目标设备问题大概率在目标设备本身。第四扫描超时机制。部分设备广播间隔时间较长比如1秒以上如果你的扫描只持续了2秒很可能刚好错过广播窗口。建议把扫描持续时间设置在5~10秒以上。4. 设备连接与数据交互4.1 连接流程的正确姿势扫描到设备后下一步是发起连接。Android低功耗蓝牙的连接是通过BluetoothGatt来完成的bluetoothGatt device.connectGatt(context, false, gattCallback)这行代码看似简单里面的门道不少。第一个参数传context需要注意如果传的是Activity的this在Activity销毁时如果没有正确关闭Gatt会导致连接残留下次再连就会失败。更稳妥的做法是传applicationContext避免持有Activity引用导致内存泄漏。第二个参数autoConnect官方解释是true表示自动连接即设备在范围内时自动重连false表示只主动连接一次。实际测试中autoConnecttrue 在部分设备上反而更容易出问题连接耗时更长、失败率更高。我的建议是业务逻辑自己控制重连机制把 autoConnect 设为false这样每次连接都是主动发起行为更可控。第三个参数gattCallback是整个连接过程的核心回调里能感知到连接状态变化、服务发现结果、特征值读写结果等关键事件private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { // 连接成功开始发现服务 gatt.discoverServices() } else if (newState BluetoothProfile.STATE_DISCONNECTED) { // 连接断开处理重连逻辑 } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { // 服务发现成功可以操作特征值了 } } override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { // 读取特征值的结果回调 } override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { // 写入特征值的结果回调 } }连接流程中有一个顺序必须严格遵守连接成功后必须等待discoverServices完成才能对特征值进行读写操作。如果跳过这个步骤直接读取特征值会得到GATT_READ_NOT_PERMITTED之类的错误或者说根本拿不到有效的特征值对象。4.2 服务发现与特征值操作原理BLE数据交互不是像网络socket那样直接发送字节流而是基于GATT协议下的“服务-特征值”层级结构一个设备可以包含一个或多个服务Service每个服务下面可以包含多个特征值Characteristic每个特征值有对应的属性读、写、通知等可以这样理解服务是分类特征值是具体的功能项。比如一个智能手环设备可能有一个“电池服务”下面有个“电量百分比”特征值可读还有一个“运动服务”下面有个“步数数据”特征值可通知。找到目标特征值的典型代码fun findCharacteristic(gatt: BluetoothGatt, serviceUuid: String, charUuid: String): BluetoothGattCharacteristic? { val service gatt.getService(UUID.fromString(serviceUuid)) ?: return null return service.getCharacteristic(UUID.fromString(charUuid)) }这里的UUID要从设备厂商提供的协议文档里获取。没有协议文档的情况下可以通过笼统地遍历所有服务和特征值来“猜”但实际开发中这种方式效率极低。我见过有开发者用蓝牙调试助手比如某款通用的BLE调试工具先把设备所有服务和特征值打印出来对照着找到感兴趣的UUID再写进代码里。这个方法虽然朴素但确实是在没有协议文档时最实用的方案。特征值有三种常见操作权限对应三种交互模式特征值属性含义常见用途可读App主动向设备请求数据读取静态状态如设备型号、电量可写App向设备下发数据发送控制指令、配置参数通知/指示设备主动向App推送数据实时数据上报如心率、温度变化4.3 通知功能的订阅细节实时数据上报是BLE设备最常见的交互方式实现方式是“订阅通知”。订阅的本质是向特征值的客户端特征配置描述符CCCD写入特定值表示“我对你的通知感兴趣”。fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic): Boolean { return gatt.setCharacteristicNotification(characteristic, true) }很多初学开发者以为调用setCharacteristicNotification就能收到通知了实际上这只是第一步还差一个关键步骤必须向CCCD写入0x01或0x02。如果不写设备不会主动推送任何数据val cccdUuid UUID.fromString(00002902-0000-1000-8000-00805f9b34fb) val cccd characteristic.getDescriptor(cccdUuid) ?: return false cccd.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(cccd)这两步缺一不可。我遇到过不少次把setCharacteristicNotification理解为全部操作结果回调里怎么都等不到数据排查了很久才发现是CCCD没写。还有一点要注意通知的写入操作通常需要延迟。我在代码里会在setCharacteristicNotification和writeDescriptor之间加一个短暂的延时比如200~300毫秒。这是因为在部分低端手机上两步操作靠得太近会导致后续写入失败。这个说法没有官方依据完全是实测经验但稳扎稳打总比偶现bug好。4.4 写入命令时的分包策略BLE有一个重要限制单次写入的数据长度受限于MTU最大传输单元默认情况下只有20字节左右Android 5.0设备实际值可能稍高但通常不超过23字节。如果一次要下发超过这个长度的数据比如一个256字节的固件升级包就必须做分包发送。分包的核心逻辑fun writeChunkedData(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, data: ByteArray, mtu: Int 20) { val chunkSize mtu - 3 // 实际可用负载GATT头部占3字节 var offset 0 while (offset data.size) { val end minOf(offset chunkSize, data.size) val chunk data.copyOfRange(offset, end) characteristic.value chunk gatt.writeCharacteristic(characteristic) offset end // 注意每包之间需要等待上一条写入完成的回调 } }这段代码有一个隐含问题writeCharacteristic是一个异步操作连续快速调用多条写入在部分设备上会直接丢失或者产生写入顺序错乱。正确的做法是维护一个发送队列每一条写入确认完成后再发送下一条class WriteQueue(private val gatt: BluetoothGatt) { private val queue ArrayDequeBluetoothGattCharacteristic() fun enqueue(characteristic: BluetoothGattCharacteristic) { queue.addLast(characteristic) if (queue.size 1) { sendNext() } } fun onWriteComplete() { queue.removeFirst() if (queue.isNotEmpty()) { sendNext() } } private fun sendNext() { val characteristic queue.first() gatt.writeCharacteristic(characteristic) } }这种队列模型看起来简单却是保证BLE数据完整性的关键。很多设备交互不稳定、数据丢失根源就在于没有做发送序列化。MTU协商方面如果想突破默认20字节的限制可以在连接后主动请求增大MTUgatt.requestMtu(247)请求结果在onMtuChanged回调中获取override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { // 实际协商到的MTU } }不是所有设备都支持MTU协商部分老设备会直接拒绝。协商失败就老老实实用20字节的分包策略不要强求。5. 经典蓝牙的实现路径与差异5.1 什么时候必须走经典蓝牙前面提到BLE适合小数据量、低频交互场景。如果你的外设是蓝牙串口模块比如常见的HC-05、HC-06或者需要传输音频流、大批量文件那么要走的协议栈就是经典蓝牙的SPP串口透传协议。识别方法很简单如果设备文档里明确写了支持SPP、RFCOMM或者设备通过蓝牙配对后映射为一个串口COM口那就是经典蓝牙的范畴。这类设备在Android上不能用BLE的Gatt API操作。经典蓝牙的连接流程与BLE有较大差异// 1. 获取设备对象 val device bluetoothAdapter.getRemoteDevice(macAddress) // 2. 创建RFCOMM通道 val socket: BluetoothSocket when (device.type) { BluetoothDevice.DEVICE_TYPE_LE - device.createL2capChannel(PSM) else - device.createRfcommSocketToServiceRecord(SPP_UUID) } // 3. 阻塞式连接 socket.connect()一个关键区别是connect()是同步阻塞方法不能放在主线程调用否则直接ANR。需要放到子线程里执行thread { try { socket.connect() // 连接成功通过socket的输入输出流转发数据 val input socket.inputStream val output socket.outputStream } catch (e: IOException) { // 连接失败 } }5.2 经典蓝牙的配对流程经典蓝牙在建立RFCOMM通道之前通常需要在系统层完成配对操作。Android提供了配对APIdevice.createBond()配对状态通过广播接收器监听val filter IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED) registerReceiver(bondReceiver, filter) private val bondReceiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val bondState intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1) if (bondState BluetoothDevice.BOND_BONDED) { // 配对完成可以尝试连接了 } } }这里有个体验上的坑Android系统会在配对时弹出PIN码确认框如果你的设备是需要输入固定PIN码的比如0000或1234系统弹窗其实不能直接输入只能确认。对于这类设备有些场景需要调用系统隐藏API自动输入PIN码这个属于偏门需求非必要不推荐使用一来反射调用在Android 8.0以上可能被限制二来涉及系统UI交互逻辑很容易出兼容性问题。5.3 两种蓝牙的数据交互对比功能点经典蓝牙SPP/RFCOMM低功耗蓝牙GATT数据传输方式类似TCP Socket的双向流基于服务的特征值读写最大包长度基本不受限制流式传输受MTU限制需分包连接前是否必须配对通常必须配对多数设备无需配对功耗高低Android API复杂程度较简单较复杂适合场景音频、文件传输、串口透传IoT设备、传感器、低功耗外设从开发工作量来看经典蓝牙反而比BLE简单因为它的数据通道就是一个流式读写跟文件IO很像。难点主要在连接时序和配对交互上。BLE的难点则分散在权限、扫描、服务发现、特征值操作等多个环节每一步都有讲究。6. 常见问题与排查技巧6.1 高频问题排查对照表这是我历次项目交付时整理的一张速查表很多问题不止一次遇到直接对照着排查能节省大量时间问题现象可能原因解决方案扫描不到设备定位权限未开启设备未处于广播态检查权限重启设备进入广播模式延长扫描时间扫描到设备但连接失败设备已连接其他主机蓝牙CONNECT权限被拒断开其他连接检查权限等待设备空闲连接成功但读不了数据服务发现未完成就操作特征值属性不匹配确保等onServicesDiscovered后再操作对照协议确认UUID写入失败写入包过大多条写入并发冲突分包发送实现写队列串行化通知收不到CCCD未写入服务未找到正确写入descriptor确认UUID匹配连接后频繁断开信号弱设备电量低系统省电策略靠近设备检查设备电量关闭相关省电模式App退到后台收不到数据系统限制后台运行蓝牙服务被回收使用前台服务持有连接正确处理生命周期偶尔收到乱码或数据错位分包顺序错乱粘包未处理实现可靠的包序号机制按帧协议解析6.2 最让人崩溃的后台连接断开问题低功耗蓝牙在App退到后台时的行为是开发中最困扰我的一段经历。Android系统的省电机制会毫不犹豫地杀死后台进程蓝牙Gatt连接也会随之断开等用户回到前台连接已经没了业务逻辑就断了。解决这个问题的标准方案是使用前台服务。Android 8.0以上后台服务会被系统限制而前台服务因为有通知栏常驻通知系统会高概率保留进程。蓝牙交互类App在需要持续接收数据时必须使用前台服务来支撑连接和接收逻辑。uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE /另外Android 14之后对前台服务类型有更严格的要求蓝牙功能要声明connectedDevice类型否则启动前台服务会抛异常。这个适配问题建议尽早处理等上线了用户反馈“进后台就断”再排查代价就大了。实际使用中前台服务加通知栏常驻提示是必须的用户会看到“某某App正在运行”的常驻通知。有些产品经理很反感这个但这是保连接稳定性的必要代价。如果业务对后台连接要求很高也可以考虑系统级的startForegroundService或其他更底层的方案但那些方案的门槛更高一般项目不需要。6.3 调试利器与上手建议调试蓝牙项目光靠日志打点是远远不够的。我强烈建议下载一个通用的BLE调试工具到手机上比如知名的BLE调试助手类App它可以看到所有广播设备、手动连接、浏览服务和特征值、自由读写数据、甚至看实时MTU。有了这个工具你可以先不看自己App的代码用第三方工具手动操作一遍设备的完整流程。如果工具能正常交互说明设备本身没问题问题在代码如果工具也不行那大概率是设备或系统环境问题。这种“先用手动工具验证链路再查代码”的排查思路屡试不爽。我在项目里就曾花了两天排查一个写入失败问题最后发现是设备端固件对写入间隔有要求用调试工具一试就暴露了。另外一点建议代码里务必做日志记录时带上时间戳和方向标记发送还是接收数据量不大时可以直接打印HEX字符串。蓝牙问题往往是时序问题有完整的日志能够还原整个时间线排查效率能提高数倍。7. 实操过程中的个人经验做完这个项目我对Android蓝牙开发的最大体会是这个领域没有银弹任何方案都有坑而坑往往藏在你最没防备的地方。比如权限我无论如何都想不到某些国产手机会在系统层面对“附近设备”权限做独立管控代码里API都调对了就是扫不到设备最后是在系统“权限管理”页面里手动打开了一个开关才解决。这种问题查个一两天很正常心态不能崩。再比如连接管理我最初为了让代码看起来清爽把Gatt连接和扫描逻辑直接写在了Activity里结果Activity一重建就全部乱套。后来重构为独立的连接管理类用applicationContext持有Gatt在设置页提供手动断开和重连入口整个系统才稳定下来。好的架构不是设计出来的是被问题逼出来的。分包发送那块也是被真实场景的批量上传需求鞭策出来的。最初想着简单循环写入就行“应该不会出问题”结果设备端曲型地丢了几次数据才痛下决心把写队列做完整。从那以后我再也不在产品不催的情况下写简化版逻辑了。最后想说的是蓝牙开发天然依赖真实物理环境很多东西在文档里看一百遍都不如在真机上一遍故障复现来得深刻。如果你刚开始做这个方向建议先把蓝牙调试工具玩熟把自己当成一个“懂行用户”多折腾几台不同的设备积累体感再上手代码时很多设计取舍自己就会有答案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑