简介:本资源是一个面向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 31(Android 12)起,BLUETOOTH_ADMIN已废弃;API 33(Android 13)起,后台扫描需额外BLUETOOTH_SCAN运行时权限;而从 API 34(Android 14)开始,即使前台扫描也强制要求NEARBY_DEVICES权限,且该权限属于dangerous组,必须动态申请。更关键的是:ACCESS_FINE_LOCATION不再是“可选”,而是BLUETOOTH_SCAN的硬性前置条件——因为蓝牙地址(BD_ADDR)在物理层可被用于粗略定位。
<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <!-- 以下为 API 31+ 必须 --> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <!-- API 33+ 后台扫描需此权限 --> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" /> <!-- 定位权限:非可选,是蓝牙扫描的法定前提 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- API 34+ 新增:替代旧权限组 --> <uses-permission android:name="android.permission.NEARBY_DEVICES" />提示:
NEARBY_DEVICES权限在targetSdkVersion >= 34时不可降级回BLUETOOTH_SCAN。若你仍用BLUETOOTH_SCAN,系统会静默拒绝扫描请求,Logcat 仅输出模糊的Scan failed: reason=2(即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 设备发现必须用BluetoothLeScanner+ScanCallback:startDiscovery()已成历史
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: List<ScanResult>) { // 批量结果回调,省电模式下触发 } override fun onScanFailed(errorCode: Int) { Log.e("BLE", "Scan failed: $errorCode") // errorCode=1: SCAN_FAILED_ALREADY_STARTED // errorCode=2: SCAN_FAILED_APPLICATION_REGISTRATION_FAILED // errorCode=3: SCAN_FAILED_INTERNAL_ERROR } }参数说明:SCAN_MODE_LOW_LATENCY每 100ms 扫描一次,耗电高但响应快,Demo 阶段必用;MATCH_MODE_AGGRESSIVE降低匹配阈值,避免因信号抖动漏掉设备;setDeviceAddress()是硬过滤,生产环境慎用——BLE 设备地址可能随机化,应优先用ScanFilter.Builder().setServiceUuid()匹配服务 UUID。
3. 建立可靠连接:为什么createRfcommSocketToServiceRecord()总是 timeout?
3.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 参数。参数说明:timeoutMs=10_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)必须用InputStream+BufferedInputStream防粘包
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是标准 CCCD(Client 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 值必须为0x0001(enable 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: 0 |
BLE:AA:BB:CC:DD:EE:FF:DISCOVER | 服务发现完成 | Services discovered: 3 services |
BLE:AA:BB:CC:DD:EE:FF:READ | 特征值读取 | Read success: [0x01, 0x02] |
SPP:AA:BB:CC:DD:EE:FF:WRITE | SPP 数据写入 | Write 12 bytes to output stream |
我带过的三个项目里,有两次重大线上故障,都是靠这种带 MAC 地址的日志,在 10 分钟内定位到是某批次设备固件的 CCCD 响应超时。后来我把这套日志规范写进了团队《蓝牙开发红线手册》第一条。希望帮到你。
本文还有配套的精品资源,点击获取