简介:面向Android开发者的蓝牙BLE GATT通信与实时绘图工程源码,适用于需要让安卓手机接收单片机数据并绘制图表的物联网场景。压缩包共28个文件,以7个Java源码为核心,完整覆盖设备扫描、连接、服务发现、特征读写与通知订阅等流程;13个XML文件统一管理界面布局与配置,8张PNG图片资源配合界面显示,整体仅205KB,结构紧凑、便于按需查阅。已有1097人学习浏览,适合具备基础Android编程能力、希望进阶蓝牙开发的开发者借鉴。资源不仅提供可运行的工程骨架,还给出将单片机上报的二进制数据解析为温度等数值的方法,并结合MPAndroidChart图形库实时刷新曲线;同时涉及Android 6.0以上的动态权限申请、多线程更新UI、断开连接释放资源等实践要点,可帮助读者系统掌握从底层通信到上层展示的完整链路,快速搭建自己的蓝牙数据监控应用。
1. 用 Android 收单片机蓝牙数据并绘图:为什么绕不开 BluetoothGATT
拿到一个“bluetoothgatt.zip_android_单片机APP_蓝牙接收绘图”这样的工程包,大多数人第一反应是装个 APK 看效果,但真正做单片机采集端的人会先问一句:单片机那边走的是 BLE 还是经典蓝牙?这个问题的答案直接决定 Android 端要写哪一套 API。如果你用的模块是 HC-05、HC-06 这类经典蓝牙,那标题里的 BluetoothGATT 其实派不上用场;只有 BLE(Bluetooth Low Energy)设备才走 GATT 协议,Android 端通过 BluetoothGATT 这个核心类去扫描、连接、发现服务、订阅特征值通知,再把单片机上报的字节流解成曲线。这个标题代表的是一类典型场景:单片机采集传感器数据,通过蓝牙模块发到手机 App,App 实时绘图。做这个方向的人通常是嵌入式工程师想补 Android 端,或者 Android 开发者在接硬件项目,痛点在于 BLE 的 MTU 限制、分包粘包、连接掉线,以及采样率一高绘图就开始卡顿。这也是本文想解决的四个核心问题:边界在哪、参数怎么设、坑在哪、值不值得投入。
2. 先厘清协议与硬件:BLE 与经典蓝牙选型,以及蓝牙 6.0 带来的新约束
2.1 为什么单片机项目里 BLE 逐渐取代经典蓝牙模块
经典蓝牙(BR/EDR)模块比如 HC-05、HC-06,走的是串口透传,Android 端用 BluetoothSocket 就可以收发。它的优点是开发简单,单片机侧直接当串口用,Android 侧也是流式读写;缺点是功耗高、连接慢,而且 Android 从 4.2 之后对经典蓝牙的权限和扫描限制越来越严,很多手机在后台扫描经典蓝牙设备经常扫不到。BLE 模块比如 JDY-08、DX-BT05、或者直接用 ESP32 的 BLE 功能,功耗低、连接快,Android 端通过 BluetoothLeScanner 扫描,通过 BluetoothGatt 连接和通信。如果你做的是电池供电的采集设备、或者要长时间挂在身上记录数据,BLE 是唯一合理的选择。标题里的 BluetoothGATT 已经帮你做了决定:这套方案默认走的就是 BLE。
还要注意到一个新的趋势:蓝牙 6.0 规范里增加了信道探测等特性,对 Android 端的影响主要体现在扫描回调的过滤参数和连接间隔协商上,实际开发中你不需要关心底层射频细节,但需要知道一点——新版 Android(13 以上)对蓝牙权限分成附近设备权限(NEARBY_DEVICES)和精确定位权限两套,扫描 BLE 必须要精确定位权限,这个坑后面第 5 章会具体讲。
2.2 MTU、GATT 服务与特征值的概念,先讲清楚再选库
GATT 是一个层次化的数据结构:一个设备有一个或多个 Service,每个 Service 下有若干 Characteristic(特征值),每个特征值可以声明 read、write、notify、indicate 等属性。单片机端如果用的是现成 BLE 模块,一般会内置一个透明传输服务,比如常见的 UUID0000ffe0-0000-1000-8000-00805f9b34fb,对应的特征值 UUID 是0000ffe1-...。Android 端要做的事情就是找到这个 Service 和 Characteristic,然后开启 notify 通知,单片机每次发数据,手机端就会回调onCharacteristicChanged。
这里有个新手最容易理解错的地方:BLE 的通知不是 TCP 流,而是一个个“包”。默认 MTU 是 23 字节,扣掉 3 字节头,实际一包最多带 20 字节有效数据。如果你的单片机每次上报 30 字节的结构体,就必须做分包发送,或者 Android 端先把 MTU 协商到更大的值(比如 512 字节)。我在实际项目中一般让 Android 端主动请求requestMtu(512),单片机端如果用的 ESP32 或 nRF52832,可以把 MTU 也配置成 512,这样一包能传近 500 字节,绘图数据的吞吐量就上来了。如果两端 MTU 不一致,会以较小值为准,所以这个地方必须两端都查一下实际生效值。
2.3 经典蓝牙、BLE 还是 HID:怎么选更适合“接收绘图”
很多做单片机的朋友会搜到“蓝牙 HID”这个词,那是把设备伪装成键盘鼠标,专门做免安装 App 的场景,数据绘图完全不适合,因为 HID 通道的数据速率低且延迟不稳定。经典蓝牙协议虽然在 Android 上依然可用,但 Android 的 API 对它的支持已经明显收缩,createInsecureRfcommSocketToServiceRecord这类方法在某些厂商 ROM 上会超时或直接抛异常。所以做“单片机 → App → 绘图”这个链路,我的建议很明确:新项目一律选 BLE + GATT,模块选支持 AT 指令改广播名和 UUID 的型号,比如 JDY-08、DX-BT05,或者直接用 ESP32 的 NimBLE 库自己搭服务。这样 Android 端代码固定,单片机端只要保证数据包格式稳定就行。
这里有一个重要的边界:如果数据速率超过 BLE 实际吞吐(在 MTU 512、连接间隔 7.5ms 的情况下,理论吞吐约 100KB/s 左右,但实际稳定在 30-50KB/s 就算不错),那就需要回到经典蓝牙或者直接换 WiFi。绘图场景如果只是几十 Hz 的采样率、每包几十字节,BLE 绰绰有余;如果要做音频波形或者高速振动分析,BLE 会很快让你踩到吞吐瓶颈。
3. 最小可跑通架构:从单片机定时上报到 Android 端 Service 接收的完整链路
3.1 端到端的数据流设计:谁定协议、谁做包解析
在写任何代码之前,先确定一条最简单的数据流。我用一个典型的心率/电压采集场景为例:单片机(比如 STM32F103 + JDY-08 蓝牙模块,或者直接一块 ESP32)每 20ms 采集一次传感器数据,打包成固定格式:帧头0xAA+ 类型0x01+ 数据长度0x02+ 两个字节的 int16 采样值 + 校验和0x55。这样一包 6 字节,即使不做 MTU 协商也能稳定传输。Android 端收到的每一个 notify 包就是完整的一帧,不需要粘包处理,这比在 BLE 上解析可变长协议要省心得多。
为什么固定长度这么重要?因为 BLE 通知是可靠的,但安卓系统会把单片机端一次发的数据包尽量原样递交给回调。如果单片机端一次写 20 字节,App 端收到就是 20 字节;但如果单片机端用了esp32的BLECharacteristic直接写入大于 MTU 的数据,Android 端会收到被 BLE 协议栈自动拆分后的多个包,你得按序缓存再合并。最稳妥的方案是:单片机端自己控制包长,保证每次写入不超过协商后的 MTU 值,并且每一包都是独立完整的一帧。这个决策能让第 4 章的解析代码少掉一半逻辑。
3.2 Android 端最小工程结构:Service、GattCallback 与主线程的分离
Android 端不要把所有蓝牙逻辑写进 Activity。一个可复用的最小结构是三层:BleService负责连接生命周期,BleCallback处理 GATT 事件,DataParser负责把字节流解析成浮点数组并回调给 UI。这样做的好处有两个:一是 Activity 重建(比如转屏)时蓝牙连接不会断;二是绘图页面只依赖数据回调接口,不关心底层是 BLE 还是经典蓝牙,以后想换协议栈不用动 UI。
class BleService : Service() { private var bluetoothGatt: BluetoothGatt? = null private val parser = DataParser() var onDataFrame: ((FloatArray) -> Unit)? = null fun connect(macAddress: String) { val device = BluetoothAdapter.getDefaultAdapter() ?.getRemoteDevice(macAddress) ?: return bluetoothGatt = device.connectGatt(this, false, gattCallback) } private 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 service = gatt.getService(BLE_UUID_SERVICE) val characteristic = service?.getCharacteristic(BLE_UUID_CHAR) characteristic?.let { gatt.setCharacteristicNotification(it, true) // 需要同时给 CCCD 写入 0x01,通知才会真正生效 val cccd = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") val descriptor = it.getDescriptor(cccd) descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) gatt.requestMtu(512) } } override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { val data = characteristic.value val frame = parser.parse(data) frame?.let { onDataFrame?.invoke(it) } } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { Log.d("BleService", "MTU changed to $mtu") } } }这段代码里有三个关键点。第一,connectGatt的第二个参数autoConnect我传了false,因为在 App 主动发起连接时传true会导致连接速度变慢,适合设备端主动重连的场景;如果你做的是“打开 App 自动连上次设备”,传false是正常的。第二,setCharacteristicNotification只是告诉本地协议栈“我关心这个特征值”,真正让单片机端开始推送数据,必须再向 CCCD 描述符写入0x01,这一步漏掉会导致onCharacteristicChanged永远不触发——这是我见过最多的“连上了但收不到数据”的原因。第三,requestMtu(512)要在服务发现完成之后调用,而且它是异步的,实际生效值看onMtuChanged回调,不是所有手机都支持 512,很多国产手机在 Android 10 以下最大只给 247,代码里要做兼容。
3.3 数据解析器与 UI 回调:把字节变成曲线
class DataParser { // 固定帧格式:0xAA 0x01 0x02 [int16 高字节] [int16 低字节] 0x55 fun parse(data: ByteArray): FloatArray? { if (data.size < 6 || data[0] != 0xAA.toByte() || data[5] != 0x55.toByte()) { return null } val value = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF) val floatValue = value / 100.0f // 假设数据是整数放大100倍 return floatArrayOf(floatValue) } fun parseBatch(buffer: ByteArray, start: Int, length: Int): List<FloatArray> { val frames = mutableListOf<FloatArray>() var offset = start val end = start + length while (offset + 5 <= end) { val frame = parse(buffer.copyOfRange(offset, offset + 6)) if (frame != null) { frames.add(frame) offset += 6 } else { offset++ // 丢字节,逐字节滑动找帧头 } } return frames } }这个解析器用了一个滑动窗口的容错策略:如果某一帧校验失败,就往前挪一个字节继续找帧头。这在蓝牙无线环境下特别重要,因为虽然 BLE 有 CRC 校验,但单片机端如果反复重启、或者发送频率超过手机处理能力,仍然可能丢包或错位。parseBatch方法是为了应对 MTU 较大时一个回调携带多帧的情况,实测在 MTU 512 时,一个 notify 包可能包含十几个完整帧,用批量解析会比逐帧回调 UI 高效很多。注意解析时的符号位处理:data[i].toInt() and 0xFF这步不能省,Java/Kotlin 的Byte是有符号的,直接左移会把负数高位补齐,导致数值错乱。
3.4 权限声明与 Android 12/13 的运行时权限变化
现在大多数项目的targetSdk已经是 33 或 34,这意味着蓝牙权限不能只用AndroidManifest.xml里的BLUETOOTH和BLUETOOTH_ADMIN。Android 12 开始有BLUETOOTH_SCAN、BLUETOOTH_CONNECT,Android 13 开始扫描还要求ACCESS_FINE_LOCATION精确定位权限。这三个权限都要在运行时申请,而且扫描回调里拿到的ScanResult中的设备名称,在没有定位权限时会直接被系统置空。/storage/emulated/0/android/data/这种路径在 Android 11 之后访问受限,跟蓝牙本身无关,但如果你有“导出采集数据文件”的需求,不要试图直接写那个目录保存数据,要使用MediaStore或getExternalFilesDir。
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />从 Android 12 开始,BLUETOOTH_SCAN权限如果声明了neverForLocation属性,在某些设备上会导致扫描不到设备——因为 BLE 扫描确实可以用来做位置推断,很多手机厂商的协议栈做了额外过滤。如果你在开发一款附带定位功能的 App,就别加这个属性,老老实实用精确定位权限;如果确定不需要位置,加上android:usesPermissionFlags="neverForLocation"可以降低审核难度,但要做好部分国产手机扫码不到设备的心理准备。这个取舍没有标准答案,属于“按产品形态做选择”。
4. 在 Android 端落地 BluetoothGATT:扫描、连接、特征订阅与数据解析
4.1 扫描的坑:用 BluetoothLeScanner 而不是旧 API
老代码里常见的startLeScan方法在新版本里被标记为 deprecated,而且很多国产手机上它对广播包中 service UUID 的过滤规则并不严格。现在标准的做法是用BluetoothLeScanner+ScanFilter。如果你在做一个蓝牙接收绘图 App,设备名一般是固定的前缀,比如JDY-08或者ESP32,用名字过滤最直接。
private fun startScan(callback: (BluetoothDevice) -> Unit) { val scanner = BluetoothAdapter.getDefaultAdapter()?.bluetoothLeScanner ?: return val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) .build() val filters = listOf( ScanFilter.Builder().setDeviceName("MY_DEVICE").build() ) val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { result.device?.let(callback) } override fun onScanFailed(errorCode: Int) { Log.e("Scan", "scan failed: $errorCode") } } scanner.startScan(filters, settings, scanCallback) // 记得在 onStop 里 stopScan,否则一直扫会发热掉电 }setScanMode(SCAN_MODE_LOW_LATENCY)会以大约 100ms 的间隔持续扫描,适合“用户主动点扫描按钮”的场景;如果是后台维持连接,用SCAN_MODE_BALANCED。setReportDelay(0)表示实时回调,这里要注意:如果你设成 500ms,回调会打包返回多个ScanResult,很多新手在这里只取results.first(),导致随机丢设备。另外,扫描回调是 Binder 线程池里的线程,不要在里面做 UI 操作,立刻把设备对象发到主线程或者 ViewModel 里。
4.2 连接参数:autoConnect 与连接间隔的取舍
device.connectGatt(context, autoConnect, callback)里的autoConnect参数,系统的默认行为是:true时设备进入后台连接模式,Android 会不断重试连接,但连接建立会慢(可能要 10-30 秒);false时立即发起一次连接,失败就回调onConnectionStateChange且状态为STATE_DISCONNECTED,不会自动重试。做绘图类 App,我一般首选false,因为用户已经在界面上点了连接按钮,恨不得马上看到曲线。然后业务逻辑里自己准备一个重试计数器,连续失败 3 次就提示用户检查设备电源或距离——这个方案可以完全掌握连接状态,不会出现界面已经提示“连接中”但实际系统在后台空转的情况。
连接间隔(Connection Interval)影响到数据吞吐和延迟。大多数模块出厂默认是 30ms-50ms 的连接间隔,这在 20 字节/包的 MTU 下,每秒大约只有 20-30 包有效传输,达不到绘图需求。Android 端没有暴露直接设置连接间隔的 API,但你可以通过requestConnectionPriority来请求提高优先级:
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)这个方法对应的是蓝牙协议里的connectionParameterUpdateRequest,android 系统会尽力把连接间隔压缩到 7.5ms 左右。实际项目中,我把这个调用放在onServicesDiscovered之后、requestMtu之前。这样 MTU 512 + 高优先级组合下,一包能传约 500 字节,连接间隔 7.5ms,峰值吞吐能到 40-60KB/s 左右,画一个 10Hz 采样率的曲线毫无压力。但要注意,CONNECTION_PRIORITY_HIGH会增加功耗,如果只是低频传输(比如每分钟一个温度值),完全没必要调。
4.3 特征订阅与服务发现:两个“隐性顺序”问题
服务发现不是连上就能立刻做。你调用discoverServices()后,必须等onServicesDiscovered回调里才能拿到 Service 和 Characteristic。有些设备(比如某些国产 BLE 模块)在连接建立后 100ms 内就广播服务,但也有部分设备需要先做一次手动握手才会稳定上报服务;此时最好的做法是连接后延迟 300ms 再做discoverServices(),给从机一点时间准备。延时可以用postDelayed包裹,或者放到onConnectionStateChange里判断state == STATE_CONNECTED && status == GATT_SUCCESS后再发。
订阅通知的两个步骤前面已经写过:setCharacteristicNotification+ 写 CCCD。再补充一个细节:如果这个特征值支持 notify 且你已经requestMtu到 512,在onCharacteristicChanged里用characteristic.value拿到的字节数可能超不过 512,但如果系统自动把一个大包拆成两次回调,你需要自己判断帧头。处理方法是维护一个ByteArrayOutputStream,遇到帧头0xAA就清空重来,没有帧头的字节就追加写入,帧数据满 6 字节后立即解析。这也是“蓝牙接收绘图”类 App 最核心的一段代码,值得多花时间测试丢包和错位情况。
4.4 一个完整的数据流时序示例:Android 进度条与连接状态联动
在真正绘图之前,App 界面通常需要给用户一个连接进度反馈。我建议用一个 Android 进度条来显示“扫描中 → 连接中 → 服务发现中 → 订阅成功 → 数据接收中”五个阶段,因为蓝牙连接是异步的,用户不知道卡在哪一步。实现方式是在 callback 的对应函数里更新一个状态枚举:
enum class BleState { IDLE, SCANNING, CONNECTING, SERVICE_DISCOVERING, SUBSCRIBED, RUNNING }在onConnectionStateChange连接成功时置为SERVICE_DISCOVERING,onServicesDiscovered里置为SUBSCRIBED,第一次收到有效帧时置为RUNNING。进度条用 indeterminate 模式,收到 RUNNING 后隐藏。如果你收到数据但曲线不动,大概率是解析器出了问题,而不是连接问题——这时候状态栏能直接帮用户定位阶段。这个“状态枚举 + 进度条”的方案,是我在android studio里调试蓝牙类 App 的第一件事,比你打印一大串 log 更直观。
5. 常见问题排查:连不上、收不到数据、绘图卡顿的 5 个踩坑记录
5.1 扫描不到设备:现象 → 原因 → 解决
现象是点击扫描按钮后列表一直空白,或者偶尔出现一次就消失。原因有三个高频来源:第一,Android 12 以上没申请BLUETOOTH_SCAN与定位权限,系统直接过滤结果甚至不回调;第二,ScanFilter里设备名匹配不正确,很多 BLE 模块的广播名带空格或者全角字符,你用"MY_DEVICE"精确匹配失败,解决方法是先不加 filter 扫一遍,在回调里打印所有设备名称和 RSSI,肉眼排查一遍;第三,手机蓝牙缓存了旧广播数据,需要到系统设置里“忽略此设备”后重新扫描。我的建议是开发阶段先不设ScanFilter,用result.device.name过滤,这样能把协议栈和业务过滤分开排查。
5.2 连接成功但收不到任何数据:CCCD 没写
现象是onConnectionStateChange回调了STATE_CONNECTED,服务也找到了,但onCharacteristicChanged从没触发过。这是我接手项目时遇到最多的问题。原因是只调了setCharacteristicNotification(true),没有往 CCCD 描述符写入ENABLE_NOTIFICATION_VALUE。BLE 协议里,通知开关其实是客户端(手机)写给从机(单片机模块)的一个配置值:0x01表示开启通知,0x02表示开启指示。有些模块(比如 CC2541 方案)固件里也会自动开启,但大多数通用透传模块必须要你写这一步。解决:在onServicesDiscovered里获取特征值的描述符,写入BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,并调用writeDescriptor,在onDescriptorWrite回调里确认成功后再去请求 MTU。
5.3 数据乱码或数值跳变:字节数与符号位的双重陷阱
现象是曲线偶尔出现一个巨大的尖峰,或者出现负数乱跳。这通常不是射频问题,而是解析端把有符号字节直接参与运算。Byte.toInt()在 Kotlin/Java 里会把高位当作符号位,例如0xFF.toInt()结果是 -1,与0xFF按位与后才能得到 255。另一个陷阱是单片机端发的uint16_t大端序,Android 端读出来的(high shl 8) or low没有做and 0xFF,导致高字节被符号扩展,整体数值变成负数。解决:统一封装一个bytesToInt16工具,带掩码处理;并且在数据解析后做一次合理性校验,比如采样值超出传感器量程就丢弃这一帧,用前一个有效值填充,曲线就不会出现惊悚尖峰。这属于“数据边界”问题,比协议本身更影响用户体验。
5.4 绘图卡顿掉帧:主线程渲染瓶颈
现象是接收数据正常,log 里帧数也对,但曲线在Canvas上绘制时明显卡顿。原因有两个:一是onCharacteristicChanged回调里直接在主线程更新 View,600Hz 采样率的数据把 UI 线程打满;二是在draw里循环绘制成千上万个点,没有做数据压缩。解决:第一,接收回调里把数据放进ArrayDeque<Float>,用handler.post在 16ms 一次的垂直同步周期里批量更新 UI;第二,自定义 View 的onDraw只画可视区域内的点,比如屏宽 400dp 时只取最近 400 个点,而不是把所有历史点都画一遍;第三,关闭hardwareAccelerated的高频重绘反而不合理,正确的做法是善用clipRect只裁剪脏区域,具体代码在第 6 章会展开。绘图卡顿还有一个常被忽略的原因:采样率与屏幕刷新率不同步,导致同一帧重复绘制或丢失半个周期,适当降采样到屏幕刷新率的 1/3 到 1/2,曲线看起来反而更顺滑。
5.5 后台运行掉线:扫描回调未停止与连接管理混乱
现象是连接正常绘图,锁屏再点亮后设备掉线,必须重连。原因一般是两个:一是扫描回调没有在onStop或onPause里stopScan,蓝牙协议栈被扫描与连接并发操作搞混;二是connectGatt使用的Context是 Activity 实例,Activity 销毁后连接被系统回收。解决:把BleService作为连接宿主,用applicationContext调用connectGatt,Service 里维护一份bluetoothGatt引用,并在onDestroy里手动disconnect + close。另外,锁屏掉线多与手机厂商的省电策略有关,需要在 Service 里申请一个前台服务通知,提升进程优先级。这里不做“保活”那种激进操作,但前台服务是目前最体面的方式。
6. 进阶技巧:数据落盘验证与自动重连参数这样调
6.1 用 CSV 落盘验证协议解析的正确性
写完解析器和绘图 View 后,第一件事不是看曲线,而是验证数据链路。我会在BleService里加一个可开关的文件记录器,把原始字节和解析后的 float 值同时写入 CSV。这个文件放到context.getExternalFilesDir("logs")目录下,用SimpleDateFormat生成文件名,每行格式:时间戳, 原始帧hex, 解析值。这样出现问题时,你可以把文件导出来和单片机端的原始采集序列逐行比对,定位是发送端丢了包还是接收端解析错了。
fun writeLog(timestamp: Long, rawHex: String, value: Float) { if (!enableLog) return val line = "$timestamp, $rawHex, $value\n" try { val file = File(context.getExternalFilesDir("logs"), todayFileName()) file.appendText(line) } catch (e: IOException) { Log.e("BleLog", "write log failed", e) } }这个 CSV 文件本身就是最好的验证工具:第一,检查相邻行的时间戳间隔是否均匀,如果忽长忽短,说明单片机端的定时上报不准确或者蓝牙缓冲导致批量到达;第二,检查原始帧 hex 是否有重复帧,重复帧说明手机端回了 ACK 重传或者单片机端发了两次;第三,把解析值表格导进 Excel 画个折线图,和 App 里的曲线对比,能快速排除 View 绘制层面的问题。我的一线经验是,数据链路的 bug 用 log 排查只能定位一半,用文件比对才能看到全貌。
6.2 自动重连策略:指数退避与参数上限
蓝牙设备离开范围再回来,或者手机蓝牙被系统回收后重启,App 需要自动重连。重连不能写死while(true)狂试,我习惯用指数退避:第一次失败等 1 秒,第二次 2 秒,第三次 4 秒,封顶 30 秒。每次重连前先确保bluetoothGatt已close,否则旧的 Gatt 实例会占用连接资源,导致“越连越慢”的玄学现象。
private fun scheduleReconnect() { val interval = min(30_000L, 1000L * (1L shl reconnectTimes.coerceAtMost(5))) reconnectTimes++ reconnectHandler.postDelayed({ if (isConnected.get()) return@postDelayed bluetoothGatt = device.connectGatt(this, false, gattCallback) }, interval) } override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_DISCONNECTED) { reconnectTimes = 0 if (autoReconnectEnabled) scheduleReconnect() } }注意:onConnectionStateChange里status != GATT_SUCCESS时,即使 newState 是连接中,后续也会立刻掉线,此时应立即关闭 gatt 并进入重连流程。另外,重连时不要再做全量扫描,直接用之前保存的 MAC 地址getRemoteDevice发起连接即可,省去用户手动选择的麻烦。重连成功后的 MTU 协商和服务发现会自动走一遍 normal 流程,不需要额外处理。
6.3 绘图 View 的具体实现:只画可视区 + 复用 Path
这是我的最后一个技巧,也是让我在“绘图卡顿”这个问题上彻底翻过坑的做法:自定义 View 维护一个环形缓冲区,保存最近 1024 个点,onDraw时根据 View 宽度算出需要显示的下标范围,只把范围内的点连成Path再drawPath。
class WaveformView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { private val data = ArrayDeque<Float>().apply { ensureCapacity(2048) } private val paint = Paint().apply { style = Paint.Style.STROKE strokeWidth = 2.dp color = Color.parseColor("#00A8FF") isAntiAlias = true } fun append(value: Float) { if (data.size >= 1024) data.pollFirst() data.addLast(value) invalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (data.isEmpty()) return val step = width.toFloat() / data.size val path = Path() var index = 0 for (v in data) { val y = height / 2f - (v - minValue) / (maxValue - minValue) * height if (index == 0) path.moveTo(0f, y) else path.lineTo(index * step, y) index++ } canvas.drawPath(path, paint) } }关键参数有三个:缓冲区大小1024点;step按宽度动态计算;append里用invalidate()而不是postInvalidate()(后者会增大延迟)。如果你的采样率是 100Hz,1024 个点相当于 10 秒的历史波形,足够观察趋势;如果要做 30 秒以上的长时间滚动,可以改成滑动窗口取平均。另外,ArrayDeque的pollFirst在 Java/Kotlin 里时间复杂度是 O(1),比ArrayList.remove(0)快得多,这是低配手机上不卡的关键。如果还想再省一点,可以在onDraw里判断data.size变化量,只有新增帧时才重新构建 Path,但实测对 100Hz 数据意义不大。
这个项目做到最后,我会把第 5 章的排查清单和第 6 章的 CSV 验证工具固化成一套内测流程:连上设备后先静止 10 秒看基线是否平稳,再连续画 5 分钟曲线看有没有丢帧,最后拔掉设备电源重开 3 次,确认自动重连能恢复。把这套流程跑完,蓝牙接收绘图这种项目基本就不会再出现“用户反馈连不上但开发现场怎么测都是好的”那种尴尬局面了。希望帮到你。
本文还有配套的精品资源,点击获取