尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android蓝牙调试助手源码解析:BLE开发中的权限、扫描与GATT通信实战

Android蓝牙调试助手源码解析:BLE开发中的权限、扫描与GATT通信实战 简介一套基于Android平台的蓝牙调试助手源码工程面向想深入掌握蓝牙通信的开发者与进阶学习者完整覆盖从设备扫描、配对到通过蓝牙套接字建立串口连接并传输数据的实现流程同时涉及系统蓝牙权限申请、广播接收器监听状态变化、多线程处理收发操作以及低功耗蓝牙扫描与连接等知识点。压缩包共53个文件整体大小约116KB包含可直接安装的应用安装包、工程源码与界面布局文件、编译产物和所依赖的库以及图标资源和项目配置信息目录结构清晰便于按模块分析或二次开发。已有1026人学习浏览是一份轻量但内容完整的蓝牙开发参考范例。借助这份工程学习者可以快速通读蓝牙API在真实项目中的调用顺序与连接状态管理学习界面交互、异常捕获和日志调试的处理手法还可复用其模块化设计思路快速移植到物联网外设、运动健康或自定义通信设备等场景中。1. 从“想装一个调试工具”到“自己改一个蓝牙调试助手”——这个源码解决什么大半年里我接过三四个蓝牙外设联调的项目每次都会遇到同一个场景硬件同事把样品寄过来协议文档只有两页 PDF手机端需要一个能看广播、能连 GATT、能手动写特征值的工具。市面上的蓝牙调试 App 装了好几个不是广告挡住日志就是某个服务列表解析不出来想多打一行日志都无从下手。后来我养成了一个习惯——拿到 Android 源码就去翻蓝牙调试助手这类项目自己改一套能用的。所谓“Android高级应用源码-蓝牙调试助手”本质上是一个带完整工程的 Android 蓝牙调试工具覆盖扫描、连接、服务发现、读写特征值、订阅通知这几条主链路并且是源码形态而不是 APK。对于 Android 应用开发者和物联网工程师来说它比串口助手更好使因为直接把 BLE 协议栈暴露在界面上协议对不上时可以一帧一帧地看原始数据。这篇文章我不讲某个具体项目的目录而是顺着这套源码最常见的技术组成把编译、权限、扫描、数据交互、排错整个链路讲清楚让你拿到类似压缩包时能在半天内改造出自己团队可用的版本。2. 拿到蓝牙调试助手源码先看权限与工程配置别急着编译2.1 Android 12 蓝牙权限已经分层manifest 不能再照抄很多老源码拿过来直接编译失败第一关不是代码而是权限。Android 6 时代只需要BLUETOOTH和BLUETOOTH_ADMINAndroid 12API 31把蓝牙权限拆成了BLUETOOTH_SCAN和BLUETOOTH_CONNECTAndroid 13 之后又对附近设备权限做了运行时申请要求。如果你打开源码发现 manifest 里还在用老权限先别急着跑对照下面的清单改。uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-feature android:nameandroid.hardware.bluetooth_le android:requiredtrue /这里maxSdkVersion30是为了避免 Android 12 以上系统把旧权限当作新权限重复弹窗新权限不加maxSdkVersion。真机调试时我都会把 project 的compileSdk和targetSdk保持同步比如compileSdk 34 targetSdk 33是常见组合不要 targetSdk 高、compileSdk 低那样 Android Studio 会在构建时直接报依赖冲突。android:requiredtrue表示无 BLE 能力的设备不能安装对工具类应用是有意义的避免用户装到不支持蓝牙的平板上。Android 版本需要的权限是否需要运行时申请Android 11API 30及以下BLUETOOTH、BLUETOOTH_ADMIN扫描需定位权限定位权限需申请Android 12API 31BLUETOOTH_SCAN、BLUETOOTH_CONNECTBLUETOOTH_SCAN 需申请Android 13/14API 33BLUETOOTH_SCAN、BLUETOOTH_CONNECTBLUETOOTH_SCAN、CONNECT 均需运行时申请2.2 运行时动态权限申请流程与设备前置条件Android 12 以后BLUETOOTH_SCAN和BLUETOOTH_CONNECT属于运行时权限只在 manifest 里声明不够必须在代码里请求。我一般会在进入扫描界面之前统一检查用ActivityCompat.requestPermissions一次性把扫描和连接权限申请完。private fun ensurePermissions(): Boolean { val permissions mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { if (checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) ! PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.BLUETOOTH_SCAN) } if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ! PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.BLUETOOTH_CONNECT) } } else { if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } } if (permissions.isNotEmpty()) { requestPermissions(permissions.toTypedArray(), REQUEST_BLUETOOTH_PERMISSION) return false } return true }这段代码把 Android 12 以下和以上的权限申请分开处理原因是低版本系统不认识BLUETOOTH_SCAN直接申请会静默失败。注意 Android 12 的设备不一定要求定位权限但部分国产 ROM 在 BLE 扫描时仍会检查定位服务开关所以我会在初始化蓝牙适配器前确认isLocationEnabled()如果关闭就弹窗引导用户去系统设置打开。2.3 编译报错先看 SDK 版本与 Gradle 依赖权限改完之后常见编译错误集中在三个地方。第一个是uses-sdk:minSdkVersion低于 21导致ListScanFilter使用不了BLE 扫描 API 在 API 21 才有老源码如果 minSdk 是 18需要升到 21或者用TargetApi做版本判断。第二个是依赖库版本冲突比如 AndroidX 的core-ktx和主工程编译版本不一致我一般统一用implementation(platform(androidx.compose:compose-bom))这种方式管理但简单项目直接在gradle.properties里加一句逻辑就够了。第三个是资源文件里用了低版本没有的属性比如android:foregroundServiceType只出现在 API 29 以上把 compileSdk 升到 33 一般能解决。提示源码包里不一定自带gradlew脚本下载后先在/gradle/wrapper/里检查没有 wrapper 就自己配一个 Gradle 版本避免用本机全局版本号去猜不同 Gradle 版本对 AGP 版本要求不同乱换会让依赖解析变得很折腾。3. 把蓝牙调试助手的扫描与服务发现做对先解决“看不到设备”3.1 用 ScanFilter 把广播包过滤到目标设备真机调试时最烦的就是扫描列表几十个设备一半是周围人的手机。蓝牙调试助手源码里通常都有设备过滤逻辑但很多实现只按名字前缀过滤BluetoothDevice.getName()在某些设备上返回 null过滤效果很差。我更常按 Service UUID 过滤因为广播包里的 UUID 是协议层面固定的不存在厂商对名字的随意改动。val filters mutableListOfScanFilter() if (targetServiceUuid ! null) { val uuid ParcelUuid.fromString(targetServiceUuid) filters.add(ScanFilter.Builder().setServiceUuid(uuid).build()) } val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0L) .build() bluetoothLeScanner?.startScan(filters, settings, scanCallback)ScanSettings的三个参数在这里各有用处。setScanMode在调试期我固定用SCAN_MODE_LOW_LATENCY扫描间隔短、回报快适合丢包排查代价是功耗高写日志时要提示自己这不是省电方案。setReportDelay(0L)表示结果立即回调不批量上报否则某些国产手机芯片会把一秒钟内的扫描结果攒在一起UI 列表刷新会有一顿一顿的感觉。startScan必须捕获SecurityException部分 ODM 系统在权限未完全就绪时会抛异常而不是返回错误码不捕获的话 App 直接闪退。3.2 扫描结果回调与 RSSI、厂商数据解析扫描回调里拿到ScanResult后蓝牙调试助手一般会展示设备名、MAC 地址、RSSI 和广播数据。设备名别直接取scanResult.device.name这个字段在经典蓝牙设备上频繁为 null我习惯从scanRecord?.deviceName再兜底一次两个都为空就显示地址后四位。RSSI 是判断设备距离最直观的信号-40 dBm 左右说明紧贴手机-90 dBm 以下基本不可用。源码里如果只显示数字我会加上色条或者根据阈值改变文字颜色连续扫描时 RSSI 抖动很大不做平滑处理很难判断设备是否在移动。广播数据里真正有价值的是厂商自定义段也就是manufacturerSpecificData。BLE 外设常用 0xFF 类型广播自定义数据比如设备电量、固件版本。解析这种段通常要自己遍历SparseArrayByteArray我建议在解析函数里打一行 Hex 日志保持原始字节输出协议对不上的时候看 Hex 比看解析结果更直接。3.3 连接 GATT 与 Service/Characteristic 的读取扫描列表点击某个设备后蓝牙调试助手要做的是connectGatt并且开启onServiceDiscovered后把 GATT 表刷新到 UI。连接参数这里容易踩坑autoConnect我默认传false工具类应用不需要后台自动重连true 会导致手机和应用进程处于后台时反复发起连接请求日志里会出现大量133错误。val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { val services gatt.services runOnUiThread { serviceAdapter.submitList(services) } } } bluetoothGatt device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)TRANSPORT_LE是明确指定走低功耗蓝牙通道如果不传系统在有经典蓝牙和 BLE 双模设备时可能选错传输层。status和newState是排错最关键的字段newState为STATE_DISCONNECTED时看status0 是正常断开133 表示连接参数不被接受常见于从设备不支持当前间隔257 是 GATT 内部错误通常是操作频率太高。蓝牙调试助手源码里如果没把这两个值显示出来我会在日志区补一行连接到一半失败时能省很多时间。4. 数据交互的真正门槛在蓝牙调试助手里把写特征值、订阅通知、MTU 分包理清4.1 先判断特征值可写再发数据别让写入静默失败代码跑到这一步蓝牙调试助手就算“能用”了但离“好用”还有距离。很多人在BluetoothGattCharacteristic上直接调writeCharacteristic发现设备没反应原因是没检查特征值的属性一个特征值可能只读、只写或者支持通知属性不满足时底层不会报错数据直接被丢弃。我每次 Select 一个特征值后会把properties字段解析出来显示在界面上。fun describeProperties(props: Int): String { val flags mutableListOfString() if (props and BluetoothGattCharacteristic.PROPERTY_READ ! 0) flags.add(READ) if (props and BluetoothGattCharacteristic.PROPERTY_WRITE ! 0) flags.add(WRITE) if (props and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE ! 0) flags.add(WRITE_NO_RESPONSE) if (props and BluetoothGattCharacteristic.PROPERTY_NOTIFY ! 0) flags.add(NOTIFY) if (props and BluetoothGattCharacteristic.PROPERTY_INDICATE ! 0) flags.add(INDICATE) return flags.joinToString( | ) }写操作本身要注意writeType。属性支持WRITE_NO_RESPONSE时我一般优先走无应答写吞吐量更高但丢包不会告诉你WRITE是有应答写从设备收到后会回一个 ATT Write Response可靠性高。蓝牙调试助手里我会把这两个模式做成切换按钮连调阶段先用有应答写确认链路测吞吐再切无应答。4.2 订阅通知与 Indicate 的完整链路CCCD 描述符不能漏打开通知是蓝牙调试助手高频率操作也是最容易被源码实现坑的地方。新手只调setCharacteristicNotification(true)数据却收不到因为少了给客户端特征配置描述符CCCDUUID 0x2902写值的步骤。CCCD 是普通属性却决定了通知的开关状态往里面写入0x01是开启 Notification写入0x02是开启 Indicate。fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic): Boolean { val ok gatt.setCharacteristicNotification(characteristic, true) if (!ok) return false val descriptor characteristic.getDescriptor(UUID.fromString(00002902-0000-1000-8000-00805f9b34fb)) ?: return false descriptor.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE return gatt.writeDescriptor(descriptor) }这段代码有两个关键点。第一个是setCharacteristicNotification和writeDescriptor的先后顺序不能反只写 descriptor 不调setCharacteristicNotification系统不会把回调分发到onCharacteristicChanged。第二个是ENABLE_NOTIFICATION_VALUE和ENABLE_INDICATION_VALUE的区别Notification 不需要从设备确认Indicate 需要后者可靠性高但吞吐低具体用哪个要看从设备的 service 定义。部分从设备要求你优先检查characteristic.descriptors列表里是否存在 0x2902不存在就说明这个特征值不支持通知。回调里的数据是原始字节ByteArray蓝牙调试助手界面上一定要有 Hex 视图。我习惯把每次接收到的包打印成带时间戳的 Hex 文本并记录包与包之间的时间间隔粘包和断包在时间戳面前非常明显。override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray ) { val hex value.joinToString( ) { String.format(%02X, it) } val timestamp SimpleDateFormat(HH:mm:ss.SSS, Locale.US).format(Date()) appendLog([$timestamp] RX(${value.size}) $hex) }String.format(%02X, it)把每个字节转成两位大写 Hex这是调试工具的标配加timestamp是为了后续对应写命令的时序点排查“我发了设备有没有回”这类问题只需要对齐两行日志的时间。4.3 MTU 协商与长数据包拆分payload 100 字节时才开始遇到问题BLE 4.x 默认 MTU 是 23 字节减去 3 字节 ATT 头一次最多发 20 字节。很多蓝牙调试助手源码没有做 MTU 协商导致写超过 20 字节的数据时数据被截断或直接失败。我一般在onConnectionStateChange收到连接成功后立刻调requestMtu(247)。override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { currentMtu mtu val maxPayload mtu - 3 appendLog(MTU$mtu, max payload$maxPayload) } else { appendLog(MTU request failed: $status) } }注意requestMtu是异步的结果在onMtuChanged里返回必须在回调里更新 UI 提示而不是调用后马上查。另一个坑是mtu协商是双向约束本地请求 247对端设备只支持 180最终生效值是较小或更保守的值所以发送端要按onMtuChanged返回的实际值动态分片。分片逻辑我在工具里不做太复杂先量出单包上限然后按上限切割长数据并在 Hex 日志里显示发送序号。有些设备端协议本身就不支持拆分重组那这种长包问题就不是 MTU 能解决的需要找硬件确认 PDU 设计。5. 从“能连上”到“能排错”给蓝牙调试助手补离线检测和日志导出5.1 设备离线不是只有断开连接这一种表现做外设联调时我最头疼的问题不是连不上而是“连上之后过一会儿不理你了”。onConnectionStateChange里的STATE_DISCONNECTED只是最后结果信号变差、连接参数协商失败、设备端重启都会先经历一段静默期。蓝牙调试助手源码里如果没有任何离线检测排查这类问题基本靠猜。我常见的做法是增加一个 RSSI 周期探测利用readRemoteRssi()定期读取链路质量读不到或者连续多次 RSSI 低于阈值比如 -95 dBm就主动断开连接并提示用户重新靠近设备。每 5 秒读一次 RSSI 对功耗影响不大但能把链路劣化提前暴露出来。5.2 把协议会话导出成文件串口助手能存日志蓝牙助手也该能BLE 调试中最有价值的资产是完整会话日志。屏幕上的日志列表一刷新就没了我一般会在源码里加一个文件日志模块所有 RX/TX 数据行同时写入context.filesDir下的日志文件按天轮转保留最近 3 天。文件路径用应用的内部存储不需要任何权限如果要把日志分享给硬件同事再用FileProvider生成一个content://开头的 URI 给微信或邮件附件。这里可以顺便把设备名称、MTU、连接间隔等参数作为文件头写进去对端拿到日志时不需要再问“你当时 MTU 是多少”。5.3 用模拟外设回归一遍扫描-连接-通知链路改完代码之后我不直接拿真机外设测试先用手机 A 作为 BLE 外设Android 提供onCharacteristicWriteRequest回调模拟从机跑一遍下面的验证清单通过之后再对接硬件设备。这套回归方式用蓝牙调试助手自己的源码测另一个设备能快速区分问题出在本地实现还是远端协议上。验证项操作预期结果扫描外设按固定 UUID 广播过滤列表中只出现目标设备连接点击连接状态变为已连接日志无 133 错误服务发现查看 GATT 表服务/特征值列表与模拟端配置一致读特征值点击读取Hex 区出现请求及响应数据写特征值发送 20 字节以内数据对端收到完整数据无截断订阅通知开启通知并让对端主动发送日志区出现带时间戳的 RX 数据行MTU 协商查看日志中的 MTU 值值大于 23payload 计算正确七项全部通过说明蓝牙调试助手本身没有链路问题再拿去联调实际硬件。如果哪一项失败优先回去查该环节的日志而不是改代码。本文还有配套的精品资源点击获取
返回列表