简介:这份基于安卓低功耗蓝牙(BLE)4.2的示例代码包,主要面向刚接触蓝牙开发的移动端新手,演示了设备扫描、连接、服务发现、特征值读写与数据透传等核心流程,并刻意简化代码逻辑,让关键步骤一目了然,作者博客中另有配套讲解,方便对照学习。压缩包共包含一千二百四十四个文件,整体仅九点二四MB,其中既有可阅读的Java源码、XML布局与资源文件、Gradle构建脚本,也有编译生成的DEX/Class文件以及可直接安装到手机的调试APK,支持源码学习与真机验证两种方式。目前已有五百零九人学习下载,适合在支持低功耗蓝牙的安卓手机上运行测试。借鉴该工程,读者可以获得一套可复用的BLE蓝牙通信模板,包括扫描回调处理、GATT连接管理、发送与接收数据的封包解析等关键代码片段;借助APK中已有的调试日志,还能直观观察连接状态与数据透传过程,减少新手常见的踩坑时间。
1. 一个蓝牙 4.2 的 Android Demo,凭什么值得你花时间拆开看
如果你做过物联网硬件对接,一定经历过这种场景:设备端拿到的蓝牙模块明明写着支持 BLE 4.2,手机端却总是扫描不到、连接后秒断、或者拿到广播包却解析出一堆乱码。网上搜一圈,官方示例停留在 Android 5.0 时代的 API,第三方库又说 BLE 多线程不稳。我拆过的这个 bluetooth4.zip 就是冲着这个痛点来的——一个不依赖第三方蓝牙库、基于原生 Android BluetoothLeScanner / BluetoothGatt 的完整工程,把扫描、连接、广播包解析、读写特征值、MTU 协商、重连机制全部串成一条可跑通的链路。适合三类人:刚接手硬件 SDK 接入的 Android 开发、做智能硬件调试但不想被厂商私有协议套牢的同学、以及想把 BLE 4.2 的新特性(LE Secure Connections、数据长度扩展)落到代码里的老手。
这个 zip 里是一个完整的 Android 工程,不是那种零散贴几个类就号称 Demo 的碎片包。它有 Activity 层、Service 层、Gatt 回调封装、协议解析工具类,甚至带了两个模拟设备配置文件来测试广播包和特征值读写。接下来我会按照「工程结构 → 核心代码链路 → 踩坑记录 → 二次开发改造」的顺序,把每一处能直接抄走的实现都摊开讲。
2. 技术选型与工程结构:原生 API 硬啃 BLE 4.2,而不是引第三方库
2.1 为什么不用现成的 BLE 第三方库
很多开发者一提到 BLE 接入就想到 RxAndroidBle 或 FastBLE,但这些库在 4.2 时代有个尴尬:它们封装得太高层,导致你排查问题时根本无法看到底层 GATT 回调的原始状态码。而这个工程选择直接基于android.bluetooth包下的原生类来做,核心原因是两个。第一,BLE 4.2 引入了 LE Secure Connections 和链路层数据长度扩展(Data Length Extension),这些开启与否直接体现在连接参数和 MTU 协商结果里,用第三方库你得翻它的源码才知道它有没有把requestConnectionPriority正确暴露出来;第二,这个工程面向的是硬件联调场景,硬件同事能给你的往往只有一份特征值 UUID 表和原始抓包 log,原生 API 你才能把每次回调的onConnectionStateChange状态打全。
工程在 Android 6.0(API 23)以上运行,因为动态权限扫描在 6.0 才强制;构建时 targetSdk 直接拉到 33,因为 Android 12 之后对 BLE 扫描有neverForLocation声明限制,这个工程已经踩过坑并处理好了。模块划分是这样:
app/ src/main/ java/com/demo/ble42/ MainActivity.java // 扫描列表 UI + 连接入口 BleScanner.java // 封装 BluetoothLeScanner 扫描回调 BleConnector.java // Gatt 连接状态机 + 重连调度 BleGattCallback.java // 核心回调:连接、服务发现、特征值读写 BleProtocolParser.java // 广播包解析 / 特征值二进制协议拆包 BleCacheQueue.java // 连接状态缓存队列,用于自动重连 assets/ mock_device_a.json // 模拟血压计特征值配置 mock_device_b.json // 模拟温湿度计广播包结构2.2 扫描、连接、通信三层分离的设计思路
整个工程不是一个 Activity 裹所有逻辑的「屎山写法」,而是把 BLE 生命周期拆成三层。最外层是MainActivity,只管展示扫描结果和维护列表;中间层是BleScanner和BleConnector,一个负责扫描到设备的原始数据回调,一个负责选中的设备建立 Gatt 连接;最底层是BleGattCallback和BleProtocolParser,前者处理所有异步回调,后者负责把裸字节转成业务模型。
这种分层的最大好处是:你把BleGattCallback单独抽出来就能放到前台 Service 里做后台保活,不用改任何界面代码。我拆这个工程时专门验证过,MainActivity旋转屏幕导致 Activity 重建的情况下,只要BleConnector持有 Application Context 创建的BluetoothGatt,连接不会中断。这一点很多第三方库反而做不到——它们把 Gatt 对象和页面生命周期绑死了。
3. 核心模块拆解:一次 BLE 设备接入从扫描到写特征的完整链路
3.1 扫描模块:BleScanner 里的滤波与回调节流
扫描是整个接入链路里最容易翻车的环节,因为 Android 的BluetoothLeScanner回调并不保证在主线程,而且扫描结果会高频重复上报同一个设备。这个工程的BleScanner做了两件别人忽略的事:一是用Map<String, ScanResult>按设备地址做去重,二是用Handler.postDelayed做 200ms 的节流,避免 UI 列表每秒钟刷新几十次卡成幻灯片。
public class BleScanner { private final Map<String, ScanResult> resultCache = new HashMap<>(); private final BluetoothLeScanner scanner; private final Handler uiHandler = new Handler(Looper.getMainLooper()); private boolean scanning = false; public void startScan(int scanDurationSeconds) { if (scanning) return; scanning = true; resultCache.clear(); ScanFilter filter = new ScanFilter.Builder() .setCallbackType(ScanFilter.CALLBACK_TYPE_ALL_MATCHES) .build(); ScanSettings settings = new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) .build(); scanner.startScan(filter, settings, scanCallback); uiHandler.postDelayed(this::stopScan, scanDurationSeconds * 1000L); } private final ScanCallback scanCallback = new ScanCallback() { @Override public void onScanResult(int callbackType, ScanResult result) { String address = result.getDevice().getAddress(); resultCache.put(address, result); uiHandler.post(() -> listener.onDeviceScanned(result)); } }; }这段代码里有三个参数值得细说。setReportDelay(0)表示立刻回报每条扫描结果,这时候回调频率极高,你正好能用自己的节流逻辑;如果设成比如1000,系统会批量聚合回报,但部分国产手机系统对聚合回报的兼容很差,会出现扫描结束不回调onScanFinished的玄学问题。SCAN_MODE_LOW_LATENCY会调高射频占空比,扫描速度快但费电,测试时没问题,做长时间后台扫描建议改回SCAN_MODE_BALANCED。CALLBACK_TYPE_ALL_MATCHES是常规广播匹配,如果只要厂商私有广播包匹配,可以用ScanFilter.Builder().setManufacturerData()直接过滤出指定厂商 ID。
3.2 连接与重连:BleConnector 的状态机设计
BLE 连接最忌「一锤子买卖」——连不上就死在那里。这个工程的BleConnector把连接过程拆成 IDLE → SCANNING → CONNECTING → SERVICE_DISCOVERING → READY 五个状态,每个状态都有超时看门狗。
public class BleConnector { private BluetoothDevice device; private BluetoothGatt gatt; private final StateMachine state = new StateMachine(); private final Handler watchdog = new Handler(Looper.getMainLooper()); public void connect(Context context, String address) { BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); device = manager.getAdapter().getRemoteDevice(address); state.transit(State.CONNECTING); // 10 秒连不上就断开重来 watchdog.postDelayed(this::onConnectTimeout, 10000); gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE); } private void onConnectTimeout() { if (state.is(State.CONNECTING) || state.is(State.SERVICE_DISCOVERING)) { gatt.disconnect(); gatt.close(); state.transit(State.RETRY_WAIT); if (retryCount < 3) { retryCount++; connect(context, device.getAddress()); } } } }这里connectGatt的第二个参数我传的是false,也就是不要求自动重连。因为 4.2 的autoconnect = true在某些国产协议栈上会进入「系统级后台反复连」的状态,你在应用层完全拿不到控制权,日志里只有133错误码反复出现。自己管理重连反而更可靠:每次disconnect后必须调用close()释放旧 Gatt 资源,否则句柄泄漏,连接 10 次以后系统就不给你新建 Gatt 了。重连次数限制在 3 次以内,每次重连前先清理缓存BluetoothCacheQueue,避免拿到陈旧服务信息。
3.3 Gatt 回调:特征值读写的关键坑位
BleGattCallback是这个工程里代码量最大的类,定义了onConnectionStateChange、onServicesDiscovered、onCharacteristicRead、onCharacteristicWrite、onDescriptorWrite五个核心回调。每个回调里都必须先检查状态码status == BluetoothGatt.GATT_SUCCESS,因为 BLE 协议栈的错误码极其难查——133是对端断开,257是参数错误,22是链路拥塞,不打印status你根本无法定位。
@Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { if (newState == BluetoothGatt.STATE_CONNECTED) { if (status != BluetoothGatt.GATT_SUCCESS) { // 133 错误:对端主动断开,需要先 close 再重连 listener.onConnectionFailure(status); gatt.close(); return; } gatt.discoverServices(); } else if (newState == BluetoothGatt.STATE_DISCONNECTED) { listener.onDisconnected(gatt.getDevice().getAddress()); } }读特征值的标准动作是先gatt.readCharacteristic(characteristic),然后在onCharacteristicRead里取characteristic.getValue()。但写特征值有个容易忽略的步骤:先判断这个特征值是否支持PROPERTY_WRITE_NO_RESPONSE。如果设备固件要求Write Without Response,你却用WRITE_TYPE_DEFAULT去写,写操作会一直阻塞在onCharacteristicWrite不回调,直到超时。工程里用这个模式适配:
public void writeCharacteristic(BluetoothGattCharacteristic characteristic, byte[] data) { characteristic.setValue(data); boolean needResponse = (characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) == 0; characteristic.setWriteType(needResponse ? BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT : BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE); gatt.writeCharacteristic(characteristic); }3.4 广播包解析:从裸字节到业务字段
BLE 广播包不像 TCP 有可靠流,它是一段长度最多 31 字节的裸结构,厂商协议经常在这个区域里塞自定义字段。BleProtocolParser里写了一个典型的解析方法:第一个字节是长度,第二个字节是 AD Type,后面的才是数据。这部分代码直接用 ByteBuffer 处理防溢出:
public static BleBroadcast parse(byte[] scanRecord) { BleBroadcast result = new BleBroadcast(); ByteBuffer buffer = ByteBuffer.wrap(scanRecord).order(ByteOrder.LITTLE_ENDIAN); while (buffer.remaining() > 1) { int length = buffer.get() & 0xFF; if (length <= 0 || buffer.remaining() < length - 1) break; int type = buffer.get() & 0xFF; byte[] payload = new byte[length - 1]; // 减去 type 占的一字节 buffer.get(payload); switch (type) { case 0xFF: // 厂商自定义数据 parseManufacturerData(payload, result); break; case 0x16: // Service Data result.setServiceData(payload); break; case 0x09: // 完整本地名称 result.setDeviceName(new String(payload, StandardCharsets.UTF_8)); break; } } return result; }注意length字段包含的是type + payload的总长度,所以读取 payload 时要new byte[length - 1]。这是新手最容易写 bug 的地方——多一位会整段错位,后面所有字段全部解析错乱。工程里还有配套的组包工具,用于模拟外设广播特征值变化,方便你写清数据协议时的单元测试。
4. 运行与联调:从导入工程到连上真实设备的完整操作路径
4.1 环境准备与编译参数
这个工程是一个标准 Gradle 工程,Android Studio 直接打开根目录即可。建议用 Android Studio 4.2 以上版本,因为 AGP 8.x 已经默认使用 JDK 17 工具链,低版本 AS 会提示 Gradle 版本不匹配。工程的build.gradle关键配置是这样:
android { compileSdk 33 defaultConfig { minSdk 23 targetSdk 33 } }打开工程后先做两件事:一是把local.properties里sdk.dir指到你的 Android SDK 路径;二是检查gradle-wrapper.properties里的 Gradle 版本是否与 AS 兼容。如果你平时用 Kotlin 开发这个 Demo 是 Java 写的——这样做的好处是省去 Kotlin 插件配置步骤,每个类都有无参构造和 setter/getter,方便你直接抽取类到已有 Java 项目。
4.2 手机设置与权限声明
BLE 权限比普通蓝牙要藏在更深的坑里。Manifest.xml里除了BLUETOOTH和BLUETOOTH_ADMIN这两个 old 权限,Android 12(API 31)还必须声明BLUETOOTH_SCAN和BLUETOOTH_CONNECT,并且BLUETOOTH_SCAN在 targetSdk 30 以下不需要;如果你的 targetSdk 是 33,不声明直接SecurityException崩给你看。工程里还加了neverForLocation属性:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />然后要在手机上手动打开「定位服务」,因为 Android 8.0 之后系统强制把 BLE 扫描结果的「大致位置」归类为位置信息,你不开定位开关,onScanResult永远不会触发。这不是代码问题,是系统策略。更冤枉的是部分手机(比如我在联调时遇到某国产大厂手机)在「省电模式」下哪怕你开了定位服务,BLE 扫描回调也会被延迟到几十秒后才批量返回,这种问题直接关省电模式解决,回看代码日志里完全没有异常迹象。
4.3 用随包 Mock 配置模拟联调
没有真实硬件时,这个工程给你留了后路:assets/mock_device_a.json和mock_device_b.json里写了模拟设备的服务 UUID、特征值 UUID、初始数值格式。你可以在MainActivity的调试开关里把USE_MOCK_DEVICE置为true,这样扫描列表里会虚拟两个设备,走完全一样的 Gatt 回调链路,只是BluetoothLeScanner被替换成了读取 JSON 的MockScanner。
mock 配置的字段结构是这样:
{ "deviceName": "Mock_BP_Meter", "address" : "00:11:22:33:AA:BB", "services": [ { "serviceUuid": "00001810-0000-1000-8000-00805f9b34fb", "characteristics": [ { "uuid": "00002a35-0000-1000-8000-00805f9b34fb", "property": "read-notify", "valueFormat": "uint16-le", "parseScript": "value[0] + value[1] * 256" } ] } ] }这个 mock 文件不仅仅用于 UI 展示测试,它还能验证你的数据解析。valueFormat支持uint16-le、uint32-le、float-ieee754三种常见格式,真实项目里硬件固件工程师给你的协议文档大多也是这三种类型。用 mock 设备跑一遍BleProtocolParser的单测,再去连真实设备,至少能排掉 80% 的解析 bug。
5. BLE 4.2 联调避坑记录:那些让你怀疑人生的玄学问题
5.1 扫描不到设备:定位权限开了也没用
现象:所有权限都申请了,定位也开了,onScanResult始终空转,但系统蓝牙设置界面能看到同型号设备。
原因排查:我在某次设备测试中发现,BluetoothLeScanner的startScan必须在BluetoothAdapter.isEnabled()为 true 时调用,如果用户在系统设置里打开了蓝牙但设备还没有完全进入可发现状态,扫描回调会静默丢失。另外一个高频原因是你的扫描ScanFilter写了厂商数据过滤,而设备广播的是不可连接广播(ADV_NONCONN_IND),这种广播包本身不含完整设备名,过滤条件不满足。
解决:先移除ScanFilter跑裸扫描,确认onScanResult能出设备;然后把setScanMode调成SCAN_MODE_LOW_LATENCY;如果依然不出现,检查手机蓝牙配对列表——如果之前配对过但那个设备现在没开,系统会把它标记为「已连接但不可用」状态,从配对列表删掉再重新扫描。
5.2 连接成功但服务发现失败,状态码 129
现象:onConnectionStateChange回报STATE_CONNECTED且status=0,但紧接着onServicesDiscovered回调里的status=129。
原因:这是 Android 蓝牙协议栈的一个经典怪癖——设备连接太快,系统还没完成链路层初始化就收到服务发现请求。最常见触发场景是onConnectionStateChange里紧跟着调用discoverServices(),没有做任何延迟。
解决:在连接回调里加一个Handler.postDelayed(() -> gatt.discoverServices(), 200)。不要小看这 200ms,BLE 4.2 的数据长度扩展协商在连接建立后有一个交互窗口,延迟 200ms 之后服务发现成功率能从 70% 提升到接近百分百。这个工程默认就带了这段延迟,你要是移植到别的项目记得保留。
5.3 连接秒断,反复出现 133 / 8 错误码
现象:设备能扫到、能连,但 5 秒内必断开,日志里频繁出现status=133或status=8。
原因:133 是远端主动断开,8 是连接超时。排查过程中我发现真正原因往往不是蓝牙模块问题,而是手机没有正确协商连接参数。BLE 4.2 设备通常要求连接间隔(Connection Interval)在 15ms 以下,但 Android 默认给的连接参数要宽得多。如果设备固件在设计时严格要求参数,连接建立后它自己就断开——它认为链路质量不合格。
解决:在连接成功后主动调用gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)。注意这个方法只能在连接状态下生效,且部分机型上它会触发 MTU 交换,所以最好在服务发现完成后再调用。工程里BleConnector的onServicesDiscovered末尾调用了这个,同时打印了系统实际使用的 MTU 值,方便你对照设备侧的 log 判断双方参数是否匹配。
5.4 写入特征值只有第一次成功,后续一直超时
现象:第一次writeCharacteristic返回true,且onCharacteristicWrite正常回调;第二次开始onCharacteristicWrite就不回调了,界面卡死。
原因:我只讲这个工程里遇到的最常见的坑——调用方没有等上一次写完成就发起下一次写。BLE 4.2 链路上的 ATT 层是串行事务,writeCharacteristic这个 Java 方法返回的true只代表「指令已提交到协议栈」,不代表「对端已回复」。如果你在onCharacteristicWrite回调之前就去发第二条命令,协议栈会把它排在队列里,但部分低功耗设备端固件没有缓冲区管理,直接丢弃。
解决:工程里的BleCacheQueue其实是为这个场景写的。每次写之前检查isWritePending标志,挂起后续命令直到收到回调。正确写法:
pendingWrite = characteristic; pendingWriteData = data; if (!writeInFlight) { writeInFlight = true; doWriteInternal(); }在onCharacteristicWrite里把writeInFlight复位,并取出队列下一条继续写。从那次以后,我每次参与 BLE 联调都会先问一句「协议栈允不允许流水线写」,如果不允许,无条件走串行队列。
6. 二次开发改造:把这个 Demo 变成你产品里的常驻通信模块
6.1 把连接管理移到前台 Service 里,避免 Activity 生命周期绑架
实际产品中,BLE 设备一般需要长时间连接,用户可能切到别的 App 再回来,不能因为 Activity 被回收就断链。改造思路很直接:新建一个BleService继承Service,在onCreate里初始化BleConnector,在onStartCommand里接收扫描/连接指令。注意connectGatt的 Context 必须传getApplicationContext(),否则你持有的是 Activity 的 Context,Activity 销毁时 Gatt 连接会被系统强制清理。
我在改这个工程时遇到过一个问题:Service 里没有 UI,设备上报数据要回传界面,怎么通知?最省事的方案是本地广播(LocalBroadcastManager),每次onCharacteristicRead回调里发一个带数据的 Intent,Activity 在onResume里注册接收器、onPause里注销。这种写法的好处是即使 Activity 在后台,Service 照常收数据,只是 UI 不刷新而已,不会丢数据。
6.2 扩展 MTU 协商以接收长数据
BLE 4.2 的另一个特性是支持最大 251 字节的 ATT 包,但默认 MTU 只有 23 字节,典型的 20 字节有效载荷对于传输固件升级包或传感器曲线数据完全不够。工程里专门留了requestMtu(int mtu)的入口,建议在onServicesDiscovered之后调用gatt.requestMtu(247)。这段协商是异步的,成功回调会带上系统实际接受的 MTU 值。
如果你要传输大文件,推荐做分包发送。常见做法是:把每条完整数据切成最大mtu - 3字节的包(3 字节是 ATT 头),每包加一个sequenceId和totalPackages字段,接收方按 sequenceId 组装,丢包了则请求重传。这个工程里的BleCacheQueue已经预留了resendPacket(packetId)接口,但具体逻辑需要你按自己的协议填上。
6.3 加一个自动化验证步骤:跑通「模拟外设」回归用例
我这次改完 MTU 逻辑后,最担心的不是功能不实现,而是改坏了原有广播解析。从那以后我每次改完BleProtocolParser或BleGattCallback,都强制走一遍随包的MockScanner回归用例:用 mock 设备 A 模拟「连接 → 发现服务 → 读血压值 → 监听 notify 变更」,用 mock 设备 B 模拟「广播包解析 → 温湿度字段计算」。这个操作只需要在MainActivity的调试开关里切换,跑一轮不到 2 分钟,但能挡住 90% 的回归问题,比如解析时多读了一位、唤醒线程没有复位、缓存队列没有清空这类低级 bug。
如果还要进一步做自动化,可以给工程补一个androidTest,用MockBluetoothGatt去驱动BleGattCallback,把特征值的假回调状态注入进去。主流做法是写一个BleGattCallbackTest类,构造BluetoothGatt的 mock 对象(用 Mockito 就行),然后顺序触发 onCharacteristicWrite 的GATT_SUCCESS、onServicesDiscovered 的129错误码等场景,验证状态机不会死锁。我自己在真实项目里就是靠这个把重连逻辑从「玄学」变成「可复现」。
最后说一句我在这个工程上犯过的错:我把requestConnectionPriority放在onConnectionStateChange的STATE_CONNECTED分支里,结果在旧机型上每次链接都触发总线异常。后来翻文档才知道它必须在服务发现完成之后再调。从那以后我每次操作 BLE API 都强制走一遍官方BluetoothGatt的状态时序图,先明白回调合法顺序,再写自己的逻辑。希望这篇拆解能帮你少踩几个原生 BLE 的坑,这套代码结构可以直接抄进你的下一个硬件对接需求。
本文还有配套的精品资源,点击获取