简介:Android BLE蓝牙4.2数据透传示例工程,面向刚接触低功耗蓝牙开发的移动端新手,围绕设备扫描、建立连接与双向收发数据给出尽量精简的实现,代码去除了多余封装,便于快速抓住BLE通信的主干流程。压缩包共1244个文件,以Java源码、XML布局与清单、JSON与properties配置、PNG图片资源及Gradle构建脚本为主体,同时包含dex、class等编译中间产物和可安装的debug版APK,整体约9.24MB。工程保留完整的模块目录与构建缓存,可直接导入Android Studio对照调试,重点展示GATT服务发现、特征值读写与通知订阅等关键环节的代码组织方式,也保留了资源编译与打包痕迹,方便排查构建问题。目前已有510人学习下载,适合作为BLE入门练手、课堂示例或二次开发的基础模板,帮助读者理解蓝牙4.2在Android端的调用流程与常见参数配置。
1. 从一个“扫不到设备”的下午说起
手上的外设是个自研的温湿度采集模块,厂商只给了一张 UUID 表,扫描、连接、订阅通知这一整套都要自己写。这类活儿最容易出问题的环节往往不在协议本身,而在 Android BLE 蓝牙 4.2 这套流程里的权限、线程和状态管理:扫不到、连上秒断、通知永远不回调,一个比一个难以捉摸。bluetooth4.zip 就是一份针对这个场景的实战工程包,里面是能跑通的扫描、连接、发现服务、读写特征值、订阅通知的完整链路,权限适配和回调封装也一并带着。它适合两类人:刚接手 BLE 硬件对接、需要一个能对着改的骨架的新手;和被 133、通知丢失、MTU 异常折腾过、想看看别人状态机怎么写的老手。下面按我拆包的顺序走,先把 GATT 的结构讲透,再落到每个环节的代码和参数。
2. GATT 的分层结构:Profile、Service、Characteristic 怎么对应
你拿到的 UUID 表通常只有几行,但设备内部的数据是按四层组织的。搞不清这四层的对应关系,后面写代码时连“该读哪个对象”都定不下来,更别说排错。
2.1 四层结构在代码里的对应物
Profile 是业务语义层面,比如“心率”“电池”“串口透传”,它本身没有统一的 UUID,只存在于规范和文档里。往下是 Service,一个 Service 是一组特征值的集合,用 16 位短 UUID 或 128 位长 UUID 标识;再往下是 Characteristic,这才是能读能写能订阅的数据点,它带着 value、properties、permissions 三样东西;最底下是 Descriptor,描述特征的元数据,日常几乎只用到 CCCD(UUID 0x2902),也就是通知开关。
| 层级 | 标识方式 | Android 里的对象 | 实际要关心的 |
|---|---|---|---|
| Profile | 无统一 UUID | 无对应对象 | 业务语义,确定目标 Service |
| Service | 16 位或 128 位 UUID | BluetoothGattService | 拿对 UUID,别拿成包含服务 |
| Characteristic | UUID + Properties | BluetoothGattCharacteristic | 能不能读、写、通知 |
| Descriptor | 0x2902 等 | BluetoothGattDescriptor | 订阅开关写没写对 |
properties 是个位掩码,PROPERTY_READ、PROPERTY_WRITE、PROPERTY_WRITE_NO_RESPONSE、PROPERTY_NOTIFY、PROPERTY_INDICATE各占一位。我一般会在连接成功后打一行日志把这个值打出来,因为大量“写不进去”的问题,根因是特征值本身不支持写,而不是代码写错了。
提示:同一个 UUID 可能在多个 Service 下重复出现,取特征值一定要先
getService(serviceUuid)再getCharacteristic(charUuid),别直接在gatt.services里遍历找。
2.2 为什么这份工程贴着 BLE 4.2 写
蓝牙 4.2 最实用的改动是 LE Data Length Extension,链路层单包载荷从 27 字节扩到 251 字节,应用层吞吐能到原来的两倍多。对温湿度、心率这种小包没感觉,但只要外设一发固件包、日志块、波形数据,有没有这个就完全是两种体验。4.2 还带了 Privacy 1.2 和 IPSP,日常对接基本用不上,可以忽略。
Android 这边的对应接口是requestMtu()。默认 MTU 是 23,实际可用载荷 = MTU - 3 = 20 字节。发起协商后,能谈到多少取决于两端同时支持的上限:外设是 4.2 且固件开了 DLE,常见能到 247(载荷 244);只支持到 4.0/4.1 的外设,往往就停在 23 或 185。这份工程把协商结果写进了连接状态里,后面所有分包逻辑都用运行时拿到的值算,而不是写死 20。
注意:不要在代码里硬编码 MTU。不同外设、不同手机返回的值不一样,同一台手机连不同外设也不一样,只有
onMtuChanged回调里的那个数才是真的。
2.3 一次完整会话的状态机
BLE 的坑有一半来自“状态没管住”:上一步还没回调,下一步就发出去了。工程里用一个显式状态机把这条链串起来,每一步只有成功才能进下一步,失败就回退到 IDLE 并释放资源。
// 状态机骨架:所有 GATT 操作都从这里出发,避免并发 enum class BleState { IDLE, SCANNING, CONNECTING, DISCOVERING, READY, CLOSING } private var state = BleState.IDLE fun step(next: BleState) { // 只允许按 IDLE -> SCANNING -> CONNECTING -> DISCOVERING -> READY -> CLOSING 推进 if (!allowed(state, next)) return state = next log("state: $state") }allowed()就是一张白名单表,写了合法迁移,非法迁移直接丢弃并打日志。看着笨,但线上排查时这行日志能省下大半天。对应的失败表现我列在下面这张表里,出问题时先看卡在哪个状态,比盲猜快得多。
| 状态 | 触发动作 | 典型失败表现 |
|---|---|---|
| SCANNING | startScan | 无任何回调,权限或定位开关问题 |
| CONNECTING | connectGatt | status 133、长时间不回连 |
| DISCOVERING | discoverServices | 回调 status 129,服务列表为空 |
| READY | 拿到目标特征值 | 读写回调乱序、通知不来 |
| CLOSING | close()并置空引用 | 再次连接失败,需要隔几秒重试 |
3. 扫描阶段的权限与参数:Android 12 前后的两套写法
扫描是整个流程里最容易被系统拦下来的环节。它不是“代码写错了”,而是权限模型改过两次,很多老工程直接照抄就废。
3.1 权限矩阵:分水岭在 Android 12
Android 6.0 到 11,扫描走的是定位权限那条路:BLUETOOTH、BLUETOOTH_ADMIN加上ACCESS_FINE_LOCATION,而且系统定位开关必须打开,否则startScan会静默失败,一个回调都不给。Android 12 起引入了邻近设备权限组,扫描用BLUETOOTH_SCAN、连接用BLUETOOTH_CONNECT,不再强制定位权限。
| 系统版本 | 扫描所需权限 | 容易漏的点 |
|---|---|---|
| 6.0 – 11 | BLUETOOTH、BLUETOOTH_ADMIN、ACCESS_FINE_LOCATION | 定位服务总开关未开则无回调 |
| 12 及以上 | BLUETOOTH_SCAN、BLUETOOTH_CONNECT | 声明了 neverForLocation 就不能再拿扫描结果推位置 |
工程里的做法是在 Manifest 里同时声明两套,用maxSdkVersion和usesPermissionFlags分流,运行时再按Build.VERSION.SDK_INT分支申请。
<!-- 旧版本走定位权限,新版本走邻近设备权限,两套并存 --> <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" /> <!-- 声明本机确实需要蓝牙硬件,避免被系统过滤掉 --> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />usesPermissionFlags="neverForLocation"这行的含义是向系统承诺“我不用扫描结果推断地理位置”,声明之后在部分机型上就不会再弹定位相关的授权,但反过来你也别在代码里拿 rssi 去做距离计算,两边口径要对得上。运行时申请要分成两个分支:12 以下申请定位权限并顺手检查LocationManager.isLocationEnabled,12 以上只申请BLUETOOTH_SCAN和BLUETOOTH_CONNECT。
3.2 ScanSettings 与 ScanFilter 的参数取舍
扫描参数决定了两件事:多久能看到设备、耗多少电。这份工程默认给了前台和后台两套配置,实际改的时候主要动三个值。
val scanner = context.getSystemService(BluetoothManager::class.java) ?.adapter?.bluetoothLeScanner ?: return val settings = ScanSettings.Builder() // 前台用 LOW_LATENCY,能最快看到广播;后台换成 LOW_POWER .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 0 表示每条广播都回调;非 0 则批量回调,省电但延迟 .setReportDelay(0L) .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) .build() // 按服务 UUID 过滤,能把大量无关广播挡在回调之外 val filter = ScanFilter.Builder() .setServiceUuid(ParcelUuid(UUID.fromString(TARGET_SERVICE_UUID))) .build() scanner.startScan(listOf(filter), settings, scanCallback)setScanMode有 LOW_POWER、BALANCED、LOW_LATENCY 三档,对应扫描窗口占空比不同。前台页面等用户点“搜索设备”,用 LOW_LATENCY;后台长时间挂着找设备,换 LOW_POWER,功耗差好几倍。setReportDelay(0)时走onScanResult;设成比如 2000 毫秒,就变成批量模式走onBatchScanResults,一次给你一批结果,适合列表页按时间窗刷新,注意批量模式下 rssi 是这一批里的采样值,不是实时值。
还有个常被忽略的参数是超时。startScan不会自己停,工程里用 Handler 挂了 15 秒的定时停止,页面onPause也要主动stopScan,否则后台一直扫,耗电和连接冲突都跟着来。
3.3 广播包解析与设备去重
BLE 广播每秒可能来好几条,同一个设备一秒内回调四五次是常态,直接往列表里塞就是疯狂闪烁。工程里的做法是按 MAC 做时间窗去重,同时把广播里的厂商自定义字段解出来,提前展示电量、型号这类信息,用户不用连上就能分辨。
private val seen = HashMap<String, Long>() private val WINDOW_MS = 1000L private fun onResult(result: ScanResult) { val now = SystemClock.elapsedRealtime() val last = seen[result.device.address] ?: 0L // 同一 MAC 一秒内只处理一次,避免 UI 抖动 if (now - last < WINDOW_MS) return seen[result.device.address] = now val record = result.scanRecord // 厂商自定义数据:前两字节是厂商 ID,后面才是业务负载 val raw = record?.getManufacturerSpecificData(0x1234) val battery = raw?.takeIf { it.size >= 1 }?.get(0)?.toInt()?.and(0xFF) log("dev=${result.device.address} rssi=${result.rssi} battery=$battery") }getManufacturerSpecificData的 key 是厂商 ID,不是随意定的,得跟外设固件对齐;and(0xFF)是因为 Kotlin 里 Byte 是有符号的,0x80直接转 Int 会变成 -128,这个坑我见过不止一次。去重用的 map 记得在stopScan时清空,不然下次进页面全是旧数据。
4. 连接的建立与读写:同一时刻只能有一个 GATT 操作进行
从connectGatt开始往后,所有问题基本都能归到一句话:Android 的 GATT 栈同一时刻只允许一个操作在飞。上一条没回调就发下一条,轻则回调丢失,重则直接断开。
4.1 connectGatt 的参数选错就会得到 133
connectGatt有四个参数,context、autoConnect、callback、transport。context 用 Application 而不是 Activity,这一条是硬性要求,用 Activity 会导致连接持有页面引用,页面销毁后回调打到空对象上,表现为“连上了但什么都没发生”。autoConnect 传 false 是直连,有自己的超时窗口,通常 30 秒左右,失败会明确回调;传 true 则交给系统后台挂着,设备一出现就自动连,省电但回落慢,适合“配对一次长期使用”的场景。
// 用 applicationContext,避免页面销毁后回调悬空 val gatt = device.connectGatt( appContext, false, // 直连,失败有明确回调 gattCallback, BluetoothDevice.TRANSPORT_LE // 强制走 BLE,双模设备必须指定 ) override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) { gatt.requestMtu(247) // 先协商 MTU,成功了再发现服务 } else { // 失败必须 close 并置空,否则下次连接大概率还是 133 gatt.close() this.gatt = null } }133 这个错误码没有官方解释,通常等价于“底层连接建立失败”,常见诱因是上一次连接没close()、外设已经被别的中心设备占着、或者连接间隔太小外设扛不住。工程里的处理是失败后 close、延迟 500 到 1000 毫秒再重试,最多三次,三次还不行就上抛给用户。
4.2 MTU 协商:23 起步,247 是常态,517 理论上限
协商的顺序不能反:连接成功 → 请求 MTU → 在onMtuChanged里discoverServices()。如果先发现服务再改 MTU,服务列表已经缓存下来了,后面读长特征值还是会按 20 字节切。
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { // 有效载荷要减 3 字节协议头,这个值后面分包全用它 payloadSize = mtu - 3 } else { payloadSize = 20 // 协商失败就退回默认值,别让流程断掉 } gatt.discoverServices() }requestMtu(517)是很多人爱写的值,但 Android 侧很多机型实际上限是 517 而外设只认 247,最后回调给你的可能是 247 甚至 23。别用请求值当结果值。另一个细节是部分机型在协商成功后会悄悄断一次重连,工程里在onMtuChanged回调里加了个 200 毫秒的延迟再发现服务,能规避掉一部分机型的时序问题。
4.3 特征值写入与 CCCD 订阅:顺序错了就是没有回调
写特征值分两种:WRITE_TYPE_DEFAULT带响应,写完会回onCharacteristicWrite;WRITE_TYPE_NO_RESPONSE不带响应,速度快但没有任何成功反馈。选哪个看特征值的 properties,不是你想选哪个就选哪个。带响应的写有流控意义,无响应的写如果发太快,外设缓冲区满了会直接丢包,血泪经验是连续发超过三条就必须自己加节流。
订阅通知是两步,少一步都不行。第一步setCharacteristicNotification只是打开本地接收开关,第二步往 CCCD 描述符写0x01 0x00才是真的告诉外设“开始推数据”。
private fun enableNotify(gatt: BluetoothGatt, ch: BluetoothGattCharacteristic) { // 第一步:打开本地开关,缺了这步数据到了也进不来 gatt.setCharacteristicNotification(ch, true) val cccd = ch.getDescriptor(CCCD_UUID) ?: return // 0x2902 cccd.value = if (ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) { BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE // 01 00 } else { BluetoothGattDescriptor.ENABLE_INDICATION_VALUE // 02 00,带确认的推 } // 第二步:写描述符,成功与否以 onDescriptorWrite 为准 gatt.writeDescriptor(cccd) }PROPERTY_NOTIFY和PROPERTY_INDICATE的区别在于后者每条都要中心设备回确认,吞吐低但不丢;前者快,链路差的时候可能丢包。数据是周期性的小包用 NOTIFY,是关键配置回读用 INDICATE。写描述符的返回值只代表“请求发出去了”,真正的结果在onDescriptorWrite里,status == GATT_SUCCESS才算订阅成功,这时候再更新 UI 状态。
4.4 把 GATT 操作串成一个队列
工程里最值得抄的一段是队列。所有读写、描述符操作都进队列,同一时刻只有一个在飞,回调里再出队下一个。这样写长了啰嗦,但能把 90% 的回调丢失问题挡在门外。
private val queue = ArrayDeque<BleOp>() private var busy = false @Synchronized fun enqueue(op: BleOp) { queue.add(op) next() } @Synchronized private fun next() { if (busy) return val op = queue.poll() ?: return busy = true op.execute(gatt) // 内部调用 read/write/descriptor } @Synchronized private fun finished() { busy = false next() } // 每个回调里都要调 finished(),这是队列往前走的唯一动力 override fun onCharacteristicWrite(g: BluetoothGatt, c: BluetoothGattCharacteristic, status: Int) { finished() }分包写也在这个队列里做:按payloadSize把数据切片,每片单独入队,等上一片的onCharacteristicWrite回来再发下一片。无响应写的话没有回调,就得用固定间隔的定时器推进,间隔取连接间隔的 2 到 3 倍比较稳。
5. 高频问题排查:五个现象、五个原因、五个解法
下面这几条都是我在真实对接里反复遇到的,按“现象 → 原因 → 解决”记,出问题时照着对。
5.1 扫描与连接阶段
现象一:startScan调用成功,但一个回调都没有。原因基本锁定在权限和系统开关上:Android 11 及以下定位权限没给、或者系统定位总开关关着,扫描会被静默拦截;Android 12 以上则是BLUETOOTH_SCAN没申请到,或者申请了但用户在弹窗里拒了。解决方式是调用前做一次自检:权限是否授予、BluetoothAdapter.isEnabled是否为 true、低版本再查LocationManager.isLocationEnabled,三者任一不满足就直接提示用户,别硬扫。
现象二:连接回调 status 133,重试几次偶尔能成。原因通常是上一次连接没释放干净,或者外设还挂在别的中心设备上。还有一种是connectGatt传了 Activity 当 context,页面重建后旧连接还活着。解决是每次失败都gatt.close()并把引用置空,延迟 500 到 1000 毫秒再重试,重试上限三次;同时确认外设端已经断开旧连接再发起。
现象三:连上后discoverServices回调 status 129,服务列表为空。129 是 GATT 内部错误,多半是 MTU 协商和发现服务挨得太近,或者外设固件在连接参数更新期间不响应。工程里的处理是在onMtuChanged之后延迟 200 毫秒再发现服务,并且发现失败时先gatt.disconnect()再重连一次,仍失败才上报。
5.2 读写与通知阶段
现象四:writeCharacteristic返回 true,但永远等不到onCharacteristicWrite。两种可能:写入长度超过了当前 MTU 允许的载荷(比如 MTU 还是 23 时写了 30 字节),或者上一条 GATT 操作还没回调就又发了新的。前者按payloadSize切片,后者交给队列。还有一种隐蔽情况是特征值 properties 里只有WRITE_NO_RESPONSE,你用了带响应的写,系统不报错但也不会回调。
现象五:订阅成功、外设也在推,但 App 收不到onCharacteristicChanged。最常见的是漏了setCharacteristicNotification或者 CCCD 没写;其次是主服务和特征值 UUID 拿错,订阅到了另一个同 UUID 的特征上;再就是回调里做了耗时操作把主线程堵住,数据在 BLE 栈里堆着。排查顺序是:先打印onDescriptorWrite的 status 确认订阅真的成功,再在onCharacteristicChanged入口打一行日志确认有没有进来,两步一卡就能定位到底是没订阅上还是没收到。
注意:所有 GATT 回调都在 Binder 线程,直接在里面改 UI 会崩。统一 post 到主线程,或者在回调里只做数据拷贝,业务处理另起线程。
6. 封装成可测的 BLE 通道:两处验证与一个习惯
工程包直接跑起来只是第一步,能不能改、改了能不能验证,取决于有没有把链路切成可观测的几段。
6.1 分层:Client、队列、状态回调
我的做法是把 BLE 相关代码压成三层:最底层是BleClient,只管connectGatt、读写、订阅这些原子操作,对外只暴露回调;中间是队列和状态机,负责串行和迁移;最上面是业务层,拿到的是“连接成功”“收到一帧数据”这类语义化事件,完全不碰BluetoothGatt对象。这么切的好处是换外设只改 UUID 常量和解析函数,业务层不动;坏处是代码量翻倍,所以小 Demo 不必这么干,但凡是要上线的产品,这层迟早得补。
// 业务层只依赖这个接口,不依赖任何 GATT 类型 interface BleChannel { fun connect(address: String) fun subscribe(onFrame: (ByteArray) -> Unit) fun send(payload: ByteArray): Boolean fun close() }send返回 Boolean 表示“是否入队成功”,不是“是否发送成功”。这个语义要在接口注释里写死,否则调用方会误以为返回 true 就代表外设收到了。
6.2 三条验证路径
第一条是日志。每次状态迁移、每次回调都打一行带毫秒时间戳的日志,格式统一成时间 | 状态 | 动作 | 结果。抓一次完整会话,把日志按时间排开,问题基本自己就浮出来了——你会很直观地看到“写完 3 毫秒就发了下一条”这种事。
第二条是外设侧确认。App 说订阅成功了不算数,得让固件那边打日志确认 CCCD 真的被写了、数据真的在往外推。很多“App 收不到”的问题,查到最后是外设根本没使能推送。
第三条是交叉比对。用一个通用的 BLE 调试工具连同一个外设,看它能不能正常读写和订阅。如果调试工具正常、你的 App 不正常,问题在代码;如果两边都不正常,问题在外设或环境。这一步能砍掉一半的排查时间。
| 验证方式 | 能确认什么 | 局限 |
|---|---|---|
| 时间戳日志 | 时序、状态迁移、回调是否触发 | 看不出外设侧行为 |
| 外设固件日志 | CCCD 是否写入、数据是否推送 | 需要固件配合 |
| 通用调试工具交叉比对 | 区分代码问题与设备问题 | 无法复现业务逻辑 |
最后说个习惯。早年我调一个温湿度模块,扫描正常、连接正常、订阅也返回成功,就是收不到数据,折腾了一整天,最后发现是特征值 UUID 抄错了一位,订阅到了同一服务下另一个只读特征上。从那以后我每次接新的 BLE 外设,都强制先跑一遍扫描日志、再把onServicesDiscovered里的服务和特征值全量打印出来,对着厂商文档一条条勾,勾完才动业务代码。这半小时的笨功夫,比后面瞎猜一天划算得多。希望帮到你。
本文还有配套的精品资源,点击获取