☰
基于BluetoothGatt实现Android手机BLE传感器波形实时显示
2026/10/28 16:24:36 网站建设 项目流程

简介:面向Android与单片机之间基于蓝牙BLE GATT协议的数据接收与实时绘图场景,这份压缩包提供了一套可直接学习的Android Studio工程。项目中覆盖设备扫描、连接、服务发现、特征读写与通知订阅等完整流程,并演示了如何将单片机发送的二进制数据解析成温度等数值,再借助MPAndroidChart实时绘制曲线;资源还按步骤拆分了各回调方法,给出数据解析和界面更新示例,同时整理了Android 6.0以上定位权限的动态申请、断开连接与释放资源等处理思路,便于避开常见坑点。资源包共28个文件,主要包含7个Java源文件、13个XML布局与配置文件、8个PNG图标素材,压缩包整体约205KB。工程结构紧凑,适合导入Android Studio后对照源码理解GATT服务与特征的关系,也可直接抽取扫描、连接或绘图模块作为课程设计或物联网项目原型复用。目前已有1097人学习下载,对希望快速掌握蓝牙数据通信与可视化绘图开发的读者,是一份简洁实用的参考资料。

1. 这个工程解决什么问题:手机实时显示单片机传感器波形

你在调一台电机闭环,或者采集温湿度曲线的时候,最烦的不是数据不准,而是数据只能一串一串从串口助手往外滚。想看一眼波形趋势,要么盯着电脑,要么把数字抄下来扔进 Excel。bluetoothgatt.zip 这个包,从命名就能看出来,它干的事情很直接:用 Android 的 BluetoothGatt API,走 BLE 通道把单片机端的采样数据实时收上来,在 App 里画成滚动波形。很多人第一反应是用 HC-05 那种经典蓝牙串口透传,但 SPP 协议在 Android 上的连接效率和兼容性都差一口气,BLE 的 GATT 才是 Android 原生支持最顺的路子。这个方案适合谁——用 51、STM32、ESP32 做采集、控制、仪表类项目,又想在手机上直接看数据的嵌入式开发者。手机就是你的便携示波器。

2. GATT 不是串口透传:先想清楚手机和单片机各自该干什么

很多人拿到这种蓝牙绘图工程,第一件事就是改个 UUID 然后烧进单片机,结果手机连上了却收不到一个字节,然后开始怀疑硬件。其实问题出在思维惯性上:你还在用经典蓝牙串口透传的思路理解 BLE。GATT 和 SPP 是两套完全不同的数据通路,这章先把机制讲透,单片机端的配置才有依据。

2.1 角色、分层与数据流:中心设备、外围设备、特征值

BLE 通信里,手机永远是中心设备(Central),单片机加 BLE 模块是外围设备(Peripheral)。经典蓝牙 SPP 的逻辑是“建一条串口管道,两边随便灌字节”,而 BLE 根本没有管道概念,它是一套属性协议,数据必须挂在一个叫 GATT 的层级结构里。

理解 GATT 只需要抓住三层:一个外围设备上有一个或多个服务(Service),每个服务下面挂一个或多个特征(Characteristic),特征才是真正装数据的地方。每个特征有属性,比如可读、可写、可通知。要把单片机数据推给手机,最常见的方式是定义一个带 Notify 属性的特征,单片机往特征里写数据,手机订阅该特征后,就能在回调里收到通知。注意这里的方向:是单片机主动推,手机被动收,手机不需要一条一条去读。

用一张表格对比 SPP 和 GATT 的差异,能省掉很多后面排错的时间。

对比项经典蓝牙 SPPBLE GATT
连接模型串口管道,连上直接收发中心-外围,按服务/特征访问
数据主动上报两边随时写需先订阅 Notify/Indicate
Android 原生支持部分机型兼容差系统级 API,支持度高
功耗高,不适合常开低,适合低功耗采集
连接后找不到数据的坑少多,CCCD 没写是头号翻车点

所以,标题里那个 bluetoothgatt 不是随便起的,BluetoothGatt 就是 Android 蓝牙 API 里最核心的那个类,整个工程的链路就是围绕它展开的。先记住一个结论:GATT 模式没有“连上就通”这回事,必须按服务找特征、按特征订通知。这是后面所有步骤的地基。

2.2 单片机端最小配置:一个自定义 Service 加一个 Notify 特征

要配合 Android 端绘图,单片机端广播的服务至少要有一个自定义 Service 和一个带 Notify 属性的特征。UUID 可以随便约定一组,只要两端写一致就行。我一般习惯用 0000FFF0-0000-1000-8000-00805F9B34FB 做服务,0000FFF1-0000-1000-8000-00805F9B34FB 做通知特征,这个 FFF0/FFF1 段是自定义服务常用的私有地址段。

以 ESP32 的 Arduino 环境为例,最小可用的广播端配置是这样:

#include <BLEDevice.h> #include <BLEServer.h> #include <BLEUtils.h> BLECharacteristic *pNotifyChar; #define SERVICE_UUID "0000FFF0-0000-1000-8000-00805F9B34FB" #define CHAR_NOTIFY_UUID "0000FFF1-0000-1000-8000-00805F9B34FB" void setup() { BLEDevice::init("BLE_DAQ"); // 广播名称 BLEServer *pServer = BLEDevice::createServer(); BLEService *pService = pServer->createService(SERVICE_UUID); pNotifyChar = pService->createCharacteristic( CHAR_NOTIFY_UUID, BLECharacteristic::PROPERTY_NOTIFY // 只声明 Notify 属性 ); pService->start(); BLEDevice::startAdvertising(); } void loop() { // 打包好的传感器数据帧,见下方协议说明 uint8_t frame[10]; frame[0] = 0xAA; frame[1] = 0x55; frame[2] = 6; // 数据长度 frame[3] = 0x01; // 数据类型:单通道波形 // frame[4..7] 放一个 int32 采样值 frame[8] = 0; // 校验位,按需填充 pNotifyChar->setValue(frame, 9); pNotifyChar->notify(); // 主动推送一次 delay(10); // 100Hz 采样率 }

这段代码你拿去烧进 ESP32 就能被手机扫描到。注意几个参数:广播名称 BLE_DAQ 建议别用中文,部分 Android 机型对中文广播名的解析有兼容问题;notify 之前必须 setValue,顺序反了会推到旧数据;loop 里的 delay(10) 是发送节拍,对应 100Hz 采样,如果你的传感器 ADC 采样率只有 20Hz,这里就要改成 50ms。

如果你用的是 51 或 STM32,情况简单很多——不要去做 BLE 协议栈,那是给自己挖坑。常见做法是外接一个 BLE 从机透传模块,比如 HC-08、JDY-23 这类,模块内部已经实现了 GATT 服务,你的单片机只需要用串口往模块发字节,模块会自动以 Notify 方式转给手机。这时候重点就变成了串口波特率和帧格式匹配,51 单片机尤其是 STM32,串口发送用中断还是轮询都不影响,但注意不要让串口缓冲区不够导致丢帧。

2.3 数据帧设计:绘图质量七成靠协议,不靠绘图代码

绘图项目最容易忽略的是数据协议。很多人直接发裸的 ADC 值,结果手机端要么粘包,要么偶发错位,画面像心电图失常。我一般会用固定帧格式:帧头 0xAA 0x55,随后是数据长度、数据类型、数据体、尾部校验。帧头的作用不是好看,而是让接收端能从字节流里找到“从哪里开始切一帧”。因为 BLE 的通知虽然一包一包发,但 Android 端的回调并不保证一次回调就是一包完整的数据,中间完全可能拆包或合并,没有帧头定位根本没法干活。

数据类型字节建议一开始就预留好。即使你现在只画单通道温湿度,也把类型字段写上,后续扩展多通道时不用改协议。数据体部分,如果你传的是 float,注意大小端要和 Android 端一致,Android 的 ByteBuffer 默认是大端模式,C 语言单片机大多是小端,我建议协议层就统一用小端传输,代码写起来最省事。校验位我常用异或校验,把所有字节异或一遍,够轻量,也能拦住大部分干扰。

3. Android 端用 BluetoothGatt 走完整链路:扫描、连接、订阅、收数

这一章是整个工程的核心。从打开 BluetoothGatt 这个类开始,到收到第一帧有效数据,中间隔着权限、扫描回调、连接回调、服务发现、特征订阅五道关。每一道都是异步的,没有一步是“调用完就同步返回结果”,这是 Android 蓝牙开发劝退新手的地方,也是三个月后你成为老手的分水岭。

3.1 权限、Android 版本差异与初始化的正确姿势

Android 的蓝牙权限从 12 开始分得更细,这是标题项目最容易被忽略的坑。如果你的 targetSdk 是 31 及以上,必须在 AndroidManifest 里声明 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT,并且这两个是运行时权限,需要动态申请。而如果你还要兼容 Android 11 及以下的旧设备,ACCESS_FINE_LOCATION 仍然必须声明并申请,否则低版本机型直接扫描不到任何 BLE 设备。我用一个权限集合管理类,统一处理。

String[] permissions; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { permissions = new String[]{Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT}; } else { permissions = new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; } // Activity 中动态申请,onRequestPermissionsResult 里确认后 // 再调用 BluetoothAdapter.getDefaultAdapter() 并开启蓝牙

注意,Android 12 以上的 BLUETOOTH_SCAN 权限在申请时,系统弹窗会带有“仅使用一次”和“使用时允许”的选项,如果用户选了“仅使用一次”,你的 App 退到后台再回来就扫描不到设备了,这是权限语义的问题,不是你代码的问题。另外,targetSdk 正好是 31 的工程,即便声明了权限,也建议真机跑一遍 Android 13 或 14,因为厂商系统对蓝牙权限的二次确认各不相同,联想的机器和 Pixel 的行为就不完全一样。

3.2 扫描、连接、发现服务:四个异步回调串起来

从扫描到拿到数据,Android 端要依次经过四个回调。第一步用 BluetoothLeScanner 启动扫描,在 ScanCallback 的 onScanResult 里拿到设备对象,然后调用 connectGatt,连接状态在 onConnectionStateChange 里回调,连上后调用 discoverServices,服务发现完成在 onServicesDiscovered 里回调,最后找到目标特征并订阅。我把这段核心链路放在一个管理器里,代码已经是能直接跑的最小骨架:

private BluetoothGatt mGatt; private BluetoothGattCharacteristic mNotifyChar; private final BluetoothAdapter mAdapter = BluetoothAdapter.getDefaultAdapter(); // 1. 扫描 mAdapter.getBluetoothLeScanner().startScan(new ScanCallback() { @Override public void onScanResult(int callbackType, ScanResult result) { BluetoothDevice device = result.getDevice(); if (device.getName() != null && device.getName().equals("BLE_DAQ")) { mAdapter.getBluetoothLeScanner().stopScan(this); // 2. 连接 mGatt = device.connectGatt(context, false, mGattCallback, BluetoothDevice.TRANSPORT_LE); } } }); // 3. 连接状态与发现服务 private final BluetoothGattCallback mGattCallback = new BluetoothGattCallback() { @Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { if (newState == BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices(); } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { gatt.close(); // 关键:断开后必须 close } } @Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { BluetoothGattService service = gatt.getService( UUID.fromString("0000FFF0-0000-1000-8000-00805F9B34FB")); mNotifyChar = service.getCharacteristic( UUID.fromString("0000FFF1-0000-1000-8000-00805F9B34FB")); // 4. 订阅通知 gatt.setCharacteristicNotification(mNotifyChar, true); // 额外:写 CCCD 描述符,这一步最容易漏 BluetoothGattDescriptor cccd = mNotifyChar.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")); cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); gatt.writeDescriptor(cccd); } };

这段代码里最值得背下来的是 setCharacteristicNotification 和写 CCCD 描述符必须成对出现。很多人只调用 setCharacteristicNotification(true),以为万事大吉,实际上这个 API 只是让 Android 系统把通知事件交给回调,真正让单片机开始推数据的是往描述符 0x2902 里写 0x0001。这两个动作缺一个,结果就是连接正常但永远收不到 onCharacteristicChanged。参数方面,connectGatt 的第二个参数 autoConnect 我用 false,因为扫描到信号后主动连接成功率远高于让它后台自动重连;TRANSPORT_LE 明确指定走低功耗蓝牙通道,避免双模设备走到经典蓝牙上。

3.3 订阅成功后的数据解析:用状态机处理半包和粘包

onCharacteristicChanged 回调里拿到的 byte[] 是一次通知的内容,但不要假设它一定等于单片机发的完整一帧。BLE 协议本身不保证报文边界和业务帧边界对齐,加上 Android 系统有时会把多次通知合并交付,你的解析层必须自己做拆帧。我通用状态机处理,先找帧头再取整帧,不完整就攒着,这是从单片机串口接收那里继承过来的思路。

private static final int FRAME_HEAD = 0xAA; private byte[] mRxBuffer = new byte[512]; private int mRxIndex = 0; private int mExpectedLen = 0; @Override public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { byte[] data = characteristic.getValue(); System.arraycopy(data, 0, mRxBuffer, mRxIndex, data.length); mRxIndex += data.length; int headPos = -1; for (int i = 0; i < mRxIndex - 1; i++) { if ((mRxBuffer[i] & 0xFF) == FRAME_HEAD && (mRxBuffer[i + 1] & 0xFF) == 0x55) { headPos = i; break; } } if (headPos < 0) { mRxIndex = 0; // 没找到帧头,整包丢弃 return; } // 把帧头移到缓冲区起点 System.arraycopy(mRxBuffer, headPos, mRxBuffer, 0, mRxIndex - headPos); mRxIndex -= headPos; if (mRxIndex >= 4) { mExpectedLen = (mRxBuffer[2] & 0xFF) + 4; // 数据长度 + 帧头2 + 类型1 + 校验1 } if (mRxIndex >= mExpectedLen) { // 提取一帧,交给上层解析 ADC 值 parseFrame(Arrays.copyOf(mRxBuffer, mExpectedLen)); System.arraycopy(mRxBuffer, mExpectedLen, mRxBuffer, 0, mRxIndex - mExpectedLen); mRxIndex -= mExpectedLen; } }

逻辑分三步:追加数据、找帧头、按长度凑够一帧。帧头找不到就丢弃缓冲区,这是处理干扰最直接的手段,代价是损失自同步能力,但本来绘图数据也不要求 100% 不丢帧,丢一帧波形上顶多是一个毛刺。mExpectedLen 的算法要注意:第 3 个字节是数据长度,固定帧结构里总帧长是数据长度加 4,如果你把帧头设计成 4 字节,这里要对应调整。我在代码里用 512 字节缓冲区,如果你单片机发送频率很高,建议改成 1024 或者环形队列,防止单次回调数据量过大时覆盖。

拿到完整帧后,数据类型 0x01 表示单通道波形,数据体按小端解析成一个 int,转成 float 后就能送进绘图缓冲区。这里 Android 的解析用 ByteBuffer 要小心:LITTLE_ENDIAN 要显式设置,默认大端会把你所有负数解成正数。

4. 实时绘图:选型、刷新策略与性能参数

数据到手了,接下来是“蓝牙数据绘图”里那个绘图。这个环节做得好不好,直接决定 App 是像示波器还是像 PPT。绘图本身不难,难在刷新策略——蓝牙回调的线程、UI 绘制的线程、数据缓冲区的拥挤程度,这三件事混在一起,就是卡顿掉帧的根源。

4.1 选型对比:想要快速出效果 vs 想要长期可控

Android 绘图方案常见就三条路:MPAndroidChart、自定义 View、GraphView。我在这个场景下的建议很明确:如果单片机采样率在 200Hz 以内,数据量不大,又没有缩放拖拽这类复杂手势需求,自定义 View 是最轻量也最可靠的选择。反之,如果你以后要查看历史数据、双指缩放、多曲线叠加,MPAndroidChart 能省大量时间。

方案包体大小实时滚动支持自定义程度适用场景
MPAndroidChart较大支持,需开 ViewPortHandler中多曲线、缩放、历史查看
自定义 View无依赖完全自己控制高单通道/多通道实时波形
GraphView较小一般低简单绘图,不推荐

选 MPAndroidChart 时注意一个隐藏问题:它的 notifyDataSetChanged 之后会自动重绘,但如果你每秒调用多次,整个图表会频繁触发测量和布局,低端机上卡顿明显。选自定义 View 则不用怕这个问题,onDraw 里只做一件事:把缓冲区里的点连成线。用 Android Studio 跑真机调试时,你可以用自带的 Profiler 看一眼主线程 CPU 占用,如果持续超过 30%,基本就是刷新频率太高或者绘制逻辑太重。

4.2 刷新节奏:不要在 BLE 回调里碰 View 和 Chart

必须先建立这个原则:onCharacteristicChanged 运行在 Binder 线程,你在这里直接更新 UI 是典型的“在非主线程修改 UI”,轻则丢帧,重则直接抛出 CalledFromWrongThreadException 崩溃。即使你套了 runOnUiThread,高频通知也会把主线程的消息队列塞满。更合理的设计是双缓冲:蓝牙线程解析数据后只写入环形缓冲区,UI 线程用一个 Handler 定时从缓冲区取数据并绘制。

// 环形缓冲区:写入端是蓝牙回调线程,读取端是 UI 线程 private final float[] mBuffer = new float[4096]; private volatile int mWritePos = 0; private final int VIEW_SIZE = 300; // 一屏显示的采样点 // UI 线程定时刷新,每 33ms 一次,约 30fps private final Handler mUiHandler = new Handler(Looper.getMainLooper()); private final Runnable mDrawTask = new Runnable() { @Override public void run() { // 从缓冲区读全部新数据并刷新 View waveformView.postData(mBuffer, mWritePos % mBuffer.length); waveformView.invalidate(); mUiHandler.postDelayed(this, 33); } }; // 在 onResume 里 mUiHandler.post(mDrawTask),在 onPause 里移除回调

参数怎么调,给一组我实际用过的参考:采样率 100Hz 时,33ms 的刷新间隔足够流畅,视觉上不会掉帧;如果采样率到 500Hz,建议刷新间隔降到 20ms,同时把 VIEW_SIZE 扩大,否则波形横向压缩太严重,细节全部糊在一起。环形缓冲区的 4096 个 float 约 16KB,如果你采样率只有 20Hz,可以缩到 1024,但如果你画的是加速度计三轴,三个通道各要一块缓冲区,内存预算要按通道数乘以单缓冲算。

有一点很关键:postData 里做一次 System.arraycopy 把数据拷进 View 内部的显示数组,然后 invalidate 只在主线程调用一次。不要在 onDraw 里去读环形缓冲区,原因是 onDraw 频率由系统 VSYNC 决定,并不和你写入节奏匹配,读指针被写指针追着跑会闪出撕裂效果。这个坑我踩过一次,后来养成习惯:写侧只 push,读侧全量拷贝。

5. 避坑与排查:蓝牙绘图项目最常见的 5 个翻车现场

这个方向做完一遍,你会发现成功率不是由会不会写代码决定,而是由排错速度决定。下面的问题清单来自实际调试,每条按现象、原因、解决三步展开,照着对就能省下半天查资料的时间。

5.1 连接上了但收不到任何数据

现象:手机显示 BLE 已连接,服务也发现了,但 onCharacteristicChanged 一次都不回调。

原因:setCharacteristicNotification(true) 只开了系统侧的通知监听,没有往 0x2902 这个 CCCD 描述符写 0x0001。单片机的协议栈不会主动向未订阅的客户端推送数据。用惯 HC-05 经典蓝牙的人最容易在这里犯迷糊,觉得连上就该传数据。

解决:在 onServicesDiscovered 里,给目标特征的描述符写值,代码就是 3.2 节里那个 cccd.setValue 加 writeDescriptor。注意顺序不能反,先 setCharacteristicNotification 再 writeDescriptor。还有少数国产模块要求先等待 100ms 再写描述符,否则模块内部还没准备好,这时候在你的回调里加一个延迟任务来写描述符就能解决。

5.2 波形断断续续、出现毛刺或数值跳变

现象:波形大体能看,但每隔一段时间会掉一段,或者某几个点突然变成超大值。

原因:多半不是蓝牙丢了数据,而是帧同步出了问题——你收到的是半包或粘包,按整帧解析自然错位。这跟串口通信是同一个道理,唯一区别是串口可以靠字节间隙判断,BLE 不行,Android 系统层面不保证每个 characteristic.getValue() 对应一次 notify。

解决:解析层必须按 2.3 节的状态机处理,关键是帧头定位。毛刺出现的另一个原因是数据帧里顺序偶发错乱,这时候检查单片机端是不是在中断里发送,发送缓冲区有没有被打断。可以先用低采样率比如 10Hz 测试,如果波形稳定则说明是发送频率太高导致单片机来不及打包。

5.3 扫描不到设备,或者扫描到一半就停了

现象:App 点开始扫描,列表一直为空;或者第一下扫到了,几秒后列表不再更新。

原因:先查权限。Android 11 及以下必须打开定位开关,Android 12 以上要看 BLUETOOTH_SCAN 是否给了“仅使用一次”,以及系统是否把应用标为“受限后台活动”。另外很多国产手机有厂商级的“蓝牙扫描省电策略”,会在后台自动暂停扫描,跟蓝牙 6.0 的新特性没有关系,别往那方面想。

解决:扫描回调里一旦发现目标设备,立即 stopScan。如果必须持续扫描,就要用 setScanMode 的 SCAN_MODE_LOW_LATENCY,并且加上 onScanFailed 回调打印错误码,SCAN_FAILED_ALREADY_STARTED 多见于重复调用 startScan 没停上一次。真机调试时用 nRF Connect 这类工具先扫一遍,如果它也扫不到,问题就在广播端而不在 App。

5.4 断开后重连失败,必须重启手机蓝牙才能恢复

现象:第一次连接正常,App 里断开或蓝牙信号断开后再点连接,一直连不上,日志里 status 返回 133 或 257。

原因:133 是 GATT_ERROR,257 通常是 GATT_FAILURE。绝大多数情况是上次连接没有正确释放资源——你复用了一个已经关闭的 mGatt 对象,或者没有调用 close(),系统侧旧连接没清干净。

解决:断开回调里先 gatt.close() 再把 mGatt 置 null。重连时不能拿旧对象,必须重新拿 BluetoothDevice 再 connectGatt。如果你用了 autoConnect=true,它不会立即发起真正连接,而是等系统广播后再连,这时候 AMS 可能还挂着旧的连接状态,解决方案是合上掉 autoConnect 或先调用 refreshDeviceCache 后再试一次。代码层面强制封装:

private void disconnectCleanly() { if (mGatt != null) { mGatt.disconnect(); mGatt.close(); // 必须先断开再关闭 mGatt = null; } }

5.5 绘图卡顿、掉帧、内存逐渐上涨

现象:波形能画,但跑几分钟后明显变卡,甚至出现一次数据都没画就先 OOM。用 Android Studio 的 Profiler 看,发现主线程 CPU 占用持久在 50% 以上。

原因:常见三个。一是直接在 onCharacteristicChanged 里 new 对象,高频回调造成大量 GC 内存抖动;二是绘图库的 notifyDataSetChanged 调用太频繁,每次全量重排;三是把历史点无限追加到一个 List,从不清理。

解决:高频回调里零分配,byte[] 解析尽量复用静态缓冲区;绘图刷新走 4.2 节的 Handler 定时机制;历史数据只保留最近 N 个点,比如 1000 个点循环覆盖。真机验证时开 GPU 渲染模式,如果 Profile 里条形图一直顶到红色,优先把 invalidate 频率降下来,再看 onDraw 里有没有分配对象。

6. 进阶:把单通道波形改成多通道存储与回放

当单通道波形在手机上稳定跑通以后,你会很快遇到下一个需求:同时看电压和电流,或者同时看三轴加速度。这时候不要硬改界面,先把数据通路升级一下。

多通道设计的核心还是在协议层。把 2.3 节里的数据类型字段扩展,0x01 表示通道一,0x02 表示通道二,数据体里再放一个 float 值即可。Android 端解析时不再只写一个缓冲区,而是按类型分发到 n 个缓冲区。界面用 tab 或上下分栏都行,别用那种一条曲线叠很多曲线的做法,数值密集时根本分不清。我一般会顺手加一个“录波”功能——把解析后的数据按 CSV 格式存到文件,回放时用同一个绘图组件按时间戳播放。这个功能在调试运动控制时很顶用,能复现上一次的完整过程,比现场抓波形的血泪经验好用得多。

验证方法上,先用 nRF Connect 或厂商自带的调试工具订阅一遍数据并查看原始值,确认单片机发的帧格式无误后再打开你的 App,这样就把问题隔离在“App 解析”和“协议适配”两端。我吃过这种亏:单片机端调整了帧格式但忘了改 Android 解析,结果波形从天而降数值全是错的,查了半天才发现两边协议版本对不上。所以我现在每次改协议都会在代码里加一个 VERSION 常量,打印到日志里,真机连上第一时间先对版本号。

另外一个容易忽略的技巧是刷新频率自适应:记录每次 notify 回调的时间戳,算出实际的接收频率,然后动态调整 Handler 的刷新周期。单片机采样率如果只有 20Hz,你按 60fps 刷新纯属浪费 CPU;反之如果采样率 500Hz,你的 UI 还卡在 33ms 刷新,波形看起来会不够跟手。这个自适应用几行就能实现,值得一试。

我做这类项目的一点个人习惯:接线和供电优先用独立锂电池,不要直接怼电脑 USB 供电,蓝牙天线附近有地环路干扰时,波形毛刺会把你带到沟里去。真的排查不出问题时,最笨的办法也最有效——把绘图暂时改成打印数值,看分钟级的数据是否有规律,比盯着波形猜快很多。希望这个工程能帮你少踩几个坑,早一点用上自己的手机示波器。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询