资讯动态

Android BluetoothGatt连接单片机BLE数据实时曲线绘制完整工程

发布时间:2026/10/28 7:07:32 来源:尧图企业网站定制
简介这份安卓项目资源围绕低功耗蓝牙GATT协议开发面向需要将单片机数据无线传输至手机端并实时绘图的物联网开发者可解决无线数据采集与可视化展示的核心问题。压缩包内共有28个文件其中7个Java源码文件承载设备扫描、连接、服务发现、特征读写及通知订阅等关键逻辑13个XML文件提供界面布局与配置定义8个PNG图像资源补充图标等视觉效果整个包体大小仅205KB结构清晰且便于快速阅读。目前已有1097人学习参考价值较高。借助这套源码开发者能够掌握蓝牙低功耗通信的完整实现流程理解原始字节数据到图表曲线的解析方法并熟悉安卓6.0以上动态权限申请、多线程处理等实践要点工程内代码与界面资源完整可直接在Android Studio中打开运行是学习蓝牙APP与数据绘图的实用参考资料。1. 手机端接单片机数据画曲线卡在 bluetoothgatt 的最后一公里单片机板子上的数据已经采上来了下一步就是手机 App 通过蓝牙接收绘图。这套流程里最容易卡住的其实不是单片机端而是安卓这边的 bluetoothgatt扫描明明看到设备连接就是失败特征值订阅了数据就是不回调曲线画着画着UI 又卡成幻灯片。如果你正在做课程设计、毕设或者给产品做快速原型验证这份 bluetoothgatt.zip 就是帮你把这最后一公里打通的完整工程——扫描、连接、订阅通知、数据帧解析、实时绘图、断线重连全齐了。按你自己的帧格式替换解析部分剩下的代码可以直接当底座改。2. BLE GATT 与工程结构先分清 Server/Client 再谈代码拿到一份 bluetoothgatt.zip第一件事不是急着编译而是先确认它在这一层协议里扮演什么角色。BLE 的思维和经典蓝牙完全不一样如果用以前 HC05 串口透传那套思路去套后面每一步都会觉得别扭。2.1 为什么是 GATT经典蓝牙和 BLE 的协议分工经典蓝牙里最常见的是 SPP 串口透传手机用 BluetoothSocket 连上去之后两边像串口一样收发字节51 单片机加一个 HC05 就能跑。BLE 里没有这种透明串口的概念它是一套属性协议外设端单片机作为 GATT Server把自己的数据放在 Service、Characteristic、Descriptor 三层结构里手机作为 GATT Client通过 discoverServices 去读取这个结构然后再读或者订阅特征值。这个角色的区别直接决定了代码怎么写。单片机端如果用 ESP32、nRF51822、CC2541 这类带 BLE 协议栈的芯片跑的是 GATT Server 的角色安卓端用 BluetoothGatt 就是标准 Client。两边服务端的 UUID 必须完全一致哪怕差一位discoverServices 正常返回你也拿不到想要的特征值。很多教程直接把服务 UUID 抠过来用连不上就归咎于玄学其实就是角色没理清。GATT 三层结构里Service 是功能集合一个设备可以有多个Characteristic 是具体数据项比如温度、电压Descriptor 描述特征值的属性最典型的是 0x2902 这个客户端特征配置描述符它控制通知开关。discoverServices 做的事情就是把这三层结构从外设端拉回来缓存在本地之后所有 getService、getCharacteristic 查的都是这份缓存。所以如果不先 discoverServices 就直接查拿到的全是 null。理解了这层关系再看后面几章的代码就不会觉得绕。2.2 工程目录bluetoothgatt.zip 解压后通常长这样不同版本的包里文件和命名可能有出入但核心结构跑不掉。我一般先按下面这个骨架去认文件bluetoothgatt/ ├── app/src/main/java/com/example/ble/ │ ├── MainActivity.java // 权限申请、界面、按钮入口 │ ├── BleWrapper.java // 扫描/连接/订阅/写值的封装 │ ├── BleCallback.java // 连接状态、数据回调的统一接口 │ ├── DataParser.java // 字节流 - 数值的帧解析 │ ├── ChartHelper.java // 实时曲线的数据缓冲与刷新 │ └── CsvLogger.java // 数据落盘 └── app/src/main/AndroidManifest.xml逐个说职责。MainActivity 负责运行时权限申请和把状态展示到界面BleWrapper 是核心所有 BluetoothGatt 的调用都集中在它里面BleCallback 是回调接口单片机数据上来之后在这里拿到最原始的 byte[]DataParser 把你自定义的帧格式吃进去吐成 float[]ChartHelper 负责把解析出的值送进绘图控件同时控制缓冲长度避免无限增长导致 GC 卡顿CsvLogger 把原始数据按时间戳存下来方便回去用 MATLAB 做进一步分析。拿到包之后先全局搜三处再改service uuid、characteristic uuid、设备过滤名。这三处与你的单片机固件强耦合其他代码基本不用动。如果你手上的包结构跟上面不一样也没关系只要按这个职责去找对应文件即可。2.3 Android 权限Android 6 到 12 的权限不是只开一个开关就能跑BLE 扫描在 Android 6 开始必须带定位权限因为广播包里携带的 UUID、MAC 在那个年代被认为属于位置信息Android 12 之后又把蓝牙权限拆成 BLUETOOTH_SCAN、BLUETOOTH_CONNECT 等独立运行时权限。Manifest 里常规写法是这样uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /maxSdkVersion 是为了兼容旧权限声明新系统上走新权限旧系统上还能识别老权限。需要注意 BLUETOOTH_SCAN 在 Android 12 如果带 neverForLocation 标记那扫描时不会触发定位弹窗如果你的业务需要同时拿到定位就不要加这个标记否则会有行为差异。运行时请求顺序建议是先请求定位权限再请求蓝牙权限因为扫描回调依赖前者。包里 MainActivity 如果只写了老权限在 Android 12 设备上装上以后就是扫描不到设备——这是最容易出现的第一处能编译但跑不通。注意Android 12 上扫描不到设备时先检查动态权限请求是否完成不要第一时间怀疑硬件。2.4 外设端选型想让 GATT 通单片机端别用 HC05在拆这套代码之前先把自己手头的单片机外设对号入座。如果单片机端用的是 HC05 / HC06 这类经典蓝牙模块那它只支持 SPP/RFCOMM根本不支持 BLE和 bluetoothgatt 这套代码天生不匹配。能跟 GATT 对上话的是ESP32 原生 BLE、nRF51822、CC2541、CC2640以及带透传固件的 JDY-08、HM-10 之类 AT 指令 BLE 模块。51 单片机加 HC05 是经典蓝牙搭配得走 BluetoothSocket不能复用这份 GATT 工程。单片机端方案协议手机端用什么是否匹配这份资源51/STM32 HC05/HC06经典蓝牙 SPPBluetoothSocket RFCOMM不匹配STM32 AT 指令 BLE 模块HM-10/JDY-08BLE GATTBluetoothGatt匹配ESP32 原生 BLEBLE GATTBluetoothGatt匹配nRF51822/CC2541 等 BLE SoCBLE GATTBluetoothGatt匹配把这个表放在选型阶段对照。现实里很多人拿着 HC05 硬套这份代码扫描能找到设备但连不上不是代码坏了是协议根本不同。3. 连接流程落地扫描、连接、订阅通知三步走顺着包里 BleWrapper 的主线往下看。无论代码打包成什么样底层调用顺序都逃不开这三步扫描到目标设备连接并完成服务发现最后把通知订阅打开。顺序反了后面全白搭。3.1 扫描ScanFilter 帮你把想连的那台设备捞出来扫描是第一步也是最容易漏配置的一步。包里如果用了低延迟扫描模式代码长这样BluetoothLeScanner scanner BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner(); ScanFilter filter new ScanFilter.Builder() .setDeviceName(ESP32_BLE) // 和单片机广播名保持一致 .build(); ScanSettings settings new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 延迟低回调频繁 .setReportDelay(0) // 0 表示每个广播包都回调不聚合 .build(); scanner.startScan(Arrays.asList(filter), settings, scanCallback);ScanFilter 支持按设备名和设备 MAC 过滤按名字过滤时匹配的是广播包里的 Complete Local Name 字段不是 GAP 里自定义的其他名字。很多 ESP32 的 demo 广播名就是这个值如果你的单片机广播里没带名字或者名字显示成乱码就把 setDeviceName 换成 setDeviceAddress(AA:BB:CC:DD:EE:FF)按 MAC 过滤最稳。SCAN_MODE_LOW_LATENCY 会明显加快发现速度代价是功耗高一点开发阶段完全值得。setReportDelay(0) 保证每条广播都立刻回调。如果你发现设备时有时无先检查这两个值别急着改硬件。startScan 要写在权限申请成功之后不要在 onCreate 里直接调否则在 Android 12 上会静默失败logcat 里连错误都没有。3.2 连接与服务发现onConnectionStateChange 里别只写成功分支扫描到设备后调用 connectGatt回调里的状态处理是连接链路的核心Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { if (newState BluetoothProfile.STATE_CONNECTED) { // 连接成功后必须做服务发现否则后面 getService 全是 null gatt.discoverServices(); } else if (newState BluetoothProfile.STATE_DISCONNECTED) { // 这里不要立刻 close重连策略要用到同一个 gatt 对象 handleDisconnect(gatt); } }连接状态回调有三个关键点。第一服务发现必须在 STATE_CONNECTED 分支里主动触发不是连上就有数据第二status 参数是连接层面的错误码常见的 133 表示远端设备主动拒绝或掉线这个值要打日志便于排查是协议问题还是硬件问题第三STATE_DISCONNECTED 分支里不要立刻 close因为许多设备在短暂掉线后会快速广播回来留着同一个 gatt 对象做重连比重新 connectGatt 更可靠。提示onServicesDiscovered 成功后再去 getService、getCharacteristic如果拿到 null说明 UUID 和固件端对不上直接打日志回抛给界面不要在 UI 上空指针崩掉。3.3 订阅通知特征值不是连上就能收连上且服务发现完成之后很多人觉得可以直接读特征值了实际上一半设备的数据是主动推送的你需要先订阅通知。标准写法是两步BluetoothGattService service gatt.getService(UUID.fromString(您的服务UUID)); BluetoothGattCharacteristic characteristic service.getCharacteristic(UUID.fromString(您的特征UUID)); // 第一步本地缓存里打开通知标志 boolean localOk gatt.setCharacteristicNotification(characteristic, true); // 第二步写 0x2902 描述符设备看到这个值才会真正开始推数据 BluetoothGattDescriptor descriptor characteristic.getDescriptor( UUID.fromString(00002902-0000-1000-8000-00805f9b34fb)); descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); boolean writeOk gatt.writeDescriptor(descriptor);setCharacteristicNotification 只在手机本地做了标记真正让单片机外设开始推送数据的是 0x2902 描述符的值。ENABLE_NOTIFICATION_VALUE 对应 0x0001表示 notify 推送还有一种 ENABLE_INDICATION_VALUE 对应 0x0002这种推送需要设备端等待手机应答适合更可靠但流速更低的场景。具体用哪个看特征值的属性通过 characteristic.getProperties() 检查是否包含 PROPERTY_NOTIFY 或 PROPERTY_INDICATE代码里建议把这个判断写进去用错了描述符值就收不到数据。很多为什么订阅了没有回调的提问原因就在这里不是设备坏了。3.4 下行通道顺便留一个写特征值的口子绘图只是单向接收实际项目里往往还要在手机端给单片机发指令——启动采样、修改采样率、校准零点。写值和通知是两回事需要特征值本身带写属性if ((characteristic.getProperties() BluetoothGattCharacteristic.PROPERTY_WRITE) ! 0) { characteristic.setValue(commandBytes); // commandBytes 是你要下发的命令帧 gatt.writeCharacteristic(characteristic); }写值之前先判属性避免拿一个只读特征值去写。另外注意 BLE 默认 MTU 是 23 字节扣掉 3 字节协议头用户数据一次只能发 20 字节如果命令帧超过 20 字节要么拆包要么在连接成功后主动调 requestMtu(247) 协商更大包长。举例说我在测电池电压时单片机端固件里定义 0x01 是启动采样、0x02 是停止采样手机端按钮按下就把帧拼好传进来。这个口子打开之后上面的绘图功能才真正能形成闭环。4. 从字节流到实时曲线帧解析与绘图 View 的取舍onCharacteristicChanged 里拿到的是 byte[]屏幕上要滚动的是曲线中间这段转换逻辑是这类工程的核心。很多包处理不好直接把字节强转成 int 画上去或者每收到一个包就刷新一次界面数据一快就崩。4.1 先定帧格式帧头、长度、校验三件套接收端必须先有一个明确的协议框架。我一般建议用帧头 长度 数据 校验的固定结构比如0xAA 0x55 0x06 0x01 [温度高字节] [温度低字节] [电压高字节] [电压低字节] [累加和]帧头 0xAA 0x55 用来找同步0x06 表示后续数据长度0x01 是命令类型中间四个字节是两个带符号 16 位数据最后是简单累加校验。接收端不能保证一次回调就是完整一帧所以建议用状态机逐字节解析而不是按回调长度切。private static final int STATE_SYNC_1 0; private static final int STATE_SYNC_2 1; private static final int STATE_LEN 2; private static final int STATE_DATA 3; private static final int STATE_CHECK 4; public void onData(byte[] bytes) { for (byte b : bytes) { switch (state) { case STATE_SYNC_1: if ((b 0xFF) 0xAA) state STATE_SYNC_2; break; case STATE_SYNC_2: state ((b 0xFF) 0x55) ? STATE_LEN : STATE_SYNC_1; break; case STATE_LEN: len b 0xFF; index 0; state STATE_DATA; break; case STATE_DATA: buf[index] b; if (index len) state STATE_CHECK; break; case STATE_CHECK: if (checkSum(buf, len) (b 0xFF)) { // 校验通过解析 buf 里的业务数据 } else { // 校验失败说明发生粘包或丢字节回到找帧头 } state STATE_SYNC_1; break; } } }状态机的优势在于不依赖回调边界一个回调里可能包含半帧也可能包含三帧都能正确恢复同步。帧头取 0xAA 0x55 两个字节是为了降低数据区撞上帧头的概率校验和负责把真正的脏帧挡在门外。单片机端如果每次发的是 0xAA 0x55 开头的定长数据这套解析可以直接搬如果固件用的是字符串换行分隔那要改成按换行符切包代码结构类似但不用这么复杂。包里 DataParser 如果用的是 ByteBuffer 直接读建议改成这种状态机因为 ByteBuffer 遇到半个包会非常难恢复。4.2 绘图选型自定义 View 还是 MPAndroidChart绘图方面常见的做法是两个方向。自定义 View 轻量没有第三方依赖适合数据量大、需要完全掌控绘制逻辑的场景MPAndroidChart 则把坐标轴、缩放、图例都做好了改动少、上手快适合毕设和课程设计只要能接受它包体较大和动画带来的额外开销。对比项自定义 ViewMPAndroidChart依赖体积无较大坐标轴与缩放自己写内置高频刷新性能可控性高需要关动画上手周期长短如果只是把一个传感器数值画成滚动曲线我推荐先用 MPAndroidChart但要把两处关掉setScaleEnabled(false) 和动画。这两个设置能去掉大多数滑动和闪烁问题。之后如果性能不够再往自定义 View 迁移接口层留好 addPoint(float) 就不伤筋动骨。4.3 实时刷新参数缓冲长度、刷新间隔、坐标自适应高频数据直接进 UI 是大忌。常见做法是数据回调里只往缓冲队列塞值界面用 Handler 定时 50ms 拉一次。这样 50ms 窗口内哪怕来了 20 个采样点合并成一次刷新UI 压力直接降一个量级。public void onDataPoint(float value) { synchronized (buffer) { buffer.offer(value); if (buffer.size() MAX_POINTS) buffer.poll(); } } private void flushToChart() { synchronized (buffer) { while (!buffer.isEmpty()) { LineData data chart.getData(); LineDataSet set (LineDataSet) data.getDataSetByIndex(0); data.addEntry(new Entry(data.getEntryCount(), buffer.poll())); } } chart.notifyDataSetChanged(); chart.moveViewToLast(); }参数参考MAX_POINTS 一般取 600 到 1000太多会把屏幕左侧细节压没太少又看不到趋势刷新间隔 50ms 对应 20Hz 的 UI 刷新率够用moveViewToLast 每次把视图推到最新点如果不想强制跟随可以改成只在用户没有手动滑动时才调用。对多通道数据可以建多个 LineDataSet每个 set 用独立颜色addEntry 时按通道索引分别塞。4.4 数据落盘把原始字节存成 CSV回去还能分析绘图看的是实时趋势真要分析采样质量还是得存原始数据。我习惯在 onCharacteristicChanged 里同步写 CSVFile logFile new File(getExternalFilesDir(null), ble_data.csv); FileWriter writer new FileWriter(logFile, true); writer.write(System.currentTimeMillis() , value \n); writer.close();放 getExternalFilesDir(null) 下面有两个好处不需要申请存储权限且 Android 11 分区存储限制之后这个目录自己读自己写不受约束。别去碰 /storage/emulated/0/Android/data 下面那些第三方应用目录就算用 FileProvider 也不该把数据写到别人家去。CSV 文件导出后用 MATLAB 的 csvread 或者 Python pandas 几行就能把整段采样读回去做 FFT比在手机上分析舒服得多。5. 避坑笔记HC05 连不上、notify 收不到、绘图卡顿这章整理的是实际调这类工程时最容易翻车的五个点。每一条都按现象-原因-解决讲清楚顺序按踩到频率排。5.1 HC05 能扫到却连不上它压根不走 GATT现象是手机系统蓝牙列表里能看到 HC05但换到这份 GATT 工程里要么扫描不出这个设备要么连上立刻断。原因HC05 是蓝牙 2.0 时代的经典蓝牙模块只支持 SPP 串口透传根本没有任何 GATT 服务BLE 扫描器只对广播中的 BLE 外设应答HC05 在另一个协议栈里GATT 工程自然不认它。解决如果把单片机端换成 ESP32 或 HM-10、JDY-08 这类 BLE 模块这份工程直接可用如果坚持用 HC05手机端要走 BluetoothSocket 加 UUID 00001101-0000-1000-8000-00805F9B34FB 那套经典蓝牙流程和这份资源不是一回事。选型问题越早确认浪费的时间越少。5.2 订阅了 notify 数据却不回来0x2902 没写现象onCharacteristicChanged 从来没被触发setCharacteristicNotification 也返回 true。原因这个方法只改了手机本地缓存外设并不知道你要收数据真正触发推送的是写 0x2902 描述符。解决按 3.3 节的写法查到 CCCD 描述符后写 ENABLE_NOTIFICATION_VALUE同时打印 getDescriptor 是否为 null。如果返回 null说明特征值本身没有 notify 属性或者 UUID 取错了。注意0x2902 描述符的写入是异步的结果在 onDescriptorWrite 回调里确认不要只看到 writeDescriptor 返回 true 就以为成功了。5.3 回调里直接刷新 UI 崩掉onCharacteristicChanged 不在主线程现象在回调里 new AlertDialog 或者改 TextView 文本直接崩溃log 显示 CalledFromWrongThreadException。原因BLE 回调跑在 binder 线程不是 UI 主线程。解决回调里只做数据解析和进队列用 runOnUiThread 或 Handler.post 把 UI 更新包出去。数据回调里顺手做 UI 操作是这类工程最常见的崩溃来源没有之一。这个坑藏得很深因为低频率下崩得不多数据一快必现。5.4 绘图卡成幻灯片每条数据都触发一次全量刷新现象单片机以 100Hz 发数据手机端曲线还能动但明显掉帧monitor 里 GC 频繁。原因每次回调都 addEntry同时 notifyDataSetChanged、moveViewToLast动画也开着UI 线程被绘图和 GC 拖垮。解决按 4.3 节做队列合并刷新关闭 MPAndroidChart 动画必要时把 MAX_POINTS 从 1000 降到 400。这个优化做完同样 100Hz 数据流曲线顺滑程度差距肉眼可见。顺带提一句自定义 View 的话用 Path 重绘比逐点 drawCircle 快得多。5.5 锁屏之后再打开重连不成功现象手机锁屏或者设备断电重启后页面一直停在连接中手动重连也好几次失败。原因掉线回调里直接 gatt.close()把连接对象销毁了另外 BLE 芯片重启后广播地址可能变缓存的服务结构也可能是旧的。很多现成工程把重连逻辑封装成黑匣子出问题你根本不知道它内部怎么走。解决STATE_DISCONNECTED 分支不 close只把状态位复位重连用同一个 gatt 对象调 connect配合指数退避策略失败间隔 1s、2s、4s 这样推。如果反复失败再尝试隐藏 API refresh() 强制刷新 GATT 缓存。重连骨架可以先这样写public void reconnect(BluetoothDevice device) { if (gatt ! null) { // close 前必须确认连接已断开否则会把正常连接一起杀掉 gatt.close(); gatt null; } gatt device.connectGatt(context, false, gattCallback); }reconnect 里先 close 再新建是避免多实例连接指数退避的时间控制在 Activity 生命周期里页面销毁时记得停掉。加上这层断电重启后的恢复成功率能提升不少。6. 没有硬件也能跑用 nRF Connect 模拟外设验证整个流程排错最怕两边同时出问题手机端代码怀疑自己写错了单片机端又觉得是广播配置不对两头互相甩锅。我的习惯是先让 App 单独跑用 Nordic 的 nRF Connect 模拟一个 GATT Server把 App 侧链路完整验证一遍再上真实板子。6.1 Advertiser 模拟外设的四个配置点nRF Connect 的 Advertiser 功能可以直接扮演外设。配置时对照 App 代码里的三处设备名必须和 ScanFilter 一致Service UUID 必须和手机端 getService 的 UUID 一样Characteristic 属性勾选 Read/Notify并加上标准 CCCD 描述符。第四处是 Notify 发送按钮——在模拟外设的界面里可以手动发一个 byte 数组用来模拟单片机下发的数据帧。我一般从数据帧第一个字节开始发一次发 3 到 5 字节观察 App 端状态机能不能正确处理半包和粘包。这一项用真实硬件反而不方便因为固件已经写死发送节奏你没法精准制造半包场景。6.2 MTU 协商一次最多只能发 20 字节是最大陷阱默认链路层 MTU 是 23扣掉 3 字节头部用户数据一次 20 字节。很多单片机固件里无所谓但 App 端如果启了 notify数据超过 20 字节就会被底层拆成多包应用层回调时是分包到达的。所以帧解析必须支持半包累积。调试验证时可以在 nRF Connect 里主动发送超过 20 字节的帧确认 App 解析逻辑不出错如果固件支持也可以调 requestMtu(247) 把单包容量提到 244 字节左右高频传输时吞吐量会明显改善。一个细节requestMtu 是异步的结果在 onMtuChanged 里回调别在调用处立刻用新 MTU 发长包。这个时序问题很容易被忽视表现出来就是偶尔发长帧就丢。从那以后我每次做 BLE 项目都强制先花十分钟在 nRF Connect 里把外设模拟出来扫描能否找到、连接能否成功、通知能否触发、长帧能否拼对全部过一遍再碰真实固件。这样能砍掉至少一半的联调时间因为协议栈层面的问题在模拟阶段就已经暴露完了。希望这个顺序对你有用也希望这份 bluetoothgatt.zip 能帮你少踩几个我当年踩过的坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑