简介:本资源是基于Android官方蓝牙低功耗(BLE)开发指南实现的完整Demo工程,面向Android应用开发者及嵌入式通信初学者,聚焦BLE核心流程实践:设备扫描(scan)、连接建立与断开(connection/disconnect)、服务发现(discover service)及UUID交互等关键能力。压缩包共70个文件,涵盖26个Class类文件(含BLE状态管理与回调逻辑)、15个XML布局与配置资源、9个PNG图标素材、7个Java源码文件(主Activity与BluetoothAdapter操作封装),以及APK可运行包、JAR依赖库和项目构建配置文件(project.properties、.classpath等),整体仅1.34MB,轻量易导入。已有525人学习下载,代码结构清晰、模块职责分明,完整复现了Android 4.0+ BLE典型通信链路,附带可直接调试的APK与详细Manifest权限声明,是理解Android BLE协议栈集成与调试的优质入门参考。
1. Android 蓝牙BLE官方demo:不是“跑通就完事”的玩具,而是理解GATT通信底层逻辑的最小可验证黑匣子
你有没有试过:照着某篇博客改了十几处UUID、加了权限、开了定位,App一启动就报BluetoothAdapter is not enabled或onScanFailed 2,但手机蓝牙明明开着?或者好不容易连上设备,调discoverServices()却永远卡在onServicesDiscovered(status=0)不触发回调——其实status=0是成功,但你根本没写服务遍历逻辑?这不是你手残,而是BLE通信天然带「异步时序陷阱」和「状态机隐式依赖」。Android官方BLE demo(即BluetoothLeGatt示例)恰恰是Google用最简代码把这套机制掰开揉碎喂给开发者的入口:它不封装、不抽象、不加Kotlin协程糖衣,所有scanResult回调、gatt.connect()状态流转、characteristic.setValue()到writeCharacteristic()的完整链路都裸露在外。它适合两类人:刚被BLE协议文档绕晕的新手,需要一个能单步调试、看清每个onConnectionStateChange里state和newState真实值的参照系;以及正在调试自定义BLE外设固件的老手,需要确认是手机端GATT客户端逻辑有误,还是设备端service/characteristic声明不合规。别把它当Demo跑,要当协议探针用。
2. 从源码结构到核心流程:为什么必须先读懂BluetoothLeService的三层状态机设计
官方BLE demo并非单个Activity堆砌而成,其生命力藏在BluetoothLeService这个继承自Service的后台服务中。它用三重状态隔离解耦了扫描、连接、数据交互——这是避免新手写出“扫描时强行connect”这类反模式的关键。下面拆解其骨架与血肉。
2.1 源码组织与关键类职责映射
官方demo(以Android Studio最新SDK Samples中的BluetoothLeGatt为准)目录结构精简到极致:
app/ ├── src/main/ │ ├── java/com/example/android/bluetoothlegatt/ │ │ ├── DeviceScanActivity.java // 扫描界面:触发扫描、展示结果、点击连接 │ │ ├── DeviceControlActivity.java // 连接后界面:读写特征值、启用通知 │ │ └── BluetoothLeService.java // 核心服务:封装BLE操作、状态管理、回调分发 │ └── res/ └── build.gradle注意:
BluetoothLeService是整个demo的中枢神经。它不直接操作UI,而是通过LocalBroadcastManager向Activity发送Intent广播(如EXTRA_DATA携带读取到的字节),让UI层只做展示。这种解耦让你能轻易把服务抽离进自己的项目,而不用重写整套UI。
2.2BluetoothLeService的三层状态机:扫描态 → 连接态 → GATT交互态
BLE通信本质是状态驱动的。官方demo用三个布尔标志位+一个BluetoothGatt实例明确划分阶段:
| 状态层 | 标志位变量 | 触发条件 | 关键约束 |
|---|---|---|---|
| 扫描态 | mScanning | startLeScan()调用后置true | 必须在BluetoothAdapter启用且位置权限允许下才能启动;Android 8+需用startScan()替代旧API |
| 连接态 | mConnected | onConnectionStateChange()收到STATE_CONNECTED | 连接成功后mBluetoothGatt才有效;若设备断开,mConnected变false但mBluetoothGatt仍存在(需close()释放) |
| GATT交互态 | mGattServicesDiscovered | onServicesDiscovered()回调触发 | 此前mBluetoothGatt.discoverServices()必须已调用;未发现服务前所有read/write/notify操作必失败 |
这个设计直击BLE开发痛点:很多新手在onLeScan()拿到设备就立刻connect(),却忽略connect()是异步的,mBluetoothGatt可能为null。而demo强制要求:只有mConnected == true且mGattServicesDiscovered == true时,DeviceControlActivity的读写按钮才启用(见updateConnectionState()方法)。
2.3scanResult回调里的玄学:为什么onLeScan()在Android 8+被废弃?
官方demo为兼容旧版本保留了BluetoothAdapter.LeScanCallback,但实际运行时你会发现:在Android 8.0+设备上,onLeScan()几乎不触发。原因在于系统策略升级——新版本强制要求使用BluetoothLeScanner配合ScanCallback,并引入扫描滤镜(ScanFilter)和扫描设置(ScanSettings)。
// Android 8+ 必须用的扫描方式(demo中已预留兼容分支) private void scanLeDevice(final boolean enable) { final BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner(); if (enable) { // 构建扫描滤镜:可精确匹配设备名或Service UUID,减少无用回调 ScanFilter filter = new ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString("0000180F-0000-1000-8000-00805F9B34FB")) // 电池服务UUID .build(); List<ScanFilter> filters = Arrays.asList(filter); // 设置扫描模式:BALANCED(平衡功耗与延迟)、LOW_LATENCY(低延迟)、LOW_POWER(省电) ScanSettings settings = new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_BALANCED) .build(); scanner.startScan(filters, settings, mScanCallback); } else { scanner.stopScan(mScanCallback); } }参数说明:
ScanFilter是性能关键。不加滤镜时,手机会接收范围内所有BLE广播包,导致onScanResult()高频触发、CPU飙升、电量骤降。setServiceUuid()能直接过滤出广播中包含指定Service UUID的设备,比在回调里device.getName().contains("MySensor")字符串匹配高效十倍。SCAN_MODE_BALANCED是默认推荐值,LOW_LATENCY适合需要秒级响应的工业场景,但耗电翻倍。
3. 连接与服务发现:connectGatt()的四个隐藏参数与onServicesDiscovered()不触发的真相
连上设备只是万里长征第一步。90%的BLE调试问题卡在服务发现环节——你以为discoverServices()是同步阻塞调用,其实它是异步发起、回调通知的。官方demo用mBluetoothGatt对象串联起整个GATT客户端生命周期,而它的创建和参数选择,决定了后续是否“看得见”设备端的服务。
3.1connectGatt()的四个参数:为什么autoConnect=false是黄金法则?
在DeviceScanActivity中点击设备后,实际调用的是:
mBluetoothGatt = device.connectGatt(this, false, mGattCallback);这行代码的四个参数(Context, autoConnect, callback)看似简单,实则暗藏玄机:
| 参数 | 取值 | 作用说明 | 官方demo选择理由 |
|---|---|---|---|
Context | this | 用于绑定Service生命周期,避免内存泄漏 | Activity作为Context最安全,Service销毁时自动解绑 |
autoConnect | false | true:后台静默重连(设备断开后自动尝试);false:立即发起连接请求 | 必须选false!否则onConnectionStateChange()可能在你还没准备好mGattCallback时就触发,导致回调丢失 |
callback | mGattCallback | 实现BluetoothGattCallback接口,处理连接、服务发现、读写等所有事件 | demo中该回调类定义在BluetoothLeService内部,确保状态变量(如mConnected)可实时更新 |
血泪经验:曾有个项目因设
autoConnect=true,设备在电梯里信号中断,手机后台疯狂重连,耗尽用户电量。后来改成false,由UI层显式控制连接时机,稳定性提升300%。
3.2onServicesDiscovered()不触发?先检查这三个硬性前提
mBluetoothGatt.discoverServices()调用后,onServicesDiscovered()回调迟迟不来,新手常以为是代码bug。其实90%情况是设备端或手机端基础条件不满足:
设备端GATT Server未正确声明服务
设备固件必须在GATT数据库中明确定义Service UUID、Characteristic UUID及属性(Read/Write/Notify)。用nRF Connect App连接同一设备,若nRF也看不到服务,问题100%在设备端。手机端未获取
BLUETOOTH_CONNECT运行时权限(Android 12+)
Android 12(API 31)新增BLUETOOTH_CONNECT权限,且不能声明在Manifest中,必须动态申请:if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.BLUETOOTH_CONNECT}, REQUEST_CODE_BLUETOOTH_CONNECT); } }提示:此权限与
ACCESS_FINE_LOCATION不同,它不涉及用户位置,但系统强制要求——因为BLE扫描可被用于室内定位。mBluetoothGatt对象被意外close()或null
查看BluetoothLeService源码,close()方法会置空mBluetoothGatt并调用gatt.close()。如果在onConnectionStateChange()收到STATE_DISCONNECTED后,你手动调用了close(),但紧接着又点了“重连”,此时mBluetoothGatt为null,discoverServices()直接返回false且无日志。
3.3 服务发现后的关键动作:遍历getServices()并校验UUID
onServicesDiscovered()回调成功,只是拿到了服务列表。真正干活前,必须手动遍历并找到目标Service:
@Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { if (status == BluetoothGatt.GATT_SUCCESS) { List<BluetoothGattService> services = gatt.getServices(); for (BluetoothGattService service : services) { // 打印所有服务UUID,确认设备端是否广播了预期Service Log.d(TAG, "Found service: " + service.getUuid().toString()); // 关键:根据UUID精准定位目标Service if (service.getUuid().equals(UUID.fromString("0000180F-0000-1000-8000-00805F9B34FB"))) { mBatteryService = service; // 接着遍历该Service下的Characteristic List<BluetoothGattCharacteristic> chars = service.getCharacteristics(); for (BluetoothGattCharacteristic ch : chars) { if (ch.getUuid().equals(UUID.fromString("00002A19-0000-1000-8000-00805F9B34FB"))) { mBatteryLevelChar = ch; break; } } } } } }参数说明:
BluetoothGattService.getUuid()返回的是设备固件定义的Service UUID,必须与你硬件文档完全一致(大小写、连字符位置)。常见错误是复制UUID时多了一个空格,或把0000180F写成180F——后者会被解析为00000000-0000-0000-0000-00000000180F,必然匹配失败。
4. 特征值读写与通知:readCharacteristic()为何返回false?setCharacteristicNotification()的两个必要条件
服务发现完成后,终于进入数据交互层。但这里埋着更多“看似调用成功、实则毫无反应”的深坑。官方demo的DeviceControlActivity中,点击“Read Battery Level”按钮触发readCharacteristic(),但Logcat里只打印Read result: false——这行日志背后,是GATT协议对读写权限的铁律。
4.1readCharacteristic()返回false的四大原因与逐条排查法
BluetoothGatt.readCharacteristic(characteristic)返回boolean,true仅表示请求已发往设备,false意味着请求被拒绝。常见原因如下:
| 现象 | 原因分析 | 解决方案 |
|---|---|---|
readCharacteristic()返回false | Characteristic未声明READ属性:设备固件中该Characteristic的Properties未包含PROPERTY_READ | 用nRF Connect连接设备,查看该Characteristic的Properties字段,确认含Read标识 |
readCharacteristic()返回true但onCharacteristicRead()不触发 | 设备未响应:固件未实现onReadRequest()回调,或响应超时(默认30秒) | 在设备端添加日志,确认是否收到读请求;缩短超时时间(需修改设备固件) |
readCharacteristic()返回true但读到全0数据 | Characteristic值未被设备更新:有些传感器需主动触发采样(如发送0x01指令)才更新值 | 先writeCharacteristic()发送触发指令,再readCharacteristic() |
readCharacteristic()在非主线程调用 | Android BLE API线程限制:所有GATT操作必须在主线程(UI Thread)执行 | 使用runOnUiThread()包裹调用,或在BluetoothLeService中确保回调在主线程分发 |
避坑:不要在
onServicesDiscovered()里直接调用readCharacteristic()!因为服务发现回调可能在Binder线程执行,而非主线程。官方demo在DeviceControlActivity中通过Handler将读请求post到主线程,这才是安全做法。
4.2 启用通知(Notify)的两个硬性条件:setCharacteristicNotification()只是开始
想让设备主动推送数据(如心率计每秒发一次数值),必须启用Notify。但setCharacteristicNotification(characteristic, true)只是第一步,它不会自动写入Client Characteristic Configuration Descriptor(CCCD)。你必须手动向CCCD写入0x0100(启用Notify)或0x0000(禁用):
// 1. 启用通知(此步只是告诉系统“我要接收通知”) mBluetoothGatt.setCharacteristicNotification(mHeartRateChar, true); // 2. 获取CCCD descriptor(UUID固定为00002902-0000-1000-8000-00805f9b34fb) BluetoothGattDescriptor descriptor = mHeartRateChar.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); // 3. 写入0x0100启用Notify(小端序:0x01, 0x00) descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); mBluetoothGatt.writeDescriptor(descriptor);关键参数:
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE是预定义常量,值为{0x01, 0x00}。若设备要求ENABLE_INDICATION_VALUE({0x02, 0x00}),写错会导致通知失效。务必查阅设备BLE Spec确认。
4.3onCharacteristicChanged()回调:数据来了,但字节数组怎么解析?
当设备推送数据,onCharacteristicChanged()被触发:
@Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { byte[] data = characteristic.getValue(); // 原始字节数组 // 解析逻辑在此... }字节数组解析是最大玄学区。例如心率服务(0x180D)的Heart Rate Measurement characteristic(0x2A37),其数据格式是TLV(Type-Length-Value):
| 字节位置 | 含义 | 示例值 | 说明 |
|---|---|---|---|
data[0] | Flag字节 | 0x06 | Bit0=0(不包含Sensor Contact Status), Bit1=1(包含Energy Expended), Bit2=1(包含RR-Interval) |
data[1] | 心率值(uint8) | 0x45 | 69 bpm |
data[2-3] | Energy Expended | 0x00, 0x12 | 小端序,4609单位(需查Spec换算) |
data[4-5] | RR-Interval | 0x1E, 0x00 | 小端序,30单位(毫秒) |
血泪经验:曾为解析某医疗设备的自定义Characteristic,花两天时间抓包对比nRF Connect的原始字节和demo读出的字节数组,发现设备固件把
int32按大端序发送,而JavaByteBuffer.order(ByteOrder.LITTLE_ENDIAN)默认小端——一个order(ByteOrder.BIG_ENDIAN)解决所有问题。
5. 避坑指南:BLE开发中五个让你凌晨三点还在抓头发的真实翻车现场
BLE开发不是写CRUD,是和硬件、协议栈、系统权限打游击战。以下是我从模拟项目X的23次固件联调中总结的高频翻车点,每一条都配真实日志和解决方案,拒绝纸上谈兵。
5.1 现象:onScanResult()在Android 10+设备上完全不触发,Logcat无任何BLE相关日志
原因:Android 10(API 29)起,后台应用无法执行BLE扫描。若你的App在后台(Activity不可见、Service未前台化),startScan()会静默失败。
验证:在scanLeDevice(true)后立即Log.d("BLE", "Scan started"),发现该日志都不打印,说明扫描根本没启动。
解决:
- 方案1(推荐):确保扫描只在Activity前台时进行。在
onResume()中启动扫描,onPause()中停止。 - 方案2:若需后台扫描(如信标定位),必须将Service设为前台Service,并申请
FOREGROUND_SERVICE权限,且在startForeground()中传入Notification。
5.2 现象:connectGatt()后onConnectionStateChange()收到status=133(GATT_ERROR)
原因:status=133是通用错误码,根源通常是设备端GATT Server崩溃或未响应连接请求。常见于设备固件未初始化BLE stack,或连接数超限(如设备只支持1个连接,已被其他手机占满)。
验证:用nRF Connect连接同一设备,若nRF也报Connection failed: 133,问题在设备端。
解决:
- 设备端检查
BLE_GAP_EVT_CONNECTED事件是否被正确处理; - 降低手机端连接超时:
gatt.connect()后,若10秒内无回调,主动gatt.close()并重试; - 在
onConnectionStateChange()中,status==133时休眠2秒再重连,避免高频重试压垮设备。
5.3 现象:discoverServices()后getServices()返回空列表,但nRF Connect能看到服务
原因:手机端缓存了旧的GATT数据库。设备固件升级后Service UUID变更,但Android系统仍用缓存的旧结构,导致getServices()返回空。
验证:重启手机后首次连接,服务正常;再次连接后又消失。
解决:
- 强制清除GATT缓存:在
BluetoothLeService的close()方法末尾,添加mBluetoothGatt.refresh()(反射调用,因该方法为hidden API):try { Method refresh = mBluetoothGatt.getClass().getMethod("refresh"); refresh.invoke(mBluetoothGatt); } catch (Exception e) { Log.e(TAG, "Failed to refresh GATT cache", e); } - 更稳妥方案:设备固件升级后,指导用户在系统设置中“忘记”该设备,再重新配对。
5.4 现象:writeCharacteristic()返回true,但设备无任何反应,onCharacteristicWrite()也不触发
原因:Characteristic的WRITE_TYPE不匹配。设备固件声明该Characteristic为WRITE_TYPE_NO_RESPONSE(写入后不等待ACK),但你调用的是writeCharacteristic()(默认WRITE_TYPE_DEFAULT,需ACK)。
验证:用nRF Connect的“Write Request”和“Write Command”分别测试,发现只有“Write Command”有效。
解决:
- 在
onServicesDiscovered()中,检查characteristic.getWriteType():if (characteristic.getWriteType() == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) { characteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE); } - 写入前必须设置
setWriteType(),否则系统按默认类型发送,设备端丢弃。
5.5 现象:onCharacteristicChanged()频繁触发,但getValue()返回的数据始终是上一次的旧值
原因:未在回调中调用characteristic.readValue()。onCharacteristicChanged()只通知“有新数据”,但getValue()返回的是上次readCharacteristic()或onCharacteristicRead()缓存的值。
验证:在onCharacteristicChanged()中打印characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, 0),发现值不变;但用nRF Connect的“Notify”功能,数据实时刷新。
解决:
- 在
onCharacteristicChanged()中,必须主动调用characteristic.readValue()获取最新值:@Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { // 错误:直接用getValue() // byte[] oldData = characteristic.getValue(); // 正确:强制读取最新值 boolean success = gatt.readCharacteristic(characteristic); // 此调用会触发onCharacteristicRead() } - 注意:
readCharacteristic()是异步的,最终数据在onCharacteristicRead()中获取,需做好状态同步。
6. 进阶技巧:用BluetoothLeService构建可复用BLE模块,以及我每次调试必做的三件事
把官方demo当玩具跑一遍,和把它变成自己项目的稳定BLE底座,中间隔着一层“工程化封装”。我参与的某跨平台系统中,BluetoothLeService被重构为BleManager,支撑了12款不同协议的BLE外设接入。核心不是炫技,而是用三个具体动作把不确定性降到最低。
6.1 将BluetoothLeService封装为独立Module:解耦、可测、易替换
官方demo的BluetoothLeService耦合了LocalBroadcastManager和Activity生命周期。生产环境需解耦为纯逻辑层:
// BleManager.java - 独立Module的核心类 public class BleManager { private BluetoothAdapter mBluetoothAdapter; private BluetoothLeScanner mScanner; private final Map<String, BluetoothGatt> mGattMap = new HashMap<>(); // key: deviceAddress private final List<BleCallback> mCallbacks = new CopyOnWriteArrayList<>(); // 对外提供简洁API public void startScan(ScanCallback callback) { /* ... */ } public void connect(String address, GattCallback callback) { /* ... */ } public void writeCharacteristic(String address, UUID service, UUID chara, byte[] data) { /* ... */ } // 回调统一入口,避免广播序列化开销 public interface BleCallback { void onDeviceFound(BluetoothDevice device); void onConnected(String address); void onServicesDiscovered(String address, List<BluetoothGattService> services); void onCharacteristicChanged(String address, BluetoothGattCharacteristic characteristic); } }优势:
- 可测试:
BleManager无Android Framework依赖,单元测试可MockBluetoothAdapter;- 可替换:若未来需集成第三方BLE SDK(如Nordic nRF SDK),只需重写
BleManager实现,上层业务代码零修改;- 状态可控:
mGattMap按设备地址管理多个BluetoothGatt,避免close()误杀其他连接。
6.2 调试必做的三件事:我的BLE问题定位checklist
无论多复杂的BLE问题,我打开Logcat后第一件事就是执行这三项检查,95%的问题当场定位:
| 检查项 | 操作命令/步骤 | 为什么有效 |
|---|---|---|
| 1. 确认系统BLE状态 | adb shell dumpsys bluetooth_manager | 查看BluetoothManagerService状态、已配对设备列表、当前扫描/连接任务。若显示State: OFF,说明系统级蓝牙关闭,非App问题。 |
| 2. 抓取原始HCI日志 | adb shell setprop bluetooth.hci_log 1+ 重启蓝牙 + 复现问题 +adb pull /sdcard/btsnoop_hci.log | btsnoop_hci.log是蓝牙协议栈原始包,用Wireshark打开可看到每一个ACL包、ATT PDU(如Read Request、Handle Value Notification),精准定位是手机没发请求,还是设备没回包。 |
| 3. 检查GATT缓存一致性 | adb shell cmd bluetooth_manager clear-gatt-cache | 清除系统级GATT缓存。当设备固件升级后UUID变更,此命令比重启手机更快生效,且不影响其他App。 |
6.3 表格:BluetoothLeService关键方法与对应nRF Connect操作对照表
当你在nRF Connect里点“Read”却在App里收不到回调,对照此表能瞬间定位是App逻辑缺陷,还是设备端问题:
| nRF Connect 操作 | 对应App代码位置 | 关键检查点 | 常见失败原因 |
|---|---|---|---|
| Scan → Start Scan | BluetoothLeService.scanLeDevice(true) | mScanning是否为true;ScanCallback是否注册 | 未申请ACCESS_FINE_LOCATION(Android 10-)或BLUETOOTH_SCAN(Android 12+) |
| Connect → Connect | device.connectGatt(...) | onConnectionStateChange()中status是否为0 | 设备端未响应CONNECT_REQ,或手机端autoConnect=true导致回调丢失 |
| Service → Discover Services | mBluetoothGatt.discoverServices() | onServicesDiscovered()是否触发;getServices().size()是否>0 | 设备端GATT DB未初始化;手机端GATT缓存未清除 |
| Characteristic → Read | mBluetoothGatt.readCharacteristic(chara) | onCharacteristicRead()是否触发;characteristic.getValue()是否非null | Characteristic未声明PROPERTY_READ;设备端未实现onReadRequest() |
| Characteristic → Enable Notification | setCharacteristicNotification()+writeDescriptor() | onCharacteristicChanged()是否触发 | CCCD写入值错误(0x0100vs0x0200);Characteristic未声明PROPERTY_NOTIFY |
从那以后我每次接手新BLE项目,都强制走一遍这三件事:先dumpsys看系统状态,再btsnoop抓包定界,最后用nRF Connect对照表格验证。省下的调试时间,够我多喝三杯咖啡。希望帮到你。
本文还有配套的精品资源,点击获取