简介:面向Android BLE开发新手的蓝牙4.2数据透传参考工程,覆盖设备扫描、连接、收发数据全流程,代码刻意简化,减少多余封装,核心逻辑集中,适合刚接触BLE的新手快速理解并修改。压缩包共1244个文件,除31个Java源码、67个XML布局与配置、123个JSON数据、16张PNG图片外,还包含编译过程生成的195个dex、193个class及Gradle相关构建文件,并附有一份可直接安装的app-debug.apk,整体体积仅9.24MB。已有509人学习。解压后可直接导入Android Studio运行,或直接安装APK体验。工程内源码与作者博文中的Android BLE蓝牙4.2数据透传操作对应,可对照学习GATT连接、特征值读写、数据回调等关键实现。对于想快速搭建BLE透传功能或排查常见连接问题的开发者,这是一份轻量而完整的参考样例。
1. 一个能跑起来的 Android BLE 蓝牙 4.2 工程:从设备扫描到数据上行的完整链路
我最早拆这个 bluetooth4.zip,是因为要调一批蓝牙 4.2 的设备,官方给的 Demo 只能连上却收不到数据,日志打出来全是空包。拆开这个压缩包才发现,它把扫描、连接、MTU 协商、分包收发做成了几个独立的类,整个链路在onCharacteristicChanged里一条龙拿到完整数据。这不是一个只演示“如何连接”的玩具工程,而是把 Android BLE 蓝牙 4.2 开发里的常见坑全部踩平之后留下的模板。如果你在做手环、体脂秤、血压计这类低功耗蓝牙外设的 App,或者想找一个不是停留在概念、真能改完就能上项目的通信底座,这个资源值得仔细看。
2. 工程目录与依赖:拆开 bluetooth4.zip 后的真实结构
2.1 目录结构与核心类职责
解压之后是一个标准 Android Studio 工程,没有复杂的多模块架构,但分层很清晰。最核心的代码集中在app/src/main/java下,总共就五个 Java 文件:
bluetooth4/ └── app/ ├── src/main/ │ ├── java/com/example/ble4/ │ │ ├── BleManager.java # 连接、发现服务、读写、通知 │ │ ├── BleScanner.java # 扫描与过滤 │ │ ├── BlePacket.java # 分包与粘包处理 │ │ ├── DeviceAdapter.java # 设备列表适配器 │ │ └── MainActivity.java # 入口,串联扫描与连接 │ ├── res/ │ └── AndroidManifest.xml └── build.gradleBleManager是绝对核心,所有 GATT 回调都在这里处理,包括连接状态、MTU 协商结果、服务发现完成、特征值变化。BleScanner单独拿出来是因为扫描回调的生命周期跟连接回调完全不同,扫描线程要自己做超时控制。BlePacket则负责把超过单包能力的业务数据拆成 N 个 BLE 包,并在接收端把它们重新拼起来。
这种拆法最大的好处是,你在 MainActivity 里看不到一堆 BLE 回调嵌套,UI 代码只做设备展示和按键逻辑,所有蓝牙操作都收敛到BleManager。如果后续要换蓝牙协议栈或者加日志,只改一个类就行。当时我把这个工程导进 Android Studio,没有做任何额外配置,Gradle Sync 通过后就能直接编译,这是它作为“可运行模板”最舒服的地方。
2.2 权限与依赖配置:Android 12 和旧版本的权限差异
这个工程在AndroidManifest.xml里声明了传统蓝牙权限,但真正跑起来会发现,Android 6.0 之后光是清单声明不够,Android 12 之后又有新的一套运行时权限。我建议你直接按下面这套来配,兼容 4.2 设备的同时也覆盖目标 SDK 31+ 的手机:
<uses-permission android:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>前两个是老权限,Android 12 上如果 targetSdk 是 30 以下还能用,但新机器上只提示定位权限会装不上。BLUETOOTH_SCAN加了neverForLocation标记,等于告诉系统你不会拿 RSSI 做定位,这样在部分手机上能少掉一次定位权限弹窗。但注意,小米、华为等定制 ROM 上,即使有neverForLocation,扫描回调里拿到设备后如果代码里调用了getRssi(),有些系统还是会要求位置权限,所以ACCESS_FINE_LOCATION建议保留。
工程里的build.gradle依赖很简单,没有引入第三方 BLE 库:
dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.recyclerview:recyclerview:1.3.1' }这里没有用 RxAndroidBle 这类封装库,而是基于原生 API 硬写,反而对我理解整个链路帮助更大。如果你不介意额外依赖,RxAndroidBle 可以省掉很多状态判断,但那个库的学习曲线也很陡。对于 BLE 4.2 这种协议栈相对可靠的版本,原生 API 完全够用。
权限这东西,一次没踩够根本不会记住。我第一版偷懒只加了BLUETOOTH和ADMIN,结果在 Android 10 的机器上扫描回调永远不触发。后来把动态权限申请逻辑补上,把BLUETOOTH_SCAN也加了进去,才在 Android 14 的测试机上跑通。你接手这个工程后,第一步不是改业务代码,而是把所有权限申请流程确认一遍,尤其是动态权限回调里要处理用户点击“仅本次允许”这种分支。
3. 核心模块实战:扫描、连接、MTU 协商与透传实现
3.1 扫描器:设备过滤与 ScanCallback 回调
打开BleScanner.java,里面真实有用的方法其实就两个:startScan()和stopScan()。工程里的扫描策略不是暴力全扫,而是通过ScanFilter过滤掉无关设备:
public void startScan(ScanCallback callback) { BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); BluetoothLeScanner scanner = adapter.getBluetoothLeScanner(); if (scanner == null) { return; } ScanFilter filter = new ScanFilter.Builder() .setDeviceName("BLE4.2_") .build(); ScanSettings settings = new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build(); scanner.startScan(Arrays.asList(filter), settings, callback); }这段代码里有几个点需要注意。setDeviceName是精确匹配广播包里的设备名,如果你的外设名称不是固定的前缀,这里会漏掉设备,我后面在避坑章节会专门展开。SCAN_MODE_LOW_LATENCY表示尽可能快地返回结果,适合需要马上连设备的调试场景,但代价是功耗高、蓝牙堆栈会频繁回调。如果是长时间扫描,建议改成SCAN_MODE_LOW_POWER,否则手机蓝牙在 5 分钟内会提示耗电异常。
扫描结果回调里,工程的做法是把BluetoothDevice直接丢给MainActivity刷新列表,同时记录下它的getType():
public void onScanResult(int callbackType, ScanResult result) { BluetoothDevice device = result.getDevice(); if (device.getType() == BluetoothDevice.DEVICE_TYPE_LE) { uiHandler.post(() -> adapter.addDevice(device)); } }这里加一个DEVICE_TYPE_LE判断很有必要,因为 4.2 时代有些手机是双模蓝牙,广播里可能混着 BR/EDR 经典设备。只接收 LE 设备可以避免用户点击之后连接失败。不过也要注意,部分外设广播时类型返回的是DEVICE_TYPE_DUAL,如果你只认DEVICE_TYPE_LE一样会漏,稳妥做法是接受 DUAL 和 LE 两种类型。
3.2 连接与 MTU 协商:顺序错了就白忙
BleManager里把连接封装成了一个异步操作,connectGatt的第二个参数autoConnect在工程里设为false,这是有意为之:
public boolean connect(BluetoothDevice device, BleManagerCallback callback) { this.callback = callback; gatt = device.connectGatt(context, false, gattCallback); return gatt != null; }autoConnect设成false表示直接发起连接,失败会立刻回调,不会在后台重试。设成true时,系统会在信号变弱后自动尝试重连,但代价是第一次连接时间可能很长,而且回调时机不可控。调设备时我一般用false,产品化时再根据业务需要切换成true。
真正卡住很多新人的地方是连接成功后的操作顺序。工程里的onConnectionStateChange写得很明确,连接成功第一件事不是discoverServices(),而是先请求加大 MTU:
@Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { if (newState == BluetoothProfile.STATE_CONNECTED) { gatt.requestMtu(200); } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { callback.onDisconnected(); } }为什么第一件事是requestMtu(200)?因为 BLE 4.0 默认 MTU 只有 23 字节,扣除 ATT 头,用户数据只剩 20 字节。如果不协商,后续每次写一个稍大的数据就要砍成 20 字节一包,非常容易出问题。BLE 4.2 默认支持大 MTU,协商到 200 之后,单包可以传 197 字节,分包次数大幅减少,粘包概率也下来了。
然后onMtuChanged里再去发现服务:
@Override public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { if (status == BluetoothGatt.GATT_SUCCESS) { gatt.discoverServices(); } else { gatt.discoverServices(); // 协商失败也要继续走 } }这里有个血泪教训:MTU 协商失败之后不能卡住整个流程,很多设备固件不响应requestMtu,回调的status是GATT_FAILURE,但服务发现还是能正常完成的。如果在这里直接 return,后面所有特征值操作都无从谈起。所以工程里不管成功失败都继续走discoverServices(),只是记录实际协商结果,后续分包大小按 20 字节兜底。
3.3 分包发送与拼包接收:MTU 不是唯一坑点
数据发送这部分,工程提供了一个BlePacket工具类,核心方法是splitAndSend():
public void splitAndSend(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] payload, int mtu) { int chunkSize = mtu - 3; // ATT 头占 3 字节 int offset = 0; while (offset < payload.length) { int length = Math.min(chunkSize, payload.length - offset); byte[] chunk = Arrays.copyOfRange(payload, offset, offset + length); characteristic.setValue(chunk); gatt.writeCharacteristic(characteristic); offset += length; } }每次循环先算好这一包能装多少字节,mtu - 3是因为 ATT 写请求的头占 3 字节。比如协商成功到 200,那单包最多写 197 字节;如果没协商成功回到 23,就必须用mtu = 23来兜底,也就是单包 20 字节。分包大小直接决定数据完整性,我看到不少项目在这里写死 20,导致 MTU 协商成功也浪费带宽,但更危险的是反过来——按大包拆,但设备实际没协商成功,发出去就被截断。
写特征值时还有个隐性问题:不能连续快速调writeCharacteristic多次,低端手机蓝牙栈会丢包。工程里没有处理并发写,这是它留给你改造的空间,我在最后一章会写队列方案。
接收端,onCharacteristicChanged里有个简单的拼包逻辑:
@Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { byte[] value = characteristic.getValue(); receiveBuffer.write(value); if (value[0] == 0x01) { // 假设自定义包头:0x01表示首包, 0x00表示中间包 callback.onDataReady(receiveBuffer.toByteArray()); receiveBuffer.reset(); } }这种“按包头判断结束”的做法很灵活,但必须跟设备固件约定好协议。工程默认首包 0x01,中间包 0x00,最后一包怎么判断没有写死,因为不同外设的结束标记完全不同。你移植到自己的设备时,要先去分析日志,看看固件发出来的数据结构,再改这里的判断逻辑,不能指望一套代码通吃所有设备。
4. 常见问题避坑:扫描不到、连接断开、粘包乱序怎么排查
4.1 现象:扫描不到设备,回调完全没触发
一般在Android 12以上的新手机上最容易出现。Log 里没有onScanResult,甚至onScanFailed也没输出。
原因大概率有两个:一是动态权限没给全,BLUETOOTH_SCAN和ACCESS_FINE_LOCATION缺一不可;二是设备广播名的规则跟 ScanFilter 不匹配。工程里setDeviceName("BLE4.2_")是精确匹配,如果外设厂商把前缀改成了厂商缩写,比如 “ABC_”, 那过滤条件就把设备挡掉了。
解决方式:先检查系统设置里 App 的权限,把位置和邻近设备权限都打开,在代码里用统一的权限检查入口。如果确认权限没问题,把 ScanFilter 里的setDeviceName注释掉,先全量扫描看设备在不在列表里,再用device.getName()打印广播名,比较一下大小写和空格。蓝牙名称通常不能支持模糊匹配,要么用setServiceUuid按服务 UUID 过滤,要么在回调里自己判断,别完全依赖系统过滤。
4.2 现象:连接成功后 3 秒内自动断开,onConnectionStateChange回调newState变成 DISCONNECTED
最常见的原因是 GATT 操作时序错了,discoverServices()或requestMtu()还没有完成,就立即去writeCharacteristic(),导致协议栈状态机被打乱。另一个隐蔽原因是手机连接过同一台设备,蓝牙缓存里保存了旧的 GATT 服务,然后新连接后返回的 handle 不匹配。
解决办法是严格按照“连接 → MTU 协商 → 服务发现 → 找特征 → 注册通知 → 读写”的顺序,每个回调里确认上一步成功后再做下一步,这个顺序真不能跳。工程代码的顺序是正确的,你改代码时千万不要因为“MTU 失败但不影响服务发现”就把discoverServices提前。至于缓存问题,用device.connectGatt()之前先调用BluetoothDevice的createBond()重新配对,或者让你在 App 设置页加一个“清除蓝牙缓存”按钮,在断开状态下调用BluetoothGatt的refresh()方法(虽然这是一个隐藏 API)。
4.3 现象:数据收到但内容粘包,一帧数据变成了两帧两倍长
并非真的粘包,而是你按 MTU 协商后的大小拆包,但设备端实际发包时没按这个 MTU 来,把多个逻辑帧塞进了连续的通知。BLE 4.2 支持大包后会延长单包传输时间,如果设备固件使用不同 MTU,就会在边界处拼错。
排查时先把onMtuChanged返回的实际值打印出来,跟固件文档核对。如果固件坚持用 20 字节广播包,那么你的接收拼包也应按 20 字节边界,而不是动态的大 MTU。更通用做法是协议里增加序号或长度字段,比如开头两个字节表示逻辑帧长度,收到后按长度截取,而不是按包头。工程里的BlePacket只有简易包头,如果你要处理多帧拼接,建议改成长度字段 + CRC 的方式,这在业务数据里可靠性会高得多。
4.4 现象:在部分安卓手机上,requestMtu回调一直不触发
这其实不是异常,而是部分国产 ROM 的蓝牙栈没有实现 MTU 协商,或者设备端没有主动响应。此时代码会在requestMtu后一直卡死在onMtuChanged里,然后连接超时。
解决思路:不要依赖onMtuChanged作为服务发现的触发点,它只是一个优化项。正确做法是onConnectionStateChange里发起discoverServices(),在onServicesDiscovered回调里再尝试requestMtu(),之后用onMtuChanged去更新分包大小。这样即使 MTU 协商完全不回调,服务发现和读写操作也照样能继续。我改造时把 MTU 协商挪到服务发现之后,整体连接成功率提升了不少,对低端手机尤其明显。
5. 二次开发改造:把 Demo 变成业务模块的落地思路
5.1 用状态机管理连接生命周期
工程里的BleManager没有对外暴露连接状态,回调是零散的消息。你如果拿它做产品,第一件事是加一个状态机,把连接流程变成“可查询、可判断”的:
public static final int STATE_IDLE = 0; public static final int STATE_SCANNING = 1; public static final int STATE_CONNECTING = 2; public static final int STATE_MTU_NEGOTIATING = 3; public static final int STATE_SERVICE_DISCOVERING = 4; public static final int STATE_READY = 5; public static final int STATE_DISCONNECTED = 6; private int state = STATE_IDLE;然后在各个回调的入口处更新状态,对外提供一个getState()方法。这样做的好处非常直接:UI 层可以按状态灰度按钮——比如STATE_READY才允许点击“上传数据”,扫描时禁止重复扫描。最关键的,当调试中出现“连接后断开”这类问题时,你可以在日志里打印状态迁移路径,一眼看出是哪一步跳错了。
5.2 加一个写队列,解决并发写冲突
原生 API 的writeCharacteristic不能在多个线程同时调用,如果你在主线程和服务发现回调之间共用这一个方法,极容易出现“GATT error 133”这种意料之外的错误。我的习惯是给BleManager加一个写队列:
private Handler writeHandler = new Handler(Looper.getMainLooper()); private ArrayDeque<byte[]> pendingQueue = new ArrayDeque<>(); public void enqueueWrite(byte[] data) { pendingQueue.add(data); processNextWrite(); } private void processNextWrite() { if (isWriting) return; byte[] item = pendingQueue.poll(); if (item != null) { isWriting = true; writeCharacteristicInternal(item); } }每次发送完,在onCharacteristicWrite回调里把isWriting置为false,再调用processNextWrite()。这样无论外部怎么并发调用,实际发往协议栈的始终只有一个写请求。设备端处理能力弱时,写队列还能顺便加一个 10ms 左右的间隔,避免瞬间发包太多触发流控。
这套改造做完,再配合状态机,基本上能覆盖生产环境的大部分诉求。我自己后来在做某个模拟项目时,没给BleManager加队列,上线后一周内收到三次“数据上传失败”的反馈,全部都是因为用户在页面连点两下“同步”按钮,两个写请求撞在了一起。从那以后,我每次接入新的蓝牙模块,都会强制先走一遍“状态机 + 写队列 + MTU 兜底”这三板斧,把回调链路打印出来确认时序后再提交代码。希望帮到你。
本文还有配套的精品资源,点击获取