资讯详情

资讯详情

Android BLE开发实战:BluetoothLeGatt官方Demo核心代码解析

简介面向Android蓝牙4.0开发者的官方BLE示例工程以安卓开发者官网的Bluetooth LE指南为蓝本整理完整演示设备扫描scan、连接与断开connection/disconnect、服务发现discover service以及UUID读写操作等关键流程适用于刚接触蓝牙低功耗通信的移动开发者也可作为已有项目快速集成BLE功能的参考模板。压缩包共70个文件包含26个class、15个xml、7个java并附jar库、APK成品、项目配置与缓存文件整体仅1.34MB从目录结构可区分src源码、res资源、gen生成文件与bin输出方便直接导入开发环境对照学习。已有523人学习下载。通过这个demo可直观理解BLE的回调机制和GATT服务交互逻辑降低环境搭建与API调用的门槛帮助读者绕过常见坑点快速跑通扫描、连接与数据交互流程是一份轻量而完整的入门实战资料。1. 项目概述1.1 核心需求解析做Android蓝牙开发几乎所有人都会先接触到官方提供的BLE demo。这套demo托管在Android官方示例代码仓库中项目名叫BluetoothLeGatt涵盖了BLE开发中最核心的完整链路设备扫描、设备连接、发现服务、读写特征值、接收通知。我当年刚接触BLE时就是靠啃这套demo入门的前前后后把它翻来覆去改过好几遍对它的评价是基础功能都有但直接搬到生产环境是不够用的。这篇文章把整套demo从环境准备到位运行到关键代码拆解讲一遍我的实操经验包括那些官方注释里没写清楚、你得踩几次坑才能总结出来的东西。这个demo适合谁看刚接触BLE开发、想搞清楚Android手机和外设之间是怎么通信的或者已经在看demo但有些地方没看明白的同学。如果你需要BLE设备做OTA升级、大数据传输这类场景这篇也会涉及相关的MTU和分包策略但核心还是从demo出发把基础链路走通。1.2 demo能做到什么先给一个直观认识。BluetoothLeGatt实现了这些功能扫描周围的BLE外设展示设备名、MAC地址、信号强度连接指定设备并自动发现GATT服务列出设备支持的所有Service、Characteristic、Descriptor读取Characteristic的值支持十六进制和ASCII格式显示写入数据到Characteristic支持带响应和不带响应两种方式接收设备主动发送的Notification通知基本上BLE开发里你能碰到的常规操作它都覆盖了。搞明白这套代码的来龙去脉你再去写实际项目不管是智能家居控制还是健康设备数据采集思路都会清晰很多。2. 准备工作与关键技术背景2.1 搞清楚BLE和经典蓝牙的区别先说点基础背景很多新手在这个地方容易犯迷糊。BLE全称Bluetooth Low Energy低功耗蓝牙Android从API 18Android 4.3开始支持。它和经典蓝牙BR/EDR最大的区别在于BLE不是简单地把数据传得更省电而是整个协议栈都重新设计了。经典蓝牙支持持续的流式传输比如蓝牙音箱播放音乐BLE则在大部分时间处于睡眠状态只在需要的时候快速唤醒、发几个字节、再睡过去因此非常适合电池供电的传感器设备。两者的另一个关键区别在于通信模型。经典蓝牙建立的是RFCOMM通道你可以在上面跑socket像用TCP一样BLE则是基于GATTGeneric Attribute Profile的数据以Attribute为单位组织一个Attribute包含UUID、属性、值。设备间通信就是你读写这些Attribute设备主动推送数据则是通过Notification或Indication机制。这也是为什么看BLE的demo代码你会发现它跟传统的蓝牙socket编程完全不是一个套路。2.2 硬件与软件环境准备跑这个demo之前你得有一个支持BLE的Android手机Android 5.0以上的系统都行。不需要额外买硬件外设用手机模拟BLE外设也行但最方便的还是搞一个真实的BLE设备。我常用的是一个Nordic开发板或者也可以直接用另一台手机配合nRF Connect这个App模拟外设不过实际调试下来你会发现真机外设能暴露更多网络时序相关的问题。环境方面需要准备Android Studio推荐用较新的稳定版SDK版本官方demo最新的代码要求API 30以上但实际编译时把targetSdk和compileSdk调成当前SDK版本即可minSdk保持API 18就能兼容老设备一台有蓝牙模块的开发手机2.3 Gradle配置常见坑从GitHub拉下BluetoothLeGatt工程后第一步是Gradle同步。这里大概率会遇到依赖下载慢、SDK版本不匹配的问题。GitHub官方仓库使用的是Android Gradle Plugin较新的版本如果你本地的Gradle版本对不上会直接报错。我的建议是把build.gradle里这几个版本号统一改成自己本地环境可用的。拿我的配置举例android { compileSdk 34 defaultConfig { applicationId com.example.bluetoothlegatt minSdk 21 targetSdk 34 versionCode 1 versionName 1.0 } }注意Android 12API 31之后有个比较大的变化如果你把targetSdk升到31以上应用中需要显式声明BLUETOOTH_SCAN、BLUETOOTH_CONNECT这两个运行时权限否则在真机上会直接崩溃或者扫描不到设备。原生demo里给出的权限声明只覆盖了API 30以下的老权限方式这部分需要自己改。3. 核心代码逐段拆解3.1 权限声明不只是加两行配置先来看最容易被忽略的权限部分。官方demo在AndroidManifest.xml里声明了这些权限uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-feature android:nameandroid.hardware.bluetooth_le android:requiredtrue /如果你的app运行在Android 12以上还需要增加uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /补充一点如果你只是扫描广播包而不主动连接设备可以用neverForLocation标记来避免请求定位权限但要特别注意设备是否在广播包里携带了可解析的私有地址或者厂商自定义数据这些在Google的政策里被认定为可能推断位置信息所以Play商店审核时对这个标记的审查比较严格。实际开发中的权限处理是这样的定位权限ACCESS_FINE_LOCATION是运行时权限必须在代码里动态申请用户拒绝之后扫描功能就无法正常工作。BLUETOOTH_SCAN和BLUETOOTH_CONNECT同样需要在代码里通过requestPermissions动态申请并且要注意Android 12以上一个很烦人的点是用户在系统设置里关掉“附近设备”权限后你的app不会收到crash提示而是静默地拿不到扫描结果。这时候可以通过ActivityResultContracts.RequestMultiplePermissions一次性把几个权限都申请掉。以下是我常用的权限检查写法private fun checkPermissions(): Boolean { val requiredPermissions if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.ACCESS_FINE_LOCATION ) } else { arrayOf( Manifest.permission.ACCESS_FINE_LOCATION ) } val notGranted requiredPermissions.filter { ContextCompat.checkSelfPermission(this, it) ! PackageManager.PERMISSION_GRANTED } if (notGranted.isNotEmpty()) { ActivityCompat.requestPermissions(this, notGranted.toTypedArray(), REQUEST_PERMISSION_CODE) return false } return true }3.2 扫描机制从LeScanCallback到ScanCallbackdemo里最核心的扫描代码用的是BluetoothLeScanner配合ScanCallback。这里要说明一下官方demo的代码有多处update早期版本用的是已废弃的startLeScan方法现在正确姿势如下private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { super.onScanResult(callbackType, result) val device result.device val rssi result.rssi val name device.name ?: 未知设备 // 这里用runOnUiThread把结果更新到列表 runOnUiThread { leDeviceListAdapter.addDevice(device) leDeviceListAdapter.notifyDataSetChanged() } } override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) // errorCode为SCAN_FAILED_ALREADY_STARTED表示重复启动扫描 } } private fun startScan() { val filters: ListScanFilter? null val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() bluetoothLeScanner?.startScan(filters, settings, scanCallback) }关键点在于ScanSettings的setScanMode参数。demo里用的是SCAN_MODE_LOW_LATENCY扫描间隔短、结果出来快但功耗高适合主动扫描场景。如果你的app在后台持续扫描建议用SCAN_MODE_LOW_POWER否则会被系统判定为耗电异常。Android 8.0之后后台应用扫描BLE有严格的限制后台扫描时每30分钟只能有4次不超过30秒的扫描机会这是系统的硬性限制。扫描结果里的rssi是个很有用的字段代表信号强度实测中距离1米大概-40dBm10米大概-70dBm这可以作为粗略的距离判断依据但千万别拿来精确测距BLE信号受环境反射影响很大同一位置角度转个90度RSSI能差10dB以上。我在用demo扫描的时候发现一个问题列表经常会出现好几个同名的设备这是因为设备在广播时每次的MAC地址可能不一样隐私地址轮换过滤方式是通过result.device.address去重但实际场景中更好的做法是通过广播包里的Service UUID来过滤。后面我会讲到。3.3 连接与GATT回调这里最容易出问题点击扫描列表里的设备后进入设备控制界面代码在DeviceControlActivity中。核心方法如下private fun connectToDevice(address: String): Boolean { if (bluetoothAdapter null || address.isEmpty()) return false // 正式连接前要先取消之前未完成的连接 if (mBluetoothGatt ! null) { mBluetoothGatt!!.close() mBluetoothGatt null } val device bluetoothAdapter!!.getRemoteDevice(address) mBluetoothGatt device.connectGatt(this, false, mGattCallback) return true }第二个参数autoConnectdemo里传的是false。这个参数选择在实际开发里很有讲究autoConnectfalse是主动发起连接如果设备不在广播状态连接会马上失败回调onConnectionStateChange且status为133autoConnecttrue则是在设备不在线时不停监听广播等设备出现时自动建立连接。但后者连接速度慢系统底层有较长的backoff周期不适合需要快速连上的交互场景。我的建议是用户主动点击连接时用false需要自动重连时用true。GATT回调是整个demo中最核心也最容易出问题的地方private val mGattCallback 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) { // 遍历服务找到目标Service和Characteristic val services gatt.services for (service in services) { // 根据Service UUID判断是否是你要的服务 } } } }这个方法名discoverServices特别容易让人误会以为它是从设备上拉取一个服务列表的数据操作。实际上它做的只是向设备发送一个发现服务的请求设备返回的服务列表会缓存在GATT对象内部你调用gatt.services拿到的就是这个缓存。有个巨坑如果你disconnect之后没有调用close系统会保留服务缓存下次连接同一个设备时服务列表可能还是旧的。如果你的设备固件升级后改了Service UUID你就会奇怪为什么新服务没出现。解决方法是每次退出连接时调用gatt.close()或者连上后主动调用refreshDeviceCache()这个方法属于隐藏API需要通过反射调用。还有一个高频问题onConnectionStateChange的status参数不等于0时表示连接失败常见错误码是133不是错误是设备暂时不可达和257连接参数不满足要求。很多初学者只判断newState STATE_CONNECTED不判断status导致连接失败时UI表现很怪一会儿显示已连接一会儿显示断开。正确做法是修改为override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { when (newState) { BluetoothProfile.STATE_CONNECTED - { if (status BluetoothGatt.GATT_SUCCESS) { gatt.discoverServices() } else { // 失败时要主动关掉否则底层连接一直占着 gatt.close() runOnUiThread { showMessage(连接失败错误码: $status) } } } BluetoothProfile.STATE_DISCONNECTED - { gatt.close() runOnUiThread { showMessage(连接断开) } } } }3.4 读写与通知MTU是绕不开的话题发明特征的读写操作demo在DeviceControlActivity中提供了readCharacteristic、writeCharacteristic等方法。这里我只挑重点说。读取特征值private fun readCharacteristic(characteristic: BluetoothGattCharacteristic) { if (bluetoothGatt null) return val success bluetoothGatt!!.readCharacteristic(characteristic) if (!success) { showMessage(读取失败) } }读取结果在onCharacteristicRead回调中获取override fun onCharacteristicRead( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int ) { if (status BluetoothGatt.GATT_SUCCESS) { // value就是读取到的数据 } }注意API 33以上onCharacteristicRead的参数从byte[]类型变成了ByteArray,API 33以下还有另一个重载版本onCharacteristicRead(gatt, characteristic, status)返回值是从characteristic.value取的。如果你只重写了新版本在老设备上会静默地不触发回调藏得相当深。写入特征值分带响应和不带响应两种private fun writeCharacteristic(characteristic: BluetoothGattCharacteristic, value: ByteArray) { // API 33以上推荐写法 val writeType if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { characteristic.writeType } else { characteristic.writeType } characteristic.value value val success bluetoothGatt!!.writeCharacteristic(characteristic) }重点来了MTUMaximum Transmission Unit。BLE 4.x默认MTU是23字节其中3字节被协议头占用实际用户数据只有20字节。意味着你单次只能发20字节的数据超过20字节要么协商更大的MTU要么自己分包。协商更大MTU使用requestMtu方法val success bluetoothGatt?.requestMtu(247)但这里有个前置条件手机和从机都必须支持。Android端通常支持512字节但你的BLE外设如果固件里没配置相应的MTU大小协商就会失败。协商结果的回调在onMtuChanged里获取。实践中的经验是MTU设为185比较稳妥185-3182字节可用这是一个在兼容性和传输效率上比较平衡的值。当你需要传一张图片或一段几百字节的数据时正确做法是先查MTU确定单包能传多少字节计算总包数按顺序发送每发送一包后等待对端返回确认包再发下一包我见过很多新手把数据split成每20字节一个包然后一股脑全发出去结果对端收到的是乱序的或者直接丢包。原因在于BLE底层的流控如果你在收到对端应答之前发了太多包对端会直接丢弃后续的。所以我在demo基础上增加了简单的ACK确认机制感觉是用的最顺手的方案。接收通知则需要先开启Notification这一步对初学者来说有些玄学很容易踩坑。开启Notif的流程是private fun enableNotification(characteristic: BluetoothGattCharacteristic, enabled: Boolean) { val descriptor characteristic.getDescriptor(UUID.fromString(00002902-0000-1000-8000-00805f9b34fb)) if (descriptor ! null) { descriptor.value if (enabled) { BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE } else { BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE } bluetoothGatt?.writeDescriptor(descriptor) } // 这里还需要指定接收通知的特征 bluetoothGatt?.setCharacteristicNotification(characteristic, enabled) }顺序很重要先setCharacteristicNotification把特征注册到回调里再去写CCCDClient Characteristic Configuration Descriptor。如果顺序反了会收不到通知。写的这个Descriptor的UUID是固定的00002902-0000-1000-8000-00805f9b34fb这是BLE协议规定的CCCD所有有Notification属性的特征都得配置它。还有个比较容易混淆的点setCharacteristicNotification的返回值没法代表通知是否真正开启真正的成功回调是onDescriptorWrite。如果CCCD写入失败最常见原因是设备端这个特征的Property不包含Notify或Indicate。3.5 连接管理不要只是disconnect要closedemo里退出页面时会执行override fun onDestroy() { super.onDestroy() if (mBluetoothGatt ! null) { mBluetoothGatt!!.close() mBluetoothGatt null } }这个close()很多人都漏了但它是必须的。disconnect()只断开当前连接但GATT对象仍然保存着连接尝试的状态如果不close后续重连会出现各种诡异问题最常见的表现是连接成功后discoverServices一直不回调或者回调的status是133。原因是系统底层同一时刻只允许一个GATT连接实例在跑你不close掉旧的新连接就被占着资源起不来。我还遇到过一种情况设备断开后没有调用close就立刻重新连接同一个设备结果连接一直失败。日志里也没有明确的报错就是连不上隔一会儿又莫名其妙地好了。后来才意识到这是系统蓝牙协议栈的处理逻辑蓝牙连接断开后底层需要一段时间做资源回收你的代码如果在这个窗口期里重连就可能失败。解决方案是断开后延迟几百毫秒到1秒再发起重连。4. 实际开发中的常见问题与处理方案4.1 扫描不到设备这是最高频的问题通常有以下几个原因原因现象解决方案权限未申请点击扫描无任何反应动态申请定位权限和附近的设备权限手机蓝牙未开启扫描回调不触发调用BluetoothAdapter.enable()或引导用户去设置页开启系统蓝牙服务异常startScan抛SecurityException重启蓝牙开关设备没有广播扫描列表为空用nRF Connect确认外设是否在广播代码里设置了不匹配的ScanFilter扫描结果为0暂时去掉filter或确认Service UUID正确我遇到过特别折腾的一次在Android 12手机上扫描没问题Android 8手机上却扫不到任何设备。后来发现是targetSdk升到31之后在Android 8设备上运行时因为系统没有强制权限模型BLUETOOTH_SCAN权限被忽略了但定位权限又没有申请成功导致扫描直接返回空。解决办法很简单在检查权限时同时检查targetSdk对老设备特殊处理保证定位权限必须拿到。4.2 连接之后老是掉线掉线问题一般出在连接参数上。BLE设备在建立连接后Android系统会基于设备的建议更新连接间隔。如果你连接的是一个没有优化过连接参数的低成本外设或者外设的建议连接参数太激进Android系统会在某个时刻判定连接质量差强制断开。这个问题用demo原版代码几乎没法解决因为你没法直接控制Android底层的连接参数。你能做的是检查外设固件里建议的连接间隔间隔太短比如7.5ms容易导致掉线连接成功后主动通过GATT服务里的Connection Parameters服务如果设备支持修改参数保持app在前台运行不要切到后台因为Android后台对BLE连接的资源管理更严格在真机实测中Nordic的SDK设置默认连接间隔为30ms~50ms连接稳定性就很好某些便宜的模块把连接间隔设到10ms以下手机连接后运行几分钟就会断。4.3 数据收发不完整如果你发现收到的数据比设备发出来的少或者写入大片数据后设备只收到了前面的字节大概率是MTU没协商好或者分包策略有问题。我总结的经验是先协商MTU再审数据。协商成功后的onMtuChanged回调里拿到的mtu值会告诉你单包数据的最大可用大小。假设协商结果是mtu247那么单包实际可用数据是244字节247-3。你的代码里应该有这样一个工具方法fun splitPackets(data: ByteArray, mtuSize: Int): ListByteArray { val maxPayload mtuSize - 3 val packets mutableListOfByteArray() var offset 0 while (offset data.size) { val length minOf(maxPayload, data.size - offset) packets.add(data.copyOfRange(offset, offset length)) offset length } return packets }发送时加一个简单的超时重传机制。网上有人这么做发完一包后启动2秒超时定时器收到对端ACK就取消定时器发下一包超时则重发。这种做法在数据量不大时完全够用。4.4 动态广播过滤避免收到一堆无关设备原版demo把周围所有正在广播的设备都列出来了在一个办公楼里扫码能刷出一大屏。实际项目里你只会关心自己公司那款设备。常规做法是启动扫描时传入ScanFilterval scanFilter ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString(0000ffe0-0000-1000-8000-00805f9b34fb)) .build() val filters listOf(scanFilter) bluetoothLeScanner?.startScan(filters, settings, scanCallback)setServiceUuid会匹配广播包里的Service UUID这个UUID是设备厂商定义的你得从设备文档里找到。如果设备在广播包里没有广播Service UUID还可以用setDeviceAddress指定MAC地址过滤但这种方式限制太死不够灵活。有一种更高级的过滤方式解析广播包raw data里的Manufacturer Specific Data。很多设备会把设备类型、是否连接中、电量这类信息放在厂商自定义数据里。你可以在ScanResult.scanRecord?.bytes里手动解析这一段数据实现比较智能的判断逻辑。4.5 Android 12及以上版本适配Android 12是BLE开发的分水岭权限模型变化影响面非常大。老代码如果不适配直接面临两个问题使用startLeScan或BluetoothAdapter.startDiscovery扫描时直接抛SecurityException连接设备时因为没动态申请BLUETOOTH_CONNECT在connectGatt就会崩正确的适配思路是if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { // 使用新的权限模型 requestPermissions(arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT ), REQUEST_BLE_PERMISSION) }此外Android 12的权限弹窗有两个一个扫描权限一个连接权限它们可以分开授权。如果用户只授权了扫描没授权连接你能扫描到设备但连不上反过来你能主动连设备但扫描不到新设备。这种割裂状态对体验影响很大建议在UI层做好引导如果发现某个权限缺失弹出明确的说明而不是让用户盲猜。5. 实操总结从demo到可用项目的改造建议官方demo跑通之后你可以直接在这个基础上做几个升级让它更接近真实项目。第一个建议是增加重连机制。demo里连接断了就断了产品上这肯定不行。我会在断开后记录设备地址弹出重连提示或者直接定时重试重试次数限制在3-5次之间每次间隔1-2秒。第二个建议是抽离BLE管理类。原版demo把GATT回调、扫描、连接都写在Activity里业务一复杂就变成母类大妈几百行代码揉在一起。我一般会抽一个BleManager单例封装扫描、连接、断开、读写、通知等所有操作通过回调接口把结果抛给UI层。第三个建议是处理设备连接的超时机制。原版demo里点击设备后如果一直连不上UI就一直卡在连接中。我会加一个8秒超时超时后自动调disconnect并提示用户重试。这个8秒不是随便定的是根据实际测试绝大多数正常设备的连接握手在8秒内能完成超过8秒基本是设备离线或者信号差。第四个建议是增加日志记录。把关键的状态变化、错误码、每次收发的数据都记录到文件中。BLE这种无线通信的问题极其依赖现场日志排查很多问题只有设备端能看到两端日志一对比问题立刻就能定位。最后再分享一个我自己的体会官方demo就像一把钥匙帮你打开BLE世界的大门但它展示的是最基础的用法真正落地到产品里有太多脏活累活在等着你。上面提到的权限处理、MTU协商、分包策略、连接管理这些问题如果等踩坑后再查浪费的时间可不少。把demo吃透在上面把这些坑提前填掉后面开发其他蓝牙项目就会轻松很多。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →