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而是以GATTGeneric 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:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / !-- 定位权限BLE 扫描在 Android 6.0 被归类为定位行为 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /!-- Android 12API 31及以上权限收得更严 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.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 一律用falsetrue只适合做“靠近自动连接”这类后台功能。连接之后还有一个判断条件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与安卓默认值不匹配或者连接请求因autoConnecttrue走了后台慢速路径响应超时。解决两个方法配合。第一连接前显式请求系统使用更短的连接参数gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)第二把connectGatt的autoConnect改为false。我遇到过一个极端情况某款外设只在autoConnectfalse时才能稳定连接换成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()并把引用置空重连前至少延迟 500msoverride 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 里的做法是连接后立刻协商 MTUgatt.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 的模块拆分省掉了大量排查耗时。推荐你也把它下载下来跑通一遍再做自己的业务层能少走很多弯路希望帮到你。本文还有配套的精品资源点击获取