简介:这份代码包围绕温湿度监控手机APP开发,面向具备Java/Android基础、希望打通传感器数据采集、云端交互与移动端展示的物联网学习者与开发者。内容以Android工程源码和可运行APK为核心,配合XML布局、Java类、第三方jar包、图片与配置属性文件,呈现从界面搭建、网络请求到LED开关控制的实现线索,适合课程设计、毕业设计或原型验证时参考拆解。压缩包共143个文件,约3.98MB,其中93个png界面素材、17个class与3个java源码文件、11个xml布局/配置、10个jar依赖以及apk、dex等构建产物,便于快速安装体验并对照工程结构理解模块关系。已有1619人学习,读者可据此梳理APP端与传感器、服务器通信的代码组织方式,学习数据展示、实时更新和基础控制逻辑,并借助现成素材与依赖减少环境搭建成本。
1. 从一块传感器到手机屏幕:这套代码补的是哪一段
去年帮一个做食用菌种植的朋友看棚,他每天早晚各跑一趟库房抄温湿度,碰上连阴天菌丝长得慢,等发现时已经晚了两天。这套温湿度监控手机APP开发代码对准的就是这个场景:设备端定时读传感器,把数据发到消息服务器,手机APP订阅后实时显示数值、画曲线、越界提醒。代码分成固件和APP两个工程,协议文档也在包里,不是只给一个界面截图。
它适合三类人:做物联网课程设计、需要一条完整链路的学生;想给小机房、大棚、库房加远程看板的小团队;手里已经有传感器、想先跑通采集到手机再改业务的开发者。项目没有做庞大的设备管理后台,胜在链路短,改一处不用顺藤摸瓜翻十个模块。
2. 技术栈与目录结构:一份代码里到底有哪几层
拿到包先别急着点编译,先看它分了几层。这类项目最容易出的问题是固件和APP各写各的,协议对不上,最后靠抓包猜字段。这套代码把固件、APP、协议文档放在同一个仓库,目录本身就是它的架构图。
2.1 技术选型:为什么是 ESP32 + MQTT + Android 原生
设备端用 ESP32,自带 WiFi,Arduino 框架下写采集和联网代码不用额外搭环境;传感器默认 DHT22,单总线、两根线就能读,精度要求高的场景可以换 SHT30,走 I2C,代码里换一个读取函数就行。通信选 MQTT,不是因为它新,而是这个场景天然适合发布订阅:设备只管往主题上发,APP 只管订阅,加一个手机看板不用改固件。HTTP 轮询也能做,但实时性差一截,流量也不省。
APP 端是 Android 原生 Java,图表用 MPAndroidChart,MQTT 客户端用 Eclipse Paho。为什么不上 Flutter 或 uni-app?这类工具做界面快,但后台长连接、通知渠道、前台服务这些事,原生写起来更直接,出问题也容易定位。如果团队是前端背景,换成 uni-app 也常见,协议层不用动,只换展示层。常见做法是把 MQTT 连接封装成单例,页面只订阅数据事件,不直接碰网络。
| 层 | 选型 | 作用 |
|---|---|---|
| 设备端 | ESP32 + Arduino | 采集、打包、上报 |
| 传感器 | DHT22 / SHT30 | 温度、湿度读数 |
| 通信 | MQTT 3.1.1 | 设备到 APP 的消息通道 |
| APP | Android Java | 连接、解析、展示、告警 |
| 图表 | MPAndroidChart | 实时曲线 |
| 本地存储 | Room / SQLite | 断网缓存和历史回看 |
2.2 目录结构:固件、APP、协议文档三块
仓库大致是下面这个形状,不同版本可能多几个测试脚本,主干不会变:
TemperatureHumidityMonitor/ ├── firmware/ │ └── esp32_th/ │ ├── esp32_th.ino # 主循环:采样、上报、重连 │ ├── sensor.cpp/.h # 传感器读取与滤波 │ ├── mqtt_client.cpp/.h # MQTT 连接与发布 │ └── config.h # WiFi、broker、deviceId 配置 ├── app/ │ └── android/ │ └── app/src/main/java/com/example/thmonitor/ │ ├── net/MqttManager.java # 连接、订阅、回调分发 │ ├── data/ThSample.java # 数据模型 │ ├── data/SampleDao.java # 本地缓存 │ ├── ui/MainActivity.java # 曲线和数值展示 │ └── util/AlertGate.java # 越界告警状态 ├── docs/ │ ├── protocol.md # 主题和 JSON 字段说明 │ └── wiring.md # 接线图说明 └── README.md先改firmware/esp32_th/config.h,里面是 WiFi 名称密码、broker 地址、设备编号;APP 侧对应改net/Constants.java里的 broker 地址。两个地方对不上,后面所有联调都是白费。docs/protocol.md不要跳过,主题层级和字段名以它为准,代码只是协议的实现。
2.3 通信协议:主题层级与 JSON 字段
主题按设备编号分层,固件发布、APP 用通配符订阅:
{ "deviceId": "demo01", "temp": 26.5, "humi": 61.2, "ts": 1710000000, "ver": 1 }主题约定为th/{deviceId}/data,状态主题th/{deviceId}/status用来发在线离线,命令主题th/{deviceId}/cmd留给后续远程改采样周期。APP 订阅th/+/data,加号只匹配一层,所以固件千万别把主题改成th/demo01/data/1,层级一变就收不到。QoS 用 1,保证至少到达一次;retain设 false,避免新连上的客户端立刻收到一条过期数据。ts用秒级 Unix 时间,不要用设备启动后的毫秒数,原因在第 5 章会讲。
字段名统一小驼峰,固件和 APP 两边都按这个来。ver是协议版本,现在用不上,等到你要加字段时就知道它的好处:APP 可以先判断版本再决定怎么解析,老固件不至于把新 APP 弄崩。
2.4 环境准备:三个工具装完再动手
设备端装 Arduino IDE,加 ESP32 开发板支持,再装三个库:DHT sensor library、PubSubClient、ArduinoJson。APP 端装 Android Studio,JDK 用 Studio 自带的就行。broker 建议先在电脑上跑一个本地实例,Ubuntu 下可以直接装:
sudo apt install mosquitto mosquitto-clients sudo systemctl enable mosquitto # 先在本机验证 broker 能收发 mosquitto_sub -h 127.0.0.1 -t 'th/+/data' -v另一终端用mosquitto_pub发一条测试消息,订阅端能看到就说明 broker 没问题。测试阶段别一上来就连公网免费 broker,延迟和权限都不受控,出了问题分不清是代码还是网络。手机和电脑要在同一个局域网,broker 监听地址不能只绑127.0.0.1,否则手机连不上,这个坑在第 5 章展开。
3. 设备端采集与上报:从传感器读数到一条可信数据
设备端代码不长,但决定了 APP 看到的数据质量。读一次就发,曲线会像心电图;读不到就发 0,APP 会误报。这一章按“读数、过滤、打包、发送、重连”的顺序走一遍,每段都能直接对着改。
3.1 传感器读取与异常值过滤
DHT22 是单总线器件,对时序敏感,单次读取偶尔会返回 NaN 或者明显跳变的值。直接把它发上去,手机端就会看到尖刺。常见做法是连续采几次,去掉最大最小再取均值:
#include "DHT.h" #define DHTPIN 4 #define DHTTYPE DHT22 DHT dht(DHTPIN, DHTTYPE); struct ThSample { float temp; float humi; bool valid; }; ThSample readFiltered() { const int N = 5; float t[N], h[N]; int ok = 0; for (int i = 0; i < N; i++) { float rt = dht.readTemperature(); float rh = dht.readHumidity(); if (!isnan(rt) && !isnan(rh)) { t[ok] = rt; h[ok] = rh; ok++; } delay(2200); // DHT22 两次采样至少间隔 2 秒 } if (ok < 3) return {0, 0, false}; // 有效样本太少,本次丢弃 // 简单排序后去掉一个最大值和一个最小值 for (int i = 0; i < ok - 1; i++) { for (int j = i + 1; j < ok; j++) { if (t[j] < t[i]) { float tmp = t[i]; t[i] = t[j]; t[j] = tmp; } if (h[j] < h[i]) { float tmp = h[i]; h[i] = h[j]; h[j] = tmp; } } } float sumT = 0, sumH = 0; for (int i = 1; i < ok - 1; i++) { sumT += t[i]; sumH += h[i]; } int cnt = ok - 2; return {sumT / cnt, sumH / cnt, true}; }这段代码的关键参数是N和采样间隔。N=5是精度和耗时的折中,采样间隔必须大于 2 秒,否则 DHT22 内部还没准备好,读出来全是 NaN。ok < 3时直接返回无效,调用方不发布,而不是拿 0 去填。如果换成 SHT30,把readFiltered内部换成 I2C 读取即可,滤波逻辑不用改。
3.2 数据打包:JSON 字段别在 APP 里猜
读数有效之后,按协议拼 JSON 再发布。字段名、主题层级都要和docs/protocol.md一致:
#include <PubSubClient.h> #include <ArduinoJson.h> WiFiClient espClient; PubSubClient mqtt(espClient); void publishSample(const char* deviceId, float t, float h, long ts) { StaticJsonDocument<160> doc; doc["deviceId"] = deviceId; doc["temp"] = round(t * 10) / 10.0; // 保留一位小数,别发一长串浮点 doc["humi"] = round(h * 10) / 10.0; doc["ts"] = ts; doc["ver"] = 1; char payload[160]; size_t len = serializeJson(doc, payload); char topic[64]; snprintf(topic, sizeof(topic), "th/%s/data", deviceId); if (mqtt.connected()) { mqtt.publish(topic, payload, len); // QoS 由连接参数控制 } }StaticJsonDocument<160>的容量按字段数量估,字段多了就往上加,容量不够时序列化会截断,表现是 APP 解析出半个 JSON。round(t * 10) / 10.0是为了避免26.499999这种值占满屏幕。主题用snprintf拼,不要用字符串加法,ESP32 上频繁拼接容易把内存切碎。发布前判断mqtt.connected(),连接断了就别往缓冲区里塞。
3.3 采样节奏与断线重连:别用 delay 卡住 loop
采集不能写成loop里一路delay到底,那样重连和心跳都会被卡住。用millis()做非阻塞定时,采样和重连各走各的:
const unsigned long SAMPLE_INTERVAL = 5000; // 5 秒一次 unsigned long lastSample = 0; void loop() { if (!mqtt.connected()) reconnect(); mqtt.loop(); // 必须持续调用,否则收不到下行消息、心跳也会断 if (millis() - lastSample >= SAMPLE_INTERVAL) { lastSample = millis(); ThSample s = readFiltered(); if (s.valid) { publishSample(DEVICE_ID, s.temp, s.humi, time(nullptr)); } } } void reconnect() { static unsigned long lastTry = 0; if (millis() - lastTry < 5000) return; // 失败后等 5 秒再试,别死循环重连 lastTry = millis(); char willTopic[64]; snprintf(willTopic, sizeof(willTopic), "th/%s/status", DEVICE_ID); if (mqtt.connect(DEVICE_ID, MQTT_USER, MQTT_PASS, willTopic, 1, true, "offline")) { mqtt.publish(willTopic, "online", true); } }SAMPLE_INTERVAL不要小于传感器的最小采样周期,DHT22 给 5 秒比较稳。reconnect里加 5 秒退避,是为了避免 broker 没起来时设备疯狂重连把路由器拖垮。遗嘱消息offline配合retain=true,APP 侧能感知设备掉线,比只看“最后一条数据时间”更直接。time(nullptr)依赖 NTP 对时,ESP32 在setup里调一次configTime就行,不对时的话时间戳会从 1970 开始。
3.4 烧录与配网:从编译到第一条数据
步骤不复杂,但顺序错了会多花半小时。先改config.h里的 WiFi 名称密码、broker 局域网 IP、DEVICE_ID,然后 Arduino IDE 选对开发板型号和串口,编译上传。打开串口监视器,波特率 115200,看三件事:WiFi 有没有连上、broker 有没有连上、第一条数据有没有发出去。
电脑端另开一个终端订阅验证:
mosquitto_sub -h 192.168.1.10 -t 'th/+/data' -v能看到 JSON 就说明设备端链路通了,这时再去动 APP。如果串口一直打印重连失败,先确认 broker 的监听地址,再确认手机和电脑是不是同一个网段。烧录阶段还有一个常见问题:ESP32 的 DHT 数据脚接错,串口会一直返回 NaN,接线图在docs/wiring.md里,照着对一遍比猜快。
4. APP端核心链路:连接、解析、渲染、告警四段式
APP 端最容易写成“能显示就行”,结果断网白屏、切后台丢数据、曲线越跑越卡。这套代码把网络、数据、UI 分开,改的时候不用满工程找setText。下面按一条消息从进入到上屏的顺序拆。
4.1 MQTT 连接与订阅:客户端 ID、心跳和重连
连接封装在MqttManager里,页面启动时连一次,回调里只做解析和分发,不直接碰 UI:
MqttClient client = new MqttClient( "tcp://192.168.1.10:1883", "app-" + UUID.randomUUID().toString().substring(0, 8), new MemoryPersistence()); MqttConnectOptions options = new MqttConnectOptions(); options.setAutomaticReconnect(true); options.setCleanSession(true); options.setConnectionTimeout(10); options.setKeepAliveInterval(30); client.setCallback(new MqttCallbackExtended() { @Override public void connectComplete(boolean reconnect, String serverURI) { // 每次重连成功都要重新订阅,cleanSession=true 会清掉旧订阅 client.subscribe("th/+/data", 1); } @Override public void messageArrived(String topic, MqttMessage message) { String json = new String(message.getPayload()); ThSample sample = GsonHolder.get().fromJson(json, ThSample.class); if (sample != null && sample.isValid()) { SampleBus.post(sample); // 只发事件,UI 在主线程收 } } @Override public void connectionLost(Throwable cause) { SampleBus.postOffline(); } @Override public void deliveryComplete(IMqttDeliveryToken token) { } }); client.connect(options);客户端 ID 加随机后缀,是为了避免两台手机用同一个 ID 互相踢下线。cleanSession=true表示不保留会话,重连后必须重新订阅,很多人只写一次subscribe,断线重连后收不到数据就是这个原因。keepAliveInterval=30秒,比默认的 60 秒更容易发现死连接。回调运行在 Paho 的后台线程,这里只做反序列化和事件投递,不做 View 操作。
4.2 数据模型与解析:Gson 映射和字段校验
模型类字段名必须和固件 JSON 完全一致,大小写都不能差:
public class ThSample { public String deviceId; public float temp; public float humi; public long ts; public int ver; public boolean isValid() { if (deviceId == null || deviceId.isEmpty()) return false; if (temp < -40 || temp > 80) return false; // 超出传感器量程,判为脏数据 if (humi < 0 || humi > 100) return false; return ts > 0; } }Gson.fromJson遇到缺失字段会给默认值 0,所以不能只靠解析成功来判断数据可用,要加isValid()。温度范围按传感器量程卡,DHT22 是 -40 到 80 摄氏度,湿度 0 到 100。ts > 0是为了挡住设备没对时的情况。字段名如果固件写成temperature、APP 写成temp,解析不会报错,只会得到 0,这种问题查起来很费时间,协议文档就是干这个用的。
4.3 实时曲线与阈值提醒:MPAndroidChart 的刷新节奏
曲线只保留最近 120 个点,避免数据越跑越多把内存拖垮。收到样本后先更新数据集,再通知图表,最后把视口移到最新位置:
private final List<Entry> tempEntries = new ArrayList<>(); private LineDataSet tempSet; private void onSample(ThSample s) { tempEntries.add(new Entry(s.ts, s.temp)); if (tempEntries.size() > 120) { tempEntries.remove(0); // 只留最近 120 个点 } tempSet.notifyDataSetChanged(); chart.notifyDataSetChanged(); chart.moveViewToX(s.ts); float high = prefs.getFloat("temp_high", 30f); // 阈值从设置页读,别硬编码 if (alertGate.shouldNotify(s, high)) { NotificationHelper.push(this, "温度越界", s.deviceId + " 当前 " + s.temp + " 摄氏度"); } }Entry的 x 轴用秒级时间戳,不要用数组下标,否则断网一段时间后曲线会挤在一起。moveViewToX只在数据追加后调用,频繁调用会卡。阈值放SharedPreferences,设置页改完立刻生效,比写死在代码里强。通知要走NotificationChannel,Android 8 以后没有渠道的通知不会展示,这是很多人“代码没错但收不到”的原因。
4.4 本地缓存与断线重连:让曲线在断网后还能接上
MQTT 不保存历史,APP 被杀掉再打开,之前的曲线就没了。常见做法是收到样本就落库,启动时先读最近一段再订阅实时数据:
@Entity(tableName = "sample") public class SampleEntity { @PrimaryKey(autoGenerate = true) public long id; public String deviceId; public float temp; public float humi; public long ts; } @Dao public interface SampleDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void insert(SampleEntity entity); @Query("SELECT * FROM sample WHERE deviceId = :dev AND ts > :since ORDER BY ts ASC LIMIT 500") List<SampleEntity> recent(String dev, long since); @Query("DELETE FROM sample WHERE ts < :before") void purge(long before); }插入放在后台线程,别在主线程写库。保留策略按天删,比如只留 7 天,purge在每次启动时跑一次。启动顺序是“读缓存画静态曲线,再连 MQTT 接实时点”,这样断网时页面不是空的,用户能看出最后一条数据停在什么时候。重连成功后不要指望 broker 补发,QoS 1 只保证在线期间的投递,历史要靠本地库或另外的存储服务。
4.5 编译运行:Android Studio 导入与模拟数据
用 Android Studio 打开app/android,等 Gradle 同步完,改Constants.java里的 broker 地址,确认AndroidManifest.xml里有INTERNET权限,然后连真机或模拟器运行。没有设备时可以用命令行发模拟数据,先把 APP 的展示链路验通:
mosquitto_pub -h 192.168.1.10 -t 'th/demo01/data' \ -m '{"deviceId":"demo01","temp":26.5,"humi":61.2,"ts":1710000000,"ver":1}'发一条,APP 上应该出现一个点;连续发几条,曲线往右走。模拟器连本机 broker 时地址写10.0.2.2,真机写电脑的局域网 IP。如果 APP 显示离线但命令行能收到,先看 APP 的 clientId 有没有和另一个客户端撞,再看订阅主题是不是写成了完整主题而不是通配符。
5. 避坑与排查:联调阶段最容易翻车的五个点
联调阶段的问题大多不是“代码不会写”,而是链路上某一环悄悄没工作。下面五条是我在类似项目里反复遇到的,按现象、原因、解决拆开写,照着对能省不少时间。
5.1 APP 显示已连接但收不到数据
现象是 APP 日志里能看到 connected,曲线一动不动,但电脑上mosquitto_sub能收到设备发的消息。原因通常有两个:一是订阅主题层级不匹配,固件发的是th/demo01/data,APP 订成了th/demo01/data/1或者th/+/+,加号数量不对就匹配不上;二是客户端 ID 重复,两台设备用同一个 ID,broker 会把前一个踢掉,表现就是反复连接、偶尔收到。
解决方式很直接:先用mosquitto_sub -h <broker> -t '#' -v把真实主题打出来,确认层级,再把主题常量抽到一个类里,固件和 APP 都引用同一份约定。客户端 ID 加随机后缀或设备唯一标识,别用固定字符串。如果用了cleanSession=true,每次重连后都要重新subscribe,这一点在 4.1 的connectComplete里已经处理。
5.2 温度每隔几秒跳一次,湿度偶尔变成 0
现象是曲线有尖刺,或者湿度突然掉到 0 又恢复。原因多半在传感器侧:DHT22 供电不稳、数据脚没加上拉电阻、采样间隔小于 2 秒,读取失败返回 NaN,代码里没判断就当成 0 发了出去。还有一种情况是传感器线和 WiFi 天线挨得太近,射频干扰导致单次读数异常。
解决分三层:硬件上给数据脚加 4.7k 上拉,电源脚并一个 100nF 电容,传感器尽量远离天线;时序上把采样间隔放到 2.5 秒以上;代码上读失败不要发布,保留上一次有效值并标记valid=false,APP 收到无效样本就不往曲线里加。滤波只能压住偶发跳变,不能替代硬件处理,供电问题不解决,滤波参数怎么调都像玄学。
5.3 曲线更新时 APP 闪退
现象是收到消息后应用直接崩,Logcat 里能看到Only the original thread that created a view hierarchy can touch its views。原因是 Paho 的messageArrived跑在后台线程,代码里直接调了TextView.setText或chart.notifyDataSetChanged。开发时用模拟数据发得慢,不一定复现,一上真设备连续发就必现。
解决是把“解析”和“上屏”分开:回调线程只做 JSON 解析和事件投递,UI 更新统一切主线程,用runOnUiThread、Handler或LiveData.postValue都行。图表的数据集更新也放在主线程,notifyDataSetChanged和moveViewToX不要跨线程调用。改完之后可以故意用脚本一秒发十条压一下,确认不再崩。
5.4 手机锁屏后不再告警
现象是亮屏时通知正常,锁屏几分钟后消息还在收,但通知不弹,或者干脆连数据也停了。原因是 Android 8 以后有后台执行限制,Doze 模式会挂起后台进程,APP 没有前台服务就保不住长连接。很多“保活”教程会引导你去加各种黑科技,实际最稳的是把 MQTT 连接放进前台服务,带一条常驻通知。
具体做法是新建一个Service,在onStartCommand里启动 MQTT 连接和通知渠道,APP 退到后台时服务继续跑。测试阶段可以在系统设置里把 APP 的电池优化关掉,确认是不是这个原因。通知渠道的优先级要设成高,否则锁屏时不弹横幅。注意别把采样频率设得太高,前台服务常驻本来就耗电,5 秒一条对温湿度监控足够了。
5.5 时间轴错乱,曲线来回折
现象是设备重启后曲线突然跳到左边,或者多个点叠在一起,图表看起来像来回折线。原因是固件用millis()/1000当时间戳,设备一重启就从 0 开始,APP 直接拿这个值当 x 轴,新点自然跑到旧点左边。另一种情况是多台设备的时间不同步,同一个图表里混着两个时间基准。
解决是设备端接 NTP,configTime之后用time(nullptr)取 Unix 秒;APP 收到ts为 0 或者明显小于上一条记录的,用本地接收时间替换,并在协议里加一个递增的seq字段用于排序。如果设备实在不能联网对时,就在 APP 侧统一用接收时间做 x 轴,设备时间只做参考,别直接上图。这个问题不解决,后面做历史查询会对不上号。
6. 二次开发:把演示工程改成能长期跑的监控端
演示代码跑通只是起点。真放到库房或大棚,第一周就会遇到多设备、查历史、告警重复这三件事。我一般先改四个地方,每改一处都配一个验证动作,避免改完不知道有没有改坏。
| 改造项 | 改动位置 | 验证方式 |
|---|---|---|
| 多设备接入 | 固件config.h的DEVICE_ID;APP 的DeviceRepository | 同时跑两个 deviceId,切换看曲线是否隔离 |
| 历史数据 | app/data/SampleDao,加按天清理 | 杀进程重开,最近 24 小时数据还在 |
| 告警去重 | app/util/AlertGate,记录越界状态 | 连续越界只推一次,回落后再越界再推 |
| 协议版本 | payload 的ver字段,解析时判断 | 老固件配新 APP 不崩,缺字段有默认值 |
告警去重是最容易被忽略的一项。不做状态机,设备每 5 秒发一条,越界期间手机每 5 秒弹一次通知,用户第一天就会关通知。加一个边沿触发的小类就够:
public class AlertGate { private boolean tempHigh = false; public boolean shouldNotify(ThSample s, float high) { boolean now = s.temp > high; boolean fire = now && !tempHigh; // 只在进入越界那一刻返回 true tempHigh = now; return fire; } }high从设置页读,不要写死;tempHigh只在内存里记状态,APP 重启后第一条越界会再推一次,可以接受。如果要更严谨,把状态和最后通知时间存到SharedPreferences,加一个最小通知间隔。
验证别只测“正常发数据”。我一般会手动走三条路径:断网十分钟再恢复,看本地缓存和实时点能不能接上;给设备断电重启,看时间戳和在线状态对不对;把阈值调到当前温度以下,看通知是不是只弹一次。三条都过,再谈加功能。从那以后我每次拿到这类采集加 APP 的代码,都先把断网和重启两条路径手动走一遍,再谈功能。希望帮到你。
本文还有配套的精品资源,点击获取