简介这份资源是面向物联网与移动应用开发学习者的温湿度监控手机APP完整代码包适合具备一定Android基础、希望打通传感器采集到手机端展示全链路的开发者参考。内容围绕DHT11/DHT22等传感器数据采集、微控制器处理、无线模块上传云端以及APP通过API获取并展示实时温湿度这一典型IoT场景展开涉及前端界面布局、后端接口设计、数据库存储、WebSocket实时通信、图表可视化与权限安全等关键环节。压缩包共143个文件约3.98MB以93个png界面与图标素材、17个class编译文件、11个xml布局配置、10个jar依赖库及3个java源码为主另含apk安装包与dex文件便于直接运行验证与逆向分析。目前已有1617人学习下载可帮助读者理解数据获取、处理、展示及LED开关控制等完整实现流程。1. 从一块开发板到手机屏幕这套温湿度监控 APP 代码到底能跑通什么上个月帮一个做仓储环境监测的朋友看项目他手里有一套硬件采集端SHT30 传感器每两秒往串口吐一次温湿度数据但手机端一直没落地——要么用现成的通用串口助手看十六进制要么拿 Excel 手动记录。他问我有没有一套能直接跑的 Android 端代码把数据接过来、画成曲线、超阈值能响一声我翻了一遍他手里的资料正好就是这套「温湿度监控_手机APP开发代码」。它解决的不是传感器怎么选、电路怎么画而是数据从设备到手机这一段链路怎么用代码串起来串口或蓝牙收包、协议解析、本地存储、UI 刷新、阈值告警。适合两类人一是手里已经有采集硬件、缺一个能改能调的移动端壳子的嵌入式工程师二是想拿一个完整物联网小项目练手 Android 网络与串口编程的学生或转行者。代码本身不绑定某一款传感器只要你的设备能按约定格式输出温湿度两个浮点数这套框架就能接。下面我按实际拆包的顺序把能跑通的路径和容易翻车的地方一条条说清楚。2. 先看清数据链路从传感器到 APP 的四个环节2.1 硬件侧输出格式决定了 APP 端解析逻辑这套代码默认的输入不是直接读传感器寄存器而是假设采集端已经完成了一次封装通过串口或蓝牙透传发出结构化字符串。常见做法是采集端 MCU 把温湿度拼成一行 ASCII例如T:25.6,H:60.2\n或者用更紧凑的二进制帧0xAA 0x55 4字节温度 4字节湿度 校验。两种格式在 APP 端的处理成本完全不同ASCII 行协议调试方便拿串口助手就能肉眼验证但传输效率低、容易受干扰二进制帧效率高可一旦字节序或校验位对不上APP 收到的就是乱码排查起来像在黑匣子里摸。我一般建议第一次跑通链路时用 ASCII 行协议等 UI 和存储都验证完了再换二进制。代码里ProtocolParser这个类就是干这个的它对外暴露一个parse(byte[] raw)方法内部根据你选的模式走不同分支。如果你拿到的采集端输出格式和默认不一致改这一个类就够了不用动后面的存储和 UI 层。这也是这套代码结构上比较舒服的地方解析、存储、展示三层是分开的换传感器或换通信方式时改动面可控。2.2 通信方式选型串口、蓝牙、WiFi 的取舍代码包里同时给了三种通信通道的实现但实际项目里你只需要选一种。选型不是看哪个高级而是看你的设备形态和手机位置。通信方式适用场景延迟开发复杂度常见坑USB 串口手机固定安装在设备旁有线连接最低低OTG 兼容性、权限弹窗蓝牙 SPP/BLE手持巡检、设备可移动中等中BLE 分包、MTU 协商WiFi Socket设备已联网、远程查看取决于网络中高IP 变动、断线重连如果你做的是仓储固定点位监测手机就挂在墙上USB 串口最稳不用考虑配对和重连。如果是巡检人员拿着手机走近设备读取蓝牙更合适但要注意 BLE 默认 MTU 只有 23 字节温湿度数据虽然短可如果你后面要加时间戳和设备 ID一包就可能超得主动请求更大的 MTU。WiFi 方案适合设备本身已经接了网关的场景APP 只做 TCP 客户端但 IP 地址硬编码在代码里是大忌我一般会留一个配置页让用户输入。2.3 数据存储为什么用 Room 而不是直接写文件代码里本地持久化用的是 Room。有人觉得温湿度数据量不大直接追加到 CSV 文件就行何必引入数据库。短期看确实如此但一旦你要做「查昨天下午三点的温度」或者「统计本周超阈值次数」文件方案就得自己写索引和解析逻辑改起来很痛苦。Room 把实体类和查询语句绑在一起TemperatureDao里一个Query就能按时间范围取数UI 层拿到的直接是对象列表。// 实体类每条记录包含时间戳、温度、湿度 Entity(tableName th_records) public class THRecord { PrimaryKey(autoGenerate true) public long id; ColumnInfo(name timestamp) public long timestamp; // 毫秒级时间戳 ColumnInfo(name temperature) public float temperature; ColumnInfo(name humidity) public float humidity; } // DAO按时间倒序取最近 N 条供曲线图使用 Dao public interface THRecordDao { Query(SELECT * FROM th_records ORDER BY timestamp DESC LIMIT :limit) ListTHRecord loadRecent(int limit); Query(SELECT * FROM th_records WHERE timestamp BETWEEN :start AND :end) ListTHRecord loadBetween(long start, long end); }上面这段是存储层的核心。timestamp用毫秒级 long 存避免时区和格式化问题查询按时间倒序取最近记录曲线图只需要反转一下列表就能从左到右画。注意LIMIT参数不要设太大手机上画 500 个点以上肉眼已经分不清反而拖慢渲染。我一般设 200 到 300足够看趋势。2.4 UI 刷新别在主线程里碰数据解析Android 的 UI 只能在主线程更新但串口读数据和解析绝对不能放主线程否则界面会卡到 ANR。代码里用了一个HandlerThread做后台读取解析完通过Handler把结果 post 回主线程。这个模式比AsyncTask更可控也比RxJava轻量适合这种数据频率不高的场景。// 后台线程读取串口解析后抛回主线程 HandlerThread readThread new HandlerThread(serial-read); readThread.start(); Handler readHandler new Handler(readThread.getLooper()); Handler mainHandler new Handler(Looper.getMainLooper()); readHandler.post(() - { byte[] buffer new byte[64]; int len inputStream.read(buffer); // 阻塞读 if (len 0) { THRecord record ProtocolParser.parse(buffer, len); if (record ! null) { // 先存库再通知 UI db.thRecordDao().insert(record); mainHandler.post(() - updateChart(record)); } } });这里有个细节inputStream.read是阻塞的如果设备没发数据这个线程就挂在那里等不会消耗 CPU。但如果你在onDestroy里不把这个线程停掉它就会一直活着下次进页面又起一个几次之后线程数就失控了。代码里在onDestroy调了readThread.quitSafely()这个不能省。3. 把代码跑起来环境配置与首次联调3.1 工程导入与依赖版本对齐拿到代码包后第一步不是直接点运行而是先看build.gradle里的依赖版本和你本机 Android Studio 是否匹配。这套代码用的 Gradle 插件版本和编译 SDK 版本如果比你本地低太多同步阶段就会报一堆红。常见做法是先把compileSdk和targetSdk改成你本地已安装的版本然后让 IDE 自动提示升级 AndroidX 相关库。# 查看本地已安装的 SDK 版本 ls $ANDROID_HOME/platforms/ # 输出示例android-30 android-31 android-33 android-34 # 如果 gradle wrapper 版本太旧手动改 gradle/wrapper/gradle-wrapper.properties # distributionUrlhttps\://services.gradle.org/distributions/gradle-7.5-bin.zip改完版本后执行一次./gradlew clean再同步比直接点 Sync 更干净。如果遇到Duplicate class错误多半是某个库被重复引入用./gradlew app:dependencies看依赖树把重复的 exclude 掉。3.2 串口权限与设备过滤USB 串口方案在 Android 上不是插上就能读需要先申请权限。代码里用了一个UsbPermissionReceiver来接收授权结果但前提是你在AndroidManifest.xml里声明了 USB 设备附件过滤。!-- 声明支持 USB 主机模式 -- uses-feature android:nameandroid.hardware.usb.host / !-- 在 Activity 中动态注册权限接收器 -- receiver android:name.UsbPermissionReceiver intent-filter action android:namecom.example.USB_PERMISSION / /intent-filter /receiver注意USB_PERMISSION这个 action 字符串必须和代码里PendingIntent用的完全一致大小写都不能差。我见过有人复制代码时改了个包名结果 action 没跟着改权限弹窗死活不出来查了半天以为是 OTG 线的问题。另外不是所有手机都支持 USB 主机模式跑之前先用UsbManager.getDeviceList()看看能不能枚举到设备枚举不到就是硬件或系统裁剪的问题跟代码无关。3.3 蓝牙 BLE 连接的分包处理如果你选的是 BLE 方案onCharacteristicChanged回调里拿到的数据可能分多次到达。温湿度数据虽然短但如果你在采集端加了时间戳和校验一包超过 20 字节就会被拆开。代码里在BleDataAssembler里做了一个简单的拼接缓冲每次收到数据先追加到ByteArrayOutputStream然后检查是否以\n结尾是则认为一包完整交给解析器。// BLE 分包拼接以换行符为帧结束标志 private ByteArrayOutputStream buffer new ByteArrayOutputStream(); private void onDataReceived(byte[] chunk) { buffer.write(chunk, 0, chunk.length); byte[] data buffer.toByteArray(); int newlineIndex indexOf(data, (byte) \n); if (newlineIndex 0) { byte[] frame Arrays.copyOfRange(data, 0, newlineIndex); buffer.reset(); // 剩余部分继续留在缓冲区 if (newlineIndex 1 data.length) { buffer.write(data, newlineIndex 1, data.length - newlineIndex - 1); } ProtocolParser.parse(frame, frame.length); } }这段逻辑的关键是buffer.reset()之后要把换行符后面的残余数据写回去否则下一包的开头就丢了。很多人写分包只处理了「找到结尾」忘了「保留剩余」结果数据偶尔丢一两个字节曲线图上出现莫名其妙的尖刺。3.4 首次联调用模拟数据验证 UI 和存储硬件还没接上、或者采集端还没烧录程序时不要干等。代码里留了一个MockDataSource它每两秒生成一条随机温湿度记录走的是和真实数据完全相同的存储和 UI 路径。你可以在MainActivity里把数据源切成 Mock先把曲线图、阈值告警、历史查询这些功能验证一遍。// 切换数据源真实串口 or 模拟数据 IDataSource source; if (BuildConfig.DEBUG USE_MOCK) { source new MockDataSource(); // 每 2 秒生成 T:20~30, H:40~70 } else { source new SerialDataSource(usbManager, device); } source.setCallback(record - { db.thRecordDao().insert(record); runOnUiThread(() - updateChart(record)); }); source.start();这样做的好处是把「通信问题」和「业务逻辑问题」隔离开。如果 Mock 模式下曲线正常、告警正常接上真实设备后出问题那排查范围就缩小到通信层不用再怀疑数据库或图表控件。4. 避坑与排查五个真实翻车记录4.1 曲线图只画一个点就不动了现象接上设备后UI 上只显示第一个数据点后面数据明明在存库但图表不更新。原因图表控件的setData方法被调用时传的是同一个List对象引用控件内部做了缓存认为数据没变。代码里updateChart每次从数据库查最新列表但如果你在外部持有了一个固定 List 并反复传进去就会触发这个问题。解决每次更新都 new 一个 ArrayList或者调用控件的notifyDataSetChanged。我一般在updateChart里直接new ArrayList(dao.loadRecent(200))确保引用不同。4.2 蓝牙连上了但收不到数据现象onConnectionStateChange返回STATE_CONNECTED但onCharacteristicChanged一直不触发。原因BLE 连接成功后还需要主动调用gatt.discoverServices()并且在onServicesDiscovered回调里对目标特征值使能setCharacteristicNotification和写CLIENT_CHARACTERISTIC_CONFIG描述符。少一步都不行。解决检查BleManager里的调用顺序connect → discoverServices → onServicesDiscovered → setCharacteristicNotification → writeDescriptor。任何一步没走到数据都不会来。4.3 时间戳显示差 8 小时现象数据库里存的时间戳转成日期后比实际时间少了 8 小时。原因SimpleDateFormat没有指定时区默认用了 UTC。或者你在存库时用了System.currentTimeMillis()但展示时用了Calendar的默认时区两者不一致。解决统一用System.currentTimeMillis()存展示时用SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.getDefault())并且显式设置TimeZone.getDefault()。不要混用LocalDateTime和Date。4.4 阈值告警只响一次现象温度第一次超阈值时手机震动了但后面继续超阈值就不再提醒。原因代码里用一个boolean isAlerted标志位触发后置为 true但没有在温度回落到阈值以下时重置。结果就是「一次超限终身静音」。解决在每次解析到新数据时判断如果当前值低于阈值且isAlerted为 true则重置为 false如果高于阈值且isAlerted为 false则触发告警并置 true。这样形成迟滞既不会重复响也不会漏掉下一次超限。4.5 应用切到后台几分钟后数据断了现象APP 在前台时数据正常切到后台或锁屏后过几分钟再打开发现中间有一段数据缺失。原因Android 从 8.0 开始对后台服务做了限制普通的Service在后台会被限制运行。如果你的数据读取放在Service里但没有前台通知系统会把它挂起。解决把数据读取逻辑放在ForegroundService里并在通知栏显示一个常驻通知。代码里DataCollectionService已经继承自Service你需要在onStartCommand里调用startForeground并传一个Notification。注意 Android 13 以上还需要申请POST_NOTIFICATIONS权限。5. 进阶把告警阈值做成可配置项并验证5.1 用 SharedPreferences 存阈值避免硬编码代码里默认温度上限 30 度、湿度上限 70%但实际项目里不同仓库的阈值不一样。硬编码在Constants里改起来要重新编译不现实。我一般会加一个设置页用SharedPreferences存两个 float 值读取时给默认值兜底。// 阈值配置读写 public class ThresholdConfig { private static final String KEY_TEMP_MAX temp_max; private static final String KEY_HUMI_MAX humi_max; private static final float DEFAULT_TEMP 30.0f; private static final float DEFAULT_HUMI 70.0f; public static float getTempMax(Context ctx) { return ctx.getSharedPreferences(settings, Context.MODE_PRIVATE) .getFloat(KEY_TEMP_MAX, DEFAULT_TEMP); } public static void setTempMax(Context ctx, float value) { ctx.getSharedPreferences(settings, Context.MODE_PRIVATE) .edit().putFloat(KEY_TEMP_MAX, value).apply(); } }注意用apply()而不是commit()前者异步写、不阻塞 UI。读取时每次从 SP 拿虽然有一点点开销但阈值判断频率不高完全可以接受。如果你追求极致可以在Application启动时读一次缓存到内存但记得在设置页修改后同步更新缓存。5.2 验证告警逻辑用边界值测试改完阈值配置后不要凭感觉测。我习惯用边界值法设温度上限 30然后手动构造 29.9、30.0、30.1 三个值看告警触发是否符合预期。特别是 30.0 这个临界点浮点数比较用还是结果不同代码里统一用意味着正好等于阈值不告警。这个规则要跟需求方确认清楚别自己拍脑袋。// 告警判断严格大于阈值才触发 boolean tempAlert record.temperature ThresholdConfig.getTempMax(this); boolean humiAlert record.humidity ThresholdConfig.getHumiMax(this); if ((tempAlert || humiAlert) !isAlerted) { triggerAlert(); // 震动 通知 isAlerted true; } else if (!tempAlert !humiAlert isAlerted) { isAlerted false; // 回落后重置允许下次再触发 }5.3 数据导出把 Room 里的记录转成 CSV有些场景下用户想把历史数据导出来用 Excel 分析。Room 本身不提供导出功能但你可以查出来自己拼 CSV。注意文件要写到getExternalFilesDir下避免 Android 10 以上的分区存储权限问题。// 导出最近 7 天数据为 CSV public File exportCsv(Context ctx, long startTime, long endTime) throws IOException { ListTHRecord records db.thRecordDao().loadBetween(startTime, endTime); File file new File(ctx.getExternalFilesDir(null), th_export.csv); try (FileWriter writer new FileWriter(file)) { writer.write(timestamp,temperature,humidity\n); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss, Locale.getDefault()); for (THRecord r : records) { writer.write(sdf.format(new Date(r.timestamp)) , r.temperature , r.humidity \n); } } return file; }导出后可以用FileProvider分享出去但注意FileProvider的paths配置要覆盖getExternalFilesDir对应的路径否则会抛IllegalArgumentException。从那以后我每次接这类温湿度监控项目都强制自己先用 Mock 数据把存储和 UI 跑通再接真实硬件调通信层最后才动阈值和导出这些边角功能。顺序反了出了问题就要在通信、存储、UI 三个层之间来回猜时间全耗在排查上。希望这套代码和上面的踩坑记录能帮你少走一段弯路。本文还有配套的精品资源点击获取