ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android蓝牙开发实战:经典蓝牙与BLE全流程踩坑指南

Android蓝牙开发实战:经典蓝牙与BLE全流程踩坑指南 做Android蓝牙开发十个有九个一开始都会被各种概念绕晕经典蓝牙、BLE、UUID、GATT、RFCOMM、SPP……这还不算完权限版本一升级官方API改一次网上老帖子就废一批。我自己接手这个项目的时候需求其实很朴素App要连接外部蓝牙设备完成配对、收发数据、处理断线重连同时还要兼容市面上七八款主流机型。这篇文章就把我这一路踩过的坑、验证过的方案整理出来给正在做Android蓝牙开发的朋友一条相对顺的路。文章中会拆解经典蓝牙和低功耗蓝牙BLE两套技术路线的完整开发流程讲清楚每一步为什么要这么写遇到问题怎么排查属于可以直接照着改的实战笔记。适合刚开始接触蓝牙开发、被各种回调整得头疼或是准备对接硬件但又没有硬件文档支撑的朋友参考。1. 蓝牙开发的核心选型经典蓝牙还是BLE1.1 经典蓝牙和BLE的本质区别很多人第一次接触Android蓝牙开发时查到的资料是老的讲的是BluetoothAdapter、BluetoothDevice这类API结果一搜新项目又是一堆BluetoothLeScanner、BluetoothGatt直接看懵。实际上这是两条完全不同的技术路线。经典蓝牙BR/EDR在Android里走的是BluetoothSocket这套通道适合传输音频、文件这类数据量比较大的场景比如蓝牙耳机、蓝牙音箱、蓝牙打印机、车载免提。它的特点是带宽高、延迟低但功耗也高配对流程相对固定连接稳定后链路会很扎实。经典蓝牙里最常见的串口协议是SPPSerial Port Profile很多单片机、串口透传模块、工业设备用的就是它。BLEBluetooth Low Energy则是另一套逻辑数据包小、功耗极低协议上以GATTGeneric Attribute Profile为核心设备之间先建立GATT连接然后在Service与Characteristic构成的属性表里读写数据。手环、心率带、体温计、门锁、Beacon定位基本都是BLE。BLE不适合大流量传输单次写入的数据量默认只有20字节需要额外协商MTU才能扩大。1.2 开发前先搞明白设备协议比写代码更重要我见过不少人上来就写代码扫描到设备就连接然后卡在UUID这一步最后发现连设备的UUID都不知道从哪来。这是蓝牙开发最容易踩的坑API只是通道协议才是灵魂。动手之前一定要先从硬件方或设备厂商拿到这些信息是经典蓝牙还是BLE设备连接的UUIDSPP固定串口是00001101-0000-1000-8000-00805F9B34FB但定制设备可能不同如果走BLE需要知道Service UUID、Notify/Write的Characteristic UUID数据格式定长包还是不定长、有没有包头、CRC校验、数据是大端还是小端设备的广播包格式、厂商自定义的Service Data选型上的建议很简单能和硬件工程师确认的一定提前确认确认不了就先抓广播包、抓log分析再决定开发路线。否则代码写一半再推倒重来返工成本极高。2. 开发前的环境准备与权限配置2.1 工程配置与最低版本要求现在Android Studio已经更新了很多个版本新建项目时模板默认会帮你配好Gradle和依赖但这不代表蓝牙权限也帮你配好了。蓝牙相关的权限从Android 6.0到Android 12经历过两次大调整直接决定你的App在什么机型、什么系统版本上能不能跑起来。先看manifest里必须有的几个权限uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /这三项是Android 11及以下必须的其中ACCESS_FINE_LOCATION是扫描蓝牙设备时需要的因为蓝牙扫描可以反推出用户位置系统把它当成位置信息处理。如果不申请这个权限或者用户在设置里关掉了定位扫描结果会一直是空的。如果targetSdkVersion升到了31及以上Android 12权限体系又变了uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE /这三个是Android 12引入的新权限其中BLUETOOTH_SCAN用来扫描设备BLUETOOTH_CONNECT用来连接和通信BLUETOOTH_ADVERTISE是广播用的。如果App的targetSdkVersion已经到31以上还只写老权限运行时会直接SecurityException崩溃连扫描都触发不了。2.2 动态权限申请的正确姿势权限不只是写在manifest里就完事了Android 6.0之后危险权限必须运行时动态申请。蓝牙扫描涉及的位置权限和Android 12新增的蓝牙权限都被划进了运行时权限范畴。我的做法是封装一个权限入口在页面onCreate或点击按钮时统一触发private fun checkBluetoothPermissions() { val permissions mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { if (checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) ! PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.BLUETOOTH_SCAN) } if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ! PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.BLUETOOTH_CONNECT) } } else { if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } } if (permissions.isNotEmpty()) { requestPermissions(permissions.toTypedArray(), REQUEST_BLUETOOTH_PERMISSION) } else { startBluetoothWork() } }注意一个细节在Android 12及以上可以在同一套逻辑里同时兼容新旧权限但低版本机型上如果申请了新权限常量会编译不过所以需要用SDK_INT判断保证两个分支在各自的系统版本上正常编译。实测下来Android 8到Android 14都能用这套逻辑稳定运行。还有个小坑华为、小米等厂商对定位权限要求更严格只申请粗略定位还不够弹窗里建议把“精确定位”也一并申请否则扫描结果会时有时无。3. 经典蓝牙SPP开发全流程3.1 设备扫描与列表展示经典蓝牙扫描用广播接收器接收系统回调流程是注册Receiver调用BluetoothAdapter的startDiscovery()然后等待ACTION_FOUND广播从intent里取出BluetoothDevice。扫描的代码大概长这样private val receiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { BluetoothDevice.ACTION_FOUND - { val device intent.getParcelableExtraBluetoothDevice(BluetoothDevice.EXTRA_DEVICE) if (device ! null) { device.name?.let { name - if (name.isNotEmpty()) { deviceList[name _ device.address] device } } } } BluetoothAdapter.ACTION_DISCOVERY_FINISHED - { // 扫描结束刷新列表 refreshAdapter() } } } } override fun onResume() { super.onResume() val filter IntentFilter().apply { addAction(BluetoothDevice.ACTION_FOUND) addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED) } registerReceiver(receiver, filter) }扫描开始前要确认蓝牙已经打开。如果没打开用Intent跳转系统设置页val enableBtIntent Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivity(enableBtIntent)关于扫描有三个经验点值得记下来。第一startDiscovery()是异步的同一时间全局只能有一个扫描任务如果之前有扫描在跑要先进调cancelDiscovery()再启动新的。第二扫描过程中不要直接在Receiver里做耗时操作比如数据库存储、网络上报否则会拖垮UI线程。第三接收到的设备名称很多低端设备会给一个null要做空判断否则加进列表后会显示成“未知设备”。3.2 配对流程与Socket连接很多SPP设备是必须先配对才能连接的配对和连接是两步。配对触发比较简单if (device.bondState ! BluetoothDevice.BOND_BONDED) { val result device.createBond() Log.d(BT, createBond result: $result) }配对状态通过广播监听BluetoothDevice.ACTION_BOND_STATE_CHANGED回调里可以判断BOND_BONDED、BOND_BONDING、BOND_NONE这三种状态BOND_BONDED之后才能继续连接。连接这块是经典蓝牙最费时间的一个环节坑特别多。先看基础代码private fun connectDevice(device: BluetoothDevice) { val uuid UUID.fromString(00001101-0000-1000-8000-00805F9B34FB) val socket device.createRfcommSocketToServiceRecord(uuid) bluetoothAdapter.cancelDiscovery() socket.connect() }这里有几个非常容易踩的坑。第一个坑UUID不能乱填。常规的串口透传模块默认是SPP的固定UUID就是我上面写的那个。但有些设备会自定义比如某些厂家模块用新版SPP UUID或根据文档改一个私有UUID。如果连接返回Service discovery failed优先去确认UUID。第二个坑createRfcommSocketToServiceRecord底层走的是SDP服务发现这个过程比较慢。很多人等几秒还没连上就以为失败了其实还在重试。建议把socket.connect()放到子线程连接超时控制在10到15秒之间不要用默认的无限等待。第三个坑有些设备兼容性比较差createRfcommSocketToServiceRecord方式连不上可以试试反射调用createRfcommSocketval method device.javaClass.getMethod(createRfcommSocket, Int::class.javaPrimitiveType) val socket method.invoke(device, 1) as BluetoothSocket这个方法是我在几款老式蓝牙继电器模块上验证过的官方不推荐但非常好用。3.3 数据收发与流处理经典蓝牙连接成功后数据通过socket.getInputStream()和getOutputStream()收发。但一个最基础也最容易犯的错是所有读写必须放到子线程不能直接在主线程跑阻塞式read()。我的数据收发结构是一个独立的线程类专门管理输入输出流class BluetoothChatThread(private val socket: BluetoothSocket) : Thread() { private val inputStream: InputStream socket.inputStream private val outputStream: OutputStream socket.outputStream private var isRunning true override fun run() { val buffer ByteArray(1024) while (isRunning) { try { val bytes inputStream.read(buffer) if (bytes 0) { val data ByteArray(bytes) System.arraycopy(buffer, 0, data, 0, bytes) onDataReceived(data) } } catch (e: IOException) { isRunning false onConnectionLost() } } } fun write(data: ByteArray) { try { outputStream.write(data) outputStream.flush() } catch (e: IOException) { Log.e(BT, write failed, e) } } fun close() { isRunning false try { socket.close() } catch (e: IOException) { // ignore } } }写数据时有个细节如果数据包比较长split或者断包都没关系OutputStream.write本身会把整包写出去但要注意整体字节长度不能超过底层缓冲区否则有些设备会丢包。稳妥的做法是写之前先按设备的MTU或帧长分包每包不超过256字节中间加小延时。读数据那边蓝牙串口是流式数据没有严格的包边界所以应用层最好自己做分包和粘包处理。我的习惯是定义一个包头结构比如AA55开头后面跟长度字段再解析正文解析不到完整包就先缓存等下一包来了再拼。4. BLE低功耗蓝牙开发全流程4.1 BLE扫描与过滤策略BLE的扫描API和经典蓝牙完全不同用的是BluetoothLeScanner。这里最容易被忽略的是ScanFilter不设置过滤条件会把周围几十个蓝牙设备的广播全部扫回来列表又乱又慢设置了过滤条件又可能因为设备广播格式特殊而什么都扫不到。我的扫描配置是这样写的val scanSettings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) .build() val scanFilters ArrayListScanFilter().apply { // 可以按设备名过滤 add(ScanFilter.Builder().setDeviceName(MyDevice).build()) // 也可以按Service UUID过滤但这种方式要求设备广播包里带128bit UUID // add(ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(0000ffe0-0000-1000-8000-00805f9b34fb)).build()) } bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback)扫描模式建议用SCAN_MODE_LOW_LATENCY回调频率高用户体验好缺点是费电。如果App在后台扫描系统会限制回调频率这个后面讲。关于过滤我的经验是如果手头有设备先别加过滤条件扫一遍把设备的广播内容打出来看确认name、Service UUID这些字段到底是什么再决定过滤条件。而不是凭空猜一个设备名放进Filter。4.2 GATT连接与Service解析BLE连接的核心是BluetoothGattCallback下面这一步是每个开发者的必经之路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) { handleDisconnect() } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { val service gatt.getService(serviceUuid) service?.let { val writeCharacteristic it.getCharacteristic(writeCharUuid) val notifyCharacteristic it.getCharacteristic(notifyCharUuid) // 开启通知、保存特征值准备收发 } } } } gatt device.connectGatt(context, false, gattCallback)connectGatt的第二个参数autoConnect我建议传false。autoConnecttrue听起来很方便设备出现后会自动连但实际上连接过程慢失败后还会自己重连在没有明确重连策略的情况下很容易浪费系统资源。自己管理重连反而更可控。onServicesDiscovered是关键节点这里能拿到设备的Service列表。拿到Service后先用getCharacteristic拿到你要读写的Characteristic。如果拿不到十有八九是UUID写错了或者设备把Service放到了广播包里但你连的对象不对。4.3 特征值读写与通知BLE数据通信主要是三种方式读、写、通知。读比较简单直接调gatt.readCharacteristic(characteristic)结果在onCharacteristicRead回调里拿到。写有两种writeCharacteristic是写一次适用于短数据长数据要分段写或者用writeCharacteristic的WRITE_TYPE_NO_RESPONSE配合自定义分包。Android系统本身对单次写入长度有限制默认是20字节超过20字节的写入设备端基本会失败。所以写长数据前先协商MTU。协商MTU代码如下gatt.requestMtu(247)协商结果在onMtuChanged回调里override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { // 现在单次可以写mtu-3个字节比如247-3244字节 } }要注意MTU协商是异步的写完requestMtu后不能立刻去writeCharacteristic必须等onMtuChanged回调过来了再写入否则写入还是会按20字节来。另外设备端不一定支持修改MTU很多廉价模组默认就是23这种情况下只能老老实实分包写。通知是最常用的方式设备主动上报数据App被动接收。开启通知有固定流程gatt.setCharacteristicNotification(characteristic, true) val descriptor characteristic.getDescriptor(UUID.fromString(00002902-0000-1000-8000-00805f9b34fb)) descriptor?.let { it.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(it) }很多人只调了setCharacteristicNotification(true)却忘了往CCCDClient Characteristic Configuration Descriptor里写值结果回调永远不触发。写完descriptor后设备端的通知就会通过onCharacteristicChanged回调带过来。4.4 BLE回调线程与数据解析BLE回调默认跑在Binder线程不是UI线程。更新UI必须切回主线程。最简单的方式是Handler或runOnUiThreadoverride fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val data characteristic.value runOnUiThread { updateTextView(data) } }数据解析方面BLE设备通常是按消息包格式上报的比如一包数据里带设备状态、传感器值、电量等。我习惯把解析逻辑抽成独立工具类输入原始字节数组输出一个结构化的数据模型避免在主线程里做位运算。还有一点必须提醒BLE设备通常有一个连接状态机。不要在onConnectionStateChange回调里反复调用connectGatt很容易造成“半连接”状态。所有Gatt对象的close、disconnect、connect操作都要做好状态判断不能把disconnected后再调connect当成重连——这种写法在部分手机上会直接导致底层蓝牙服务崩溃或长时间无法再连接。5. 高频问题排查与避坑实录5.1 经典蓝牙的常见问题扫描不到设备绝大多数是三个原因App没有定位权限或定位开关没开蓝牙没有打开或者对方设备不可被发现手机蓝牙缓存过旧偶发情况下需要关闭蓝牙再打开连接失败最典型的是UUID不匹配。虽然SPP有固定UUID但一些国产模块用的UUID是私有值一定以设备文档为准。还有一种情况是连接后立刻断开。经典蓝牙的Socket链接是独占的一个设备同时只能被一个客户端连接。如果手机已经和这个设备配对但又有另一个App占用了连接你的App连上去瞬间就会被挤掉。遇到这种情况先把手机蓝牙配件列表里的配对记录删掉再重新配对。连接时还建议关闭蓝牙扫描。startDiscovery()和socket.connect()同时进行时手机底层资源冲突连接经常失败。所以协议上都是先cancelDiscovery()再connect。5.2 BLE连接与通信的常见问题BLE扫描不到设备和经典蓝牙的权限问题基本一样但多了一个过滤条件设置过严的可能性。比如按设备名过滤名字里有特殊字符或空格Filter匹配不上就永远扫不到。可以先不设置过滤条件扫一次调试通了再加过滤。BLE连接失败或连接后马上断开常见原因是设备端固件问题。有些低端模组对并发连接支持很差手机上只能有一个App建立GATT连接。如果系统设置里已经有一个连接或者后台有其他App占用就会一直连不上。关掉其他蓝牙相关App再试一次。关于通知收不到我排查的时候基本按这个顺序走确认setCharacteristicNotification有没有调用确认CCCD描述符有没有写入启用值确认订阅的Characteristic UUID是否正确确认设备端是否真的在发数据可以用日志看)如果以上都正确还是收不到可以在onDescriptorWrite回调里加日志确认descriptor写入成功而不是静默失败。写入数据失败大概率是数据长度超了。Android 13默认MTU还是23一次只能写20字节。先requestMtu(247)等成功后再写长数据。如果设备固件不支持大MTU就自己拆分数据包每包20字节包间加50到100毫秒延时。5.3 兼容性与系统差异蓝牙开发还有一个绕不开的坎国产ROM的兼容性。华为、小米、OPPO、vivo这几个厂商对后台蓝牙操作、扫描频率、甚至动态权限都有各自的限制。扫描回调频率在部分机型上会被限流表现为前几秒还能收到设备后几秒就完全没有回调了。这种情况多发生在App退到后台或锁屏后前台操作问题不大。另一个比较头疼的问题是Android 6到Android 11之间扫描BLE设备需要位置权限但很多用户不理解为什么连蓝牙要开定位。在权限弹窗之前给一个清晰的功能引导文案能明显降低权限被拒绝的概率。还有一个很少人注意到的点从Android 8开始很多手机对蓝牙设备的MAC地址做了随机化处理设备每次广播的MAC地址都可能变。如果你在配对后把设备地址存到本地用于下次自动重连可能下次拿这个地址连接却发现根本找不到设备。这种设备只能每次实时扫描用设备名或广播数据去匹配。5.4 调试工具与日志分析蓝牙开发没有趁手的调试工具排查问题会非常痛苦。我自己最常用的几样东西分享给大家Android Studio自带的Logcat配合分区过滤把所有蓝牙相关log的tag统一为BT_xxx排查时直接搜这个前缀。nRF Connect一个第三方App能完整展示周围BLE设备的广播数据、Service列表、Characteristic还能手动读写调试BLE协议时几乎是标准配备。硬件层面的逻辑分析仪或串口工具能直接抓设备端的串口log快速区分是手机端问题还是设备端问题。日志输出方面有一个值得养成的习惯把所有关键节点都打上状态和参数。连接、断开、发现服务、MTU协商、描述符写入、数据收发每一条都带时间戳和结果状态。真出问题时log就是你的破案线索。另外Android系统自带的蓝牙日志也是排查的重要手段开发者选项里可以开启蓝牙HCI抓包抓下来的日志能看到手机和设备的完整交互过程。这一步对分析设备没有响应或者数据包被丢弃的情况特别有效只是日志文件比较专业需要一定的协议基础才能看懂。6. 从项目实战中总结的几点心得与经验最后说几个我个人的体会。第一蓝牙开发最忌讳“什么都想自己搞定”。如果设备端是有成熟SDK的直接用厂商SDK比自己折腾底层省事得多。只有SDK太烂或定制太深的时候才自己封装底层蓝牙逻辑。第二状态管理要统一。无论经典蓝牙还是BLE都可以抽象成一个状态机IDLE、SCANNING、CONNECTING、CONNECTED、DISCONNECTING。所有回调都先更新状态再分发给UI。没有状态机的蓝牙模块后期加需求时会改到怀疑人生。第三连接复用和释放一定要做干净。页面上按钮点击连接后返回键退出页面前必须释放资源否则下次进来时系统底层还会认为App占用着蓝牙通道导致一系列“鬼打墙”式的问题。这类问题排查起来非常迷惑因为代码看着没错但连接就是失败。常见原因就是上一次的gatt对象没有close。最后分享一个实用小技巧如果App里同时集成了经典蓝牙和BLE逻辑尽量把所有蓝牙控制层都封装成单例并通过回调接口向UI层暴露结果。这样业务页面不需要关心底层用的是Socket还是Gatt切换协议时只要改控制层页面代码一行都不用动。我在接入第二个硬件设备时就是靠这个抽象结构把新设备适配时间压缩到了一天以内。蓝牙开发上手容易深挖坑多但只要把协议、权限、状态机这三件事理顺后面就是熟练工了。希望这篇文章能帮你少走一些弯路。
返回列表