简介这份资源是面向Android开发者的低功耗BLE蓝牙连接示例代码适合正在接触物联网、智能硬件通信的初中级开发者参考。内容围绕BluetoothGatt核心接口展开涵盖扫描设备、建立连接、发现服务、读写特征值以及开启通知等完整流程并涉及连接失败、设备丢失、服务未发现等异常情况的回调处理帮助读者理解BLE设备与服务的层次结构。资源包为rar格式共59个文件约521KB包含25个class、15个xml、6个java源码及png图标、jar依赖、prefs配置等其中java与class对应示例逻辑xml与png用于界面和资源jar提供支持库结构清晰便于对照阅读。目前已有1022人学习下载开发者可借助这份代码快速搭建BLE通信骨架掌握BluetoothGattCallback回调机制为健康监测、智能家居等场景的数据交互提供可复用的实现参考。1. 低功耗 BLE 连接为什么总在真机上翻车做过 Android 蓝牙的人大概都有过这种体验在模拟器或者实验室里跑得好好的 BLE 连接示例代码一拿到真机、一进用户手里就变成玄学——有时秒连有时扫描十分钟搜不到设备有时连上了三秒就断有时息屏后再也回不来。问题不在代码写错了而在于 Android 的 BLE 栈本身就是个黑匣子扫描、连接、GATT 回调分散在四个不同线程厂商 ROM 又各自魔改权限模型从 Android 6 到 Android 14 换了四轮。你照着某篇三年前的示例代码抄下来大概率在 Android 12 以上直接崩在权限申请那一步。这篇笔记要讲清楚的是一份能真正在真机上跑通的 Android 低功耗 BLE 连接示例代码到底应该长什么样。我会从扫描参数怎么设、连接回调怎么串、GATT 服务怎么发现、断线怎么重连这几个环节把一份最小可复现的代码骨架拆开讲同时把那些只有踩过才知道的坑标出来。适合两类人刚接触 BLE 想找一个能直接跑的起点的新手以及被厂商兼容性折磨过、想回头检查自己连接流程有没有漏项的熟手。全文代码基于 Kotlin用 Android 原生 BluetoothLeScanner 和 BluetoothGatt不引入第三方库方便你直接对照自己的工程改。2. 扫描与连接的最小可跑骨架从权限到 GATT 回调2.1 先把权限和运行时申请理清楚Android 的 BLE 权限是分水岭式的。Android 6 到 11扫描需要BLUETOOTH和BLUETOOTH_ADMIN同时因为扫描能推断位置还需要ACCESS_FINE_LOCATION并且打开系统定位开关。Android 12API 31开始引入BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE三个运行时权限同时如果你不做位置推断可以在BLUETOOTH_SCAN上声明android:usesPermissionFlagsneverForLocation来免掉定位权限。这一步搞错后面所有代码都是白写。!-- AndroidManifest.xml 权限声明按 API 分版本 -- uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / !-- Android 12 以下扫描需要定位权限 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 / !-- Android 12 及以上 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / !-- BLE 硬件特性requiredfalse 允许无 BLE 设备安装 -- uses-feature android:nameandroid.hardware.bluetooth_le android:requiredfalse /逻辑说明maxSdkVersion是关键它让旧权限只在新系统以下生效避免 Android 12 上重复申请定位权限被用户反感。neverForLocation这个 flag 告诉系统你的扫描结果不用于推断位置系统就不会强制要求定位权限但代价是扫描结果里部分 beacon 会被过滤掉。参数上如果你的业务确实需要从 BLE 信号推断位置就不要加这个 flag老老实实申请ACCESS_FINE_LOCATION。运行时申请要按版本分支不能一把梭// 按 API 版本组装需要动态申请的权限列表 private fun requiredPermissions(): ArrayString { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT ) } else { arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) } } // 在 Activity 里发起申请requestCode 自定义 private val launcher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result - val allGranted result.values.all { it } if (allGranted) startScan() else showPermissionDenied() }逻辑说明Android 12 以下只申请定位权限就够了因为BLUETOOTH和BLUETOOTH_ADMIN是安装时授予的普通权限不需要动态申请。Android 12 及以上则必须动态申请BLUETOOTH_SCAN和BLUETOOTH_CONNECT。这里用registerForActivityResult而不是旧的onRequestPermissionsResult是因为后者在新版本里已经被标记为不推荐且容易在配置变更时丢回调。2.2 扫描参数怎么设才不丢设备扫描是 BLE 里最容易被忽视的一环。很多人直接调startScan(leScanCallback)不带ScanSettings用系统默认的SCAN_MODE_LOW_POWER结果就是扫描窗口极短、间隔极长广播间隔稍大的设备根本扫不到。正确的做法是根据场景选模式。// 构建扫描设置平衡功耗与发现速度 private fun buildScanSettings(): ScanSettings { return ScanSettings.Builder() // 前台扫描用 LOW_LATENCY后台用 LOW_POWER .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 匹配模式AGGRESSIVE 会为每个广播都回调STICKY 只在首次发现时回调 .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) // 报告延迟0 表示立即上报单位毫秒 .setReportDelay(0L) .build() } // 扫描回调运行在 Binder 线程不要在这里做耗时操作 private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device result.device ?: return val name result.scanRecord?.deviceName ?: unknown // 按名称或 MAC 过滤目标设备 if (name TARGET_DEVICE_NAME) { stopScan() connectToDevice(device) } } override fun onScanFailed(errorCode: Int) { // errorCode 常见值1 表示 SCAN_FAILED_ALREADY_STARTED Log.e(TAG, scan failed: $errorCode) } }逻辑说明SCAN_MODE_LOW_LATENCY是前台扫描的首选它用最高的占空比换取最快的发现速度代价是功耗高所以扫到目标后要立刻stopScan()。setReportDelay(0)表示每个广播包都立即回调如果设成比如 2000 毫秒系统会批量上报适合后台低功耗场景但会引入延迟。onScanResult运行在 Binder 线程绝对不能在这里做网络请求或数据库写入否则会阻塞扫描回调导致丢包。参数上有一个容易忽略的点setMatchMode和setCallbackType的组合会影响回调频率。MATCH_MODE_AGGRESSIVE配合CALLBACK_TYPE_ALL_MATCHES会让每个广播都触发回调如果你只关心设备首次出现应该用CALLBACK_TYPE_FIRST_MATCH这样同一个设备只回调一次能显著降低上层处理压力。2.3 连接与 GATT 服务发现的标准流程连接本身很简单device.connectGatt()一行就够难的是连接之后的状态机管理。BLE 连接是异步的connectGatt返回的BluetoothGatt对象只是一个句柄真正的连接结果在onConnectionStateChange回调里。// 连接设备autoConnect 设为 false 保证直连 private fun connectToDevice(device: BluetoothDevice) { // Android 13 以上 connectGatt 需要 BLUETOOTH_CONNECT 权限 gatt device.connectGatt( this, false, // autoConnectfalse直接连接超时约 30 秒 gattCallback, BluetoothDevice.TRANSPORT_LE // 明确指定 LE 传输 ) } private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (status ! BluetoothGatt.GATT_SUCCESS) { // status 非 0 说明连接出错常见 133 Log.e(TAG, connection error, status$status) gatt.close() return } when (newState) { BluetoothProfile.STATE_CONNECTED - { // 连接成功必须调用 discoverServices 才能拿到服务 // 注意discoverServices 返回 false 说明请求没发出去 val ok gatt.discoverServices() Log.i(TAG, connected, discoverServices$ok) } BluetoothProfile.STATE_DISCONNECTED - { // 断开后必须 close否则 GATT 资源泄漏 gatt.close() gatt null } } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status ! BluetoothGatt.GATT_SUCCESS) { Log.e(TAG, service discovery failed: $status) return } // 遍历服务找到目标 Service UUID val service gatt.getService(TARGET_SERVICE_UUID) ?: return val characteristic service.getCharacteristic(TARGET_CHAR_UUID) ?: return // 使能通知写入 CCCD 描述符 val cccd characteristic.getDescriptor(CCCD_UUID) gatt.setCharacteristicNotification(characteristic, true) cccd?.let { it.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(it) } } }逻辑说明connectGatt的第二个参数autoConnect是分水岭。设为false时是直接连接系统会主动发起连接请求超时大约 30 秒设为true时是自动连接系统会在设备出现时后台连接功耗低但延迟不可控适合需要长期保持连接的场景。第三个参数TRANSPORT_LE在双模设备上很重要不指定可能走经典蓝牙通道导致连不上。onConnectionStateChange里有一个血泪经验status非GATT_SUCCESS时尤其是 133 这个经典错误码必须调用gatt.close()而不是gatt.disconnect()。133 通常表示底层连接已经断了但上层不知道此时disconnect()会卡住只有close()能释放资源。另外discoverServices()返回false说明请求没发出去通常是因为连接还没真正就绪需要等一小段时间重试。2.4 读写特征值的正确姿势服务发现之后读写特征值是最常用的操作。但 BLE 的读写是串行的同一时间只能有一个 GATT 操作在飞行中如果你连续调两次writeCharacteristic第二次会直接返回false。// 写特征值带写类型选择 private fun writeCharacteristic(gatt: BluetoothGatt, data: ByteArray) { val service gatt.getService(TARGET_SERVICE_UUID) ?: return val ch service.getCharacteristic(TARGET_CHAR_UUID) ?: return // 根据特征属性选择写类型 ch.writeType if ((ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE) ! 0) { BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT // 有响应写 } else { BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE // 无响应写 } ch.value data val ok gatt.writeCharacteristic(ch) Log.i(TAG, write issued: $ok) } // 写回调确认写入结果 override fun onCharacteristicWrite( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { if (status BluetoothGatt.GATT_SUCCESS) { // 写入成功可以发起下一个操作 pendingQueue.poll()?.invoke() } else { Log.e(TAG, write failed: $status) } }逻辑说明writeType必须和特征的属性匹配。如果特征只支持PROPERTY_WRITE_NO_RESPONSE你却设成WRITE_TYPE_DEFAULT写入会失败。判断方式是读characteristic.properties的位掩码。onCharacteristicWrite回调是串行操作的同步点如果你有多个写操作应该用一个队列管理上一个写回调成功后再发下一个否则后面的写会静默失败。读操作类似readCharacteristic的结果在onCharacteristicRead回调里characteristic.value才是真正的数据。注意 Android 13 开始readCharacteristic和writeCharacteristic的行为有调整部分 ROM 上需要额外检查返回值。3. 连接稳定性与断线重连把状态机管起来3.1 为什么你的连接总是莫名其妙断掉BLE 断连的原因五花八门但归纳起来无非几类距离超限、信号干扰、设备主动断开、系统回收资源、以及最坑的厂商省电策略。要定位问题第一步是在onConnectionStateChange里把status和newState都打出来同时记录时间戳这样能看出是连接后立刻断还是运行一段时间后断。一个常见的误区是认为连接成功就万事大吉。实际上 BLE 连接建立后如果双方在connSupervisionTimeout时间内没有有效的数据交互链路层会认为连接丢失。这个超时时间由连接参数决定Android 默认值通常在 20 秒左右但厂商可以改。如果你的应用连上后长时间不通信就可能被底层断开。// 连接参数请求在 onConnectionStateChange 连接成功后调用 private fun requestConnectionPriority(gatt: BluetoothGatt) { // 高优先级连接间隔 11.25ms 左右适合需要快速响应的场景 val ok gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) Log.i(TAG, request priority high: $ok) }逻辑说明requestConnectionPriority有三个档位。CONNECTION_PRIORITY_HIGH连接间隔最短、功耗最高适合实时性要求高的场景比如音频或游戏手柄CONNECTION_PRIORITY_BALANCED是默认档CONNECTION_PRIORITY_LOW_POWER间隔最长、最省电适合传感器周期上报。注意这个请求不保证被外设接受外设可以拒绝或协商成别的参数。3.2 断线重连的状态机设计断线重连不能简单地递归调用connectGatt那样会在设备不在范围内时疯狂重试既耗电又可能触发系统限流。正确的做法是维护一个连接状态机区分「主动断开」和「被动断开」被动断开才触发重连并且重连要有退避策略。// 连接状态枚举 enum class ConnState { IDLE, SCANNING, CONNECTING, CONNECTED, DISCONNECTED } // 重连退避第 n 次重试等待 min(2^n * 1000, 30000) 毫秒 private fun scheduleReconnect(attempt: Int) { if (attempt MAX_RETRY) { Log.w(TAG, reconnect gave up after $MAX_RETRY attempts) return } val delay min(1000L * (1 shl attempt), 30_000L) handler.postDelayed({ if (state ConnState.DISCONNECTED) { startScan() // 重新扫描而不是直接连因为设备可能换了地址 } }, delay) }逻辑说明退避策略用指数增长第一次等 2 秒第二次 4 秒第三次 8 秒封顶 30 秒。这样在设备短暂离开范围时能快速恢复设备长时间不在时又不会耗尽电量。重连时重新扫描而不是直接connectGatt是因为部分设备尤其是用随机 MAC 的断开后地址会变直接连旧地址必然失败。状态机的另一个要点是区分断开原因。如果用户在界面上点了「断开」那是主动断开不应该触发重连如果是onConnectionStateChange里newState STATE_DISCONNECTED且不是用户操作导致的才走重连逻辑。用一个userInitiatedDisconnect布尔量标记就能区分。3.3 息屏与后台连接保活Android 从 6.0 引入 Doze 模式后息屏后 BLE 扫描会被限制连接也可能被挂起。如果你的应用需要在后台保持 BLE 连接必须申请前台服务并且声明FOREGROUND_SERVICE_CONNECTED_DEVICE类型Android 14 起强制。// 启动前台服务保活 BLE 连接 private fun startBleForegroundService() { val notification NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(BLE 连接中) .setSmallIcon(R.drawable.ic_ble) .build() // Android 14 需要指定 foregroundServiceType if (Build.VERSION.SDK_INT Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) } else { startForeground(1, notification) } }逻辑说明前台服务是后台保活的唯一可靠手段但 Android 14 开始要求声明具体的foregroundServiceTypeBLE 连接对应CONNECTED_DEVICE。同时需要在 manifest 里声明FOREGROUND_SERVICE和FOREGROUND_SERVICE_CONNECTED_DEVICE权限。注意前台服务不是万能的部分厂商 ROM 仍然会在锁屏后清理这时候需要引导用户把应用加入电池优化白名单。4. 避坑与排查那些让连接失败的经典原因4.1 坑一133 错误码反复出现现象onConnectionStateChange里status频繁返回 133连接刚建立就断重试依然 133。原因133 是 Android BLE 栈最含糊的错误码通常表示底层 GATT 连接失败。常见诱因有三个一是连接前没有stopScan()扫描和连接同时进行导致控制器资源冲突二是设备已经被系统里其他应用连接BLE 外设通常只支持单连接三是连接参数不兼容尤其是 MTU 协商失败。解决连接前确保stopScan()已调用并等待扫描回调停止在系统蓝牙设置里确认设备未被其他应用占用连接成功后先请求 MTU用requestMtu(247)并等onMtuChanged回调再继续后续操作。如果 133 持续出现调用gatt.close()后等 500 毫秒再重试不要立即重连。4.2 坑二Android 12 以上扫描返回空列表现象权限都申请了startScan也返回成功但onScanResult一个都不回调。原因最常见的是漏了BLUETOOTH_SCAN权限的动态申请或者申请了但用户选了「仅本次允许」应用退到后台后权限失效。另一个原因是neverForLocation声明了但设备广播里带有位置相关数据被系统过滤。解决在startScan前用checkSelfPermission再确认一次权限状态如果用了neverForLocation测试时先用不带该 flag 的版本确认能扫到设备再决定是否加。另外注意 Android 12 上如果同时申请了定位权限和BLUETOOTH_SCAN系统可能只授予其中一个要检查BLUETOOTH_SCAN的实际授予结果。4.3 坑三onCharacteristicChanged 不回调现象setCharacteristicNotification返回 true但外设发数据时onCharacteristicChanged就是不触发。原因setCharacteristicNotification只是让 Android 侧准备接收通知真正让外设开始发通知的是写入 CCCD 描述符UUID 通常是00002902-0000-1000-8000-00805f9b34fb。很多人只调了前者没写描述符或者写描述符时writeDescriptor返回 false 但没检查。解决确认 CCCD 描述符存在且writeDescriptor返回 true写描述符后等onDescriptorWrite回调成功再认为通知使能完成部分外设要求先写 CCCD 再写其他配置顺序不能反。如果还是不行用抓包工具确认外设是否真的发出了通知。4.4 坑四多设备连接时 GATT 操作串扰现象同时连接两个 BLE 设备操作设备 A 的特征值时设备 B 的回调被触发或者操作直接失败。原因BluetoothGatt的操作是全局串行的Android 的 BLE 栈同一时间只允许一个 GATT 操作。如果你在设备 A 的onCharacteristicWrite还没回来时就去操作设备 B后一个操作会被丢弃。解决为每个BluetoothGatt维护独立的操作队列但队列之间要加全局锁确保同一时刻只有一个 GATT 操作在飞行。或者更简单粗暴多设备场景下串行处理等一个设备的操作完全结束后再操作下一个。这个限制在 Android 13 之后有所放宽但为了兼容性按串行设计最稳妥。4.5 坑五close 之后回调还在触发现象调用了gatt.close()但onConnectionStateChange或onCharacteristicRead还在回调导致空指针。原因close()是异步的它只是释放了BluetoothGatt对象的资源但已经排队的回调可能还会执行。如果回调里引用了已经置空的gatt变量就会崩。解决在回调入口先判断gatt是否等于当前持有的实例不等就直接 return。同时用一个isClosed标志位close()时置 true回调里检查这个标志。不要依赖gatt ! null来判断因为回调传入的gatt参数可能不是 null 但已经失效。5. 用日志和抓包把连接问题钉死排查 BLE 问题光看代码是不够的必须结合日志和空中抓包。Android 侧最有用的是打开蓝牙协议栈的 verbose 日志在开发者选项里开启「启用蓝牙 HCI 信息收集日志」然后抓取btsnoop_hci.log用 Wireshark 打开就能看到完整的 HCI 交互。这个日志能看到扫描请求、连接请求、MTU 协商、特征值读写的每一个空中包是定位问题的后悔药。我一般会在代码里加一层结构化日志把每个关键节点的时间戳、状态、耗时都打出来// 结构化日志记录操作耗时 private inline fun T logCost(tag: String, block: () - T): T { val start SystemClock.elapsedRealtime() val result block() val cost SystemClock.elapsedRealtime() - start Log.d(TAG, $tag cost${cost}ms result$result) return result } // 使用示例记录 discoverServices 耗时 logCost(discoverServices) { gatt.discoverServices() }逻辑说明elapsedRealtime比currentTimeMillis更适合测耗时因为它不受系统时间调整影响。把扫描、连接、服务发现、MTU 协商、通知使能这几个阶段的耗时都打出来就能看出瓶颈在哪。正常情况扫描发现应该在 1 到 3 秒内连接在 1 秒内服务发现在 500 毫秒内。如果某个阶段明显偏长就针对性地查。验证连接是否真正稳定我习惯做一个「连续读写压测」连接成功后每秒读一次特征值连续跑 10 分钟统计成功率和平均耗时。如果成功率低于 99%说明连接参数或重连逻辑有问题。这个压测能暴露很多偶发问题比如息屏后连接降级、信号弱时丢包重传。还有一个具体技巧在onConnectionStateChange里记录从connectGatt调用到回调的实际耗时。如果这个耗时超过 10 秒说明设备广播间隔太长或者信号太弱应该调整扫描策略而不是盲目重连。我踩过最深的坑就是在一个广播间隔 2 秒的设备上用了SCAN_MODE_LOW_POWER结果扫描窗口只有 500 毫秒永远扫不到换成SCAN_MODE_LOW_LATENCY后秒连。BLE 这东西参数不对就是玄学参数对了就是确定性工程。希望帮到你。本文还有配套的精品资源点击获取