ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android BLE连接避坑:官方Demo全流程解析与实战指南

Android BLE连接避坑:官方Demo全流程解析与实战指南

简介:面向安卓开发者的蓝牙低功耗示例工程,源自官网教程,完整演示了BLE设备从扫描、连接、断开到发现服务与UUID解析的调用流程。适合正在做蓝牙4.0设备对接,或想快速上手系统BLE API的初中级程序员,也适合作为智能手环、健康监测等物联网项目的代码基底。压缩包共70个文件,仅1.34MB,包含7个Java源码实现主要逻辑,15个XML定制界面与配置,3个Jar提供依赖库,还附有可直接安装运行的APK;另有26个Class编译文件便于前后对照,工程同时保留了manifest、res、assets等标准目录,方便快速定位权限声明与资源文件。目前已有525人学习浏览,在官方示例基础上整理编译,参考热度不错。代码覆盖了连接状态机的切换、GATT回调顺序、特征值读写等核心环节,注释与日志清晰,能明显降低排查蓝牙问题的门槛;整个包体积小巧但功能完整,既适合自学入门,也可作为二次开发的起点,对需要快速验证BLE方案的开发者尤为友好。

1. 一个困扰了我一周的 BLE 连接问题,最后靠这套官方 demo 校准

去年在调一款健身手环的配套 App,扫描列表里能看到设备,点击连接后connectGatt()也确实返回了一个非空对象,可onConnectionStateChange就像石沉大海,一周都没触发。我换了三台测试机,试了网上各种“玄学”方法,最后静下心把官方这套 BLE demo 的源码逐行对照了一遍,才意识到问题根本不是我以为的硬件兼容性,而是漏了位置权限、扫描过滤条件没生效、UUID 大小写不一致这三个基础问题。这套 demo 从扫描、建连、发现服务、读写数据到接收通知,完整闭环,很适合刚接手 BLE 外设配套 App 的移动端开发者。看完你会发现,很多折腾半天的翻车现场,都能在它的代码里找到标准答案。

2. BLE 这套体系先立住:GATT 层级与 demo 工程拆解

2.1 从物理层到应用层,BLE 的数据通路长什么样

BLE 和经典蓝牙最大的区别是“连上才干活,平时睡觉”。它把功耗压得很低,但同时把协议栈简化了:设备之间不是建立一个面向字节流的 Socket,而是以GATT(Generic Attribute Profile)表格的形式互相读写数据。

一个 BLE 外设在连接后被抽象成三层结构:

  • Service(服务):一组功能的集合,比如“电量服务”“设备信息服务”。每个服务有一个 UUID。
  • Characteristic(特征):服务里面的具体数据项,比如“电池电量百分比”。每个特征同样有 UUID,并且带着一堆权限位:可读、可写、可通知。
  • Descriptor(描述符):特征下面的附加配置,最典型的是0x2902(客户端特征配置),用来开启通知开关。

连接后,两个设备之间交换的请求无非就是:读一个特征值、写一个特征值、订阅一个特征的通知。demo 里所有代码,本质上都是围绕这几件事在转。

这个模型解释了为什么 BLE 调试看起来“慢”——每次交互都要走一问一答,不存在长连接流式传输。如果你按经典 Socket 的思路去等数据,很容易误判成“设备没发数据”,实际只是订阅通知的开关没打开。

2.2 demo 工程结构:四个模块各管一段

官方 demo 的包结构并不复杂,但模块划分很规矩。我拆解下来,核心是四个部分:

模块职责关键类/组件
入口页面检查蓝牙开关、申请权限、跳转设备列表主 Activity、BluetoothAdapter
设备扫描封装扫描动作、回调结果BluetoothLeScanner、ScanCallback
设备控制连接、发现服务、读写、订阅通知BluetoothGatt、自定义连接 Service
列表与展示展示扫描结果和 GATT 服务树RecyclerView适配器、服务列表页面

我一般会把“扫描”和“连接控制”拆成两个类来读:扫描类只管拿到设备地址,连接控制类只做connectGatt之后的活儿。demo 里也是这么分的,千万别把扫描回调里的BluetoothDevice直接塞进 UI 或者连接逻辑里,否则后面加过滤、加重试时你会疯掉。

值得注意的一点是,demo 里连接控制用的是Service(系统 Service 组件)而不是 Activity。原因很简单:蓝牙连接生命周期跨越页面的退栈,如果把BluetoothGatt绑在页面上,一返回就断连。哪怕你自己写单例管理也救不回来,系统 Service 是这套 demo 最值得抄的设计之一。

2.3 权限与初始化:在这代系统上不这么写就会闪退

权限是 BLE 入门第一道坎,而且历代版本变化很大。现在的系统上,光在 manifest 里声明可不够,还要处理运行时权限。

<!-- 低版本的通用权限 --> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <!-- 定位权限:BLE 扫描在 Android 6.0+ 被归类为定位行为 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Android 12(API 31)及以上,权限收得更严 --> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

第一段里,ACCESS_FINE_LOCATION是最容易漏的。当年我做第一个 BLE 项目时,在 Android 9 上扫描回调一直为空,日志里压根没有报错提示,排查到最后才发现是系统把蓝牙扫描当成获取位置信息的手段。第二段是 Android 12 之后的新权限模型,如果你还在用旧的BLUETOOTH权限,扫描和连接都会直接SecurityException。

拿到权限之后,别急着startScan(),先确认蓝牙适配器状态:

val adapter = bluetoothManager.adapter if (adapter == null || !adapter.isEnabled) { // 引导用户去系统设置里打开蓝牙,或者在代码里申请打开 return }

这里有一个小小的点:BluetoothManager要从Context.getSystemService()获取,而不是通过getDefaultAdapter()那种静态方法。后者在新系统上已经标记废弃,但 demo 里可能会看到旧写法,自己写代码时用新方式。

3. 扫描到建立连接:ScanFilter 与 connectGatt 的实战参数

3.1 用 BLEScanner 替代旧 API,三个参数决定扫描质量

老代码里常见的startLeScan(LeScanCallback)在 API 21 之后就不推荐了。官方 demo 用的也是新扫描器BluetoothLeScanner,核心是下面这一段:

val scanner = bluetoothAdapter.bluetoothLeScanner val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) .setReportDelay(0L) .build() val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device = result.device Log.d("BLE-Scan", "找到设备: ${device.address} - ${device.name}") } override fun onScanFailed(errorCode: Int) { Log.e("BLE-Scan", "扫描失败, 错误码: $errorCode") } } scanner.startScan(listOf(filter), settings, scanCallback)

参数是这份资源里最值得反复读的部分,我逐个说:

  • setScanMode():SCAN_MODE_LOW_LATENCY扫描间隔最短,功耗最高,发现设备最快;SCAN_MODE_LOW_POWER是省电模式,但设备广播间隔稍长就会漏掉。
  • setCallbackType():CALLBACK_TYPE_ALL_MATCHES每次收到广播包都回调;CALLBACK_TYPE_FIRST_MATCH只回调一次,适合只想要一个设备不关心信号强度的场景。
  • setReportDelay(0L):0 表示实时上报。如果设成 1000,一秒钟的扫描结果会合并成一批,通过onBatchScanResults返回,能明显降低调用次数,但 UI 上的响应速度会慢一拍。

扫描是一种持续性动作,所以用完必须stopScan()。很多翻车现场是扫描没停就发起连接,导致两个流程相互干扰。

3.2 过滤条件怎么配:设备名、服务 UUID、MAC 三选一

裸扫往往一屏设备,全是一堆奇奇怪怪的外设。demo 里的ScanFilter给了三个维度的过滤,我平时也只用这三种:

val filter = ScanFilter.Builder() .setDeviceAddress("AA:BB:CC:DD:EE:FF") // 按 MAC 精确匹配 .build() val filterByName = ScanFilter.Builder() .setDeviceName("MyDevice") // 按广播名字匹配 .build() val filterByService = ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString("0000fff0-0000-1000-8000-00805f9b34fb")) .build()

三个过滤条件可以同时设置,但要注意它们是“与”的关系。实际项目中,我最常组合的是设备名 + 服务 UUID:先按厂商自定义服务 UUID 过滤掉不相干的音箱和手环,再用设备名确认。

还有一个容易踩的坑:用setServiceUuid过滤,过滤依据是广播包里的 UUID,而不是连接后 GATT 服务表的 UUID。有些外设广播时做了私有化处理,广播 UUID 和连接后看到的完全不一样,表现为“扫描不到”,但同一台设备拿官方的扫描工具却能扫出来。碰到这种情况,直接用setDeviceAddress适配,或者干脆不做过滤,全量扫描后手动对名字。

3.3 连接是门玄学:autoConnect 参数和回调判断

扫描拿到ScanResult后,接下来是connectGatt()。demo 里的连接代码看起来简单,但参数选择直接影响能不能连上:

val gatt = device.connectGatt(context, false, object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.d("BLE", "连接成功") gatt.discoverServices() } BluetoothProfile.STATE_DISCONNECTED -> { Log.d("BLE", "连接断开") gatt.close() } } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { // service 列表已就绪 } })

第二参数autoConnect是最容易误解的。false表示立即向远端设备发起连接,速度快,适合用户主动点击连接的场景;true表示让系统在被连接设备进入广播范围时自动连接,速度慢,而且 Android 自己控制重连时机,经常出现回调迟迟不来。

我的经验是:面向用户的 App 一律用false,true只适合做“靠近自动连接”这类后台功能。

连接之后还有一个判断条件:onConnectionStateChange回调里的status值必须等于BluetoothGatt.GATT_SUCCESS,否则连接不算成功。很多 demo 只看newState,忽略了status,导致在连接失败时却走了成功分支。

4. 发现服务与数据收发:UUID 匹配和读写订阅

4.1 UUID 转换与 property 位判断,先搞懂外设的数据表

BLE 的 UUID 有几种写法,外设文档里给你的通常是 16 位短 UUID,比如0xFFF0。但在代码里必须转成 128 位才能用:

val uuid16 = "FFF0" val fullUuid = "0000$uuid16-0000-1000-8000-00805f9b34fb" val baseUuid = UUID.fromString(fullUuid)

这里有个隐藏的坑:UUID 解析不区分大小写,但字符串格式必须带四个连字符。UUID.fromString("fff0")会直接抛异常,别问我怎么知道的。

拿到BluetoothGattService后,要遍历它的getCharacteristics()。每个特征上挂着一组属性,决定你能对它做什么:

属性位含义对应操作
PROPERTY_READ可读readCharacteristic()
PROPERTY_WRITE可写,无响应writeCharacteristic()
PROPERTY_WRITE_NO_RESPONSE可写,无需等待writeCharacteristic()
PROPERTY_NOTIFY可通知开启订阅setCharacteristicNotification()

判断方法很简单,按位与一下就行:

val properties = characteristic.properties if ((properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { Log.d("BLE", "这个特征支持通知") }

很多外设文档只告诉你 UUID,不告诉你属性。连接后先把整个 GATT 表打印出来,这一小步能省下大量猜测时间。

4.2 发现服务的正确节奏:onServicesDiscovered 之后再取特征

初学者最容易犯的错是连接成功的回调里立刻调用readCharacteristic(),结果回调返回GATT_READ_INVALID_OFFSET或者直接没反应。因为onConnectionStateChange里STATE_CONNECTED只代表物理链路通了,GATT 服务表还没拉下来。

正确顺序是:

override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() // 第一步:发现服务 } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { // 发现失败,常见原因是设备已断开,或并发访问被系统调度 return } val service = gatt.getService(uuidOfService) val characteristic = service?.getCharacteristic(uuidOfCharacteristic) // 到这里才能读写 }

有一个我在 demo 基础上补充的做法:discoverServices()是异步的,如果外设的数据库比较大,偶尔会超过预期时间。保险起见,我在onServicesDiscovered返回失败时,做了两次重试。注意重试前要隔一小段时间,不要立刻再调,否则系统蓝牙栈会认为你在重复请求,直接忽略。

4.3 真正坑人的是通知:setCharacteristicNotification 之外还要写 descriptor

订阅通知是 BLE 交互里翻车率最高的一步。很多人发现调用setCharacteristicNotification(characteristic, true)后,onCharacteristicChanged就是不触发,于是怀疑外设坏了。

不是外设的问题,是漏了最后一步——写入0x2902描述符。

fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { // 第一步:本地通知开关 gatt.setCharacteristicNotification(characteristic, true) // 第二步:写客户端特征配置描述符,打开远端通知闸门 val descriptor = characteristic.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") ) ?: return descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) }

这两步的关系可以这样理解:第一步是告诉本地蓝牙栈“这个特征的通知我感兴趣”,第二步是告诉远端设备“你可以发通知给我了”。缺了第二步,远端设备永远不主动发数据。

ENABLE_NOTIFICATION_VALUE是字节数组{0x01, 0x00},表示启用通知。如果你要启用的是indication(指示),对应的值是{0x02, 0x00}。部分传感器这两种模式都支持,但表示的含义略有差异:通知不需要应答,指示必须要 ACK,数据量小但可靠性要求高的场景选指示。

写描述符也是异步的。写完等onDescriptorWrite回调后,再去监听onCharacteristicChanged,这样顺序最稳。

5. BLE 连接避坑手册:五条能救命的踩坑记录

5.1 扫描不到设备,先看权限而不是看天线

现象:代码完全照 demo 写的,扫描回调一个设备都没有,日志没有任何异常。

原因:Android 6.0 及以上,蓝牙扫描被归入“位置信息”行为,必须同时满足三个条件:ACCESS_FINE_LOCATION运行时权限、系统定位开关打开、蓝牙开关打开。其中系统定位开关关闭时,代码不报错、不崩溃,就是永远空列表。

解决:在扫描前加一个显式检查,避免用户来回切换应用:

val locationOn = Settings.Secure.getInt( contentResolver, Settings.Secure.LOCATION_MODE, Settings.Secure.LOCATION_MODE_OFF ) != Settings.Secure.LOCATION_MODE_OFF if (!locationOn) { // 提示用户去设置里开启定位 }

从那以后,我每次排查“扫不到设备”都会先看这个开关,而不是拿起万用表去测天线——后者真的是个血泪教训。

5.2 连接即断,大概率是 autoConnect 与连接参数不匹配

现象:connectGatt()返回非空,onConnectionStateChange里状态短暂进入CONNECTED,一两秒后立刻变成DISCONNECTED。

原因:外设的连接参数(connection interval、supervision timeout)与安卓默认值不匹配,或者连接请求因autoConnect=true走了后台慢速路径,响应超时。

解决:两个方法配合。第一,连接前显式请求系统使用更短的连接参数:

gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)

第二,把connectGatt的autoConnect改为false。我遇到过一个极端情况:某款外设只在autoConnect=false时才能稳定连接,换成true必断。

5.3 onCharacteristicChanged 永远不回调

现象:设备确实在发数据,但你的回调函数里连一次onCharacteristicChanged都没走到。

原因:漏写0x2902描述符。这是成功率最低的一步,我也是拆了 demo 才意识到,setCharacteristicNotification(true)只是半程,描述符才是真正的开关。

解决:检查代码是否有这两步。我建议大家把“开启通知”封装成一个独立函数而不是直接写在onServicesDiscovered里,这样每次出问题都能单测这一环。

5.4 停止扫描后仍有回调进来

现象:调用了stopScan(),但onScanResult还在持续打印设备。

原因:stopScan()执行后,之前已经在回调队列里的广播结果不会消失,它们还会陆续派发到ScanCallback。另一个原因是多次调用了startScan(),系统没有取消之前的请求。

解决:在回调里加一个isScanning状态位:

override fun onScanResult(callbackType: Int, result: ScanResult) { if (!isScanning) return // 处理结果 }

同时不要在onScanResult里调用stopScan(),让扫描自己结束会产生状态混乱。

5.5 断开后立刻重连失败

现象:用户断开连接后马上点重连,connectGatt返回 true,但回调一直不触发,或者直接GATT_ERROR。

原因:蓝牙协议栈还保留着上一次连接的缓存记录,没有完全释放干净。安卓的BluetoothGatt在onConnectionStateChange里收到DISCONNECTED后,必须调用close()释放底层资源,否则系统不会立刻把它从连接表中移除。

解决:在断开分支里gatt.close()并把引用置空,重连前至少延迟 500ms:

override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_DISCONNECTED) { gatt.close() bluetoothGatt = null handler.postDelayed({ connectDevice(device) }, 500) } }

delay 时间别太短,我试过 100ms,在部分芯片上仍然会复现断连问题。500ms 到 1 秒是一个比较稳妥的区间。

6. 进阶技巧:MTU 协商与连接状态机

6.1 先协商 MTU,再谈大数据传输

BLE 默认每次传输的有效数据载荷非常有限(约 20 字节左右),如果你要传输图片或批量日志,默认 MTU 会逼你把数据拆成几十包。demo 里的做法是连接后立刻协商 MTU:

gatt.requestMtu(247)

在onMtuChanged回调里拿到实际协商结果:

override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { Log.d("BLE", "MTU 协商成功: $mtu") } }

注意:requestMtu()要在连接成功之后、但不必等到onServicesDiscovered。通常我放在连接成功回调里,和discoverServices()并列执行。不是所有外设都支持 247,协商失败会自动回退到默认值,代码不需要额外处理。

6.2 把连接状态机抽出来,别让回调满天飞

demo 里回调都在一个匿名对象里,业务一复杂代码就失控。我现在习惯把连接状态抽成枚举,用一个单例管理,页面只订阅状态变化:

enum class BleState { IDLE, SCANNING, CONNECTING, CONNECTED, DISCONNECTED }

所有关键回调汇总到一个地方,切换状态时打印日志,后续排查问题直接看状态流转:

fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { currentState = when (newState) { BluetoothProfile.STATE_CONNECTED -> BleState.CONNECTED BluetoothProfile.STATE_DISCONNECTED -> BleState.DISCONNECTED else -> BleState.CONNECTING } listener?.onStateChanged(currentState) }

这样 UI 层永远只关注当前状态,不会再在多个回调里穿插写 UI 逻辑。

6.3 校准基线:每次调 BLE 都强制走一遍全流程

这套官方 demo 给我最大的启发不是代码本身,而是它提供了一个“标准流程基线”:扫描、过滤、连接、发现服务、读写、订阅,每一步都有对应的回调方法。从那以后,我每次在真机上验证 BLE 功能,都会强制走一遍这六个环节,并且把onConnectionStateChange、onServicesDiscovered、onCharacteristicChanged的日志打印到文件里。确认哪个环节没日志,问题就锁定在哪一环。自己写项目时也沿用 demo 的模块拆分,省掉了大量排查耗时。推荐你也把它下载下来,跑通一遍再做自己的业务层,能少走很多弯路,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进