物联网温湿度监控APP实战:MQTT、离线缓存与阈值告警
简介:面向Android初学者与物联网应用开发者,这份温湿度监控手机APP开发代码围绕传感器数据采集、无线通信与移动端展示展开,可用于理解环境监测类应用的完整实现路径。压缩包共143个文件、约3.98MB,以93张PNG界面与资源图、17个class编译文件、11个XML布局与配置、10个JAR依赖库和3个Java源文件为主,并附APK安装包、dex及工程属性文件,便于对照源码、资源与可运行产物。已有1619人学习,适合作为课程设计、毕业设计或IoT入门项目的参考素材。读者可从中梳理Android端界面搭建、网络请求与线程处理、温湿度数据展示以及LED开关指令下发等环节,结合编译产物和APK验证效果,快速建立从传感器数据到手机端呈现的整体认知,并积累调试与工程组织经验。
1. 从一颗探头到一部手机:温湿度监控手机APP真正要解决的问题
接过一个档案室的改造需求,对方开口第一句话并非“给我做个后台”,而是“我人不在楼里,掏手机就能看见库里现在几度、几成湿”。这句朴素的话其实已经把温湿度监控手机APP的全部难点抖了出来:探头读数怎么稳定采上来、数据怎么一路走到手机、手机断网那段时间谁来兜底、超了阈值又由谁把人叫醒。这套方案适合三类人:要交毕设或课设的学生、想给自己场地搭一套监控的从业者、手里已经有采集板只差一个手机端的开发者。下面按需求、选型、架构、实现、避坑、验收的顺序走一遍,代码和字段尽量做到能直接抄改。
2. 需求拆解与技术选型:采样频率、通信链路和端侧框架怎么定
动手写第一行代码之前,我一般会先把需求拆成三张纸:一张写清楚要测什么、测多密、留多久;一张画数据从探头到手机要经过几跳;一张列手机端必须提供的三个界面。这三张纸定下来,后面选型基本就是填空题,不用纠结。
2.1 三个真实场景决定采集端形态
温湿度监控听起来是一个需求,实际落到场地差别很大。机房、档案库房这类场景,温湿度变化慢,5 分钟一个点足够,但要求长期在线、掉线要补传;果蔬保鲜、菌菇房这类场景,湿度和通风强相关,30 秒一个点才看得出趋势,而且采集点分散、走线困难;实验室恒温箱这类场景,采集点只有一两个,但精度要求高,往往要 SHT 系列而不是几块钱的 DHT。场景不同,采集端形态完全不同:第一类适合“市电供电 + Wi-Fi 常连”,第二类适合“电池 + 低功耗定时唤醒”,第三类适合“USB 直连 + 上位机轮询”。
采样周期不能凭感觉定。一个 30 秒周期的单点设备,一天的原始记录是 2880 条,一条记录按 40 字节算约 115 KB,一年下来 40 MB 出头,手机端完全存得住;改成 5 秒周期,数据量直接翻六倍,一年 250 MB,本地缓存策略就必须配合滚动清理。所以我通常的做法是在需求阶段就把“周期 × 点数 × 保留天数”算成一张小表,写进方案书,避免开发到一半才发现本地库撑爆。下面这段是数据点模型,字段名故意压短,是为了在 MQTT 报文里省流量:
// 一条温湿度采样点的最小数据模型 class ThSample { final String deviceId; // 设备唯一编号,建议用芯片ID而非随机UUID final int ts; // 毫秒时间戳,统一由服务端校准后下发 final double temp; // 温度,单位 ℃,保留两位小数 final double humi; // 相对湿度,单位 %RH final int quality; // 质量位:0正常 1传感器异常 2补传数据 const ThSample({ required this.deviceId, required this.ts, required this.temp, required this.humi, this.quality = 0, }); factory ThSample.fromJson(Map<String, dynamic> j) => ThSample( deviceId: j['dev'] as String, ts: (j['ts'] as num).toInt(), temp: (j['t'] as num).toDouble(), humi: (j['h'] as num).toDouble(), quality: (j['q'] as int?) ?? 0, ); Map<String, dynamic> toJson() => {'dev': deviceId, 'ts': ts, 't': temp, 'h': humi, 'q': quality}; }quality这一位看起来多余,但它是后面所有脏数据处理的抓手。传感器读失败时不要抛异常、不要塞 0,而是标一个质量位照常上报,服务端和 APP 都按质量位过滤。这个约定一旦定死,曲线突然掉到谷底这种玄学问题会少掉一大半。ts用毫秒整数而不是格式化字符串,是因为排序、去重、区间查询都靠它,字符串时间在这三件事上都要额外转换。
2.2 通信链路的三条路线与取舍
设备到手机的数据通路,常见三种走法,没有绝对优劣,只有场景匹配。
第一种是设备直接对手机,走 BLE。优点是不依赖路由器、不依赖外网,演示和答辩场景最稳;缺点是一次只能连一台手机,手机离开范围就断,历史数据要等重新连上才补。适合单点、近距离、以现场查看为主的场景。
第二种是设备走局域网,手机和采集端在同一个路由器下,用 MQTT 或 HTTP 直连。优点是延迟低、不花流量费、数据不出内网;缺点是手机一旦切到移动网络就看不见了,跨网段还要处理设备发现。适合固定场地、有稳定 Wi-Fi 的机房和库房。
第三种是设备上报到一台中转服务,手机也连这台服务。优点是随时随地能看、多台手机共享同一份数据、告警可以服务端推送;缺点是多了一层要维护,服务端一挂全挂。真正要交付给非技术用户长期使用的方案,我基本都选这条,代价是必须把断线重连和离线补传做扎实。
选型时还有个容易忽略的细节:MQTT 主题怎么设计。我一般用三段式,上行dev/{deviceId}/up,下行dev/{deviceId}/cmd,服务端广播app/{userId}/notify。设备只订阅自己的 cmd 主题,手机只订阅自己 userId 的 notify 加上关注的 dev 上行主题。这样设备端的订阅表固定,权限也好收,不会出现一台设备收到别人指令的情况。QoS 上,数据上报用 QoS1,允许重复但不容许丢,重复正好用(dev_id, ts)唯一索引做幂等;指令下发用 QoS1 加请求 id 回执,避免开关动作丢了没人知道。
2.3 手机端技术栈与数据模型
端侧框架的选择主要看交付时间和目标平台。只做安卓、要求后台常驻和通知稳定,原生 Kotlin 最省心;要同时交安卓和 iOS 两套,Flutter 或 uni-app 更划算。这个项目里我倾向 Flutter,原因是图表库成熟、串口和蓝牙插件齐全、一份代码两套壳,答辩时演示机不用挑。如果课程要求必须体现 Android 四大组件,那就老老实实上 Kotlin,别为了炫技选型,后期联调会很痛苦。
不管选哪个框架,APP 内部的数据分层建议固定成三层:网络层只负责连接和收发原始报文,仓储层负责把报文转成模型并落库,界面层只订阅仓储层暴露的流。很多同学把 MQTT 客户端直接塞进页面里,页面一销毁连接就断,切回来数据全丢,这类翻车在答辩现场特别常见。分层的意义就是把连接的生命周期从页面手里收回来,交给一个全局单例管理。
依赖清单通常就这几样:MQTT 客户端(如mqtt_client)、本地数据库(sqflite)、图表(fl_chart)、本地通知(flutter_local_notifications)、状态管理(Provider 或 Riverpod,够用就行,别上全家桶)。第三方库版本不要盲目追新,锁定在项目初始化时能跑通的版本,写进pubspec.yaml并提交锁文件,这是最便宜的后悔药。
还有一项常被跳过的工作:本地数据保留策略。我的默认配置是原始点保留 7 天、每小时聚合值保留 90 天、每天聚合值保留 2 年。聚合表的存在让“看一年趋势”这种需求不用扫几十万行原始数据,查询基本在百毫秒级返回。这套策略在需求文档里写清楚,验收时对方问“存一年会不会卡”,你能直接翻到那一页。
3. 总体架构设计:四层结构与一次数据的完整旅程
架构图不用画得多漂亮,关键是把“一次采样从产生到显示”这条路走通,每一跳的职责边界写死,出问题时能立刻定位是哪一层的事。我习惯把这套系统切成采集层、传输层、服务层、应用层四段,下面逐段说清楚它们各自管什么、不管什么。
3.1 四层结构与数据流向
采集层是探头加主控,负责定时读取、做一次量程校验、打上质量位、带上时间戳发出去。它不管存储,也不管告警判断,读失败就标质量位继续发下一条,绝不阻塞。
传输层是链路本身,Wi-Fi 加 MQTT 是主流组合。这一层只保证“尽力送达”,重连、心跳、遗嘱消息在这里配置。设备端设 LWT 遗嘱主题dev/{deviceId}/status,意外掉线时 broker 自动发 offline,手机端据此把设备标灰,比用户盯着不动的曲线猜要体面得多。
服务层是可选的,但一旦涉及多用户、历史回溯、远程推送就绕不开,还会顺带把设备时间校准这件事接过去。服务端收到上报后做三件事:按(dev_id, ts)幂等入库、按阈值规则判断是否需要推送、把校准后的时间戳回给设备。
应用层是手机 APP,负责展示、查询、告警确认和本地缓存。它不信任任何一条数据的时间,展示前先用服务端对齐过的时间戳排序。
数据流向是单向的:探头 → 主控 → broker → 服务端 → APP。告警确认是唯一反向动作,走dev/{deviceId}/cmd或者服务端接口,别让 APP 直接连设备,那会把权限模型搞乱。
3.2 协议字段与数据表设计
字段设计的原则是“上行短、下行清”。上行报文控制在 128 字节内,能省一字节省一字节;下行指令则可以啰嗦,带上请求 id、超时时间、期望状态,方便做幂等和重试。下面这张是本地缓存表,APP 端和服务端都可以照这个结构落:
-- 本地缓存表:只存最近 7 天原始点,滚动删除 CREATE TABLE th_sample ( id INTEGER PRIMARY KEY AUTOINCREMENT, dev_id TEXT NOT NULL, ts INTEGER NOT NULL, -- 服务端对齐后的毫秒时间戳 temp REAL NOT NULL, humi REAL NOT NULL, quality INTEGER NOT NULL DEFAULT 0, synced INTEGER NOT NULL DEFAULT 0 -- 0未上报 1已上报 ); CREATE UNIQUE INDEX uk_dev_ts ON th_sample(dev_id, ts); -- 幂等去重的关键 CREATE INDEX idx_ts ON th_sample(ts); -- 会话表:记录每次连接,用于判断设备在线状态 CREATE TABLE dev_session ( dev_id TEXT PRIMARY KEY, last_seen INTEGER NOT NULL, -- 最后一次收到报文的时间 rssi INTEGER, -- 信号强度,弱信号是掉线高发原因 fw_ver TEXT -- 固件版本,方便远程排查 );uk_dev_ts这个唯一索引是整个设计里最值钱的一行。设备补传、网络重发、服务端重推都会造成重复,有了它,插入时用INSERT OR IGNORE直接吞掉重复行,不用写任何去重逻辑。synced字段是给离线补传用的:APP 在无网时把数据写本地标 0,恢复网络后按时间顺序批量上报,成功一条改一条。
字段类型上有个小坑:温湿度用REAL而不是整数放大一百倍存,是因为 SQLite 的 REAL 是 8 字节双精度,精度完全够,而放大存储会在聚合计算时多一步除法,容易漏。湿度上限 100,温度范围按探头的实际量程约束,落库前做一次区间校验,超出范围的直接标质量位,别让它污染统计。
3.3 连接状态机与离线缓存策略
移动端最不可靠的就是网络,所以连接必须有明确的状态机,不能靠“连上了就用、断了就崩”。我一般定义五个状态:空闲、连接中、已连接、重连中、已断开。连接中超过 8 秒没收到 CONNACK 就转重连中;已连接状态下心跳 30 秒一次,连续两次没响应转重连;重连采用指数退避,1 秒、2 秒、4 秒、8 秒,封顶 30 秒,避免弱网下疯狂重试把电量吸干。
离线缓存的关键是“写入优先于上报”。采样到达时先写本地库标未同步,再尝试发送,发送成功才更新标志。这样即使进程被杀,数据也还在库里,下次启动扫一遍synced = 0的记录补齐就行。补传时要限速,一次取 50 条、间隔 200 毫秒,别一口气把几百条怼上去,服务端和 broker 都吃不消。
设备端的时间校准放在这里说。很多采集板没有 RTC,重启后时间是 1970 年,上报的时间戳全是乱的,曲线画出来像心电图。稳妥的做法是设备启动后先通过 SNTP 对时,拿到时间再开始上报;服务端收到时间戳明显偏离的报文(比如和服务器时间差超过 5 分钟),统一用服务器时间覆盖并打标记。这个逻辑写在服务层,比指望每个固件都做对要现实得多。
4. 手机APP核心模块实现:实时曲线、历史查询与阈值告警
界面清单通常就三屏:设备列表、实时详情、历史与告警。屏不在多,在于每屏都有明确的一件事。下面按接入、实时、历史、告警四块讲实现,代码都是按能跑起来的最小骨架写的,缺的部分补上就成。
4.1 设备接入与配网流程
配网是新手最容易卡住的一步。常见两条路:智能配网(手机把 Wi-Fi 账号密码编码后广播,设备抓包解析)和热点配网(设备开热点,手机连上去填表单)。课程设计和毕设我建议直接上热点配网,协议简单、成功率接近百分之百、答辩现场演示不尴尬。智能配网虽然体验好,但不同手机对组播和广播的限制不一致,现场翻车概率高。
配网流程固定成四步:设备进入配网模式并亮灯、手机连上设备热点、提交 Wi-Fi 凭据和设备名、设备连上路由后向 broker 发一条上线报文,APP 收到上线报文即视为配网成功。第四步的等待给 60 秒超时,超时提示检查密码或信号。设备编号在出厂时就烧进芯片,不要让用户在 APP 里手输,输错一位后面全乱。
配对成功后把 deviceId 存进本地偏好,下次启动直接尝试静默连接,连不上再引导用户检查。这套流程写下来不到两百行,但把“用户第一次用”的路径堵死了大半。
4.2 实时数据订阅与曲线渲染
实时曲线是这套 APP 的门面,也是最容易卡的地方。核心做法只有一条:连接只建一次,数据流广播出去,页面只订阅、不持有连接。
// 订阅实时数据:一次订阅,多处消费,避免每个页面各连一条链路 class RealtimeService { final MqttClient _client; final _controller = StreamController<ThSample>.broadcast(); Stream<ThSample> get stream => _controller.stream; RealtimeService(this._client, String deviceId) { _client.updates!.listen((events) { for (final e in events) { if (e.topic != 'dev/$deviceId/up') continue; final s = ThSample.fromJson(jsonDecode(e.payload as String)); if (s.quality != 0) continue; // 脏数据不进曲线 _controller.add(s); } }); } void dispose() => _controller.close(); }broadcast()是关键,普通 Stream 只允许一个监听者,第二个页面订阅会直接抛异常。quality != 0的过滤放在入口处,是为了让曲线组件永远只处理干净数据,别在渲染层写 if。
曲线渲染有三个参数必须调:点数上限、刷新频率、X 轴跨度。一次展示最多保留 200 个点,超了从头裁掉;界面刷新按 500 毫秒合并一次,采样再快也别每来一条就重绘;X 轴按最近 1 小时、6 小时、24 小时三档切换。温度湿度的 Y 轴不要共用,各自独立刻度,否则湿度那条线会把温度压成一条平线。
还有一点:图表库默认会在数据变化时重建整个 widget,几百个点时就明显掉帧。做法是把数据列表换成不可变对象,比较新旧列表的identical结果再决定是否重绘,这一招能把帧率从个位数拉回流畅。
4.3 历史数据分页查询与本地缓存
历史查询的坑在于“一次要全部”。用户点开 24 小时视图,你一口气查 2880 条,低端机上直接卡三秒。稳妥做法是分页加聚合两条腿走路:看趋势用聚合表,看细节用分页原始表。
-- 原始点分页:按时间倒序,每页 100 条 SELECT ts, temp, humi, quality FROM th_sample WHERE dev_id = ? AND ts BETWEEN ? AND ? ORDER BY ts DESC LIMIT ? OFFSET ?; -- 按小时聚合:趋势图专用,查询量从千级降到几十 SELECT (ts / 3600000) * 3600000 AS hour_ts, AVG(temp) AS avg_t, MAX(temp) AS max_t, MIN(temp) AS min_t, AVG(humi) AS avg_h, MAX(humi) AS max_h FROM th_sample WHERE dev_id = ? AND ts BETWEEN ? AND ? AND quality = 0 GROUP BY hour_ts ORDER BY hour_ts ASC;聚合语句里quality = 0必须加,否则一条传感器异常记录就能把最大值拉到离谱的位置。(ts / 3600000) * 3600000是整数除法取整到小时,比用日期函数快得多。分页参数LIMIT 100 OFFSET n在数据量大时越翻越慢,超过一万条建议换成基于游标的写法:WHERE ts < 上一页最后一条的 ts ORDER BY ts DESC LIMIT 100,走索引,稳定在毫秒级。
本地缓存和远程查询要有个优先级约定:先查本地,本地命中且时间范围完整就直接返回,不完整再拉远程并合并。这样断网时历史仍可看,联网时也不会重复拉同一段数据。
4.4 阈值告警与本地通知
告警最容易出的问题是刷屏。温度刚过阈值上下浮动 0.2 度,一晚上推三十条通知,用户第一件事就是把通知关了,等于告警系统失效。解决办法是加回差和持续时间,连续几个点确认才算真告警。
// 连续 3 个采样点超阈值才触发,回落需要连续 2 个点,避免临界抖动刷屏 class ThresholdGuard { final double high; final double hysteresis; // 回差,建议取 0.5 度或 3%RH int _hit = 0; bool _alarming = false; ThresholdGuard({required this.high, this.hysteresis = 0.5}); /// 返回 true 表示本次应发出告警 bool feed(double v) { if (!_alarming) { _hit = v > high ? _hit + 1 : 0; if (_hit >= 3) { _alarming = true; return true; } } else { _hit = v < high - hysteresis ? _hit + 1 : 0; if (_hit >= 2) { _alarming = false; _hit = 0; } } return false; } }hysteresis就是回差,触发值和恢复值拉开一段距离,物理上对应“温度确实降下来了”而不是“抖了一下”。三个点确认意味着 30 秒周期下大约 90 秒才报,这个延迟对温湿度场景完全可接受,比误报有价值得多。
通知通道单独建一个,别和聊天类通知混在一起,否则用户静音了别的消息会连告警一起漏掉。安卓上要申请通知权限并建高优先级通道,iOS 上要在推送 payload 里声明中断级别。本地通知和远程推送同时存在时,用消息 id 去重,避免同一条告警弹两次。告警确认动作要落到库里,记下谁在什么时候确认的,验收时这是能拿出来的证据。
5. 避坑与排查:联调阶段最容易翻车的五件事
项目能不能按时交,往往不取决于功能写得多全,而取决于这几个坑有没有提前躲开。下面五条都是我在类似项目里真实见过的,按“现象、原因、解决”的格式写,遇到时直接对号。
5.1 曲线一打开就卡,滑动掉帧
现象:实时详情页进入三秒后开始卡,左右滑动明显掉帧,低端机上一分钟能卡死。
原因:每收到一条采样就调用setState重建整个页面,图表组件带着几百个点全量重绘;另外每个页面各自起了一个 MQTT 连接,三个页面就是三条链路,回调互相打架。
解决:连接收敛成全局单例,数据用广播流分发;界面刷新做 500 毫秒节流,用定时器合并批量更新;图表数据列表保持不可变,用长度和首尾时间戳做一次快速比较,没变化就不重绘。改完之后同样的机器上帧率稳定在 55 以上。
5.2 数据里偶尔冒出一个 0 或者 85
现象:曲线偶尔出现一个贴底的尖刺,温度显示 0℃ 或湿度 85%,下一条又恢复正常。
原因:传感器读取时序不对,DHT 类探头两次读取间隔小于两秒会直接返回上一次的缓存值甚至错误码,代码里没判空就当成有效值写进了库;有的固件读失败返回 0,被当成真实温度。
解决:读失败时不要写 0,而是标质量位照常上报;固件里加最小读取间隔并做 CRC 校验;APP 端和聚合查询都过滤quality != 0。另外在协议里约定好量程,温度超过 -40 到 80、湿度超过 0 到 100 的一律判为异常。
5.3 切后台再回来,设备一直显示离线
现象:手机锁屏十分钟,解锁后 APP 显示设备离线,实际上设备一直在发数据,杀掉 APP 重开又正常。
原因:系统在后台挂起了网络线程,MQTT 心跳停了,broker 判定客户端超时后踢掉连接;APP 回到前台没有主动检查连接状态,还拿着一个已经死掉的客户端对象。
解决:监听应用生命周期,回到前台先检查client.connectionStatus,不是已连接就走一次重连;把心跳间隔调到 30 秒以内并开启自动重连;服务端侧用last_seen判断在线,不要只依赖 broker 的会话状态。设备在线状态在界面上用颜色加文字双标识,别只用一个小圆点。
5.4 历史数据顺序混乱,同一条记录出现两次
现象:历史列表里时间跳来跳去,偶尔能看到两条一模一样的数据,统计总数比预期多。
原因:设备重启后时间归零,上报的时间戳比之前的还小;网络重发和设备补传造成重复,而表里没有唯一约束。
解决:设备启动先对时,服务端收到偏差超过 5 分钟的报文用服务器时间覆盖;建(dev_id, ts)唯一索引,写入用INSERT OR IGNORE;查询统一按ts排序,不依赖插入顺序。
5.5 阈值设了 60,58 度就开始报
现象:阈值明明设的 60℃,58℃ 的时候通知就来了,用户以为系统有毛病。
原因:比较的时候拿错了字段,温度告警用了湿度值;或者单位不统一,传感器返回华氏度而阈值按摄氏度填。这种错误在多人协作时特别常见。
解决:单位在模型层就固定死,字段名带单位后缀(tempC、humiPct),比较前做一次显式转换;阈值配置里也标明单位,界面上显示“60 ℃”而不是裸数字。上线前拿一支温度计对着吹热风做一次端到端验证。
6. 验收与答辩:怎么证明这套监控是准的、稳的、能交付的
功能跑通只算及格,验收看的是三件事:数据准不准、链路稳不稳、异常有没有被记录。我带过的项目里,最后一周基本都花在这三件事上,而不是加新功能。
先说准。拿一支经过校准的温湿度计放在探头旁边,静置二十分钟,同一时刻读数差值控制在温度 ±0.5℃、湿度 ±3%RH 以内算合格。这个测试要做三组:常温常湿、开空调降温、人为加湿,覆盖量程两端。数据记进表格,作为验收证据。
再说稳。拔掉路由器电源五分钟再插回,观察 APP 是否自动重连、数据是否补齐、曲线有没有断点;把手机切飞行模式十分钟再恢复,检查本地缓存的synced = 0记录是否全部上报成功;连续跑 72 小时,统计丢点率,超过 1% 就要回头查心跳和重连参数。
最后说异常留痕。日志里要能查到每次连接、断开、重连、告警触发与确认的时间点,这是答辩时唯一能证明“系统真的跑过”的东西。
| 验收项 | 方法 | 合格标准 |
|---|---|---|
| 读数准确性 | 与校准仪表同点静置对比 | 温度 ±0.5℃,湿度 ±3%RH |
| 断网补传 | 断网 10 分钟后恢复 | 补传成功率 100%,曲线无断点 |
| 长时间稳定性 | 连续运行 72 小时 | 丢点率低于 1%,无崩溃 |
| 告警有效性 | 手动升温越阈值 | 90 秒内触发,不重复推送 |
| 历史查询性能 | 查 30 天聚合曲线 | 首屏 1 秒内出图 |
答辩时被问得最多的三个问题,提前准备好答案:采样周期为什么是 30 秒而不是 5 秒,回答时把数据量估算和传感器响应时间一起说;断网期间的数据怎么保证不丢,回答写入优先于上报、synced标志和补传限速;告警为什么会延迟,回答回差和连续点确认是为了防抖,并给出误报率的对比数据。这三个问题答得出来,基本就稳了。
从那以后我每次做这类项目,都会在写第一行业务代码之前先把验收表列出来,边开发边填,到交付那天表格填满,心里才踏实。希望帮到你。
本文还有配套的精品资源,点击获取