尧图精选

CuteHMI实战:用Qt/QML构建跨平台HMI与SCADA界面

🕒 发布时间:2026/9/16 13:47:04 📁 来源:尧图网络
简介CuteHMI 是基于 Qt 与 C/QML 编写的开源人机界面HMI框架面向工业物联网、SCADA、树莓派及跨平台桌面应用等场景适合有一定 C/Qt 基础、希望研究 HMI 架构或进行二次开发的工程师。资源包为 GitHub 仓库镜像共 1517 个文件主体为 C 头文件/源文件hpp/cpp、QML 界面定义和 Qbs 构建脚本搭配 SVG/PNG 图标素材、Markdown/TXT 文档以及 MIT/LGPL 等许可证文件压缩包仅 3.38MB便于快速下载与通读源码。项目以“扩展工具”方式组织可用少量代码将自研模块接入既有体系分支策略和构建状态说明清晰有助于理解模块化设计、跨平台编译流程及许可证边界。当前已有 2312 人学习下载既能作为 Qt HMI 学习样例也可作为 SCADA/IIoT 项目的基础脚手架。1. CuteHMI一套 Qt/QML 写的开源 HMI 框架给 SCADA 和物联网面板用两年前我接了一个食品产线改造甲方指定要“能跑在树莓派上、界面跟手机 App 差不多”的人机界面。当时翻遍商业 HMI 软件要么按点数收 License要么画布组件老旧最后压在 CuteHMI 上它把 Qt 的 C 控制能力和 QML 的声明式界面揉在一起不是传统组态软件那套拖拽变量绑定而是让开发者在编辑器里直接写界面与逻辑。这套框架适合两类人——给设备写上位机的工控工程师和想做边缘端可视化面板的 IoT 开发者。它解决的核心问题是HMI 项目里“画面、数据、设备通信”三者经常互相拉扯CuteHMI 用扩展模型把这三层拆开哪一层变了都不至于重写整个工程。2. 架构先行Qbs 构建体系与 CuteHMI 的扩展模型2.1 扩展、工具与库框架先按“谁来用”分层CuteHMI 不是单一大程序而是由 Qbs 组件拼起来的一套集合库libs、插件plugins、工具tools再加上用户自己写的“扩展”。扩展是这套体系里的一等公民一个自定义 HMI 工程本质就是一个顶层扩展它可以依赖任意数量的其他扩展再交给某个工具去加载组件。换句话说Modbus 协议、SQL 存储、GPIO 读写都可以是独立扩展项目层只负责把界面和业务逻辑写进自己的扩展里。实际使用中这种分层最大的收益是版本边界清晰。CuteHMI 的扩展和工具各自独立版本我维护的现场屏项目里底层通信扩展升级不影响画面扩展画面扩展改版也不碰协议插件。构建系统选 Qbs 而不是 CMake/qmake是因为 Qbs 用类似 QML 的语法描述工程依赖可以精确表达“这个扩展依赖 Qt.quick并且依赖 cutehmi 的某个具体模块”改一个依赖只重建受影响的部分比全量 CMake 增量构建快很多。2.2 克隆、切分支与 Qbs 构建从源代码到可执行先拉源码和子模块。CuteHMI 的 GitHub 仓库是镜像主仓库更新会同步过来日常开发直接在镜像上操作没问题git clone --recursive https://github.com/martinrotter/cutehmi.git cd cutehmi git branch -a克隆后别急着切 master。仓库里“master”是开发主干提交可能处于深度修改状态直接拿它构建容易撞上一半的 API 改动。先看分支列表找标着 alpha 状态的分支新项目从这里起手最稳。切换分支后开始配置构建环境# qbs 需要单独安装确认在 PATH 里 qbs --version # 自动探测本机编译器 qbs setup-toolchains --detect # 绑定 Qt 版本路径替换为你本机的 qmake qbs setup-qt /opt/Qt/5.15.2/gcc_64/bin/qmake --detect # 并发构建-j 后跟 CPU 核心数 qbs build -j 4这里每条命令都有明确作用setup-toolchains --detect让 Qbs 识别 GCC/MSVC/MinGWWindows 上如果没检测到先确认编译器装了setup-qt把指定 Qt 版本的 qmake 绑成 profile后续构建用哪个 Qt 由这一步决定qbs build -j 4开始编译输出在build/profile/目录。常见失败点集中在两步qbs 版本和 Qt 版本不匹配以及 setup-qt 时 qmake 路径带空格没加引号。2.3 分支与维护脚本master、alpha、beta 怎么选CuteHMI 的分支策略和一般开源项目不太一样它是按“稳定性等级”组织的。master 是开发分支实时合并深度改造能用但可能跑不起来alpha 状态分支会定期合入 master功能齐全但 API 可能小幅变动permanent beta 状态的分支稳定承诺不做向后不兼容的修改。我给客户的定制项目都锁在永久 beta 分支上自己玩新功能才用 alpha。仓库根目录里那些Makefile.build、Makefile.buildconfigure、cmakesrcs.awk之类的文件是维护者用来批量生成、规整源码的维护脚本不是日常构建入口。初看容易误以为项目用 Makefile 构建实际日常开发走 qbs这些脚本只在维护者做代码归档、批量改许可证头的时候用。一句话总结用户侧关心 qbs 工程文件就够了别去动这些 awk 和 Makefile。3. 手写一个 HMI 扩展C 逻辑与 QML 画面怎么拼3.1 扩展工程骨架与 Qbs 文件CuteHMI 的扩展说到底就是一个能编译出可执行程序或动态库的 Qbs 工程。下面这个骨架是我常用的最小结构专门给一个“锅炉状态面板”用的my-hmi/ ├─ hmi.qbs ├─ qml.qrc └─ src/ ├─ main.cpp ├─ BoilerTag.h ├─ BoilerTag.cpp └─ qml/ ├─ Main.qml └─ Panels/ControllerPanel.qmlhmi.qbs里这样描述工程CppApplication { name: my-hmi files: [ src/main.cpp, src/BoilerTag.h, src/BoilerTag.cpp, qml.qrc, ] Depends { name: Qt.qml } Depends { name: Qt.quick } // 按你拉取的具体版本核对 CuteHMI 扩展模块名 Depends { name: cutehmi } }qbs 的files列表严格决定了哪些文件参与构建新增一个 QML 文件必须同步加进来否则不会触发增量编译——这是和 CMake 差异最大的地方。Depends声明 Qt 模块和 CuteHMI 扩展依赖名字拼错在构建时会直接报“module not found”按提示去仓库的qbs/modules目录里核对实际名称。文件/目录职责hmi.qbs工程定义依赖声明src/main.cpp启动入口注册 C 对象到 QMLsrc/BoilerTag.*设备数据模型暴露给界面的状态src/qml/Main.qml根界面src/qml/Panels/ControllerPanel.qml操作面板3.2 用 QML 搭出操作画面按钮、状态灯与动画界面层直接用 Qt Quick Controls 2 写组件不需要额外引用 CuteHMI 的 UI 库。我习惯把状态灯、启动/停止按钮和状态文本放在一个 Row 里代码长这样import QtQuick 2.15 import QtQuick.Controls 2.15 ApplicationWindow { id: root visible: true width: 800 height: 600 title: HMI 示例面板 Row { anchors.centerIn: parent spacing: 24 Rectangle { id: lamp width: 48 height: 48 radius: 24 color: boiler.running ? #27ae60 : #7f8c8d // 状态变化时做一个呼吸动画不占 JS 线程 NumberAnimation on scale { from: 0.9 to: 1.1 duration: 300 running: boiler.running loops: Animation.Infinite } } Button { text: boiler.running ? 停机 : 启动 onClicked: { try { boiler.running !boiler.running statusText.text boiler.running ? 运行中 : 已停机 } catch (e) { statusText.text 错误: e console.error(e) } } } Text { id: statusText text: 停止 font.pixelSize: 20 } } }这里状态灯颜色直接绑定boiler.running启动按钮点一下切换布尔值文本跟着变。呼吸动画放在NumberAnimation里跑在 QtQuick 场景图渲染线程上界面其它交互不会被动画卡住。自定义进度条也是一样思路用 Rectangle 的width绑一个 0 到 1 的值根本不用引第三方控件。3.3 C 侧把实时数据喂给界面QObject 与属性绑定真实设备数据不会直接躺在 QML 里通常用一个 C 对象把通信层包装成 Q_PROPERTY。BoilerTag就是最朴素的数据模型class BoilerTag : public QObject { Q_OBJECT Q_PROPERTY(bool running READ running WRITE setRunning NOTIFY runningChanged) public: explicit BoilerTag(QObject *parent nullptr) : QObject(parent) {} bool running() const { return m_running; } public slots: void setRunning(bool running) { if (m_running ! running) { m_running running; emit runningChanged(); } } signals: void runningChanged(); private: bool m_running false; };在main.cpp里注册为单例QML 里直接boiler.running就能读写#include QGuiApplication #include QQmlApplicationEngine #include QQmlContext #include BoilerTag.h int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); BoilerTag boiler; QQmlApplicationEngine engine; engine.rootContext()-setContextProperty(boiler, boiler); engine.load(QUrl(QStringLiteral(qrc:/qml/Main.qml))); return app.exec(); }setContextProperty是 Qt 5 时代最直接的注入方式对象指针传给 QML 引擎后QML 侧访问boiler.running会自动走属性系统。注意setRunning里先判断值变没变变了才发runningChanged()避免界面无意义刷新。Qt 5.15 以上也可以用qmlRegisterSingletonInstance注册成单例类型二选一都行看团队习惯。3.4 控件点击事件报错之后如何恢复QML 里的onClicked本质是 JavaScript 函数里面一旦 throw错误只打印到控制台界面不会有任何提示按钮看着像“没反应”。我在西门子博图仿真里也遇到过按钮无反应那种通常是组态动画连接断了QML 这边问题更直接——异常把后续逻辑掐断了。恢复手法是给高风险操作包 try/catch出错时把界面状态置到一个明确的“故障”态Button { text: 启动 onClicked: { try { boiler.start() // 设备通信可能抛异常 root.state running } catch (e) { console.error(e) root.state controlError } } }故障态下弹一个复位按钮点击后把root.state复位为空字符串并把boiler拉回默认值。这样即使底层通信崩了操作员也能自己恢复不用重启整个 HMI 进程。调试期更省事的做法是在 QtCreator 里把 “JavaScript Errors” 断点打开异常发生时直接停在出错的源码行。4. 接数据、接设备SQL、GPIO 与跨平台部署4.1 用 SQL 把历史数据落库HMI 光显示实时值不够现场要查历史趋势。CuteHMI 生态本身有 SQL 扩展但大多数项目我直接调 Qt SQL因为 SQLite 驱动随 Qt 一起发布不需要额外部署数据库服务QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE); db.setDatabaseName(hmi_archive.db); if (!db.open()) { qWarning() sql open failed: db.lastError().text(); return; } QSqlQuery query(db); query.exec(QStringLiteral( CREATE TABLE IF NOT EXISTS tag_history ( ts INTEGER PRIMARY KEY, tag TEXT NOT NULL, value REAL NOT NULL))); query.prepare(QStringLiteral( INSERT INTO tag_history(ts, tag, value) VALUES(:ts, :tag, :value))); query.bindValue(:ts, QDateTime::currentMSecsSinceEpoch()); query.bindValue(:tag, boiler.temperature); query.bindValue(:value, 87.6); if (!query.exec()) qWarning() insert failed: query.lastError().text();表结构里ts用 epoch 毫秒当主键既可以排序又能直接换算成本地时间。写库用preparebindValue第一是避免字符串拼接把引号转义搞错第二是同一语句重复执行时数据库端可以复用解析结果。现场数据一秒采一次的话别每条都单独提交包在transaction()里攒一千条commit()一次写入速度能差出一个数量级。4.2 树莓派 GPIO把物理按钮接到 QML树莓派上做 HMI 面板GPIO 是绕不开的入口。老派做法是操作 sysfs 伪文件一个GPIOPin类里封装三个动作void writeGpio(const QString pin, const QString value) { writeFile(/sys/class/gpio/export, pin); writeFile(/sys/class/gpio/gpio pin /direction, out); writeFile(/sys/class/gpio/gpio pin /value, value); }顺序不能乱先 export 让系统创建 gpioN 目录再设方向最后写电平。新版树莓派 OS 默认启用了 libgpiod再用 sysfs 会提示 deprecated更干净的做法是命令行直接调gpioset/gpioget# 物理引脚 BCM 21 拉高点亮继电器 gpioset 0 211 # 读按钮状态 gpioget 0 21gpioset第一个参数是 chip 编号树莓派上一般是 0第二个参数是 BCM 引脚号跟电路图上标的物理引脚不同接错就控制不了。分不清引脚号时先跑gpioget --list或gpioinfo把对应的 line name 查出来再写。C 里也可以直接QProcess::start(gpioset, ...)调外部命令开发速度快代价是多一次进程创建对按钮这种低频操作完全够用。4.3 Windows、Linux、Android 的部署差异与 Qt 平台插件报错CuteHMI 的跨平台能力来自 Qt但每个平台部署细节不一样踩得最多的坑是 Qt 平台插件加载失败。平台编译环境发布要点WindowsMSVC 2019 或 MinGW Qt 5.15用 windeployqt 收集 DLL注意保留 QML 目录Linuxgcc Qt baseqbs 单独安装用 linuxdeployqt 或 AppImage 打包避免系统库版本冲突AndroidQt for Android SDK/NDK关注 QML 文件路径Asset 目录只读别写数据树莓派板端编译或交叉编译用 systemd 拉起 HMI 进程显示走 Wayland/X11运行时报qt.qpa.plugin: could not load ... plugin path D:\Qt\5.15.2\msvc2019_64这类错九成是环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向了开发机的 Qt 安装目录换到生产机器上路径不存在。清掉这个环境变量或者把它指到应用目录下随包分发的platforms文件夹问题就消失。Linux 上对应报错是找不到xcb八成缺libxcb-*运行库按提示用包管理器装上就行。5. HMI 窗口细节在 CuteHMI 中做无边框拖动、缩放与全屏5.1 窗口标志与根窗口工业屏很少有标准标题栏全屏、无边框是基本要求。把 ApplicationWindow 的 flags 改成无边框窗口就彻底交给你控制ApplicationWindow { id: root flags: Qt.FramelessWindowHint | Qt.Window width: 1024 height: 640 visible: true }FramelessWindowHint去掉系统标题栏但同时也拿掉了拖动和缩放能力这两件事得自己在 QML 里实现。5.2 拖动、缩放与双击全屏的实现我习惯在根窗口铺一个全屏 MouseArea 负责拖动右下角放一个透明 Rectangle 负责缩放MouseArea { anchors.fill: parent property int lastX: 0 property int lastY: 0 acceptedButtons: Qt.LeftButton onPressed: { lastX mouse.x lastY mouse.y } onPositionChanged: { if (mouse.buttons Qt.LeftButton) { root.x mouse.x - lastX root.y mouse.y - lastY } } onDoubleClicked: { root.visibility (root.visibility Window.FullScreen) ? Window.Windowed : Window.FullScreen } } Rectangle { anchors.right: parent.right anchors.bottom: parent.bottom width: 24 height: 24 color: transparent MouseArea { anchors.fill: parent onPositionChanged: { if (mouse.buttons Qt.LeftButton) { root.width mouse.x root.height mouse.y } } } }拖动逻辑里先记录按下的位置移动时用当前鼠标偏移量直接改窗口坐标。缩放区域要单独判断mouse.buttons Qt.LeftButton否则鼠标悬停也会改窗口尺寸。双击全屏切换依赖Window.FullScreen注意 Android 上无边框窗口意义不大直接用系统全屏即可。5.3 验证与踩坑把这些代码放进第 3 章的工程qbs run -p my-hmi启动后按三步验证先按住窗口空白处拖动看窗口是否跟随鼠标再拖右下角确认宽度和高度同步变化最后双击验证全屏切换。容易翻车的细节是拖动速度过快时窗口卡顿原因是每次 positionChanged 都写一次窗口坐标改成在 onPressed 里记起点、移动时算差值后一次赋值基本就没问题了。跨屏场景记得限制最小尺寸否则拖到副屏边缘容易把窗口拽丢加个if (root.width 800) root.width 800就能挡住低级操作失误。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →