☰
低功耗蓝牙温湿度监控系统:单片机与Android APP的实现全解析
2026/10/18 20:32:57 网站建设 项目流程

简介:这是一份面向物联网与移动开发者的温湿度监控APP完整代码包,覆盖从传感器数据采集、微控制器处理、云端接口交互到手机端实时显示与远程控制的典型链路,适合正在学习DHT11/DHT22、MQTT/WebSocket通信,或想快速搭建Android网关项目的读者。压缩包共143个文件,约3.98MB,以93个png界面与流程截图、11个xml布局与配置文件、17个class和3个java源码为主体,另有jar依赖、apk安装包、dex及工程描述文件,便于还原工程结构并对照学习。目前已有1617人浏览下载。代码中包含MainActivity、LED开关控制线程、客户端通信线程等关键模块,可重点研究APP如何通过TCP/HTTP与服务器交互、解析温湿度数据并驱动UI更新;配合完整APK与资源文件,既能直接安装验证功能,也能用于二次开发和课堂实训。

1. 这到底是个什么项目:手机端温湿度监控的完整数据链路

想给自己工位或家里做一个温湿度监控,又不想买现成的智能网关,最直接的办法就是一块开发板加一个手机APP。这套代码资源就是干这件事的:单片机端负责读传感器数据,通过低功耗蓝牙发给手机,APP 负责解析、展示实时曲线、存历史数据,并在温度或湿度越限时弹通知。适合谁呢?正在做毕设、学生竞赛,或者单纯想给实验室加个环境监测的人。读完你会拿到一条完整的链路——从传感器引脚怎么接,到手机通知栏弹出的报警内容长什么样,中间每一步的坑都有对应的解决办法。先把话放这儿:这个项目真正的门槛不在蓝牙连接,而在数据帧设计和 APP 的防崩处理,后面会逐个拆开讲。

2. 硬件端的通信链路:传感器选型与数据帧设计

很多第一次做这类项目的人,会下意识先把 APP 界面画出来,再去想硬件端怎么配合。实际做下来顺序应该反过来:先把传感器数据变成手机能稳定识别的一串字节,再谈界面。这一章先把硬件侧的两个关键点讲透——传感器怎么选、数据帧怎么设计,这两件事直接决定 APP 端解析代码的复杂程度。

2.1 传感器选型:DHT11 vs DHT22,别只看价格

这颗资源的示例代码默认用的是 DHT22(也叫 AM2302),但代码里也保留了 DHT11 的宏定义开关。两者最大的差别不是价格,而是采样周期和精度。DHT11 精度 ±2°C、湿度 ±5%RH,采样间隔要求大于 1 秒;DHT22 精度 ±0.5°C、湿度 ±2%RH,采样间隔可以到 2 秒。监控场景里湿度跳变本身就比较剧烈,DHT11 的 ±5%RH 很容易让曲线看起来像在抽风,所以示例代码默认走 DHT22 是对的。

// 单片机端(以 ESP32 为例)的传感器初始化片段 #define DHT_PIN 4 // 数据引脚接开发板的 GPIO4 #define DHT_TYPE DHT22 // 换成 DHT11 的话把这行改成 DHT11,同时采样间隔要调大到 2000ms 以上 DHT dht(DHT_PIN, DHT_TYPE); void setup() { Serial.begin(115200); dht.begin(); } void loop() { float h = dht.readHumidity(); float t = dht.readTemperature(); if (isnan(h) || isnan(t)) { Serial.println("sensor error"); delay(1000); return; } // 组装数据帧并走 BLE notify 发送,下一节细讲 sendDataFrame(t, h); delay(2000); // DHT22 最小间隔 2 秒,DHT11 建议改成 2500ms }

这段代码里有两个容易被忽略的细节。一是isnan判断,DHT 库读不到数据时返回的是 NaN 而不是 0,如果不拦截,后面的数据帧会算出一个很大的负数,APP 端画图的时候直接画出异常尖峰。二是delay(2000),这个是配合 DHT22 的最短采样间隔,不要为了“实时性”把它改到 500ms,DHT11 尤其不能这么干,读出来的数据会是错的。

2.2 单片机端的采数逻辑与数据帧设计

手机端解析蓝牙数据的难度,跟数据帧设计是否合理直接相关。示例代码里帧结构是这样定的:一帧 10 个字节,帧头固定0xA5,接下来是湿度整数和湿度小数、温度整数和温度小数,最后一位是校验字节,用累加和取低八位。这样设计的好处是 APP 端拿到任何一个字节流,都能通过帧头定位、按长度切帧、用校验和丢弃脏数据,不需要依赖蓝牙底层分包。

// 数据帧组装:帧头 + 湿度整数 + 湿度小数 + 温度整数 + 温度小数 + 校验 void sendDataFrame(float temp, float humi) { uint8_t buf[8]; // 一帧 8 字节,比 BLE 单包 20 字节小很多,避免拆包 buf[0] = 0xA5; // 帧头 buf[1] = (uint8_t)((int)humi / 100); // 湿度整数部分(%RH,保留两位小数) buf[2] = (uint8_t)((int)humi % 100); // 湿度小数部分 buf[3] = (uint8_t)(((int)(temp * 100)) / 100); // 温度整数部分(单位 0.01°C) buf[4] = (uint8_t)(((int)(temp * 100)) % 100); // 温度小数部分 uint8_t checksum = 0; for (int i = 0; i < 5; i++) checksum += buf[i]; buf[5] = checksum; // 累加和校验,APP 端同规则计算后比对 // 实际通过 BLE notify 发送 buf,长度为 6 }

这里的单位设计是隐式的:温湿度都放大一百倍用整数传输,APP 端再除以 100 显示。这么做是因为浮点数在蓝牙传输里很容易出现字节序问题,整数则没有歧义。校验和只覆盖前五个字节,不把帧头算进去,APP端计算时也按同样规则来,一致性比复杂度重要得多。

2.3 连接方式对比:BLE 4.0 是监控项目的默认答案

这套监控资源默认走 BLE(低功耗蓝牙),不是 WiFi。原因很实际:WIFI 模组功耗高、要配网、要处理断线重连,而 BLE 从手机解锁到看到第一个数据点,通常只需要一两秒。温湿度监控这种低频数据传输场景,BLE 的 20 字节单包限制完全够用。如果你手里只有 WiFi 模块,那 APP 端的解析逻辑要改成 TCP 或 HTTP 长连接,代码改动量不小,但数据帧的设计思路可以复用,帧头加校验的方式在 TCP 流里同样适用。

3. APP端实现关键路径:从扫描到弹窗的四个模块

这一章进入核心。APP 端是这份资源里代码量最大的部分,整体结构是四个模块串起来的:设备扫描、数据解析、曲线绘制、阈值报警。每个模块都有独立的类和回调接口,你可以直接抄走改包名,也可以只抽其中一个模块用在自己的项目里。

3.1 设备列表与扫描过滤

扫描这块最容易犯的错是不做过滤。如果你的手机蓝牙列表里同时有音箱、手环、其他开发板,扫出来的设备能塞满一屏。示例代码的做法是:在扫描回调里同时检查设备名称前缀和设备广播的服务 UUID,两者都匹配才算目标设备。

// APP端扫描回调片段,根据设备名前缀和服务UUID双重过滤 private final BluetoothAdapter.LeScanCallback scanCallback = new BluetoothAdapter.LeScanCallback() { @Override public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) { String name = device.getName(); if (name != null && name.startsWith("TH-")) { // 单片机端广播名设为 TH-DHT22 if (containsServiceUuid(scanRecord, "0000ffe0-0000-1000-8000-00805f9b34fb")) { if (!deviceList.contains(device)) { // 去重,防止列表重复跳动 deviceList.add(device); adapter.notifyDataSetChanged(); } } } } };

两处关键点:一是deviceList.contains(device)去重,bluetooth 扫描回调会高频触发,不去重的话列表会疯狂闪烁;二是 UUID 过滤要用广播包里的 Service UUID,而不是连接后查到的 GATT 服务,因为扫描阶段还没建立连接。如果你改了单片机端的服务 UUID,记得这里要同步改,否则永远扫不到。

3.2 数据解析与粘包处理

前面数据帧设计成 6 字节,就是为了规避 BLE 分包问题。但实际调试中发现,notify 回调里偶尔会把两帧数据拼在一起发过来,或者因为信号不好丢一个字节。解析代码必须做成“缓冲区内查找帧头、按长度切帧”的机制,而不是每次回调当成一帧处理。

// BLE notify 回调中的数据解析:支持粘包与半包 private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private void onDataReceived(byte[] data) { buffer.write(data, 0, data.length); // 先追加到缓冲区 byte[] bytes = buffer.toByteArray(); int idx = 0; while (idx + 5 < bytes.length) { // 至少一帧长度才尝试解析 if (bytes[idx] == (byte) 0xA5) { // 找帧头 int sum = 0; for (int i = idx; i < idx + 5; i++) sum += bytes[i]; if ((sum & 0xFF) == (bytes[idx + 5] & 0xFF)) { // 校验通过 float humi = (bytes[idx + 1] * 100 + bytes[idx + 2]) / 100.0f; float temp = (bytes[idx + 3] * 100 + bytes[idx + 4]) / 100.0f; updateUI(temp, humi); idx += 6; // 跳到下一帧 continue; } } idx++; // 帧头不对就移动一个字节继续找 } // 剩余不足一帧的字节留在缓冲区,等待下次回调拼接 buffer.reset(); if (idx < bytes.length) buffer.write(bytes, idx, bytes.length - idx); }

注意updateUI这里只负责更新变量,真正的界面刷新要放到 UI 线程。实际发现把解析放主线程也没问题,因为一帧才 6 字节,频率 2 秒一次,压力很小。处理粘包的关键是最后那两行:解析完不能直接把 buffer 清空,要把剩余的半截字节留下来,否则丢了数据会导致下一帧永远对齐不上。顺带说一句,网上很多现成代码把ByteArrayOutputStream每次重置,那是基于“一次一发”的假设,监控项目里偶尔会出现两帧连发,不改就会频繁丢数据。

3.3 实时曲线绘制:点多了会卡,这是最常见的一个坑

曲线部分用的是 MPAndroidChart,示例代码在 MainActivity 里建立 LineChart。这里有一个新手必踩的坑:每收到一个数据点就往 chart 里 addEntry,两个小时以后图表会变得非常卡,因为图表控件默认会缓存所有历史数据点并重绘坐标轴。

// 实时曲线:只保留最近 N 个点,防止UI线程过载 private static final int MAX_VISIBLE_POINTS = 120; // 显示最近 120 个点,约 4 分钟数据 private final LineDataSet tempSet = new LineDataSet(null, "温度"); private final LineDataSet humiSet = new LineDataSet(null, "湿度"); private void appendPoint(float temp, float humi) { // 以当前时间戳作为 x 轴,单位是毫秒 tempSet.addEntry(new Entry(System.currentTimeMillis(), temp)); humiSet.addEntry(new Entry(System.currentTimeMillis(), humi)); if (tempSet.getEntryCount() > MAX_VISIBLE_POINTS) { // 移除最老的点:先删数据再通知刷新 tempSet.removeFirst(); humiSet.removeFirst(); } // 只在主线程刷 UI,数据回调也可以用 runOnUiThread 包一层 lineChart.invalidate(); // 触发重绘 }

removeFirst()是这套方案的灵魂,它保证图表控件永远只维护 120 个点,内存占用恒定,重绘开销不随时间增长。如果你想要更长的历史曲线,不要直接加大这个数字,而是把原始数据存进 SQLite,图表只做最近一段时间展示,历史查看单独做页面。MPAndroidChart 的 render 效率在几百点以内都还行,超过 500 点肉眼可见地掉帧。

3.4 阈值报警与通知栏推送

报警逻辑看起来简单——温度高于某个值就弹通知。但示例代码里有一点值得抄:报警判断不在数据接收回调里直接做,而是抽了一个独立的AlertChecker类,传入最新温度湿度、上下限阈值,返回是否触发报警。这样设计是为了后面扩展——比如你想加“连续三次超限才报警”的防抖逻辑,不用改动解析代码。

// 报警判定:带防抖,连续 3 次超限才触发,避免瞬时尖峰误报 public class AlertChecker { private final float tempHigh, tempLow, humiHigh, humiLow; private int tempExceedCount = 0; public AlertChecker(float th, float tl, float hh, float hl) { this.tempHigh = th; this.tempLow = tl; this.humiHigh = hh; this.humiLow = hl; } public boolean check(float temp, float humi) { if (temp > tempHigh || temp < tempLow || humi > humiHigh || humi < humiLow) { tempExceedCount++; } else { tempExceedCount = 0; // 连续计数,恢复到正常范围立刻清零 } return tempExceedCount >= 3; } }

这里的防抖用的是连续计数:一次超限不报警,连续三次超限才推通知。温湿度本身有波动,比如开门带进来的热风会让温度瞬间跳 1°C,不做防抖的话手机一天能弹几十条通知。报警次数阈值可以调,监控精度要求高的场景改成 2,环境稳定的场景改成 5 也行。通知栏的推送用的是 NotificationManager,需要 Android 13 以上的运行时权限,在AndroidManifest.xml里声明POST_NOTIFICATIONS权限并动态申请,这个是很容易忘掉的一步。

4. 常见问题排查:连不上、粘包、曲线卡顿与后台被杀

这一章全是血泪经验。前四条是从我们同事和论坛反馈里汇总的最高频问题,最后一条是我自己调试时翻过车的地方。按“现象 → 原因 → 解决”的顺序写,方便你直接对照排查。

4.1 连上了却没数据:GATT 缓存没清理

现象:手机能扫描到设备,点连接也能连上,但 Service Discover 完成后始终回调不出数据,Logcat 里也没有任何 notify 日志。原因:Android 系统对已配对过的 BLE 设备会缓存 GATT 服务表。单片机端更新过服务 UUID 或特征值 UUID 后,手机端拿到的还是旧缓存的服务列表,自然找不到正确的 notify Characteristic。解决:连接成功后、执行 discoverServices 之前,先调用BluetoothGatt.refresh()清掉系统缓存。

// 连接成功后先清缓存再查服务,否则改过UUID后永远连不上新服务 BluetoothGatt gatt = device.connectGatt(context, false, gattCallback); // 延迟 200ms 后执行,等底层连接真正建立 new Handler().postDelayed(() -> { boolean refreshed = gatt.refresh(); // 清缓存,API 21 之后公开方法 boolean discovered = gatt.discoverServices(); }, 200);

这个refresh()方法在较新 Android 版本上标记为隐藏 API,直接调用需要反射。如果你用反射调用失败,替代方案是让用户去系统设置里手动“取消配对”再重连,这招实测有效,但体验差。我自己的习惯是:只有调试阶段用反射调用,上架版本不要依赖这个特性,而是保证单片机端 UUID 不随便改。

4.2 数据粘包:通知频率高于解析频率

现象:曲线图出现间隔性跳变,一次收到两个数据点,其中一个比实际值大很多。原因:单片机端每 2 秒发一帧,但 notify 回调可能把两帧合并在一次回调里触发,如果没有缓冲区机制,第二帧会解出错误数值。解决:用前文 3.2 节的缓冲区方案,解析函数里加while循环切帧,而不是每次回调只解析data[0]。

4.3 曲线越来越卡:Entry 数量无限增长

现象:刚启动时图表滑动流畅,运行一小时后打开 APP 会掉到 10 帧以下。原因:MPAndroidChart 的LineDataSet如果不限制getEntryCount(),所有历史点都参与坐标计算与绘制,内存和 CPU 开销随时间线性增长。解决:设置setVisibleXRangeMaximum配合removeFirst(),让数据集维持固定大小,超出部分存入 SQLite 而不是留在图表控件里。

4.4 后台被杀:报警通知不响

现象:手机锁屏放一会儿,再点亮屏幕发现 APP 不见了,报警也没推。原因:Android 系统为了省电会回收后台进程,尤其是国内定制 ROM,应用在后台待机几分钟就可能被杀。解决:使用前台服务(Foreground Service),在服务里发起通知栏常驻提示,告诉系统这个进程正在被用户使用。不要只依赖START_STICKY,国产 ROM 上那个经常失效。

<!-- 前台服务需要权限声明,Android 12+ 还需 ForegroundServiceType --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

4.5 湿度跳变剧烈:DHT11 的锅,别急着改代码

现象:湿度曲线每几分钟跳一次,波动范围接近 10%RH,温度却很正常。原因:DHT11 本身的湿度精度就是 ±5%RH,在空气流动较强的环境下读数波动本来就大。解决:先查传感器型号,如果真是 DHT11,两个办法——换 DHT22,或者在 APP 端做滑动平均滤波。示例代码里用了“最近 5 次取平均”的简单滤波,已经能压住大部分毛刺:

// 滑动平均滤波:缓冲最近 5 个数据,输出平均值 private final float[] buffer = new float[5]; private int bufIdx = 0; private float smooth(float value) { buffer[bufIdx] = value; bufIdx = (bufIdx + 1) % buffer.length; float sum = 0; for (int i = 0; i < 5; i++) sum += buffer[i]; return sum / buffer.length; }

注意这个滤波只对湿度做就行,温度跳变不大,过度滤波反而会让曲线响应变慢。顺带提一句,如果你发现温度数据每隔几秒就出现一个 0 值,那不是传感器问题,是单片机端delay(2000)改太短导致读取失败返回了 NaN,回去查硬件端的重试逻辑。

5. 把这个 Demo 推向可用:缓存、补传与低功耗轮询的取舍

做完前面几步,你已经能实时看到曲线并收到报警了,但离“能用”还差三件事:断线时数据不丢、持续运行不费电、改了阈值立刻生效。这一章讲这三个细节的取舍,是我把示例工程改造成长时间无人值守时总结出的习惯。

5.1 离线缓存与断线补传

真实场景下手机和开发板距离一远,蓝牙就断。重新连上时,中间这几分钟的数据就丢了。我的处理方式是 APP 本地用 SQLite 建一张sensor_log表,每次收到有效数据点除了更新实时图表外,同时插入一行(timestamp, temp, humi)。断线重连后,APP 主动向单片机发送一条历史请求命令,单片机把离线期间的采样点按帧格式批量发过来,APP 按时间戳插入本地表。补传时注意按时间戳升序插入,不然曲线会出现时间倒流。

-- sensor_log 表结构,时间戳做主键防止重复写入 CREATE TABLE sensor_log ( ts INTEGER PRIMARY KEY, temp REAL NOT NULL, humi REAL NOT NULL );

5.2 扫描轮询的功耗策略

BLE 扫描是很耗电的操作,持续扫描一小时能吃掉 10% 电量。这个项目不需要实时秒级响应,我会在连接建立后立即停止扫描,只有手动断开时才重新开启扫描。同时把扫描周期改成 5 秒一次、持续 2 秒的间歇扫描模式,参数如下:

// 间歇扫描:每 5 秒扫 2 秒,降低功耗且不容易漏掉设备 bluetoothAdapter.startLeScan(scanCallback); Handler handler = new Handler(); handler.postDelayed(() -> bluetoothAdapter.stopLeScan(scanCallback), 2000); handler.postDelayed(() -> bluetoothAdapter.startLeScan(scanCallback), 5000);

这套策略在待机场景下电量消耗可以忽略不计。如果你对功耗不敏感,想快速连上设备,那保持持续扫描也问题不大,但记得在进入已连接状态时主动调用stopLeScan,很多教程漏了这步,导致连接期间电量刷刷往下掉。

5.3 阈值设置页的读写同步

报警阈值是存在SharedPreferences里的,这本身没问题,问题是 APP 里存在两个对象都在读这个值——报警检查器和设置页面。如果你在设置页改了高温阈值,报警检查器里的tempHigh还是旧值,那设置页就白设了。解决办法是设置页每次保存后,通过EventBus或广播,通知AlertChecker重新读一遍配置。我一般直接传一个ConfigReloadListener接口给报警检查器,代码比 EventBus 更直观,也不会引入额外依赖。

// 设置页保存后回调刷新报警阈值 alertChecker.reloadThreshold(prefs.getFloat("temp_high", 30.0f), prefs.getFloat("temp_low", 10.0f), prefs.getFloat("humi_high", 70.0f), prefs.getFloat("humi_low", 30.0f));

从那以后我每次改完配置都会强制走一遍这个刷新接口,报警值再也没有出现“改了等于没改”的尴尬。这个项目整体踩下来,最大的体会是:数据帧设计比界面好看重要得多,滤波比报警逻辑重要得多。希望帮到你。

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

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

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

立即咨询