
简介这份代码包围绕温湿度监控手机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自带 WiFiArduino 框架下写采集和联网代码不用额外搭环境传感器默认 DHT22单总线、两根线就能读精度要求高的场景可以换 SHT30走 I2C代码里换一个读取函数就行。通信选 MQTT不是因为它新而是这个场景天然适合发布订阅设备只管往主题上发APP 只管订阅加一个手机看板不用改固件。HTTP 轮询也能做但实时性差一截流量也不省。APP 端是 Android 原生 Java图表用 MPAndroidChartMQTT 客户端用 Eclipse Paho。为什么不上 Flutter 或 uni-app这类工具做界面快但后台长连接、通知渠道、前台服务这些事原生写起来更直接出问题也容易定位。如果团队是前端背景换成 uni-app 也常见协议层不用动只换展示层。常见做法是把 MQTT 连接封装成单例页面只订阅数据事件不直接碰网络。层选型作用设备端ESP32 Arduino采集、打包、上报传感器DHT22 / SHT30温度、湿度读数通信MQTT 3.1.1设备到 APP 的消息通道APPAndroid 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 StudioJDK 用 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 看到的数据质量。读一次就发曲线会像心电图读不到就发 0APP 会误报。这一章按“读数、过滤、打包、发送、重连”的顺序走一遍每段都能直接对着改。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和采样间隔。N5是精度和耗时的折中采样间隔必须大于 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) { StaticJsonDocument160 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 由连接参数控制 } }StaticJsonDocument160的容量按字段数量估字段多了就往上加容量不够时序列化会截断表现是 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配合retaintrueAPP 侧能感知设备掉线比只看“最后一条数据时间”更直接。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里页面启动时连一次回调里只做解析和分发不直接碰 UIMqttClient 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) { // 每次重连成功都要重新订阅cleanSessiontrue 会清掉旧订阅 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 互相踢下线。cleanSessiontrue表示不保留会话重连后必须重新订阅很多人只写一次subscribe断线重连后收不到数据就是这个原因。keepAliveInterval30秒比默认的 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 ListEntry 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设置页改完立刻生效比写死在代码里强。通知要走NotificationChannelAndroid 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) ListSampleEntity 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/dataAPP 订成了th/demo01/data/1或者th//加号数量不对就匹配不上二是客户端 ID 重复两台设备用同一个 IDbroker 会把前一个踢掉表现就是反复连接、偶尔收到。解决方式很直接先用mosquitto_sub -h broker -t # -v把真实主题打出来确认层级再把主题常量抽到一个类里固件和 APP 都引用同一份约定。客户端 ID 加随机后缀或设备唯一标识别用固定字符串。如果用了cleanSessiontrue每次重连后都要重新subscribe这一点在 4.1 的connectComplete里已经处理。5.2 温度每隔几秒跳一次湿度偶尔变成 0现象是曲线有尖刺或者湿度突然掉到 0 又恢复。原因多半在传感器侧DHT22 供电不稳、数据脚没加上拉电阻、采样间隔小于 2 秒读取失败返回 NaN代码里没判断就当成 0 发了出去。还有一种情况是传感器线和 WiFi 天线挨得太近射频干扰导致单次读数异常。解决分三层硬件上给数据脚加 4.7k 上拉电源脚并一个 100nF 电容传感器尽量远离天线时序上把采样间隔放到 2.5 秒以上代码上读失败不要发布保留上一次有效值并标记validfalseAPP 收到无效样本就不往曲线里加。滤波只能压住偶发跳变不能替代硬件处理供电问题不解决滤波参数怎么调都像玄学。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 轴新点自然跑到旧点左边。另一种情况是多台设备的时间不同步同一个图表里混着两个时间基准。解决是设备端接 NTPconfigTime之后用time(nullptr)取 Unix 秒APP 收到ts为 0 或者明显小于上一条记录的用本地接收时间替换并在协议里加一个递增的seq字段用于排序。如果设备实在不能联网对时就在 APP 侧统一用接收时间做 x 轴设备时间只做参考别直接上图。这个问题不解决后面做历史查询会对不上号。6. 二次开发把演示工程改成能长期跑的监控端演示代码跑通只是起点。真放到库房或大棚第一周就会遇到多设备、查历史、告警重复这三件事。我一般先改四个地方每改一处都配一个验证动作避免改完不知道有没有改坏。改造项改动位置验证方式多设备接入固件config.h的DEVICE_IDAPP 的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 的代码都先把断网和重启两条路径手动走一遍再谈功能。希望帮到你。本文还有配套的精品资源点击获取