Android蓝牙开发源码全解析:从权限适配到BLE与经典蓝牙实战
简介面向Android蓝牙开发初学者和物联网应用开发者的完整源码包以实际工程演示蓝牙初始化、BLE扫描、设备连接与数据读写等核心技术路线解决不会调用系统蓝牙API、不知如何组织项目结构的常见问题。包体共23个文件内含Java源码、class编译产物、AndroidManifest.xml配置、APK安装包及DEX可执行文件还有PNG图标和XML布局资源整个压缩包仅约91KB结构紧凑便于快速查看与学习。源码从BluetoothManager获取BluetoothAdapter开始覆盖startScan扫描、ScanCallback回调解析设备地址/名称/RSSI、connectGatt建立GATT连接以及readCharacteristic/writeCharacteristic数据读写等关键环节并涉及权限配置与不同扫描模式处理。可直接导入Eclipse工程运行观察效果配套资源包含Android蓝牙开发的基础示例能帮助读者将官方API文档落地成可运行的代码适合课程设计、毕业设计或实际项目前期调研。已有208人学习下载是一份轻量实用的Android蓝牙开发参考资源。 很多朋友在找“android 蓝牙开发源码”的时候大概都是同一个心态GitHub 上搜了一堆项目不是三四年前的老 Demo就是连编译都过不了的半成品好不容易跑起来一个发现在自己真机上扫码配对、数据收发各种莫名失败。这篇文章不搞虚的直接把我自己在经典蓝牙BR/EDR和低功耗蓝牙BLE两条开发线上的完整源码思路、核心代码片段、权限适配和调试手段全部拆开讲覆盖从 Android Studio 环境准备到 RSSI 测距、蓝牙打印、ESP32 联动的实际落地场景。内容更偏向“能直接抄作业”的工程实现而不是贴一堆官网文档翻译。我把这些年做蓝牙项目踩过的坑、验证过的写法、以及那些网上搜不到却又极其关键的细节都放在后面希望能帮你在拿到源码之后少走几周弯路。1. 蓝牙开发前的第一道坎权限与系统版本适配1.1 不同 Android 版本下的权限模型Android 蓝牙开发看着简单实际一上手就会碰到权限问题而且 Android 12API 31之后权限模型发生了翻天覆地的变化网上很多老源码在这里就直接废了。如果你还在用targetSdkVersion 30以下的旧项目权限只需要声明三个uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /但一旦 targetSdk 升到 31 及以上Android 把蓝牙权限从位置权限中彻底拆了出来新增了三个运行时权限uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE /其中BLUETOOTH_SCAN对应扫描设备BLUETOOTH_CONNECT对应连接和配对BLUETOOTH_ADVERTISE对应广播。注意这里有个很阴间的细节如果你的应用只做中心设备Central去扫描和连接外设那BLUETOOTH_ADVERTISE可以不申请但如果你的手机要作为外设Peripheral被别的设备扫描到就必须申请。不过别高兴太早Google 的兼容逻辑是从 Android 12 开始才强制新权限所以你在代码里要同时保留旧权限声明否则在 Android 11 及以下的设备上安装时会直接因缺少权限崩溃。我项目里的做法是直接把两套权限全部声明运行时按系统版本动态申请private fun checkPermissions(): Boolean { val permissions mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { permissions.add(Manifest.permission.BLUETOOTH_SCAN) permissions.add(Manifest.permission.BLUETOOTH_CONNECT) if (isPeripheralMode) { permissions.add(Manifest.permission.BLUETOOTH_ADVERTISE) } } else { permissions.add(Manifest.permission.BLUETOOTH) permissions.add(Manifest.permission.BLUETOOTH_ADMIN) permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } // 低版本还需要定位权限用于扫描结果回调 if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } // 动态请求... }1.2 一个让无数人崩溃的细节扫描必须开定位服务很多新手照着官方文档写好了 BLE 扫描代码结果在真机上一扫就报错或者回调一直没有数据最后发现是手机的位置服务没打开。在 Android 6.0 到 Android 11 这个区间系统把蓝牙扫描结果绑定到了定位服务上你必须同时满足三个条件才能拿到扫描结果已经授予ACCESS_FINE_LOCATION权限系统定位服务处于开启状态蓝牙本身已开启国产 ROM 上更狠部分机型即使定位服务开着还需要在应用权限设置里额外给“位置信息”的“始终允许”而非“仅使用期间允许”。所以代码里判断到扫描失败时最好提示用户去检查系统定位开关不要只傻傻地请求蓝牙权限。我在onScanFailed回调里通常会给一个 Dialog用 intents 引导用户直接跳转到系统定位设置页val intent Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) startActivity(intent)这个细节在 Android 12 上有所缓解因为扫描权限已经独立出来但很多老设备用户依然存在做兼容的时候保留这个逻辑没坏处。2. 经典蓝牙BR/EDR配对、Socket与数据传输源码拆解2.1 蓝牙的开启、扫描与主动配对经典蓝牙和 BLE 在系统 API 层面是完全两套东西。BR/EDR 走的是BluetoothAdapterBluetoothSocket这条线主要用于传输速率要求高、数据量大的场景比如蓝牙串口透传、蓝牙打印机、蓝牙手柄等。开启蓝牙时有一个小坑BluetoothAdapter.enable()是异步操作直接调用后立刻去扫描会发现蓝牙没准备好。正确做法是注册ACTION_STATE_CHANGED广播监听状态切换到STATE_ON再执行后续动作val filter IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) registerReceiver(stateReceiver, filter) private val stateReceiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) { BluetoothAdapter.STATE_ON - startDiscovery() } } }扫描经典蓝牙设备用的是startDiscovery()它会同时扫描 BR/EDR 和 BLE 设备结果都通过ACTION_FOUND广播返回。设备对象可以通过device.bondState判断是否已配对通过device.type判断设备类型DEVICE_TYPE_CLASSIC/DEVICE_TYPE_LE/DEVICE_TYPE_DUAL。真正容易让人翻车的是主动配对。在一个物联网项目里你需要让手机自动去连接某个已经配对过的蓝牙设备但系统只会弹出系统级配对对话框无法在应用内直接控制。很多业务场景里用户已经配对过了这个对话框会一闪而过但如果设备之前没有配对你就得提前触发createBond()val method: Method device.javaClass.getMethod(createBond) method.invoke(device)这个createBond()是隐藏 API通过反射调用在 Android 8.0 等版本上会有非 SDK 接口限制警告但实测下来目前仍然可用。如果你发现设备没有弹窗也没绑定成功大概率是因为设备已经存在旧的配对信息或者设备端不支持某种配对方式需要先调用removeBond()删除旧绑定再重新createBond()。2.2 RFCOMM Socket通信封装配对完成后的数据通道就是BluetoothSocket。经典蓝牙最常用的 UUID 是 SPP串口)的固定值00001101-0000-1000-8000-00805F9B34FB这个 UUID 代表 SerialPortProfile几乎所有蓝牙串口透传模块、蓝牙打印机都支持。连接方式有两种一种是createRfcommSocketToServiceRecord(uuid)另一种是createInsecureRfcommSocketToServiceRecord(uuid)。前者需要对方设备做配对认证后者不需要很多时候为了省事直接走 insecure 反而更快更稳。下面是完整的客户端连接模板class ClassicBluetoothClient(private val device: BluetoothDevice) { private var socket: BluetoothSocket? null private var inputStream: InputStream? null private var outputStream: OutputStream? null fun connect() { // 连接前先取消扫描否则会明显降低连接速度 BluetoothAdapter.getDefaultAdapter()?.cancelDiscovery() socket try { device.createInsecureRfcommSocketToServiceRecord(SPP_UUID) } catch (e: IOException) { // 部分设备需要走反射方法才能建立连接 val method device.javaClass.getMethod( createRfcommSocket, Int::class.javaPrimitiveType ) socket method.invoke(device, 1) as BluetoothSocket socket!! } socket?.connect() inputStream socket?.inputStream outputStream socket?.outputStream } fun send(data: ByteArray) { outputStream?.write(data) outputStream?.flush() } fun close() { inputStream?.close() outputStream?.close() socket?.close() } }这里有个业务上经常忽略的点cancelDiscovery()很关键。如果你正在扫描的同时去 connect系统会因射频资源竞争导致连接超时或失败实测概率非常高。所以连接动作前先取消扫描是必须的。2.3 实际开发中要规避的Socket坑第一connect()是阻塞调用绝对不能放到主线程。Android 系统会直接抛NetworkOnMainThreadException这不是网络请求专属任何 Socket 连接都会被拦截。我惯用的做法是丢到一个单线程的ExecutorService里回调通过 Handler 切回 UI 线程更新状态。第二读数据的循环别用while (inputStream.read() ! -1)蓝牙 Socket 在正常断开时不一定返回 -1反而可能一直阻塞。正确做法是定义超时时间socket.soTimeout 5000再按业务帧格式解析。或者用available()先探测可读字节数避免 read 阻塞。第三设备端如果强制断开客户端可能不会立刻感知需要有心跳包机制。比如每 2 秒发一个空帧或者特定心跳字节连续 N 次无响应就判定链路断开并主动清理资源。这个不是蓝牙规范强制要求的但实际项目中不做心跳全靠异常捕获链路稳定性会差一大截。3. BLE低功耗蓝牙扫描、连接与GATT通信完整源码3.1 为什么要用GATT这套抽象模型BLE 和经典蓝牙最大的区别是它不支持 Socket 流式传输所有数据交互都建立在 GATT(Generic Attribute Profile) 分层模型上。你可以把 GATT 理解成一套树状文件系统Profile 是根节点代表一个应用场景Service 是目录用 UUID 标识Characteristic 是文件里面有 Value 和 DescriptorDescriptor 是文件的属性描述比如通知开关实际开发中我们就是去找 Service 下的 Characteristic然后对它执行读取Read、写入Write和订阅通知Notify。很多初学者不理解为什么 BLE 这么绕不直接给一个收发数据的接口这是因为 BLE 本身就是为低功耗、小数据量设计的它把所有能力都抽象成属性设备生产商也可以自定义私有 UUID 来做差异化功能所以先理解这个树状模型对排查问题特别有帮助。3.2 一次标准BLE连接流程的源码骨架BLE 的连接流程分为三步扫描 - 连接 - 发现服务。下面的代码片段可以直接复用。扫描部分用BluetoothLeScannerval scanner bluetoothAdapter.bluetoothLeScanner val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device result.device val rssi result.rssi val scanRecord result.scanRecord // 这里可以依据设备名、广播包里的Service UUID做过滤 if (device.name?.contains(ESP32) true) { connectToDevice(device) } } override fun onScanFailed(errorCode: Int) { // 常见错误码1 表示BLE扫描已被关闭2 表示内部错误 } } // 使用ScanFilter预过滤能有效降低功耗 val filters listOf( ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString(0000ffe0-0000-1000-8000-00805f9b34fb)) .build() ) val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() scanner.startScan(filters, settings, scanCallback)注意一个 Android 7.0 之后引入的限流机制官方建议扫描周期不超过 30 秒且不要在前台一直扫。如果你的 App 需要长时间扫描应该采用“扫 10 秒停 5 秒”的间歇扫描策略或者引导用户点击“扫描”按钮后再启动。连接部分用connectGatt这里有个大坑系统对同一台设备的 Gatt 连接是共享的如果你上一次连接没正确关闭下一次connectGatt可能返回一个已经断开的旧实例回调也不再触发。所以每次连接前先尝试gatt?.disconnect()、gatt?.close()清干净。private var gatt: BluetoothGatt? null fun connectToDevice(device: BluetoothDevice) { gatt?.disconnect() gatt?.close() // autoConnectfalse 表示主动连接速度更快且失败会立刻回调 gatt device.connectGatt(context, false, gattCallback) } private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { // 连接成功之后必须主动发现服务才能拿到可操作的Characteristic gatt.discoverServices() } else if (newState BluetoothProfile.STATE_DISCONNECTED) { // 业务层在这里处理重连逻辑 } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status ! BluetoothGatt.GATT_SUCCESS) return // 这里遍历服务和特征值找到目标Characteristic val service gatt.getService(UUID.fromString(0000ffe0-0000-1000-8000-00805f9b34fb)) val characteristic service?.getCharacteristic(UUID.fromString(0000ffe1-0000-1000-8000-00805f9b34fb)) // 保存实例之后所有读写都基于它 writeCharacteristic characteristic readCharacteristic characteristic // 如果是通知型特征值需要先开启通知 enableNotification(characteristic) } }3.3 数据分包、MTU和通知订阅BLE 每次传输的数据量很小默认 MTU最大传输单元只有 23 字节其中包含 3 字节的 ATT 头所以应用层每次最多只能发 20 字节。你如果想一次发一张 4KB 的图片就必须在业务层分包。一个比较土但稳定的做法是在数据帧前面加一个序号头接收方按序号拼接fun sendLargeData(data: ByteArray) { val chunkSize 20 var offset 0 var seq 0 while (offset data.size) { val length minOf(chunkSize, data.size - offset) val frame ByteArray(length 1) frame[0] seq.toByte() System.arraycopy(data, offset, frame, 1, length) writeCharacteristic?.value frame gatt?.writeCharacteristic(writeCharacteristic) offset length seq } }如果要提升单包大小可以在连接成功后协商 MTUgatt.requestMtu(247)。大部分设备支持 247 字节这样应用层单包能到 244 字节传输效率提升一个量级。不过协商是异步的要在onMtuChanged回调里确认最终值而且部分老旧外设不支持大 MTU代码要做好降级处理。通知订阅是 BLE 里数据上行外设到手机的主要方式必须设置 Characteristic 的NOTIFY属性才能收到数据fun enableNotification(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) }之后外设主动推送的数据会进入onCharacteristicChanged回调。这里尤其要注意所有回调都跑在 Binder 线程不是主线程如果你在里面操作 UI 或更新数据库必须切线程否则会出现各种莫名其妙的崩溃。4. 从热词看真实业务BLE测距、蓝牙打印与ESP32控制4.1 RSSI测距的工程化做法网络热词里“蓝牙测距”出现的频率一直不低。BLE 测距本质上不是蓝牙协议的功能而是通过 RSSI接收信号强度指示反推距离。原理是信号在传播过程中会衰减距离越远信号越弱经典的对数衰减模型是d 10 ^ ((A - RSSI) / (10 * n))其中 A 是距离设备 1 米时测得的 RSSI 绝对值n 是环境衰减因子通常 2 到 4 之间。比如你测到设备 1 米处的 RSSI 是 -59当前是 -73n 取 2.5那么估算距离就是d 10 ^ ((-59 - (-73)) / (10 * 2.5)) ≈ 10 ^ (14 / 25) ≈ 3.65 米实际工程里千万别直接拿单次 RSSI 计算因为信号受人体遮挡、墙壁反射、多径效应影响极大波动能到 ±10dB。稳定做法是采集一段时间内的多个 RSSI 值做滑动平均或卡尔曼滤波同时丢弃掉异常突变值。我自己习惯用滑动窗口加中值滤波窗口取 5效果已经足够。更要明确的是蓝牙测距只能做到“近距离判断”和“区域判定”比如“靠近展台 3 米范围内触发签到”“离开车位 10 米后自动锁车”很难做到厘米级或者亚米级定位。如果需要更精确的定位建议融合惯导或者 UWB这个在立项阶段就要跟客户讲清楚。4.2 蓝牙打印的SPP连接与ESC/POS指令蓝牙打印机是目前很成熟的应用场景Android 端绝大多数打印机走的是 SPP 协议也就是我前面讲到的经典蓝牙 Socket。拿到 Socket 之后往输出流里写入 ESC/POS 指令即可控制打印。比如打印一行文字并换行val bytes byteArrayOf( 0x1B, 0x40, // ESC 初始化打印机 0x1B, 0x61, 0x01, // ESC a 1 居中对齐 0x31, 0x32, 0x33, // 数据 123 0x0A, // LF 换行 ) outputStream?.write(bytes)真正要注意的是不同牌子打印机的指令集存在差异比如部分热敏打印机默认不支持 GBK 中文编码你需要发送ESC 0x26切换字符集或者在写入前把字符串转成 GBK 编码val gbkBytes 测试文字.toByteArray(Charset.forName(GBK))我在接一个小票打印机时就被坑过打印中文全是乱码后来用 Wireshark 抓了厂商 Windows 驱动的数据对比才发现缺了字符集切换指令。这种问题光看文档很难发现最好是先拿官方工具打印一张自检页再看它初始化的字节序列。4.3 手机通过蓝牙控制ESP32的应用示例ESP32 是低成本物联网开发板支持 BLE 和经典蓝牙热词里“蓝牙app控制esp32”也是高频搜索。这类应用典型架构是ESP32 作为 GATT Server手机 App 作为 GATT Client通过写一个控制特征值来下发 GPIO 控制指令。ESP32 端通常用 Arduino 或 ESP-IDF 实现一个 BLE Server定义两个 Service一个设备信息 Service必选一个自定义数据 Service包含读写两个 Characteristic手机端连接后向目标特征值写入指令即可。这里有一个在实际项目里发现的问题部分 ESP32 示例默认只支持单连接如果手机调试时反复扫描、连接、断开板子上的 BLE 协议栈可能卡死出现“能扫描到但连不上”的情况。遇到这种情况先给板子断电上电或者按板子上的 EN 键复位一般都能恢复。另外ESP32 的 BLE 广播名默认是ESP32但实际项目里建议改成带设备编号的名称比如ESP32-GATE-01这样手机端 ScanFilter 可以直接按名字前缀过滤也方便后期管理多台设备。5. 源码调试利器与高频踩坑记录5.1 btsnoop抓包与Wireshark分析蓝牙开发中最痛苦的场景是连接都正常但数据就是不对不知道是手机端发错指令还是设备端回包异常。这时候靠日志打印效率太低正确手段是抓蓝牙 HCI 层数据包。Android 系统内置了蓝牙 HCI Snoop Log 功能打开方式每个厂商略有差异但大体路径一致进入开发者选项找到“蓝牙 HCI 信息收集日志”Bluetooth HCI Snoop Log打开重启蓝牙复现问题后关闭开关日志文件会生成在/sdcard/MIUI/log或/sdcard/log或/storage/emulated/0/下文件名通常是btsnoop_hci.log把这个文件拷出来用 Wireshark 打开然后配置空口解码。之后你能看到 HCI 层所有蓝牙交互过程包括 ATT 读写请求、错误码、断连原因。比如设备端“连接后异常断开”日志里会明确告诉你远程设备发的断开原因码是 0x08Connection Timeout还是 0x13Remote User Terminated Connection这两个的处理思路完全不一样。抓包真的比加日志高效太多我在处理几个打印机兼容性问题时就是靠对比正常设备和异常设备的 HCI 日志定位到是初始化序列不同而不是打印机坏了。5.2 几个我几乎每个项目都遇到过的坑做蓝牙开发久了会发现很多问题和业务无关纯粹是系统兼容或代码时序问题。我总结几个高频的大家可以对照自查。第一个是** ScanFilter 和实际广播数据不匹配导致扫描不到设备**。很多设备广播包里的 Service UUID 与连接后 GATT 层暴露的 Service UUID 不是同一个。扫描时用的是广播包里的 UUID连接后则是通过discoverServices获取连接态 UUID。有些新手把这两个混为一谈ScanFilter 填了一个发现服务时又用另一个去找结果怎么都连不上。第二个是连接状态回调里的重连风暴。很多设备离开范围会触发onConnectionStateChange断开回调代码里如果直接在回调里重连信号弱时会出现“断开-重连-再断开”的死循环把手机电量耗光。我的写法是每次重连都做指数退避第一次 1 秒、第二次 2 秒、第三次 4 秒最多重试 5 次然后停下来等用户手动触发。第三个是多个 Gatt 回调竞争。如果一个页面里同时对两台设备做连接或者连接流程和接收通知在同一个 GattCallback 实例里串用很容易出现回调数据错乱。最好的做法是每个设备单独建一个 Gatt 实例和回调对象连接状态机独立维护我见过太多项目为了省事共用回调结果查了两三天才发现是串对象了。第四个坑在Activity 销毁时没有及时关闭 Gatt。如果不关闭系统会一直持有连接后续再次打开页面就会建立第二个连接造成设备端无法再接入新连接。所以onDestroy里必须override fun onDestroy() { super.onDestroy() gatt?.disconnect() gatt?.close() gatt null }5.3 一个可直接参照的Demo工程结构看完上面的内容如果你准备从零搭一个可运行的蓝牙工程我建议 Demo 的包结构按下面这样组织后面加功能模块也方便app/src/main/java/com/example/ble/ ├── BluetoothManager.kt // 统一入口封装扫描、连接、数据收发 ├── scan/ │ ├── ScanFilterFactory.kt // 构建不同业务的扫描过滤器 │ └── BleScanController.kt // 扫描状态机处理开启/停止/超时 ├── connect/ │ ├── GattClient.kt // 单个设备的Gatt连接管理 │ └── GattCallbackImpl.kt // 连接状态、服务发现、数据回调 ├── protocol/ │ ├── DataPackager.kt // 业务数据分包、粘包处理 │ └── CommandBuilder.kt // 各设备指令构建 └── ui/ ├── DeviceListActivity.kt // 设备扫描列表页 └── ChatActivity.kt // 收发数据测试页这套结构比较清晰把设备无关的扫描逻辑和单设备 Gatt 管理拆开后面接入打印机、ESP32、手环等不同外设只需要扩展 CommandBuilder 和 Protocol 层即可不需要改动核心连接代码。最后再分享一个小技巧Android Studio 设置里把 logcat 按进程过滤后再用蓝牙关键词过滤能省不少时间。如果你是用 Android Studio 的新手界面默认是英文菜单栏“File - Settings - Plugins”里搜 Chinese Language 插件装完重启就是中文界面比下载汉化版安全得多。开发调试阶段把手机的“蓝牙日志”开关打开配合 logcat 崩溃栈90% 的蓝牙问题都能在十分钟内定位到原因。本文还有配套的精品资源点击获取
