news 2026/9/20 9:47:07

Android BLE源码与Lightblue调试:GATT链路全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android BLE源码与Lightblue调试:GATT链路全解析

简介:这是一套面向 Android 开发者的 BLE 蓝牙入门与调试实例源码包,重点解决低功耗蓝牙连接、扫描、GATT 通信等常见开发问题,适合正在做蓝牙外设联调或学习 Beacon 应用的初中级开发者。资源共 171 个文件,压缩后仅 2.47MB,其中 Java 源码与 class 文件约占一半以上,配合 XML 布局与配置文件、APK 可运行包、JAR 依赖库等,既有可编译的 Demo 工程,也能直接安装辅助 App 做真机调试。包内包含 Bluetooth4_3 和 BLEDemo 两个演示工程,后者在功能覆盖上更完整;附带 Android 版 Lightblue 工具 APK,可方便地查看服务和特征值,不过该工具在 Android 5.0 以上机型无法运行,源码也未提供,使用时需注意系统版本。从编译类与工程结构看,Demo 涵盖设备扫描、名称解析、外设模式、设备控制等模块,便于按功能逐个阅读。已有 873 人学习下载,对于刚接触 BLE 开发的读者来说,是快速对照代码、理解 Android 蓝牙接口调用流程的实用参考。

1. Lightblue 这类工具,才是你拿到 Android BLE 源码后的第一道坎

做 Android BLE 开发,连接不稳定、扫描不到设备、数据写入失败,80% 是 GATT 协议没吃透,剩下的 20% 是被设备端的广播和特征值坑了。我拆过 Bluedroid 协议栈,也调过 Nordic 的 SDK,最强的调试方式,永远是先把数据链路看清楚。这套 Android BLE 实例源码包包含 Bluetooth4_3 和 BLEDemo 两个可编译工程,外加一个 Android 版 Lightblue 的 APK 辅助工具。它的价值不在于代码量多少,而在于把外设、主机、特征值、通知回调这几条链路都摆在了明面上。适合刚接手 BLE 项目的人,也适合做 NFC 互转、蓝牙测距这些工程的人对照着看。

2. 从 class 文件反推工程:BLE GATT 链路与项目结构

2.1 BLE 协议基线:BLE GAP 与 BLE GATT 的角色划分

BLE 和经典蓝牙协议最大的区别在于,它不需要走 SDP 配对流程,连接建立之后直接通过 GATT 服务去读写数据。GAP 层负责广播与扫描,GATT 层负责把数据按服务、特征值、描述符组织成一张标准的属性表。这张表才是 Android 端代码逻辑的根。蓝牙源码里的很多报错,看起来是连接失败,实际上都是属性表里某个 characteristic 的权限位没有配置对。

拿到这个源码包的时候,你会发现里面没有完整 gradle 工程,只有 resources.ap_、jarlist.cache 和若干编译后的 class 文件,像 BleWrapper、BleNamesResolver、PeripheralActivity、DeviceControlActivity、HRDemoActivity。这些后缀是编译产物,但可以从类名反推它在原工程里承担的角色。常见做法是用 jadx 或者直接写一个 class 文件头读取工具来看方法签名,不一定非要反编译成 Java。在 Android Studio 里打开这个包里的 class 文件无法直接运行,需要新建工程把源码按 gradle 结构补全,这里正好能对照下面这张职责表:

类名职责对应
BleWrapper封装 BluetoothLeScanner + BluetoothGatt 的组合操作,是外设操作的公共管家
BleNamesResolver把 16 位 UUID 映射为 SIG 定义的标准服务名称,比如 0x180D 对应 Heart Rate
PeripheralActivity外设端示例,模拟 BLE 外设广播一条通知
DeviceControlActivity主机端操作页,负责连接、发现服务、读写特征值
HRDemoActivity读取心率计设备的特征值并解出心率

BLE 服务之间的数据对象叫特征值,特征值有属性,比如 READ、WRITE、NOTIFY、INDICATE。实际开发中最容易漏掉的是:notify 特征值必须先在描述符 0x2902 写入0x0001才会主动上抛数据。这个坑在包里的 BLEDemo 里正好是有体现的,后面会单独讲。

2.2 Android 蓝牙权限与初始化配置

这个包是 Android 4.3 时代的产物,那时只需要申请 BLUETOOTH 和 BLUETOOTH_ADMIN 两个权限。放到现在,Android 6.0 以上动态申请地理位置权限,Android 12 以上还得新增 BLUETOOTH_SCAN、BLUETOOTH_CONNECT 这几个运行时权限。如果直接把旧工程的 Manifest 拷贝过来,你在主流手机上大概率连扫描回调都不触发。

常见做法是在 Manifest 中同时保留旧权限和新动态权限声明,这样既能兼容旧设备,也能在 Android 12 上通过运行时检查:

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

BLUETOOTH_SCAN里的neverForLocation是从 Android 12 起才有的标记,它声明扫描不会用来推导地理位置,可以避免部分应用商店强制要求位置权限。旧工程里没有这个标记,所以需要手动补。运行时动态申请代码里的requestPermissions也应该改成多权限组检查,不能只依赖 Android 系统自动弹窗。

初始化时还有一个容易忽略的操作:先把 BluetoothAdapter 取出来,然后立刻检查isEnabled()。很多设备上的 BLE 扫描异常,是因为系统蓝牙广播执行了重启,而适配器句柄还是旧实例。在BluetoothLeServiceonCreate里重新获取一次BluetoothManager.getAdapter(),比缓存静态引用更可靠。

2.3 反推源码包里的核心 BLE 调用链

这个包的 class 文件其实已经能看出原工程的调用顺序。DeviceControlActivity会持有BleWrapper的一个实例,BleWrapper内部通过BluetoothGattCallback对外暴露连接状态和数据事件。HRDemoActivity则直接订阅了一个固定的通知特征值,在onCharacteristicChanged里做解析。

基本调用链是:startScan扫描设备,onLeScan回调返回设备名、mac、信号强度,connectGatt(context, false, callback)建立 GATT 连接,discoverServices发现服务,setCharacteristicNotification开启通知,最后在onCharacteristicChanged接收设备主动上传的数据。这里要重点强调 connectGatt 的第二个参数autoConnect。很多例子都写成 true,结果外设在广播,手机却一直处于 pending 状态。因为 autoConnect 走的是白名单连接,系统底层默认等设备进入可连接状态才响应;对于电池供电的低功耗传感器,建议显式传 false,让设备扫描到之后立刻发起连接。

连接状态码也需要单独留意。BluetoothGattonConnectionStateChange回调里,status等于 0 才是成功,常见状态码包括 133 表示连接意外断开,22 表示设备不支持当前服务发现,257 表示 GATT 内部错误。旧版本代码经常只判断newState,忽略status,结果设备已经断开半天了,界面还显示已连接。源码包里的 BleWrapper 至少有一层status == BluetoothGatt.GATT_SUCCESS的判断,这个习惯值得保留。

3. 复现一个心率计 Demo:从扫描到特征值订阅的完整实现

3.1 扫描过滤与设备名解析(BleNamesResolver)

BleNamesResolver这个类做的是一张静态映射表。BLE 服务下的 UUID 大多不是完整的 128 位,像心率服务就是 0x180D,设备信息服务是 0x180A。Resolver 的存在价值是,你拿到一个 UUID 后能直接识别它是标准服务还是厂商私有服务,不用每次查 SIG 官网。对比看很多 Android 蓝牙实训例子,它们往往把 UUID 硬编码在 Activity 里,时间一长就不知道这一串数字到底对应什么服务。

扫描阶段最常见的过滤条件是设备名。比如要连的心率计是HC-08,可以用BluetoothAdapter.LeScanCallback里的device.getName()来判断。Android 5.0 以上推荐用LeScanCallback,但旧接口没有过滤参数,需要手动处理:

private BluetoothAdapter.LeScanCallback callback = new BluetoothAdapter.LeScanCallback() { @Override public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) { String name = device.getName(); if (name != null && name.startsWith("HC-")) { if (rssi > -80) { adapter.stopLeScan(callback); connect(device); } } } };

这段代码里的rssi是接收信号强度,单位是 dBm,范围一般在 -40 到 -100。判断rssi > -80是避免同一个房间里出现多台同类型设备时,连到一台距离太远的。scanRecord里有厂商私有字段,很多心率计会把设备序列号放在这里,如果你要在多台同款设备里精确区分,就得解析 service 广告数据。Android 5.0 之后系统给了官方扫描接口BluetoothLeScanner.startScan,支持ScanFilterScanSettings,可以设置扫描模式(低功耗、平衡、低延迟)。但这个源码包基于 Android 4.3,所以如果直接移植到新工程,最好用旧接口或者自己包一层过滤,否则扫描行为会很不稳定。

3.2 建立 BLE GATT 连接与 MTU 协商

GATT 连接的成功标志不是BluetoothGatt.STATE_CONNECTED,而是onConnectionStateChange返回成功,并且在回调里调用discoverServices()后收到了设备端的服务列表。这两个事件之间的时间差通常在两秒内,如果超过三秒,就要怀疑设备端是被之前的主机占用,或者连接参数没有满足设备的容忍范围。

连接参数这个点特别值得一提。有些设备把最小连接间隔设置得很短,例如 7.5ms,而 Android 的默认连接间隔会被系统限制在较宽松的范围。发出的连接请求不匹配,就会导致连接失败。处理方式是用requestConnectionPriority显式调整优先级。MTU 分片也是这个环节的重点。默认 MTU 是 23 字节,去掉 3 字节头部后有效载荷只有 20 字节。如果你用 Write Characteristic 去写一串 40 字节的命令,一发出就会被系统分片。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { gatt.requestMtu(185); }

requestMtu是异步的,结果在onMtuChanged里回调。协商后的 MTU 决定单次读写的数据长度上限,心率监测只需要很少的字节,但如果你把它改成 BLE 固件升级,就得按 185 或 244 去规划分包逻辑。常见做法是上位机读到的mtu != 0时就缓存这个值,之后的写操作都先按mtu - 3切分。关于特征值属性,服务和特征值上都存在权限位,下表是调试源码包四个类时最常碰到的属性组合:

属性标志含义典型应用场景
READ主机可以直接读取特征值读取设备电量 0x2A19
WRITE主机写入,不需要应答发送 AT 指令
WRITE_NO_RESPONSE写无响应,适合连续流OTA 分包写入
NOTIFY设备主动上报,无需先读实时心率测量 0x2A37

如果设备返回状态码 3 或 5,通常是在往没有 WRITE 属性的特征值上执行了写入操作。源码包里的DeviceControlActivity在写特征值前会先读一遍getProperties(),这个检查当时看起来多余,到了 Android 5.0 以上内核版本反而成为避免系统 panic 的关键屏障。

3.3 解析 HR 心率特征值数据

HRDemoActivity 的核心在onCharacteristicChanged回调里。心率服务 0x180D 下的测量特征值 0x2A37 本身是 NOTIFY 属性,所以设备主动推数据,而不是我们在 read 它。收到数据后的解析规则如下:第一个字节是 flags,最低位表示心率值格式,0 为 8 位无符号,1 为 16 位无符号;其余位还标明了是否存在能量消耗、RR 区间等信息。一个完整的解析逻辑可以浓缩成下面这段:

@Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { byte[] data = characteristic.getValue(); if (data == null || data.length < 2) return; int flags = data[0] & 0x01; int heartRate; if (flags == 1) { heartRate = (data[1] & 0xff) | ((data[2] & 0xff) << 8); } else { heartRate = data[1] & 0xff; } int energyExpended = 0; int offset = (flags == 1) ? 3 : 2; if ((data[0] & 0x08) != 0) { energyExpended = (data[offset] & 0xff) | ((data[offset + 1] & 0xff) << 8); } root.post(() -> display(heartRate, energyExpended)); }

flags不只需要读最低位。第 4 位(0x08)表示后面是否跟着能量消耗字段,这个字段是 16 位,需要分别读取低位和高位。很多心率计还会在每次测量后附带一个 RR 区间字段,解析的时候如果跳过这段,后面对齐就会错位。每次回调传上来的字节数不固定,不要写成定长数组解析。

解析完成后的 UI 更新要切到主线程,因为onCharacteristicChanged是在 binder 线程回调的。直接在这个线程里刷新 TextView 会偶现卡顿,尤其在高频运动模式下,每秒四次以上的回调会让主线程可感知地掉帧。root.post是常见做法,用Handler也行。

4. Android 版 Lightblue 的辅助用法与真机验证

4.1 Lightblue 的功能布局与操作步骤

Android 版 Lightblue 是 iOS 上那款同名 BLE 调试器的移植版,包里只有 APK,没有源码。它用起来比 Android 系统的开发者选项直观很多,打开 App 能看到三栏:扫描列表、服务列表、特征值控制台。对目标设备做只读检测时,Lightblue 是最好的搭档,因为它的 UI 直接显示 UUID 和属性,不用写一段扫描代码。

我用它排查一个 USB 转 BLE 模块的读写问题时,操作顺序是这样的:

  1. 先点扫描,找到名为 cc-2541 的设备,配对连接。
  2. 进入服务列表后,找FFE0服务下的FFE1特征值。
  3. 再点开特征值详情,尝试写入AT+VER命令。
  4. 写入栏能直接切换十六进制和 ASCII,回车即发送,设备返回的数据会出现在通知窗口里。

遇到某些私有设备不支持广播,Lightblue 的连续扫描模式就很有用。它会在失去连接后自动重新扫描,这是源码 demo 没有提供的功能。用这个循环扫描,能很敏感地识别到传感器连接断开后重新广播的时机,方便判断设备端的休眠策略。

4.2 抓 BLE 日志:logcat 与 hci log

确认问题出在协议层还是 Android 系统层,最简单的方法是开 hci log。在开发者选项里打开“开启蓝牙数据包日志”之后,蓝牙协议栈会把 HCI 层的数据包写到 log 文件里,再用adb pull拉出来查看。连接不稳定的大部分原因都能在 log 里看到是LE Connection Complete失败还是L2CAP层重传。

adb logcat -s BluetoothGatt -v time

这条命令截取BluetoothGatt标签的日志。实际开发时,每次 GATT 操作都会打对应状态码。133表示连接意外断开,22表示设备不支持当前服务发现,257表示 GATT 内部错误。把这些状态码和 Lightblue 的扫描结果对比,能定位出是设备端没有广播完整,还是 Android 端的缓存服务列表过期。

一个取巧技巧是:Lightblue 在 Android 5.0 以上的真机上经常闪退,这时可以去系统蓝牙设置里断开设备,再回到应用。它崩溃往往不是 BLE 栈的问题,而是应用内某个高版本 Android 不再兼容的组件触发了异常。如果遇到频繁崩溃,优先用 logcat 抓崩溃点:

adb logcat -b crash | grep -i lightblue

如果崩溃信息显示是SIGSEGVClassNotFoundException,多半是 APK 的 targetSdkVersion 太高或太低。没有源码的情况下,这种黑盒分析办法反而效率最高。

4.3 三个工具的取舍与适用边界

包里三个工具更适合放在一起对比:Bluetooth4_3 是最原始的外设读取器,BLEDemo 多了特征值读写和通知处理,Android_Lightblue.apk 则更接近一台独立调试仪。如果你只是验证心率和电量服务,BLEDemo 完全可以替代 Lightblue;如果你面对的是自定义私有协议,就用 Lightblue 快速确认特征值属性和写入格式,再回到源码里做二次开发。这三个工具在 BLE 蓝牙开发中的角色,正好覆盖了扫描、服务发现、数据交互三个层次。

5. 兼容性边界:Android 5.0 以上崩溃处理与 BLE 保活技巧

5.1 使用 aapt 检查 APK 元数据

Android 版 Lightblue 在 Android 5.0 以上运行不起来,第一步不是反编译,而是用 Android SDK 自带的 aapt 命令看它的配置:

aapt dump badging Android_Lightblue.apk | grep -E "sdkVersion|targetSdkVersion"

输出结果里如果sdkVersion是 21 以下,说明这个 APK 当初是按老版本系统编译的,系统应用兼容层可能在启动时做了限制。此时没有源码,只能选择保留一台 Android 5.0 以下设备跑辅助工具,或者尝试用 dumpsys 抓启动异常。不过即使 APK 不启动,BLE 的核心链路也可以依靠源码中的两个 demo 自行验证。

5.2 动态权限与旧代码改造

把 BLEDemo 移植到新工程时会发现,Manifest.permission.ACCESS_FINE_LOCATION在 Android 12 上必须配合位置开关才能扫描。需要先检查Settings.Secure.LOCATION_MODE,再用运行时权限请求。旧代码的路子是把权限声明全放在 Manifest 里,但 Android 6.0 之后运行时权限必须显式弹窗请求,光声明权限是没用的:

if (checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.ACCESS_FINE_LOCATION }, 0); }

这一层做完,还要记得在onResume里检查蓝牙是否打开,因为 Android 12 以上用户可能从通知栏切走了蓝牙开关,而旧的BluetoothAdapter.isEnabled()返回值不一定即时更新。

5.3 BLE 心跳保活与自动重连

对于心率监测这类长连接场景,就算协议层没有问题,设备端也会因为省电策略每秒或每两秒挂起连接。推荐在应用侧做两级保活:第一级,每 10 秒写一次无人值守的心跳特征值;第二级,在onConnectionStateChange里判断断开状态,延迟 3 秒重连:

@Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { if (newState == BluetoothProfile.STATE_DISCONNECTED) { handler.postDelayed(() -> { if (gatt != null) { gatt.connect(); } }, 3 * 1000); } }

connectconnectGatt的区别是,connect会复用已有 gatt 实例,并且不会重新发起服务发现,适合快速恢复。但也要注意,如果设备端重启过,reuse 的 gatt 拿到的连接句柄是旧句柄,必须close()后重新走connectGatt。我一般会加一个计数器,连续重连三次都失败就直接重置为初始扫描流程,避免死循环。每次gatt.close()之后,旧实例不能再使用,否则会反复触发 133 状态码。调试这类外设时,务必在抓取 hci log 的前提下验证重连逻辑,否则 BLE 连接心跳保活很容易掩盖真正的断链原因。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 9:47:00

MIDI资源整理与Linux编辑转简谱全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 9:46:25

Win10电脑没声音怎么办?从驱动重装到深度排查全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 9:43:57

AI招聘智能体实战:基于LangGraph的简历筛选与面试协同全流程

招聘这个活儿&#xff0c;看起来是人跟人打交道&#xff0c;实际上大量时间都耗在“人跟文档”和“人跟流程”上。我在帮几家中型公司做招聘效率优化时发现&#xff0c;HR每天至少有一半精力花在三件事上&#xff1a;筛那些明显不匹配的简历、回复候选人“在吗/看看岗位”、协调…

作者头像 李华
网站建设 2026/9/20 9:40:44

固件下载原理与实战:从JTAG失效到安全OTA全链路解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 9:40:44

ECharts监控大屏源码:对接Prometheus/Zabbix的可视化底座

简介&#xff1a;本资源是一套基于ECharts实现的监控平台数据可视化大屏完整源码&#xff0c;面向前端开发者、数据可视化工程师及运维监控系统建设者&#xff0c;解决实时数据动态呈现、多维度业务指标聚合分析与决策看板快速搭建等核心问题。压缩包共367个文件&#xff0c;涵…

作者头像 李华
网站建设 2026/9/20 9:40:41

【Java SE】基于多态与接口实现图书管理系统

文章目录一、系统整体设计&#xff1a;分层与职责划分系统模块结构二、核心模块详解&#xff1a;从数据到功能1. Book包&#xff1a;数据封装1.1 Book类&#xff1a;图书实体1.2 BookList类&#xff1a;书架管理2. User包&#xff1a;多态的核心体现2.1 User抽象类&#xff1a;…

作者头像 李华