# iot_control_app_flutter **Repository Path**: oyls/iot_control_app_flutter ## Basic Information - **Project Name**: iot_control_app_flutter - **Description**: No description available - **Primary Language**: Dart - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 3 - **Created**: 2026-09-24 - **Last Updated**: 2026-09-24 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Flutter 物联网控制 App 实战:从 Demo 到量产级 本仓库是「搏哥聊技术」公众号《物联网产品落地:MQTT/MQTTS 端到端架构与开发指南》系列中 **《Flutter 物联网控制 App 实战:从 Demo 到量产级》** 一文的配套可运行源码。 它不是「连接成功就结束」的玩具 Demo,而是把文章里反复强调的几个**量产级要点**直接落成代码: - 连接**状态机**(connecting / connected / disconnected / error) - 断开后**指数退避 + 抖动**自动重连,封顶 30s - **离线优先**:本地缓存「上次已知状态」+ UI 乐观更新 + 断网指令队列,恢复后自动补发 - **mTLS** 客户端证书 / 私钥存入系统安全存储(iOS Keychain / Android Keystore),绝不下打进 apk - 平台体验细节:系统暗色模式、触摸震动反馈 - **唯一 clientId**:每台设备安装时生成稳定 UUID v4(避免多端共用固定 id 互踢下线——这正是本文点名的 Demo 上线翻车点) > 博文(含深度讲解)在公众号;本仓库是「能跑、能对照、能打包」的代码。正文零代码、细节放 GitHub,正是这套配合的初衷。 --- ## 一、Flutter 通用准备(Android / iOS 公共) ### 1.1 环境要求 - **Flutter SDK** ≥ 3.44(本仓库用 Flutter 3.44.x / Dart 3.12 验证)。安装见 - 跑通 `flutter doctor`:确认 Flutter、Dart 已就绪 - Android 侧:安装 **Android SDK**(通过 Android Studio 的 SDK Manager) - iOS 侧:**必须有一台 macOS + Xcode**(iOS 无法在 Windows/Linux 上编译,详见第五章) ### 1.2 克隆与初始化 ```bash git clone git@gitee.com:jameschenbo/iot_control_app_flutter.git iot_control_app cd iot_control_app flutter pub get ``` ### 1.3 依赖清单 | 依赖 | 用途 | | --- | --- | | `flutter_riverpod` | 响应式状态管理(不在 UI 回调里裸调 MQTT) | | `mqtt_client` | MQTT/MQTTS 连接、订阅、发布、重连 | | `flutter_secure_storage` | 证书/私钥存 Keychain / Keystore | | `shared_preferences` | 本地「上次已知状态」+ clientId 持久化 | | `connectivity_plus` | 网络连通性感知,恢复即重连 | | `uuid` | 每台设备生成唯一 clientId(UUID v4) | ### 1.4 文章 ↔ 代码 对照表(要点落点) | 文章要点 | 代码落点 | 文件 | | --- | --- | --- | | 连接不是一次性动作(状态机) | `ConnectionStatus` 枚举 + UI 徽标 | `lib/data/models/connection_status.dart` · `lib/ui/widgets/connection_status_badge.dart` | | 自动重连 + 指数退避 | `MqttManager._scheduleReconnect` | `lib/data/mqtt/mqtt_manager.dart` | | 心跳保活 | `client.keepAlivePeriod = 20` | `lib/data/mqtt/mqtt_manager.dart` | | 离线优先:本地缓存 | `LocalStateService`(shared_preferences) | `lib/services/local_state_service.dart` | | 唯一 clientId(避免多端互踢) | `ClientIdService` 生成并持久化 UUID v4;`AppSettings.load()` 首次运行将占位默认值替换为唯一 ID | `lib/services/client_id_service.dart` · `lib/config/settings_model.dart` | | 离线优先:乐观更新 | `DevicesNotifier.sendCommand` 先改本地再下发 | `lib/state/devices_provider.dart` | | 离线优先:断网指令队列 | `MqttManager._outbox` + `_flushOutbox` | `lib/data/mqtt/mqtt_manager.dart` | | 网络恢复立即重连 | `ConnectivityService` + `MqttManager.retryNow` | `lib/services/connectivity_service.dart` | | 响应式 UI | `flutter_riverpod` 状态层 | `lib/state/*.dart` | | mTLS 证书安全存储 | `SecureStorageService` | `lib/services/secure_storage_service.dart` | | mTLS 注入 SecurityContext | `MqttManager` TLS 分支 | `lib/data/mqtt/mqtt_manager.dart` | | 平台体验(暗色/震动) | `ThemeMode.system` + `HapticFeedback` | `lib/app.dart` · 各 `*_screen.dart` | ### 1.5 目录结构 ``` lib/ main.dart 入口,挂 ProviderScope app.dart MaterialApp(M3 + 系统暗色) config/ settings_model.dart AppSettings(broker 参数,持久化) data/ models/ connection_status.dart 连接状态机枚举 device_state.dart 设备状态模型 + JSON 序列化 mqtt/ mqtt_config.dart 连接配置(含 mTLS 字段、主题约定) mqtt_manager.dart 连接/重连/退避/离线队列 核心 services/ secure_storage_service.dart Keychain/Keystore 证书存储 local_state_service.dart 本地「上次已知状态」缓存 connectivity_service.dart 网络连通性感知 client_id_service.dart 每台设备唯一 clientId(UUID v4) state/ service_providers.dart 服务单例 Provider settings_provider.dart 设置状态 + 变更重连 connection_provider.dart 连接状态 + MqttManager 桥接 devices_provider.dart 设备集合:乐观更新/下发/调和 ui/ screens/ home / device_detail / settings widgets/ connection_status_badge / device_card ``` --- ## 二、MQTT 协议规范(公共) App 与设备之间只通过 MQTT 主题交换 JSON。**默认 broker** 为 EMQX 公共测试 broker: - 明文:`broker.emqx.io` : `1883`(开箱即用) - 加密(MQTTS):`broker.emqx.io` : `8883`(需 TLS,见第七章) ### 2.1 主题约定 | 用途 | 主题 | 方向 | | --- | --- | --- | | 设备状态上报(reported) | `{namespace}/devices/{deviceId}/state` | 设备 → App | | 设备控制指令(desired) | `{namespace}/devices/{deviceId}/cmd` | App → 设备 | | App 一次性订阅所有设备 | `{namespace}/devices/+/state` | — | `{namespace}` 取 App「连接设置」页里的**命名空间**值(默认 `ns_`,首次安装自动生成,可在设置页查看/修改)。**App 与设备必须使用同一个命名空间**——公共 broker(如 `broker.emqx.io`)上裸 `devices/#` 是全局共享的,不隔离会收到陌生人设备的消息。完整设备端实现见 [docs/MQTT_DEVICE_PROTOCOL.md](docs/MQTT_DEVICE_PROTOCOL.md)。 `{deviceId}` 实际取值(Demo 内置三个种子设备): | deviceId | 设备 | 可控字段 | | --- | --- | --- | | `light_living` | 客厅灯 | `power`、`brightness`(0–100) | | `ac_bedroom` | 卧室空调 | `power`、`targetTemp`、`mode` | | `plug_desk` | 书桌插座 | `power` | | `temp_living` | 客厅温度(传感器) | `temperature`(℃),只读上报 | > 这套约定刻意贴近「设备影子」(即《设备影子与云端状态同步》)的 `desired/reported` 思路——App 发 `desired`,设备回 `reported`,真正的两端一致性调和将在后续《设备影子与云端状态同步》一文展开。 > > **设备端(ESP32 / 嵌入式)如何按本协议实现** → [docs/MQTT_DEVICE_PROTOCOL.md](docs/MQTT_DEVICE_PROTOCOL.md)(连接参数、clientId 约定、LWT 在线/离线、推荐实现流程、ESP32 联调与常见坑)。 ### 2.2 QoS 与保留消息 - **QoS = 1**(`atLeastOnce`,至少送达一次),订阅与发布均为此等级 - **retained = false**(不保留最后消息,新订阅者不会立即收到旧值) ### 2.3 状态消息体(设备 → App) 对应 `DeviceState.toReportedJson`,字段按设备类型取舍(灯不填 `targetTemp`,插座不填 `brightness`/`mode`): ```json { "power": true, "brightness": 80, "targetTemp": 26.0, "mode": "auto", "online": true, "ts": 1690000000000 } ``` 字段说明: | 字段 | 类型 | 含义 | 适用范围 | | --- | --- | --- | --- | | `power` | bool | 开关 | 全部 | | `brightness` | int(0–100) | 亮度 | 仅灯 | | `targetTemp` | double | 目标温度 ℃ | 仅空调 | | `mode` | string | 模式:`cool` / `heat` / `auto` | 仅空调 | | `online` | bool | 设备是否在线 | 全部 | | `ts` | int | **Unix 毫秒**时间戳 | 全部 | ### 2.4 指令消息体(App → 设备) 对应 `DeviceState.buildCommandPayload`,**只携带「本次变更的字段」+ 时间戳**,例如仅调亮度: ```json { "brightness": 60, "ts": 1690000000000 } ``` 仅开关: ```json { "power": false, "ts": 1690000000000 } ``` ### 2.5 clientId 约定 - 格式:`iot_`(如 `iot_3f1c…`) - 每台设备安装时由 `ClientIdService` 生成并持久化,**多次启动身份稳定**(重连 / clean session / 离线补发可靠的前提) - **必须唯一**:若两台设备 clientId 相同,后连上的会把先连的踢下线——这是典型的 Demo 上线翻车点 ### 2.6 数据流 ```mermaid flowchart LR UI[UI 屏幕/控件] -->|sendCommand| DEV[DevicesNotifier] DEV -->|乐观更新 + 持久化| LS[(LocalStateService\n本地缓存)] DEV -->|publish 指令| CONN[ConnectionNotifier] CONN --> MQ[MqttManager] MQ -->|离线进队列| OUTBOX[Outbox] MQ -->|已连接直发| BROKER[(EMQX / 公共测试 Broker)] BROKER -->|状态上报 {namespace}/devices/+/state| MQ MQ -->|onMessage| DEV NET[ConnectivityService] -->|网络恢复| MQ ``` --- ## 三、Android 篇 ### 3.1 运行(真机 / 模拟器) **前置** - Android Studio + Android SDK(已在 1.1 装好) - 真机:开启**开发者选项 → USB 调试**;USB 连接模式选「文件传输(MTP)」,**别选仅充电** - 模拟器:用 Android Studio 的 AVD Manager 建一台 **方式 A:直接跑(推荐先用来排查)** ```bash flutter devices # 确认手机/模拟器被列出 flutter run # 编译并推到设备,带热重载 + 实时日志 ``` **方式 B:打 debug 包自测** ```bash flutter build apk --debug adb install build/app/outputs/flutter-apk/app-debug.apk ``` > 默认连 `broker.emqx.io:1883`(明文,仓库 `AndroidManifest.xml` 已开 `usesCleartextTraffic`)。打开 App 点右下角「连接」→ 状态变「已连接」;首页三个 Demo 设备可开关、调亮度、调温度。 ### 3.2 打包 Release(带签名) Android 发布**必须用自己的签名密钥**,不能用 debug 密钥上架。 **① 生成签名密钥库(只需一次)** ```bash cd android keytool -genkeypair -v -storetype JKS \ -keystore upload-keystore.jks \ -keyalg RSA -keysize 2048 -validity 10000 \ -alias upload cd .. ``` 记住你输入的 **store password / key password**(下一步要用)。 **② 创建 `android/key.properties`**(此文件含密码,切勿提交进 git) ```properties storePassword=你的store密码 keyPassword=你的key密码 keyAlias=upload storeFile=upload-keystore.jks ``` > 仓库 `.gitignore` 已忽略 `*.jks` 与 `key.properties`;若未忽略,请手动加,避免密钥泄露。 **③ 仓库已配好签名引用**:`android/app/build.gradle.kts` 的 `signingConfigs["release"]` 会自动读取上面的 `key.properties`,`release` 构建类型已挂接该签名。无需再改 gradle。 **④ 打 release 包** ```bash flutter build apk --release # 单 APK # 或上架 Google Play 用 App Bundle: flutter build appbundle --release ``` 产物:`build/app/outputs/flutter-apk/app-release.apk`(或 `build/app/outputs/bundle/release/app-release.aab`)。 > **构建排错提示(作者环境特例)**:若你的 Flutter pub 缓存与本项目不在同一块磁盘(如缓存在 `F:`、项目在 `E:`),Kotlin 增量编译可能因「跨盘符相对路径」报错 `different roots`。仓库 `android/gradle.properties` 已设 `kotlin.incremental=false` 规避;若你是同盘纯净环境,可去掉该行以获得更快的增量编译。 ### 3.3 APK 尺寸裁剪(体积优化) `flutter build apk`(不带参数)打出来的是 **debug 通用包,约 187 MB**——里面塞了三套 CPU 架构、未优化的 Dart 调试内核、还有调试用的 Vulkan 校验层,**仅供本地开发,永远不要分发**。真机上架请按下面的方式构建。 **为什么 debug 包这么大(debug 通用包实测拆解)** | 组成部分 | 大小 | 说明 | | --- | --- | --- | | `assets/`(`kernel_blob.bin` 调试内核) | 77.8 MB | release 构建后消失,被 AOT 编译产物取代 | | `lib/arm64-v8a` | 50.5 MB | 含 `libflutter.so` 35.8MB(未剥符号)+ 调试库 | | `lib/x86_64` | 37.1 MB | 模拟器专用,真机从不装 | | `lib/armeabi-v7a` | 30.5 MB | 32 位老设备 | | `classes.dex` | 12.8 MB | 未混淆的 Dalvik 字节码 | | 其他(`res/`、根目录) | ~0.6 MB | — | **不同构建方式体积对比(同一套代码,作者本机 `flutter build` 实测)** | 构建方式 | 体积 | 备注 | | --- | --- | --- | | debug 通用包 | 187 MB | 本地开发,`flutter build apk` 默认产物 | | release 通用包 | 52.6 MB | `flutter build apk --release`(含三架构,不推荐直接发) | | release 分包 · arm64-v8a(主流真机) | **21.0 MB** | 用户实际下载的单包 | | release 分包 · armeabi-v7a(32 位老 ARM) | 18.5 MB | 兼容老设备 | | release 分包 · x86_64(模拟器) | 22.4 MB | 别发生产 | | release 分包 + `--shrink` | 21.0 MB | R8 对 `.so` 无效,几乎无变化 | **推荐命令** ```bash # 按架构拆分,只给用户设备需要的那一份(最推荐) flutter build apk --release --split-per-abi # 产物:build/app/outputs/flutter-apk/ # app-armeabi-v7a-release.apk app-arm64-v8a-release.apk app-x86_64-release.apk # 上架 Google Play 用 AAB(按设备动态下发,用户下载更小) flutter build appbundle --release ``` **关键认知** - **21 MB 的 arm64 单包已接近 Flutter 的体积地板**。拆开看:引擎 `libflutter.so` 约 11 MB + 嵌入层 `classes.dex`(约 10.6 MB)+ 你的业务代码 `libapp.so` 5.13 MB + 资源 0.4 MB。前两项是 Flutter 框架的「固定税」(AOT 也去不掉),你写的代码只占 ~5 MB——对一个功能完整的 IoT 面板已是健康水平。 - **`--shrink`(R8 混淆 / 资源压缩)对这类 App 几乎无效**:实测 21.0 → 21.0 MB。体积大头是 `.so` 原生库,R8 压不动;想再小只能删依赖(如 UI 全用 Material 风格时可去掉 `cupertino_icons` 的 0.25 MB 字体)或换原生框架——后者对现有项目不现实。 - **结论**:`release` + `--split-per-abi` 两步就是 Flutter APK 瘦身的正解,不必在包大小上继续死磕。 > **关于 iOS 体积**:本仓库 `flutter build ipa` 需在 macOS + Xcode 下执行,作者无 Mac,**未实测**,本文不展开。 --- ## 四、iOS 篇(需 macOS + Xcode) > ⚠️ **iOS 无法在 Windows / Linux 上编译**,必须有 Mac + 最新 Xcode。下面的步骤都在 Mac 上执行。 ### 4.1 运行(模拟器 / 真机) **前置** - 安装 Xcode(App Store),首次打开同意许可并装 Command Line Tools - `flutter doctor` 确认 iOS 工具链就绪(可能提示 `sudo xcode-select --switch` 或 `brew install cocoapods`) **模拟器(最简单,免 Apple 账号)** ```bash open -a Simulator # 或 Xcode 里选一个 iOS 模拟器 flutter run ``` **真机** - 手机:设置 → 隐私与安全性 → **开发者模式** 打开 - Xcode 用免费 Apple ID 签临时证书:打开 `ios/Runner.xcworkspace` → Signing & Capabilities → Team 选你的 Apple ID - 然后 `flutter run`(手机信任该开发者:设置 → 通用 → VPN与设备管理) **⚠️ 关于明文 1883(必读)**:iOS 默认 **App Transport Security (ATS)** 会禁止非 TLS 的网络连接。本仓库默认连的是明文 `broker.emqx.io:1883`,因此 **`ios/Runner/Info.plist` 已为 `broker.emqx.io` 加了 `NSAppTransportSecurity` 域名例外**,克隆即能连。若你改用 MQTTS(8883,推荐生产用),应移除该例外、改连加密端口。 ### 4.2 打包 Release(带签名) iOS 上架/分发需要 Apple 开发者身份: - **开发/内测**:免费 Apple ID 即可(Ad Hoc / 真机调试) - **上架 App Store**:需付费开发者账号($99/年) **步骤** 1. Xcode 打开 `ios/Runner.xcworkspace` → Runner(target) → **Signing & Capabilities** - 选 **Team**(付费账号会有自动生成的 Provisioning Profile) - 确认 **Bundle Identifier** 唯一(如 `com.yourorg.iotcontrolapp`) 2. 命令行打包: ```bash flutter build ios --release # 产出一个已签名的 App # 生成可分发/上架的 IPA: flutter build ipa --release ``` 3. `flutter build ipa` 会走 Xcode Archive → 在 Xcode Organizer 里选 **Distribute App**(App Store / Ad Hoc / Development)。 > 生产建议:用 MQTTS(8883)连你自己的 EMQX,并移除 Info.plist 里的 ATS 明文例外,让所有流量走 TLS。 --- ## 五、测试(公共部分) ### 5.1 App 自测验收清单 | 项 | 操作 | 预期 | | --- | --- | --- | | ① 连得上 | 点「连接」 | 状态:未连接 → 连接中 → 已连接,30s 内稳定不掉 | | ② 乐观更新 | 开关/调亮度/调温度 | UI **立刻**变化(不依赖真设备) | | ③ 断网不崩 | 开飞行模式再操作 | UI 仍响应、不报错、不闪退 | | ④ 恢复补发 | 关飞行模式 | 数秒内变回「已连接」,断网排的指令自动补发 | | ⑤ 持久化 | 设好状态后杀 App 重开 | 状态仍在(本地缓存读回) | | ⑥ 平台体验 | 切系统暗色 / 操作设备 | App 跟随变暗、有轻震动 | > 公共 broker 上**没有真实设备**订阅 `cmd`、也没人回 `state`,所以 ②③④ 看到的是「乐观更新 + 离线队列」效果。要验证「设备状态被真设备回填」,见 5.2。 ### 5.2 用 MQTTX 做端到端验证 [MQTTX](https://mqttx.app) 连同一个 broker,即可闭环验证 App 的发布/订阅与 clientId。 **连接 MQTTX**:Host `broker.emqx.io`、Port `1883`、**不勾 TLS**、用户名密码留空(匿名)。**Client ID 填一个与 App 不同的**(如 `mqttx_test_001`),否则同名会互相踢线。 **① 验证 App 是否真发布了消息** 1. MQTTX 订阅 `{namespace}/devices/#`(把 `{namespace}` 换成 App 设置页里的命名空间,如 `ns_a1b2c3`) 2. 手机 App 里点「客厅灯」开关 3. MQTTX 应**立刻收到** `{namespace}/devices/light_living/cmd`,payload 形如 `{"power":true,"ts":...}` → 收到 = App 发布正常 ✅ **② 验证 App 是否真订阅了消息(模拟设备回填)** 1. 在 MQTTX 里**发布**到 `{namespace}/devices/light_living/state`(注意是 `state` 不是 `cmd`,`{namespace}` 同上) 2. payload: ```json { "power": true, "brightness": 60, "online": true, "ts": 1690000000000 } ``` 3. 手机 App 的「客厅灯」卡片应**实时变成:开 + 亮度 60** → UI 变了 = App 订阅正常 ✅,也补齐了「公共 broker 无真设备」那块 **③ 验证 clientId 已唯一化** 1. MQTTX 订阅 EMQX 系统主题 `$SYS/brokers/+/clients/+/connected` 2. 手机 App 点「连接」上线后,系统主题会广播一条 connected 消息 3. payload 里的 `clientid` 应为 `iot_` 形式,**不是** `iot_app_demo` → 证明唯一化生效 ✅ ### 5.3 已知边界 / 生产化 TODO 本 Demo 为「能编译、能对照文章」做了取舍,**以下未实现**,量产化时要补: - **后台保活**:Android 前台服务(Foreground Service)、iOS 后台 MQTT 限制与对策——Demo 仅在 App 前台/短暂后台维持连接 - **设备注册/发现**:Demo 用写死的三个种子设备;真实产品应有设备清单下发或自发现 - **证书随包/远程下发**:生产可结合「首次启动从后台拉取并按设备绑定证书」,而非手动粘贴 PEM - **EMQX 生产运维**:集群、鉴权、可观测性见《EMQX 生产运维》一文 --- ## 六、接你自己的 EMQX + mTLS(公共部分) 1. App 内「设置」页: - 填你的 Broker 地址、端口(MQTTS 通常 `8883`) - 打开 **使用 TLS (MQTTS)** - 展开 **mTLS 客户端证书**:把 CA 证书、客户端证书、客户端私钥的 **PEM 文本** 粘进去(存进 Keychain/Keystore,不会写进 apk) - 点「保存并连接」 2. 证书也可在代码里改默认:`lib/data/mqtt/mqtt_config.dart` 的 `MqttConnectionConfig` 工厂方法 3. Broker 侧(EMQX 搭建、设备端 MQTTS 接入、mTLS 证书签发与 ACL 已在专栏前文讲解):开启 8883 监听器、用你自建 CA 签发客户端证书、配置按设备粒度的 ACL > 生产环境建议:broker 密码也移入 `SecureStorageService`(本 Demo 为简洁放进了 `AppSettings` 明文)。 --- **配套博文**:公众号「搏哥聊技术」— 系列《物联网产品落地:MQTT/MQTTS 端到端架构与开发指南》之《Flutter 物联网控制 App 实战:从 Demo 到量产级》 **许可**:仅供学习对照,可自由修改。 --- ## 延伸阅读 本仓库带你把 MQTT 物联网控制面板做到量产级(状态机、离线优先、mTLS)。若要系统补架构、自定义绘制、混合开发、性能、测试驱动开发,可看 2026 新书《Flutter 跨平台开发核心技巧与应用》(程序员老刘,化学工业出版社,ISBN 9787122492401,附练习册)。海报里有淘宝 / 天猫扫码入口。

《Flutter 跨平台开发核心技巧与应用》购书海报

--- ## 七、关注作者「搏哥聊技术」 嵌入式 + AI 第二职业实战笔记,公众号持续更新 MQTT/MQTTS、Flutter、ESP32 等物联网落地干货。 微信扫一扫,关注不迷路:

微信公众号「搏哥聊技术」二维码

> 觉得这个仓库有用?点个 Star ⭐ 并在公众号留言你的落地场景,搏哥会优先写你关心的那篇。