Android蓝牙开发避坑指南:从权限适配到稳定通信全链路
发布时间:2026/10/16 23:01:49 锦皓数字建站

简介本资源是一个面向Android开发初学者与中级工程师的蓝牙通信实战Demo聚焦经典蓝牙SPP场景下的设备发现、配对连接、消息收发等核心功能实现有效解决移动端短距离无线通信的入门实践难题。压缩包共1544个文件24.82MB包含552个XML布局与配置文件、498个PNG图标资源、162个编译后class字节码、133个JSON配置及元数据、11个Java源文件含BluetoothAdapter调用、BroadcastReceiver监听、BluetoothSocket连接与IO流处理等关键逻辑以及APK安装包和Gradle构建脚本结构完整可直接导入Android Studio运行调试。已有869人学习下载代码组织清晰覆盖权限声明、异常捕获、UI状态反馈与基础安全校验等工程化细节是理解Android蓝牙API体系与快速搭建通信原型的优质参考模板。1. Android 蓝牙通讯 Demo不是连上就行而是让设备在 3 米内稳定收发 200 字节/秒、不丢包、不重连的最小可验证闭环你写了个BluetoothAdapter.enable()Logcat 显示“enabled”就以为蓝牙通了错。真实产线里某智能工装模块在产线传送带旁部署后每 7 分钟自动断连一次某医疗手环 Demo 在测试机上跑得飞起换三台同型号新机就卡在fetchUuidsWithSdp()死循环还有开发者把createRfcommSocketToServiceRecord()放在主线程——UI 冻结 12 秒用户直接划掉应用。这不是玄学是 Android 蓝牙栈从 API 10 到 API 34 的权限演进、协议栈分层、状态机跳转、线程模型和底层 HCI 缓冲区共同作用的结果。这个 Demo 不是教你怎么点开系统设置配对而是带你从零构建一个能扛住弱信号、支持重连退避、区分经典蓝牙BR/EDR与低功耗蓝牙BLE双模式、且所有通信逻辑可打断、可日志追踪、可单元测试的通讯基座。适合正在做蓝牙外设对接如体征采集仪、工业传感器、POS 扫码枪、需要嵌入式侧联调、或正被「配对成功但发不出数据」问题卡住三天的 Android 开发者。它不依赖任何第三方 SDK只用原生android.bluetooth包代码量控制在 400 行以内但覆盖了从发现设备、建立连接、读写数据到异常恢复的全链路。2. 从权限声明到设备发现为什么你的 App 在 Android 12 上根本搜不到隔壁手机2.1 权限清单必须按 Android 版本阶梯式声明漏掉任意一层发现即失败Android 对蓝牙扫描的管控逐年收紧。API 31Android 12起BLUETOOTH_ADMIN已废弃API 33Android 13起后台扫描需额外BLUETOOTH_SCAN运行时权限而从 API 34Android 14开始即使前台扫描也强制要求NEARBY_DEVICES权限且该权限属于dangerous组必须动态申请。更关键的是ACCESS_FINE_LOCATION不再是“可选”而是BLUETOOTH_SCAN的硬性前置条件——因为蓝牙地址BD_ADDR在物理层可被用于粗略定位。!-- AndroidManifest.xml -- uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / !-- 以下为 API 31 必须 -- uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / !-- API 33 后台扫描需此权限 -- uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE / !-- 定位权限非可选是蓝牙扫描的法定前提 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / !-- API 34 新增替代旧权限组 -- uses-permission android:nameandroid.permission.NEARBY_DEVICES /提示NEARBY_DEVICES权限在targetSdkVersion 34时不可降级回BLUETOOTH_SCAN。若你仍用BLUETOOTH_SCAN系统会静默拒绝扫描请求Logcat 仅输出模糊的Scan failed: reason2即SCAN_FAILED_INTERNAL_ERROR不会提示缺权限。2.2 动态权限申请必须分两步走先位置再蓝牙且顺序不可逆很多翻车源于在onRequestPermissionsResult里一把梭哈所有权限。正确做法是先确保ACCESS_FINE_LOCATION已获准再申请NEARBY_DEVICES。因为系统会校验位置服务是否开启——若用户仅授予权限但关闭 GPS/Wi-Fi 定位NEARBY_DEVICES申请会直接返回PERMISSION_DENIED。// Kotlin 示例分步权限申请 private fun requestLocationThenBluetooth() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), REQUEST_CODE_LOCATION ) } else { requestNearbyDevicesPermission() // 位置已就绪再申蓝牙 } } private fun requestNearbyDevicesPermission() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34 ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.NEARBY_DEVICES), REQUEST_CODE_NEARBY ) } else { // API 31-33 ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT), REQUEST_CODE_BLUETOOTH ) } }逻辑说明REQUEST_CODE_LOCATION和REQUEST_CODE_NEARBY必须不同否则onRequestPermissionsResult无法区分回调来源。参数说明Manifest.permission.NEARBY_DEVICES是 Android 14 引入的聚合权限覆盖扫描、连接、广播三类操作不再需要单独申请BLUETOOTH_SCAN等子权限。2.3 设备发现必须用BluetoothLeScannerScanCallbackstartDiscovery()已成历史BluetoothAdapter.startDiscovery()在 API 31 后被标记为Deprecated且在 Android 12 上实际调用会抛SecurityException。真实可用的发现方式只有两种经典蓝牙BR/EDR设备用fetchUuidsWithSdp()主动探测已配对设备的服务 UUID适用于已知目标 MAC 地址BLE 设备主流必须用BluetoothLeScanner启动异步扫描通过ScanCallback接收ScanResult。private fun startBleScan() { val scanner bluetoothManager.adapter.bluetoothLeScanner val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 高频扫描适合调试 .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) // 提高匹配灵敏度 .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) // 最大匹配数 .build() val filter ScanFilter.Builder() .setDeviceAddress(AA:BB:CC:DD:EE:FF) // 可选精确过滤目标设备 .build() scanner.startScan(listOf(filter), settings, scanCallback) } private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { Log.d(BLE, Found: ${result.device.address} RSSI: ${result.rssi}) // 此处可触发连接逻辑 connectToDevice(result.device) } override fun onBatchScanResults(results: ListScanResult) { // 批量结果回调省电模式下触发 } override fun onScanFailed(errorCode: Int) { Log.e(BLE, Scan failed: $errorCode) // errorCode1: SCAN_FAILED_ALREADY_STARTED // errorCode2: SCAN_FAILED_APPLICATION_REGISTRATION_FAILED // errorCode3: SCAN_FAILED_INTERNAL_ERROR } }参数说明SCAN_MODE_LOW_LATENCY每 100ms 扫描一次耗电高但响应快Demo 阶段必用MATCH_MODE_AGGRESSIVE降低匹配阈值避免因信号抖动漏掉设备setDeviceAddress()是硬过滤生产环境慎用——BLE 设备地址可能随机化应优先用ScanFilter.Builder().setServiceUuid()匹配服务 UUID。3. 建立可靠连接为什么createRfcommSocketToServiceRecord()总是 timeout3.1 经典蓝牙SPP连接必须绕过createRfcommSocketToServiceRecord()的坑BluetoothDevice.createRfcommSocketToServiceRecord(UUID)是最常被误用的 API。它内部会发起 SDP 查询而 Android 系统对 SDP 的超时控制极不友好默认 12 秒无响应即抛IOException且该超时不可配置。更糟的是某些国产 ROM如某实验室测试用的定制 Android 11会静默拦截 SDP 请求导致永远卡在connect()。正确解法硬编码 RFCOMM 通道号用createInsecureRfcommSocketToServiceRecord()绕过 SDP。前提是你已知目标设备的 SPP 通道号通常为 1可通过sdptool browse在 Linux 下查或设备文档确认。private fun createInsecureSocket(device: BluetoothDevice): BluetoothSocket? { return try { // 硬编码通道号 1跳过 SDP 查询 val method device.javaClass.getMethod( createInsecureRfcommSocket, Integer.TYPE ) method.invoke(device, 1) as BluetoothSocket } catch (e: Exception) { Log.e(SPP, Create insecure socket failed, e) null } } // 连接时启用超时保护 private fun connectWithTimeout(socket: BluetoothSocket, timeoutMs: Int 10_000) { val thread Thread { try { socket.connect() // 此处阻塞 } catch (e: IOException) { Log.e(SPP, Connect failed, e) } } thread.start() thread.join(timeoutMs) // 主线程等待最多 timeoutMs if (thread.isAlive) { Log.w(SPP, Connect timeout, closing socket) socket.close() } }逻辑说明createInsecureRfcommSocketToServiceRecord()是隐藏 API需用反射调用thread.join(timeoutMs)是唯一可控的连接超时方案——socket.connect()本身不支持传入 timeout 参数。参数说明timeoutMs10_000是经验值低于 8000ms 易受系统调度影响误判高于 15000ms 用户体验差。3.2 BLE 连接必须用BluetoothGatt状态机别在onConnectionStateChange里直接读特征值BLE 连接是典型的异步状态机。常见错误是在onConnectionStateChange回调中立即调用gatt.readCharacteristic()此时 GATT 服务尚未发现onServicesDiscovered未触发必然返回false。private var gatt: BluetoothGatt? null private fun connectToBleDevice(device: BluetoothDevice) { gatt device.connectGatt(this, false, gattCallback) } private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { Log.d(BLE, Connected, discovering services...) gatt.discoverServices() // 必须显式调用 } else if (newState BluetoothProfile.STATE_DISCONNECTED) { Log.d(BLE, Disconnected) } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { Log.d(BLE, Services discovered) // 此时才能安全获取 characteristic val service gatt.getService(UUID.fromString(0000ffe0-0000-1000-8000-00805f9b34fb)) val characteristic service?.getCharacteristic( UUID.fromString(0000ffe1-0000-1000-8000-00805f9b34fb) ) if (characteristic ! null) { gatt.readCharacteristic(characteristic) // 现在可以读了 } } } override fun onCharacteristicRead( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { if (status BluetoothGatt.GATT_SUCCESS) { Log.d(BLE, Read success: ${characteristic.value.contentToString()}) } } }逻辑说明discoverServices()是 GATT 通信的基石它向设备发起 ATT 协议请求获取服务、特征、描述符的完整拓扑。参数说明两个 UUID 示例为常见 BLE 串口透传服务Nordic UART Service实际使用需替换为目标设备的官方 UUID。4. 数据收发与粘包处理为什么你收到的字节流总是错位、截断、或混在一起4.1 经典蓝牙SPP必须用InputStreamBufferedInputStream防粘包SPP 基于 RFCOMM本质是流式协议没有消息边界。InputStream.read()可能一次只读到 1 字节也可能一次读到 200 字节全看底层 HCI 缓冲区和系统调度。直接read(buffer)会导致数据错位。private fun startSppDataLoop(socket: BluetoothSocket) { val input BufferedInputStream(socket.inputStream, 1024) // 缓冲区设为 1KB val buffer ByteArray(1024) Thread { while (isConnected !Thread.currentThread().isInterrupted) { try { val len input.read(buffer) // 阻塞读取 if (len 0) { val data buffer.copyOfRange(0, len) handleSppData(data) // 交由业务逻辑处理 } } catch (e: IOException) { Log.e(SPP, Read error, e) break } } }.start() } private fun handleSppData(data: ByteArray) { // 方案一按固定帧头帧尾解析推荐 Demo 使用 // 假设帧格式0xAA LEN(1B) PAYLOAD(LEN B) 0x55 var i 0 while (i data.size) { if (data[i] 0xAA.toByte()) { if (i 2 data.size) { val len data[i 1].toInt() and 0xFF if (i 2 len 1 data.size data[i 2 len] 0x55.toByte()) { val payload data.copyOfRange(i 2, i 2 len) Log.d(SPP, Frame received: ${payload.contentToString()}) i 2 len 1 // 跳过整帧 continue } } } i } }逻辑说明BufferedInputStream减少系统调用次数提升吞吐handleSppData()实现简易帧解析避免粘包。参数说明0xAA/0x55是自定义帧头帧尾生产环境建议用 CRC 校验len字段确保能准确切分 payload防止跨包解析。4.2 BLE 特征值读写必须用BluetoothGattCharacteristic的WRITE_TYPE_NO_RESPONSE模式保实时性BLE 写入默认是WRITE_TYPE_DEFAULT即写后必须等设备返回onCharacteristicWrite()确认。这对传感器上报等低延迟场景是灾难——单次往返至少 50ms吞吐上限约 20Hz。改用WRITE_TYPE_NO_RESPONSE可将写入频率推至 100Hz但需承担丢包风险。private fun writeBleData(gatt: BluetoothGatt, data: ByteArray) { val characteristic gatt.getService(SERVICE_UUID)?.getCharacteristic(CHAR_UUID) characteristic?.let { it.value data it.writeType BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE // 关键 gatt.writeCharacteristic(it) } } // 读取时注意onCharacteristicChanged() 是通知回调需先 enable notification private fun enableNotification(gatt: BluetoothGatt) { val characteristic gatt.getService(SERVICE_UUID)?.getCharacteristic(CHAR_UUID) characteristic?.let { gatt.setCharacteristicNotification(it, true) val descriptor it.getDescriptor( UUID.fromString(00002902-0000-1000-8000-00805f9b34fb) // CCCD ) descriptor?.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) } }参数说明WRITE_TYPE_NO_RESPONSE仅适用于“发了就不管”的场景如实时姿态数据ENABLE_NOTIFICATION_VALUE是标准 CCCDClient Characteristic Configuration Descriptor值用于开启服务端主动推送。5. 避坑那些让你加班到凌晨三点的 BLE/SPP 连接黑匣子5.1 现象BluetoothAdapter.isEnabled()返回 true但getBondedDevices()为空且startDiscovery()无反应原因Android 系统在某些 ROM尤其厂商深度定制版中isEnabled()仅表示蓝牙射频已开启但协议栈Bluedroid 或 Fluoride可能未加载完成。此时getBondedDevices()和startDiscovery()会静默失败。解决增加协议栈就绪检测——轮询BluetoothAdapter.getState()等待其从STATE_TURNING_ON变为STATE_ON后再执行后续操作。实测某国产平板需等待 1.8 秒。5.2 现象BLE 连接后onServicesDiscovered()不触发Logcat 显示D/BluetoothGatt: onSearchComplete() 0原因目标设备未实现完整的 GATT Server或服务 UUID 未在advertising packet中广播。discoverServices()依赖设备主动响应 ATT 请求若设备固件有缺陷如未处理Find Information Request则永远卡住。解决改用BluetoothGattServer模式反向验证——用另一台 Android 设备作为 GATT Server 广播服务确认本机 Client 能正常发现。若能发现 Server则问题在目标设备固件。5.3 现象SPP 连接成功但OutputStream.write()抛IOException: Broken pipe原因远端设备如单片机在连接建立后未及时调用accept()或listen()导致 TCP 层 RST 包返回。Android 底层将其映射为Broken pipe。解决在远端设备侧增加连接确认机制——SPP 连接后Android 先发送0xFF 0x00心跳远端收到后回复0x00 0xFF双方均确认后再进入数据传输。此心跳必须在connect()成功后 500ms 内完成。5.4 现象Android 13 设备上BluetoothLeScanner.startScan()报SecurityException: Need ACCESS_FINE_LOCATION但权限已申请原因ACCESS_FINE_LOCATION权限虽已授予但系统设置中“位置信息”总开关被关闭Settings Location toggle off。Android 13 将此视为权限未满足。解决在申请NEARBY_DEVICES前用LocationManager.isLocationEnabled()检测并引导用户跳转设置页if (!locationManager.isLocationEnabled()) { startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }5.5 现象BLE 通知notification开启后onCharacteristicChanged()仅触发一次后续无回调原因BluetoothGatt.setCharacteristicNotification()调用后未紧接着writeDescriptor()写入 CCCD。部分设备尤其 Nordic nRF52 系列要求 CCCD 值必须为0x0001enable notify且写入成功否则忽略后续通知。解决严格按顺序执行——先setCharacteristicNotification(true)再getDescriptor(CCCD_UUID)再writeDescriptor()并在onDescriptorWrite()中确认status GATT_SUCCESS。6. 生产就绪技巧用BluetoothGatt的refresh()方法解决连接漂移与缓存污染6.1 为什么你需要refresh()GATT 缓存不是帮你是在拖你后腿Android 系统为提升 BLE 性能默认缓存设备的 GATT 服务拓扑。这带来一个隐蔽陷阱当设备固件升级后服务 UUID 或特征值长度变更但 Android 仍沿用旧缓存导致gatt.readCharacteristic()返回null或GATT_INVALID_ATTRIBUTE_LENGTH。更糟的是refresh()是隐藏 API官方文档从未提及但它是解决此问题的唯一合法手段。private fun refreshGattCache(gatt: BluetoothGatt): Boolean { return try { val refreshMethod gatt.javaClass.getMethod(refresh) val result refreshMethod.invoke(gatt) as Boolean Log.d(BLE, GATT cache refreshed: $result) result } catch (e: Exception) { Log.e(BLE, Refresh failed, e) false } } // 在连接成功后、discoverServices前调用 override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { refreshGattCache(gatt) // 关键清空旧缓存 gatt.discoverServices() } }逻辑说明refresh()强制清空本地 GATT 缓存迫使discoverServices()重新从设备读取完整服务表。参数说明该方法返回true表示缓存已清空但不保证后续discoverServices()成功——它只是移除错误前提。6.2 构建可中断的连接状态机用AtomicBoolean控制生命周期避免内存泄漏Demo 很容易写成“连接 → 启动线程 → 线程里死循环读”结果 Activity 销毁后线程仍在后台运行持有 Activity Context 导致 OOM。正确做法是用AtomicBoolean标记连接状态并在onDestroy()中主动中断。private val isConnected AtomicBoolean(false) private var readThread: Thread? null private fun startReading(socket: BluetoothSocket) { isConnected.set(true) readThread Thread { val input BufferedInputStream(socket.inputStream) val buffer ByteArray(1024) while (isConnected.get() !Thread.currentThread().isInterrupted) { val len input.read(buffer) if (len 0) { handleData(buffer.copyOfRange(0, len)) } } } readThread?.start() } override fun onDestroy() { super.onDestroy() isConnected.set(false) // 标记停止 readThread?.interrupt() // 中断线程 gatt?.close() // 关闭 GATT bluetoothSocket?.close() // 关闭 SPP socket }注意AtomicBoolean比volatile boolean更可靠因为它保证了get()/set()的原子性避免多线程下状态判断错乱。6.3 日志分级与可追溯性给每条蓝牙日志打上设备 ID 与操作阶段标签没有上下文的日志等于没有日志。在onConnectionStateChange、onServicesDiscovered、onCharacteristicRead等所有回调中必须打印设备 MAC 地址和当前阶段否则排查时你根本分不清是哪台设备、哪个连接出了问题。private fun logBleEvent(event: String, gatt: BluetoothGatt, stage: String) { val mac gatt.device?.address ?: UNKNOWN Log.d(BLE:$mac:$stage, event) } // 在回调中调用 override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { logBleEvent(State change: $newState, status: $status, gatt, CONNECT) }表格关键日志标签规范标签前缀触发场景示例日志BLE:AA:BB:CC:DD:EE:FF:CONNECT连接状态变更State change: 2, status: 0BLE:AA:BB:CC:DD:EE:FF:DISCOVER服务发现完成Services discovered: 3 servicesBLE:AA:BB:CC:DD:EE:FF:READ特征值读取Read success: [0x01, 0x02]SPP:AA:BB:CC:DD:EE:FF:WRITESPP 数据写入Write 12 bytes to output stream我带过的三个项目里有两次重大线上故障都是靠这种带 MAC 地址的日志在 10 分钟内定位到是某批次设备固件的 CCCD 响应超时。后来我把这套日志规范写进了团队《蓝牙开发红线手册》第一条。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。