简介:面向智能家居课程设计、毕业设计与Qt入门实战者的完整项目包,围绕跨平台家居控制系统的服务端与客户端展开:服务端承担设备指令转发、用户认证与状态同步,客户端以Qt构建可视化控制界面,并预留向ARM嵌入式设备移植的优化思路。压缩包共210个文件、约4.01MB,以C#服务端代码与配置页面、C/C++源码、resx/resources界面资源、jpg/bmp图标素材为大宗,另有dll/exe编译产物、mdf/ldf数据库文件及sln工程文件,基本覆盖界面、通信与数据存储链路。C/C++部分还涉及哈夫曼编码、量化等图像处理模块,便于理解多媒体数据在资源受限设备上的处理方式。已有3876人学习下载,适合参考服务端接口设计、Qt界面组织、数据库配置与ARM移植要点,按目录拆解复用。
1. 从一间三室两厅的房子说起:这套基于Qt的智能家居到底要解决什么
很多人拿到「基于Qt的智能家居实现」这个题目,第一反应是画一张大屏界面,把灯、窗帘、空调做成几个好看的小方块,点一下亮、再点一下灭。真按这个思路做到中期检查就会卡住:老师问一句“断网了还能不能用”“场景联动是谁在跑”“三天前的温度曲线在哪”,整个方案立刻露底。这套方案要解决的核心问题其实是:在一台本地主控设备上,把串口接进来的下位机、局域网里的网络设备、消息服务器上的无线节点统一收进同一个进程,由这个进程完成采集、控制、联动、存储和展示,外网断开时本地照常工作,恢复后再补传。它适合正在做物联网方向课程设计或毕业设计的学生,也适合要给小型住宅做一套可交付上位机的从业者——不是做一个能演示的界面,而是做一套能连续跑一周不出事的系统。下面按需求、架构、实现、避坑、验收的顺序,把这套智能家居方案从头拆一遍。
2. 总体架构设计:三层结构与四个模块的职责划分
动手写第一行代码之前,先把“谁该干什么”定死。智能家居这类项目最典型的失败方式不是功能没做出来,而是所有逻辑都堆在主窗口类里:串口读到的字节在主窗口解析,解析完直接改界面,界面按钮又反过来调串口发送,数据库也在同一个函数里顺手写一行。这样写到第三天,加一个设备就要改五个文件,改一处界面就崩一个通信线程。我一般会在方案阶段就把分层、线程和模块边界三件事定下来,后面所有代码都往这个骨架里填。
2.1 三层划分:接入层、业务层、表现层各自管什么
接入层只做一件事:把不同物理链路上的字节流,变成统一的“设备状态”和“设备命令”。它知道串口波特率、知道 TCP 粘包怎么切、知道 MQTT 主题怎么拼,但它不知道“客厅灯”叫什么名字,也不知道晚上十点该不该自动关灯。
业务层是整套系统的大脑。设备表、场景规则、定时任务、告警判定都在这一层。它拿到的永远是已经解析好的结构体,比如{deviceId: 3, key: "brightness", value: 80},不需要关心这个值是从串口来的还是从网络来的。这一层的一个硬性要求是:业务逻辑不能直接调用任何界面对象。
表现层只负责显示和接收用户操作。QML 页面拿到的是业务层抛上来的数据模型,用户点了开关,表现层发出一个“请求”,至于这个请求是立刻生效还是排队等回读确认,由业务层决定。
三层之间用信号槽和明确的接口函数通信,不允许跨层直连。判断有没有越界有一个很土但很好用的标准:把 QML 文件和界面类全部删掉,剩下的代码应该能编译通过并正常采集数据。做不到,就是耦合了。
2.2 线程模型:谁在阻塞,谁在派发
串口读、网络收、数据库写这三件事都可能阻塞,全都放在界面线程里,表现就是点一下卡一下、拖动滑块掉帧。我的做法是固定三个工作线程:
| 线程 | 承载对象 | 职责 | 阻塞风险 |
|---|---|---|---|
| 主线程 | 窗口、QML 引擎、业务层对象 | 界面渲染、规则判定、命令派发 | 不允许任何阻塞调用 |
| 通信线程 | 串口对象、TCP 套接字、MQTT 客户端 | 收字节、拆帧、发命令 | 高,允许阻塞 |
| 数据线程 | 数据库连接、历史查询 | 批量写入、按时间范围查询 | 中,写多时明显 |
通信线程用moveToThread的方式把对象搬过去,而不是继承QThread再重写run——后者会让对象实际活在旧线程里,是新手最容易踩的坑之一。
// 通信线程骨架:对象创建在主线程,但所有权移交子线程 class CommWorker : public QObject { Q_OBJECT public: explicit CommWorker(QObject *parent = nullptr) : QObject(parent) {} public slots: // 这些槽函数全部在通信线程内执行 void openPort(const QString &portName, int baudRate) { m_port = new QSerialPort(this); // 在通信线程内 new,父对象同线程 m_port->setPortName(portName); m_port->setBaudRate(baudRate); m_port->setDataBits(QSerialPort::Data8); m_port->setParity(QSerialPort::NoParity); m_port->setStopBits(QSerialPort::OneStop); if (!m_port->open(QIODevice::ReadWrite)) emit portError(m_port->errorString()); connect(m_port, &QSerialPort::readyRead, this, &CommWorker::onReadyRead); } void writeFrame(const QByteArray &frame) { if (m_port && m_port->isOpen()) m_port->write(frame); // 只在本线程调用,天然串行 } signals: void frameReady(const QByteArray &frame); // 跨线程自动排队,参数需可拷贝 void portError(const QString &msg); private slots: void onReadyRead() { m_buffer.append(m_port->readAll()); // 拆帧在下一节展开,这里只把完整帧抛出去 for (const QByteArray &f : extractFrames(m_buffer)) emit frameReady(f); } private: QSerialPort *m_port = nullptr; QByteArray m_buffer; };启动时先worker->moveToThread(thread),再用QMetaObject::invokeMethod或信号触发openPort,最后thread->start()。参数顺序反了,QSerialPort就会在主线程创建、在子线程使用,运行时直接报“QObject: Cannot create children for a parent that is in a different thread”。信号槽的跨线程连接默认是队列连接,所以frameReady里传的参数必须是可拷贝类型,QByteArray、QVariantMap都行,自定义结构体要先qRegisterMetaType。
2.3 设备抽象与模块边界:四个模块的接口契约
整套系统拆成设备管理、协议解析、场景引擎、数据服务四个模块,互相之间只认接口不认实现。设备管理负责维护设备列表和在线状态;协议解析负责字节和结构体互转;场景引擎负责条件判定和动作排队;数据服务负责落盘和历史查询。
给所有设备定义一个共同基类,三种链路各自派生:
// 设备抽象:上层只调这些接口,不关心底层是串口还是 MQTT class DeviceBase : public QObject { Q_OBJECT public: enum LinkType { SerialLink, TcpLink, MqttLink }; explicit DeviceBase(int id, QObject *parent = nullptr) : QObject(parent), m_id(id) {} virtual bool open() = 0; // 建立链路 virtual void close() = 0; // 主动断开 virtual void sendCommand(quint8 cmd, const QByteArray &payload) = 0; int id() const { return m_id; } bool isOnline() const { return m_online; } signals: void stateChanged(int deviceId, const QVariantMap &state); // 属性变化统一上报 void onlineChanged(int deviceId, bool online); // 掉线判定后上报 protected: void setOnline(bool v) { if (m_online == v) return; m_online = v; emit onlineChanged(m_id, v); } private: int m_id; bool m_online = false; };好处很直接:场景引擎里写的是device->sendCommand(CMD_SET_POWER, payload),将来换一款下位机,只需要新增一个派生类,规则和界面一行都不用动。这也是答辩时最能体现设计能力的地方——能说清楚“我加一个新设备要改几个文件”,比说十个功能都管用。
2.4 数据落盘:四张表撑起设备、场景、记录与告警
存储不要用 CSV 或者 JSON 文件对付,查询历史曲线时会非常难受。嵌入式场景下 SQLite 足够,四张表就能覆盖全部需求:
-- 设备表:设备的基本属性和链路参数 CREATE TABLE device ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- 显示名,如"客厅主灯" type INTEGER NOT NULL, -- 1灯 2窗帘 3温湿度 4门磁 link_type INTEGER NOT NULL, -- 1串口 2TCP 3MQTT link_param TEXT NOT NULL, -- JSON,存端口号/地址/主题 room TEXT, enabled INTEGER DEFAULT 1 ); -- 场景表:规则用 JSON 存,避免频繁改表结构 CREATE TABLE scene ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, rule_json TEXT NOT NULL, -- 条件数组 + 动作数组 priority INTEGER DEFAULT 0, -- 数值大的先执行 enabled INTEGER DEFAULT 1 ); -- 遥测表:只存变化值和整点采样,避免表爆炸 CREATE TABLE telemetry ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id INTEGER NOT NULL, key TEXT NOT NULL, -- temperature / humidity / power value REAL NOT NULL, ts INTEGER NOT NULL -- Unix 秒 ); CREATE INDEX idx_tel_dev_ts ON telemetry(device_id, ts); -- 告警表:记录开始、恢复和确认状态 CREATE TABLE alarm ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id INTEGER NOT NULL, level INTEGER NOT NULL, -- 1提示 2警告 3严重 message TEXT NOT NULL, start_ts INTEGER NOT NULL, end_ts INTEGER, acked INTEGER DEFAULT 0 );link_param用 JSON 字符串而不是拆成多列,是因为三种链路的参数差异太大,串口要端口号和波特率,MQTT 要主题和客户端标识,拆列会留下一堆永远为空的字段。telemetry表加复合索引是必须的,历史曲线查询基本都是“某设备某时间段”,没索引时几千条数据就能让界面白屏。数据线程里用一个定时器每 500 毫秒批量提交一次,比每条 INSERT 单独提交快一个数量级。
3. 设备接入与协议解析:串口、TCP、MQTT 三路并存
接入层是这套方案里工作量最大、也最容易返工的部分。原因在于三种链路的性格完全不同:串口是字节流,没有边界;TCP 有粘包,还有半开连接;MQTT 是消息制,但主题设计和 QoS 选错会带来重复下发和状态错乱。我的处理原则是“底层各自为战,出口必须统一”——不管哪条链路,最终都吐出同一种结构:设备 ID、属性键、属性值、时间戳。
3.1 串口通信的封装:打开、收、发、超时重发
串口部分不只是调QSerialPort的读写,真正要写的是三样东西:接收缓冲区、超时重发、错误恢复。
接收缓冲区负责把不定长到达的字节攒起来,交给拆帧函数;超时重发负责在下位机没回 ACK 时补发,否则用户点了开关灯没反应,会以为程序坏了;错误恢复负责在拔插线之后自动重连,而不是让用户重启程序。
// 超时重发:每条命令带序号,收到 ACK 才出队 struct PendingCmd { quint8 seq; QByteArray frame; qint64 sendTs; // 毫秒时间戳 int retry = 0; }; void CommWorker::onTick() { // 200ms 定时器触发 const qint64 now = QDateTime::currentMSecsSinceEpoch(); for (int i = m_pending.size() - 1; i >= 0; --i) { PendingCmd &p = m_pending[i]; if (now - p.sendTs < 500) continue; // 未到超时时间 if (p.retry >= 3) { // 三次无响应判定设备离线 emit deviceOffline(p.seq); m_pending.removeAt(i); continue; } p.retry++; p.sendTs = now; if (m_port && m_port->isOpen()) m_port->write(p.frame); // 原帧重发,序号不变 } }500毫秒和3次这两个参数是经验值:大多数下位机在 100 毫秒内能返回 ACK,取 500 毫秒可以容忍总线繁忙;重试 3 次仍无响应基本可以确认链路断了。参数不是固定的,如果下位机固件里有 200 毫秒的采集周期,超时时间要相应放大,否则会出现“明明在线却被判离线”的误报。
3.2 帧格式设计:帧头、长度、命令字、载荷与校验
自定义帧最容易翻车的地方是“以为一次readyRead就是一帧”。实际串口可能一次给你半帧,也可能一次给你三帧。所以必须设计带长度字段的帧结构,并且用缓冲区扫描的方式拆。
帧结构定为:0xAA 0x55 | len(2字节,小端) | cmd(1) | seq(1) | payload(len-2) | crc16(2),其中len统计的是cmd + seq + payload的字节数。
// 组帧:载荷长度由调用方保证小于 256 QByteArray buildFrame(quint8 cmd, quint8 seq, const QByteArray &payload) { QByteArray f; f.append(char(0xAA)).append(char(0x55)); const quint16 len = quint16(2 + payload.size()); // cmd + seq + payload f.append(char(len & 0xFF)).append(char((len >> 8) & 0xFF)); f.append(char(cmd)).append(char(seq)); f.append(payload); const quint16 crc = crc16(f.mid(2)); // 从长度字段开始算 f.append(char(crc & 0xFF)).append(char((crc >> 8) & 0xFF)); return f; } // 拆帧:缓冲区里扫帧头,够长且校验通过才切出来 QList<QByteArray> extractFrames(QByteArray &buf) { QList<QByteArray> out; while (true) { int head = buf.indexOf(QByteArray::fromHex("AA55")); if (head < 0) { buf.clear(); break; } // 没有帧头,整段丢弃 if (head > 0) buf.remove(0, head); // 丢掉帧头前的噪声 if (buf.size() < 6) break; // 头部还没收全 const quint16 len = quint8(buf[2]) | (quint8(buf[3]) << 8); const int total = 2 + 2 + len + 2; // 帧头+长度+内容+CRC if (len < 2 || len > 512) { buf.remove(0, 1); continue; } // 长度非法,滑一位重找 if (buf.size() < total) break; // 载荷没到齐,等下次 const QByteArray frame = buf.left(total); const quint16 crc = quint8(frame[total - 2]) | (quint8(frame[total - 1]) << 8); if (crc == crc16(frame.mid(2, total - 4))) out.append(frame); buf.remove(0, total); // 无论校验成败都吃掉,防死循环 } return out; }两个细节值得说:len上限设 512 是为了防止噪声里偶然出现的AA55导致程序等待一个永远收不满的“巨帧”;校验失败也要把这段字节从缓冲区移除,否则下一个循环会从同一个位置再失败一次,形成死循环。CRC 用标准 CRC16-MODBUS 即可,多项式0xA001,初值0xFFFF。
3.3 MQTT 主题规划与 QoS 取舍
无线节点一般走消息服务器,主题设计要在“好订阅”和“好鉴权”之间取平衡。我用的是一套固定三段式:
| 主题 | 方向 | QoS | 说明 |
|---|---|---|---|
home/{room}/{devId}/state | 上报 | 1 | 设备状态变化,retain 置 true |
home/{room}/{devId}/set | 下发 | 1 | 控制命令,retain 置 false |
home/{room}/{devId}/ack | 上报 | 1 | 命令执行结果,带原序号 |
home/gateway/online | 上报 | 1 | 遗嘱消息,断线由服务器代发 |
state开 retain 是关键:界面刚启动时订阅一次就能拿到所有设备的当前状态,不用挨个去问。set绝对不能开 retain,否则一条“开灯”命令会在每次重连时被重放,出现玄学的“半夜灯自己亮”。QoS 全选 1 而不是 2,是因为 QoS 2 的握手流程在弱网下开销明显,而智能家居的控制命令本身带序号、业务层有去重,重复一条的代价远小于延迟。
3.4 三种链路的统一:状态机与去重
链路再多,最终都要收敛成同一套状态机:未连接 → 连接中 → 在线 → 离线 → 重连中。每次状态变化往外抛onlineChanged,界面只监听这一个信号,不用为每种链路写一套逻辑。
去重也要在这一层做。MQTT 的 QoS 1 可能重复投递,串口的重发也可能让下位机收到两条一样的命令。做法是给每条命令分配单调递增的序号,收到 ACK 后把序号记进一个近期窗口(比如最近 32 条),窗口内重复的序号直接丢弃。这套机制不复杂,但少了它,场景联动里“开灯”动作被执行两次、亮度被设置两遍的事情就会真实发生。
4. 界面与控制逻辑:QML 与 C++ 的职责边界
界面这块,我见过最多的写法是把QSerialPort、QTcpSocket直接注册成 QML 的上下文属性,然后在.qml里serialPort.write(...)。演示时没问题,一旦需求变成“断线要重连、命令要重发、状态要回读确认”,这些逻辑就全挤进 QML 里,改一个字符都要重新编译资源。边界划清楚,界面代码量能少一半。
4.1 界面分层:页面、卡片、控件三级复用
界面按三级组织:页面(Page)负责布局和导航,卡片(Card)对应一个房间或一类设备,控件(Control)是最小交互单元,比如一个带滑条的灯、一个带开合状态的窗帘。三级复用的直接收益是新增一个房间只是多实例化一个 Card,而不是复制粘贴三百行 QML。
这套智能家居的页面规划通常是四页:首页总览(各房间温湿度和开关汇总)、设备页(按房间分组的卡片列表)、场景页(场景的增删改和手动触发)、设置页(链路参数、告警阈值、数据清理)。首页只放只读信息和高频开关,复杂的参数配置一律放到设置页,避免主界面堆成仪表盘。
4.2 QML 与 C++ 的边界:模型上移,逻辑下沉
约定三条:QML 不直接调用任何通信对象;QML 只通过Q_PROPERTY和Q_INVOKABLE与业务层交互;列表数据一律用QAbstractListModel派生模型,不用QVariantList硬塞。
// 设备列表模型:QML 里 ListView 直接绑定,增删改自动刷新 class DeviceListModel : public QAbstractListModel { Q_OBJECT public: enum Roles { IdRole = Qt::UserRole + 1, NameRole, RoomRole, OnlineRole, StateRole }; int rowCount(const QModelIndex &parent = {}) const override { return parent.isValid() ? 0 : m_items.size(); } QVariant data(const QModelIndex &idx, int role) const override { if (!idx.isValid() || idx.row() >= m_items.size()) return {}; const auto &it = m_items.at(idx.row()); switch (role) { case IdRole: return it.id; case NameRole: return it.name; case RoomRole: return it.room; case OnlineRole: return it.online; case StateRole: return it.state; // QVariantMap,QML 里直接点属性 } return {}; } QHash<int, QByteArray> roleNames() const override { return { {IdRole, "devId"}, {NameRole, "name"}, {RoomRole, "room"}, {OnlineRole, "online"}, {StateRole, "state"} }; } public slots: void onStateChanged(int deviceId, const QVariantMap &state) { for (int i = 0; i < m_items.size(); ++i) { if (m_items[i].id != deviceId) continue; m_items[i].state = state; const QModelIndex idx = index(i, 0); emit dataChanged(idx, idx, {StateRole}); // 只刷这一行,不整表重置 return; } } private: struct Item { int id; QString name; QString room; bool online; QVariantMap state; }; QList<Item> m_items; };dataChanged只传变化的角色,是保证列表流畅的关键。用beginResetModel/endResetModel处理单条状态更新,一条温度上报就会让整个列表重建,设备一多界面就开始闪。
4.3 场景联动的规则表达与执行顺序
场景是这套系统里最能体现“智能”的部分,也是最容易写成一堆if-else的部分。我的做法是把规则做成数据,存进scene.rule_json,引擎只负责解释执行。
// 规则结构:条件全部满足(AND)才执行动作,动作带延迟和去抖 struct Condition { int deviceId; QString key; // "temperature" / "power" / "online" QString op; // ">" "<" "==" "!=" QVariant value; }; struct Action { int deviceId; quint8 cmd; // CMD_SET_POWER 等 QVariantMap params; // {"power": true, "brightness": 60} int delayMs = 0; // 延迟执行,用于"回家模式"渐进开灯 }; struct Rule { int sceneId; QString name; int priority = 0; bool enabled = true; QList<Condition> conds; QList<Action> actions; int cooldownMs = 5000; // 同一规则的触发间隔,防抖 };执行顺序按priority从大到小排,同一优先级按场景 ID 排。每条规则触发后进入冷却期,冷却期内不再触发——否则温度在阈值附近抖动时,“超过 28 度开空调”会在十分钟内把空调开关几十次。动作不是立刻下发,而是进入一个执行队列,delayMs到期才发,这样“回家模式”才能做出玄关灯先亮、客厅灯三秒后亮的层次感。动作下发后要等ack回读确认,确认失败要记录日志并在界面标黄,而不是假装成功。
4.4 历史曲线与告警:降采样与分级提示
历史曲线不要一次把整月数据全查出来画。查询时按时间跨度自动选采样粒度:查一天按 1 分钟取点,查一周按 10 分钟取平均,查一个月按小时取平均。SQL 里用strftime分组就能做到,不需要在内存里做复杂处理。
-- 按粒度聚合温湿度,跨度越大粒度越粗 SELECT (ts / 600) * 600 AS bucket, AVG(value) AS avg_value FROM telemetry WHERE device_id = :dev AND key = 'temperature' AND ts BETWEEN :from AND :to GROUP BY bucket ORDER BY bucket;告警分三级:提示(门磁打开)、警告(温度超阈值)、严重(设备离线超过五分钟)。前两级只在界面侧边弹一个可关闭的条,第三级才允许打断当前页面。告警的产生和恢复都要落库,end_ts为空表示还在持续,这样“某天晚上断了多久”这种问题能直接查出来,而不是靠回忆。
5. 避坑与排查:联调阶段最容易翻车的五个点
功能写完只是开始,真正耗时间的是联调。下面五个问题几乎每个做智能家居上位机的人都会遇到,按“现象、原因、解决”写清楚,能省下不少通宵。
5.1 通信与设备侧的三处翻车
现象一:界面偶尔收不到某条状态,重启又好了。原因基本是缓冲区没做边界处理——一次readyRead收到半帧,程序直接当整帧解析,解析失败后没有保留剩余字节,下一帧的帧头就永远对不上了。解决是把接收缓冲做成成员变量,拆帧函数只从缓冲区里“切走”完整帧,剩下的留着等下次数据到达,同时给帧长度加合法性上限,防止噪声里的假帧头把程序卡死。
现象二:程序跑几分钟后崩溃,日志里一串线程相关的警告。原因是在主线程创建了QSerialPort或QTcpSocket,然后moveToThread之前就调用了open(),或者干脆在 QML 里直接调用了通信对象的函数。解决是严格遵循“对象在哪个线程用,就在哪个线程创建”,所有跨线程调用一律走信号槽或QMetaObject::invokeMethod,并且给所有跨线程信号参数注册元类型。这里没有后悔药,只能靠纪律。
现象三:设备明明在线,界面却反复显示离线又上线。原因是掉线判定太激进:一次 ACK 超时就置离线,而总线上有多个设备时,某条命令排队久了很正常。解决是把“超时”和“离线”分开:单次超时只重发,连续 N 次(通常 3 次)失败才置离线;同时给每个设备单独维护心跳计时,而不是共用一个全局计时器。另外,MQTT 那条链路要用遗嘱消息判在线,不能靠应用层轮询,轮询在弱网下误报率很高。
5.2 界面与资源侧的两处翻车
现象四:设备数量到二十个以后,切换页面明显卡顿。原因通常是模型用错了刷新方式,每次状态更新都beginResetModel,整个列表重建;或者 QML 里对同一个属性做了双向绑定的循环依赖,属性互相触发。解决是dataChanged只传变化的角色和行号,双向绑定改成单向数据流加显式信号;列表项用Loader延迟加载,非当前页不实例化。
现象五:连续跑一天后程序变慢,内存缓慢上涨。常见原因有两个:一是QSerialPort的接收缓冲只append不清理,噪声大时越积越多;二是数据库连接在多个线程里共用,QSqlDatabase的 connection 是按线程绑定的,跨线程使用会出各种奇怪问题,包括连接泄漏。解决是给缓冲区设上限(比如超过 4KB 就丢弃到最后一个帧头之后),数据库连接每个线程单独addDatabase一个具名连接,用完在同一个线程里关闭。
6. 验收与答辩:一份能自证的测试清单
功能演示谁都会做,难的是让评审相信这套东西真的能跑。我习惯在答辩前把下面这张表自己走一遍,每项都留下截图或日志,讲的时候直接翻记录,比口头描述有说服力得多。
| 验收项 | 操作方法 | 通过判据 |
|---|---|---|
| 通信可靠性 | 拔掉串口线 10 秒再插回 | 自动重连,界面 15 秒内恢复在线,无需重启 |
| 断网本地可用 | 断开外网,只保留局域网 | 串口设备与 TCP 设备控制正常,场景联动照常触发 |
| 命令去重 | 用工具向set主题重复发同一条命令 | 设备只动作一次,日志显示重复序号被丢弃 |
| 掉线判定 | 给某设备断电 | 5 分钟内状态变离线并产生严重级告警,恢复后自动消除 |
| 场景防抖 | 让温度在阈值上下反复波动 | 规则在冷却期内只触发一次,空调不被连续开关 |
| 数据完整性 | 连续运行 24 小时后查历史曲线 | 无断点、无重复点,跨天查询耗时低于 1 秒 |
| 资源占用 | 挂机 8 小时看内存曲线 | 内存平稳,无持续上涨趋势 |
答辩时最容易被追问的三件事,提前准备好答案:一是“你的帧格式为什么这么设计”,回答长度字段的作用和 CRC 覆盖范围;二是“断网了怎么办”,回答本地优先、消息队列补传;三是“多设备同时上报会不会乱”,回答单线程串行处理加序号去重。这三个问题答得利索,基本就稳了。
我自己踩得最深的一次,是早期版本把数据库查询放在了界面线程,演示时点了历史曲线,界面白屏三秒,评审以为程序卡死了。从那以后我每次做这类项目,交付前都强制走一遍“删除界面还能跑”的检查,再按上面这张表逐项打钩,宁可少做一个花哨的功能,也不让一个没验证过的链路进答辩现场。希望这套结构能帮到你。
本文还有配套的精品资源,点击获取