Qt 控件指南与进阶技巧

Qt
7.9k 词

上一篇讲元对象系统与信号槽,这一篇解决”界面怎么做才不翻车”:布局与控件的正确用法、QSS 样式、事件系统、QPainter 实时绘图、线程与界面。延续调参上位机的场景——做完这一篇,你应该能独立写出一个带实时曲线、可换肤、不卡死的上位机界面。

一、控件的本质:数据到界面的映射层

控件不是”画出来的按钮”,而是数据模型的投影:QSpinBox 显示并编辑一个数值,QCheckBox 映射一个 bool,QComboBox 映射一个枚举。理解这一点,选控件的依据就变成了”我的数据是什么类型”:

数据类型 控件 关键信号(选对信号!)
连续数值 QDoubleSpinBox / QSlider valueChanged(double)
整数枚举 QComboBox currentIndexChanged(int)(activated 只在用户操作时触发)
开关量 QCheckBox / QRadioButton toggled(bool)(clicked 在编程 setChecked 时不触发)
短文本 QLineEdit textEdited(仅用户输入)vs textChanged(含 setText)
多行日志 QPlainTextEdit appendPlainText 追加,避免频繁 setText 全量重排

坑:信号选错 = 数据不同步。 用 QLineEdit::textChanged 做”输入即下发”,会在程序自己 setText() 回显时再次触发下发,形成回环;用 textEdited 只响应真实用户输入。同理 QComboBox 的 activated 不响应代码设置。

二、UI 设计的两种方式

2.1 Designer 可视化 + 自定义控件提升

Designer 适合静态布局:拖拽控件 → 右键”转到槽”自动生成连接骨架 → 自定义连接写在构造函数里。

真正的进阶是自定义控件提升(笔记原话流程):从基类派生自己的控件类,在 Designer 里拖一个和基类同类的占位控件,右键 → 提升为 → 填自定义类名 → 编译运行后占位控件被你的类覆盖:

1
2
3
4
5
6
7
8
9
10
// 自绘控件:带刻度盘的仪表
class GaugeWidget : public QWidget {
Q_OBJECT
public:
void setValue(double v) { value_ = v; update(); } // 数据变化 → 请求重绘
protected:
void paintEvent(QPaintEvent *) override; // 见第五节
private:
double value_ = 0;
};

提升后 Designer 里继续正常布局,运行时行为完全由你的类决定——“界面所见 = 代码所得”。

2.2 代码化动态创建

运行时才知道有多少行参数时用代码创建:

1
2
3
4
5
6
7
8
9
10
11
12
// 从参数表动态生成一行行"名称 + 输入框"
for (const auto &p : params) {
auto *row = new QHBoxLayout;
auto *label = new QLabel(p.name, this);
auto *edit = new QDoubleSpinBox(this);
edit->setRange(p.min, p.max);
edit->setValue(p.def);
connect(edit, &QDoubleSpinBox::valueChanged, this,
[this, p](double v){ emit paramChanged(p.key, v); });
row->addWidget(label); row->addWidget(edit);
formLayout->addRow(p.name, edit);
}

生成代码和 Designer 代码可以混用:.ui 里放固定框架,动态部分在构造函数里 addWidget。注意动态控件也要 new 并挂父对象(进对象树)。

三、布局系统:为什么手动 move 没用

布局(QHBoxLayout/QVBoxLayout/QGridLayout)接管了子控件的位置和大小,算法基于三个东西:

  1. sizeHint:控件”想要”多大(按钮按文字+边距算);
  2. sizePolicy:空间富余/不足时如何伸缩——Fixed(不动)、Minimum(可大不可小)、Preferred(可大可小,优先 sizeHint)、Expanding(主动抢占多余空间);
  3. stretch 因子:多余空间按比例分配。
1
2
3
layout->addWidget(plotWidget, 1);      // 曲线区域:占 1 份弹性空间
layout->addWidget(controlPanel, 0); // 面板:按 sizeHint,不抢空间
layout->addStretch(1); // 弹簧:把控件推到一边

坑:布局里的控件 move()/setGeometry() 无效——布局会立刻重新计算覆盖你的坐标。要精确定位只有两条路:不用布局直接 setGeometry(窗口 resize 时不自动调整),或者用弹簧 + stretch 控制相对位置。QSpacerItem(40, 20, QSizePolicy::Expanding, QSizePolicy::Minimum) 是”只占水平弹性、竖直不变”的弹簧。

窗口缩放不塌的秘诀:关键信息用 setMinimumSize 保底,弹性区域(曲线、日志)设 Expanding,固定控件(按钮)保持 Fixed。调参面板里”曲线必须能拉大、按钮不能变形”就是这个策略。

四、QSS:样式与逻辑分离

4.1 选择器与伪状态

1
2
3
4
5
6
7
8
9
QPushButton {
background-color: #4CAF50; color: white;
border: none; padding: 8px 16px; border-radius: 4px;
}
QPushButton:hover { background-color: #45a049; }
QPushButton:pressed { background-color: #3e8e41; }
QPushButton:disabled{ background-color: #cccccc; color: #888888; }
QSlider::groove:horizontal { height: 6px; background: #ddd; border-radius: 3px; }
QSlider::handle:horizontal { width: 14px; margin: -4px 0; border-radius: 7px; background: #4CAF50; }

应用:qApp->setStyleSheet(...) 全局 / widget->setStyleSheet(...) 局部。局部样式会覆盖全局(就近原则)。

4.2 动态属性选择器:暗色模式的正确姿势

QSS 支持按 Q_PROPERTY 动态属性选择(属性变化后需 unpolish/polish 刷新):

1
2
3
4
// 切换主题
ui->centralWidget->setProperty("theme", dark ? "dark" : "light");
style()->unpolish(ui->centralWidget);
style()->polish(ui->centralWidget); // 强制重新应用样式
1
2
QMainWindow[theme="dark"]  { background-color: #2b2b2b; color: #eeeeee; }
QMainWindow[theme="light"] { background-color: #f5f5f5; color: #222222; }

机器人上位机常在强光/暗室两种环境用,主题切换是刚需。别用”每个控件 setStyleSheet 覆盖”实现换肤——改一处漏一处;动态属性 + 统一 QSS 是唯一可控的方案。

五、事件系统:从回调到拦截

5.1 事件怎么流动

1
2
系统事件 → QApplication::notify → 目标 QObject::event → 具体 handler(如 mousePressEvent)
→ accept() / ignore()
  • event() 是总入口,返回 true 表示事件已处理;
  • 具体 handler 处理后调 event->accept();ignore() 会让事件向上冒泡给父控件(比如子控件不处理的按键给父窗口);
  • 与信号槽的区别:事件是”框架发给你的”,信号是”你发给别人的”。

5.2 事件过滤器:全局拦截

1
2
3
4
5
6
7
8
9
10
11
12
bool MainWindow::eventFilter(QObject *obj, QEvent *event) override {
if (obj == targetWidget && event->type() == QEvent::KeyPress) {
auto *ke = static_cast<QKeyEvent*>(event);
if (ke->key() == Qt::Key_Return) {
applyParams(); // 回车 = 下发参数,而不是默认的确认对话框
return true; // 拦截,不再传递
}
}
return QWidget::eventFilter(obj, event);
}
// 安装
targetWidget->installEventFilter(this);

典型用途:回车/空格全局快捷键、屏蔽某控件的右键菜单、鼠标事件转发(拖拽整个无边框窗口)。

5.3 无边框窗口拖动(实战)

1
2
3
4
5
6
void mousePressEvent(QMouseEvent *e) override {
if (e->button() == Qt::LeftButton) dragPos_ = e->globalPos() - frameGeometry().topLeft();
}
void mouseMoveEvent(QMouseEvent *e) override {
if (e->buttons() & Qt::LeftButton) move(e->globalPos() - dragPos_);
}

六、QPainter 绘图:实时曲线怎么做

6.1 paintEvent 的原理

绘制只能发生在 paintEvent 里,外部不能直接画。要刷新就调 update()(异步,多个 update 合并成一次重绘,推荐)或 repaint()(同步立即重绘,可能闪烁)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void GaugeWidget::paintEvent(QPaintEvent *) override {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
p.translate(width()/2.0, height()/2.0); // 坐标系移到中心

// 画表盘
p.setPen(QPen(Qt::gray, 2));
p.drawArc(QRectF(-r, -r, 2*r, 2*r), 210*16, -240*16);
// 画指针(旋转坐标系)
p.save();
p.rotate(-120 + value_ * 240.0);
p.setPen(QPen(Qt::red, 3));
p.drawLine(0, 0, 0, -int(r*0.8));
p.restore();
}

要点:translate/rotate/save/restore 让”画指针”变成”旋转画布再画一条竖线”——云台角度仪表盘、imu 姿态球都是这个套路。

6.2 实时曲线:环形缓冲 + 定时刷新

高频数据(如 1kHz 的 PID 反馈)不能每次到数据就 update——重绘开销大且肉眼 60fps 就到顶了。正确做法:

1
2
3
4
5
6
7
8
9
// 采集线程只往环形缓冲写
QVector<double> buf(2000); int head = 0;
void onData(double v) { buf[head] = v; head = (head+1) % buf.size(); }

// UI 线程 30ms 定时刷新
connect(timer, &QTimer::timeout, this, [this]{
update(); // 触发 paintEvent,重绘整条曲线
});
// paintEvent 里把环形缓冲画成折线(从 head 处开始绕一圈)

这就是”曲线不卡、CPU 不爆”的全部秘密:数据写入与绘制解耦,绘制频率固定(~30fps),数据频率随便多高。PlotJuggler、rqt_plot 也是这个模型。

七、定时器:QTimer 的精度边界

1
2
3
4
5
QTimer *t = new QTimer(this);
connect(t, &QTimer::timeout, this, &Widget::tick);
t->start(20); // 20ms ≈ 50Hz
// 或定时器事件
void timerEvent(QTimerEvent *e) override { if (e->timerId() == id_) { ... } }

精度事实:QTimer 依赖事件循环,实际触发间隔 = 设定值 ± 数毫秒(系统调度 + 事件队列排队)。结论:

  • 界面刷新、心跳、超时检测:QTimer 足够;
  • 机器人 1kHz 控制周期:绝对不能用 QTimer。控制循环必须跑在实时线程(或专用高优先级线程)里,界面只负责显示;
  • 用 QTimer 做”超时保护”时留裕量:串口 100ms 超时,定时器设 120ms,别卡在临界值上。

八、文件与配置

8.1 文本读写与编码

1
2
3
4
5
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { qWarning() << f.errorString(); return; }
QTextStream in(&f);
in.setCodec("UTF-8"); // 不设编码 = 中文乱码的根源
QString content = in.readAll();

坑:Windows 下记事本存的中文是 GBK/带 BOM,setCodec("UTF-8") 读出来是乱码。要么统一约定 UTF-8,要么读 BOM 判断编码。跨平台上位机(Windows 调参 + Linux 工控机)建议配置文件一律 UTF-8。

8.2 参数持久化:QSettings 与 JSON

1
2
3
4
5
6
7
8
9
10
// ini 式配置:记住上次的窗口位置、PID 初值
QSettings s("MyCompany", "Tuner");
s.setValue("kp", 1.5);
double kp = s.value("kp", 1.0).toDouble(); // 带默认值
s.setValue("window/geometry", saveGeometry());

// 结构化参数下发:JSON
QJsonObject obj{{"kp", 1.5}, {"ki", 0.1}, {"mode", "speed"}};
QJsonDocument doc(obj);
QByteArray payload = doc.toJson(QJsonDocument::Compact); // 串口/网络发送

九、线程与界面:不卡死的铁律

铁律 1:控件属于创建它的线程,跨线程操作控件 = 未定义行为。

铁律 2:耗时操作(文件、网络、串口读写、算法)放工作线程,结果用信号回传。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Worker : public QObject {
Q_OBJECT
public slots:
void doHeavyWork() { /* 别碰任何控件 */ emit finished(result); }
signals:
void finished(QString result);
};

// 主线程:正确启动方式
auto *thread = new QThread(this);
auto *worker = new Worker; // 注意:worker 不能有 this 父对象!
worker->moveToThread(thread);
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
connect(this, &MainWindow::start, worker, &Worker::doHeavyWork); // Queued,自动跨线程
connect(worker, &Worker::finished, this, [this](QString r){ ui->label->setText(r); });
thread->start();
  • 槽在接收者所在线程执行(QueuedConnection),所以 doHeavyWork 跑在工作线程,finished 的回调跑回 UI 线程——信号槽自动完成了线程切换;
  • 别用 QThread 子类重写 run() + 裸指针传控件,那是教科书式的崩溃写法;
  • 工作线程里 emit 的信号,槽里若操作控件,务必确认连接是 Queued(跨线程自动就是)。

十、踩坑清单

现象 原因 解决
布局里 move() 无效 布局接管几何管理 用弹簧/stretch,或放弃布局
setText 后 textChanged 又触发一次 信号选错 用户输入用 textEdited
曲线一卡一卡 每个数据点都 update() 环形缓冲 + 30ms 定时刷新
界面点按钮后卡死 槽里同步做了耗时操作 工作线程 + 信号回传
窗口 resize 后控件乱飞 没有 sizePolicy/stretch Fixed 固定、Expanding 弹性
中文全乱码 编码没设/文件是 GBK setCodec(“UTF-8”) + 统一约定
事件过滤器没生效 忘了 installEventFilter 检查安装与返回值
定时器不准 把 QTimer 当硬实时 控制循环进专用线程

最后一句:控件、布局、样式、事件、绘图、线程——六个主题背后只有一个原则:界面层永远是被动的投影层,数据与逻辑在它下面。守住这条线,上位机怎么写都不乱。