Compare commits
8 Commits
6773f9b29e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d69cbebd5 | |||
| d94d9175b1 | |||
| d0496675e9 | |||
| d3e2e50f0e | |||
| 01b264fe97 | |||
| 993aa30a1c | |||
| 12950da129 | |||
| 82b726ab3e |
+19
@@ -4,6 +4,7 @@
|
||||
# Build outputs
|
||||
build/
|
||||
build-*/
|
||||
build_*/
|
||||
out/
|
||||
*.o
|
||||
*.obj
|
||||
@@ -14,6 +15,10 @@ moc_*.cpp
|
||||
qrc_*.cpp
|
||||
ui_*.h
|
||||
|
||||
*~
|
||||
*.bak
|
||||
*_
|
||||
|
||||
# Tooling caches
|
||||
.qt/
|
||||
.cache/
|
||||
@@ -37,3 +42,17 @@ __pycache__/
|
||||
/file.txt
|
||||
/in.txt
|
||||
/person.txt
|
||||
|
||||
# IDE 本地配置(Qt Creator 等,切勿入库)
|
||||
*.user
|
||||
*.user.*
|
||||
|
||||
# 归档 / 打包(不入库)
|
||||
*.7z
|
||||
*.zip
|
||||
|
||||
# 构建日志
|
||||
*.log
|
||||
|
||||
# Office 临时锁文件(打开 .pptx/.docx 时产生)
|
||||
~$*
|
||||
|
||||
@@ -72,8 +72,10 @@ wheelEvent 改 scale/center(以鼠标点为锚,缩放后该点复坐标不
|
||||
|
||||
## 5. 三段式对比(教学核心)
|
||||
|
||||
- **「主线程渲染」按钮**:槽里同步调用 `renderStrip` 算一整帧(在 GUI 线程,不分块、不丢池),耗时约 1–3s。
|
||||
期间事件循环停摆 → 按钮无响应、窗口拖不动 → 直观复现痛点。算完 `update()` 并显示「主线程渲染 Xms · 界面冻结」。
|
||||
- **「主线程渲染」按钮**:槽里在 GUI 线程同步调用 `renderStrip` 算整帧(不分块、不丢池)。
|
||||
实测单帧 820×640@256 仅约 70ms,不足以肉眼可感,故**自适应循环渲染到累计 ≥ 1500ms**(实测 ~23 帧 / 1506ms)。
|
||||
期间事件循环停摆 → 按钮无响应、窗口拖不动 → 直观复现痛点。算完 `update()` 并显示「主线程渲染 Xms · N 轮」。
|
||||
冻结本质是事件循环被占用,循环只是模拟一个持续 ~1.5s 的耗时操作。
|
||||
- **「单/多线程」切换**:复用同一套分块逻辑,仅切 `QThreadPool::globalInstance()->setMaxThreadCount(1 或 hardwareConcurrency())`。
|
||||
单线程不卡但慢、多线程不卡且快,耗时标签量化加速比。
|
||||
- 三段对比让学员一眼看懂:耗时任务必须离开主线程;多线程进一步压榨多核。
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# QLineEdit 自绘小眼睛 / echo mode 切换 教学示例 —— 设计
|
||||
|
||||
- **日期**:2026-07-08
|
||||
- **对应课程**:Day4 上午「消息机制和事件 + 绘图事件」(OUTLINE_4DAY.md §2 Day4 上午)
|
||||
- **目标产物**:`docs/teaching/examples/lineedit_eye_toggle/`,交付为两种形态——
|
||||
① 仓库统一构建里的 `qt_lineedit_eye_toggle` target;
|
||||
② 可独立 `cmake -S . -B build` 构建的单独 CMake 工程。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
需要一个 Day4 上午课堂演示示例,**同时覆盖两个主题**:组件绘制(`paintEvent`)、
|
||||
事件捕捉(`mousePressEvent` / `mouseMoveEvent`)。载体是 QLineEdit 扩展——在右侧加一个
|
||||
小眼睛图标,点击动态切换 `echoMode`(`Password` ↔ `Normal`),并显式处理与 QLineEdit
|
||||
内置 clear button 的位置冲突。
|
||||
|
||||
> 选 QLineEdit 而非自造控件:密码框是「自绘内嵌图标 + 点击切换」最真实的工业场景,
|
||||
> 且复用 QLineEdit 的文本/编辑能力,让示例聚焦在「绘制 + 事件」本身。
|
||||
|
||||
## 2. 关键设计决策
|
||||
|
||||
### 2.1 组织形态:梯度对照版
|
||||
窗口分三区,先简后深,让学生看清「为什么」:
|
||||
|
||||
| 区 | 内容 | 教学作用 |
|
||||
|---|---|---|
|
||||
| ① | 4 种 echo mode 静态对照(Normal/NoEcho/Password/PasswordEchoOnEdit) | 先给 echo mode 全貌,眼睛只是切换载体 |
|
||||
| ② | `addAction(QIcon, TrailingPosition)` 一行版 | Qt 内置做法,1 行实现——「为什么还要自绘」的引子 |
|
||||
| ③ | `PasswordLineEdit : public QLineEdit` 自绘版 + 「启用 clear button」复选框 | **主题落地**:自绘 + 事件捕获 + clear button 避让 |
|
||||
|
||||
### 2.2 实现方式锁定:子类化 + 自绘 + 自捕获
|
||||
`addAction` 那条路由 Qt 全权处理位置/点击/避让,**碰不到**绘制与事件这两个主题。
|
||||
故核心交付物必须是子类化 QLineEdit、覆写 `paintEvent`/`mouse*Event`。addAction 仅作
|
||||
②区对照存在。
|
||||
|
||||
### 2.3 clear button 避让:读 clearButton 子 widget 几何
|
||||
QLineEdit 内置 clear button 的宽度随主题/DPI/字号变化,**不能硬编码偏移**(坑卡)。
|
||||
clear button 是 QLineEdit 的一个 `objectName=="clearButton"` 直接子 widget(Qt5 私有
|
||||
`QLineEditIconButton`),用 `findChild<QWidget*>("clearButton")` 读其 `geometry()`,眼睛紧贴
|
||||
其左侧;未启用或无文本时贴右边。
|
||||
> 注:初版误用 `QStyle::subControlRect(CC_LineEdit, …, SC_LineEditClearButton)`,编译期即报错
|
||||
> ——`QStyle` 没有这两个成员,QLineEdit 不经 complex-control 绘制,无对应 subcontrol。读子 widget 是唯一可靠途径。
|
||||
|
||||
### 2.4 眼睛图标:QPainter 纯代码绘制
|
||||
- 不引入 `.qrc` 资源(YAGNI),完全契合「组件绘制」主题。
|
||||
- 两态:`closed=true`(密码态)= 眼眶椭圆 + 斜杠;`closed=false`(明文态)= 眼眶椭圆 + 实心瞳孔。
|
||||
- 绘制逻辑抽成自由函数 `paintEye(QPainter*, QRect, closed, hover)`,③区自绘与②区
|
||||
`QIcon` pixmap 共用(DRY,并让对照更明显)。
|
||||
- hover 态描边加深为蓝色(`#0078D7`),需 `setMouseTracking(true)` 才能在无按键时收到 move。
|
||||
|
||||
### 2.5 双形态 CMake(源文件零重复)
|
||||
- **唯一源文件**:`qt_lineedit_eye_toggle.cpp`。
|
||||
- **两个 CMake 上下文**引用它:
|
||||
- 独立工程:`lineedit_eye_toggle/CMakeLists.txt`(手写规范版,C++17,非 Qt Creator 模板)。
|
||||
- 仓库集成:`examples/CMakeLists.txt` 的 `add_teaching_example(qt_lineedit_eye_toggle lineedit_eye_toggle)`。
|
||||
- **零污染**:顶层 `CMakeLists.txt:58` `add_subdirectory(docs/teaching/examples)` 不递归,
|
||||
子目录 `CMakeLists.txt` 永不被统一构建触发;`examples/CMakeLists.txt` 无 GLOB。
|
||||
不计入 README/CLAUDE.md 里「173 目标」的生成器计数。
|
||||
|
||||
## 3. `PasswordLineEdit` 子类要点
|
||||
|
||||
```
|
||||
构造: setEchoMode(Password); setMouseTracking(true); setTextMargins(0,0,iconSize+8,0)
|
||||
paintEvent: base::paintEvent 先画原生; 再 recomputeEyeRect(); QPainter 画眼睛
|
||||
mousePressEvent: recomputeEyeRect(); 命中眼睛 → 切 m_passwordMode + setEchoMode + accept;
|
||||
否则 base::mousePressEvent(光标定位/双击选词不受影响)
|
||||
mouseMoveEvent: recomputeEyeRect(); hit-test 更新 m_eyeHover + setCursor; base 保留拖选
|
||||
leaveEvent: 清 m_eyeHover 局部刷新
|
||||
recomputeEyeRect: findChild("clearButton") 读其几何 → 眼睛贴其左 / 贴右
|
||||
信号: passwordModeChanged(bool) 供窗口状态栏显示
|
||||
```
|
||||
|
||||
**关键不变量**:命中眼睛时 `e->accept()` 并 `return`,**不**调基类——否则 QLineEdit 会
|
||||
把点击当光标定位;非命中时务必调基类,保留原生编辑行为。这是「事件捕获」教学点的核心。
|
||||
|
||||
## 4. 文件清单
|
||||
|
||||
```
|
||||
docs/teaching/examples/lineedit_eye_toggle/
|
||||
├── qt_lineedit_eye_toggle.cpp # 唯一源文件(paintEye + PasswordLineEdit + ShowcaseWidget + main)
|
||||
├── CMakeLists.txt # 独立顶层工程(手写规范版,可 cmake -S . -B build)
|
||||
└── README.md # 教学点 / 目录结构 / 构建(集成+独立两条)/ 坑卡
|
||||
```
|
||||
另:`docs/teaching/examples/CMakeLists.txt` 注册目标 + 加入 `all_teaching_examples` 依赖列表。
|
||||
|
||||
## 5. 验证标准(可证伪)
|
||||
|
||||
1. **集成构建**:`cmake --build build_mw --target qt_lineedit_eye_toggle` 绿。
|
||||
2. **独立构建**:`cmake -S lineedit_eye_toggle -B lineedit_eye_toggle/build ... && cmake --build` 绿。
|
||||
3. **离屏**:两路径产物 `QT_QPA_PLATFORM=offscreen` 跑 200ms 后退出码 0。
|
||||
4. **真实交互**(本机 Qt 实跑,截图为证):③区眼睛可见;点击切 `Password↔Normal`;
|
||||
勾选 clear button 后眼睛正确让到其左侧不重叠;hover 高亮生效。
|
||||
|
||||
## 6. 显式假设
|
||||
|
||||
- 眼睛用 QPainter 代码绘制,不引入资源文件(契合主题 + YAGNI)。
|
||||
- 独立 `CMakeLists.txt` 手写规范版(C++17 / `cmake_minimum_required(3.16)` / 无 ANDROID 噪音),
|
||||
不沿用子目录里 Qt Creator 导出的低质量模板。
|
||||
- ①区 4 个对照框 `setReadOnly(true)` 防误改;`PasswordEchoOnEdit` 的「编辑时明文」效果在
|
||||
README 注明需取消只读才能体验。
|
||||
- **不提交 git**(项目红线:未经请求不操作 git)。
|
||||
@@ -0,0 +1,366 @@
|
||||
# Qt 多线程编程 —— 一课时基准讲义
|
||||
|
||||
> **适用对象**:已掌握 C++ 基础语法与 Qt 信号槽机制的中初级学员。
|
||||
> **讲解时长**:约 60 分钟(含看代码与提问),各节标题后标注的建议时长可作节奏参考。
|
||||
> **配套环境**:Qt 5(本文示例基于 Qt 5.14 验证;worker-object 等核心模式在 Qt 6 完全一致)。
|
||||
> **核心目标**:学完后能回答三个问题——① Qt 为什么需要多线程;② 槽函数到底在哪个线程执行;③ 给你一段耗时逻辑,你能用最稳妥的方式把它移出主线程而不崩溃。
|
||||
|
||||
---
|
||||
|
||||
## 引言:为什么要多线程(5′)
|
||||
|
||||
绝大多数 GUI 程序都只有**一个主线程**(也叫 GUI 线程)。Qt 的主线程一直在跑一个
|
||||
**事件循环**(`QApplication::exec()`)——它不停地从事件队列里取事件并处理:鼠标点击、
|
||||
键盘按键、定时器到期、重绘请求、网络数据到达……
|
||||
|
||||
**关键事实**:事件循环是**串行**的。处理一个事件时,后面的事件只能排队等。
|
||||
|
||||
那么,当主线程在执行一个**耗时操作**(比如解析一个大文件、做一次复杂计算、等待网络响应)时,
|
||||
会发生什么?——它在忙,事件循环停摆,所有排队的事件(包括"请重绘界面""请响应鼠标")
|
||||
全部被卡住。表现就是:
|
||||
|
||||
- 按钮点了没反应;
|
||||
- 窗口拖不动、缩放不了;
|
||||
- 进度条不动、界面"未响应"、Windows 标题栏甚至会出现"程序无响应"。
|
||||
|
||||
这就是 Qt 多线程存在的**唯一根本理由**:**把耗时操作搬出主线程,让主线程专心响应界面。**
|
||||
|
||||
> 一句话心法:**多线程在 Qt 里的首要目的不是"更快",而是"界面不卡"。** 当然,顺带还能用
|
||||
> 多核并行加速,但那是第二位的。记住这个优先级,后面所有设计决策都有了依据。
|
||||
|
||||
---
|
||||
|
||||
## 一、基本概念(12′)
|
||||
|
||||
### 1.1 线程与进程
|
||||
|
||||
一句话带过:**进程是资源分配单位,线程是 CPU 调度单位**。一个进程至少有一个线程(主线程),
|
||||
可以再开若干子线程;同进程的线程**共享同一片内存空间**(这点对后面的"竞态/加锁"至关重要)。
|
||||
|
||||
### 1.2 主线程与事件循环
|
||||
|
||||
如引言所述,主线程跑 `exec()` 事件循环。**Qt 里只有主线程能创建和操作 GUI 控件**
|
||||
(QWidget 及其子类)——这是铁律,记住它,"最佳实践"里会反复用到。
|
||||
|
||||
### 1.3 线程亲和性(thread affinity)—— 本课最重要的概念
|
||||
|
||||
每个 `QObject` 都有一个"**归属线程**",即它的**线程亲和性**。可以用 `obj->thread()` 查询。
|
||||
|
||||
亲和性的核心规则:**一个 QObject 的槽函数,默认在它「自己归属的线程」里执行,而不是在
|
||||
调用它的线程里执行。** 这句话是理解 Qt 多线程的钥匙。
|
||||
|
||||
默认情况下,一个对象在哪条线程被 `new` 出来,它的亲和性就是哪条线程。要改变亲和性,
|
||||
用 `moveToThread(thread)`——这一步之后,该对象的槽函数就会在 `thread` 里执行。
|
||||
|
||||
> 把亲和性理解成"这个对象的家"。信号槽机制会自动把"去这个对象家敲门"(调用它的槽)
|
||||
> 这件事,调度到它家所在的线程去做——这就是下一小节"排队连接"的本质。
|
||||
|
||||
### 1.4 跨线程信号槽:三种连接类型
|
||||
|
||||
`QObject::connect` 在建立连接时,会根据**发送者和接收者是否在同一线程**,自动选择连接类型:
|
||||
|
||||
| 连接类型 | 触发方式 | 何时被 Qt 自动选用 |
|
||||
|---|---|---|
|
||||
| **DirectConnection**(直接) | `emit` 处直接调用槽,**同步**,在发送者线程执行 | 发送者与接收者**在同一线程** |
|
||||
| **QueuedConnection**(排队) | `emit` 把调用**投递**到接收者线程的事件队列,**异步**,在接收者线程执行 | 发送者与接收者**在不同线程** |
|
||||
| **AutoConnection**(自动,默认) | 运行时判断二者是否同线程,同则直接、不同则排队 | 不指定类型时的默认值 |
|
||||
|
||||
**排队连接是 Qt 多线程的魔法所在**:你在子线程里 `emit resultReady(x)`,Qt 会自动把
|
||||
"调用主线程那个槽"打包成一个事件,投递到主线程事件队列。主线程在下一轮事件循环里取出它、
|
||||
执行槽。于是——**子线程算出的结果,自动、安全地"回到"了主线程,全程不需要你手动加锁。**
|
||||
|
||||
这也是为什么官方推荐"用信号槽在跨线程间传递数据,而不是共享变量 + 锁"。
|
||||
|
||||
---
|
||||
|
||||
## 二、使用线程的方法(15′)
|
||||
|
||||
Qt 提供了几条不同的多线程路径,从底层到高层依次为:
|
||||
|
||||
### 方法一:继承 `QThread`,重写 `run()`
|
||||
|
||||
最古老、教程里最常见的写法。把耗时逻辑写进 `run()`,`start()` 后 `run()` 在子线程执行。
|
||||
|
||||
```cpp
|
||||
class MyThread : public QThread {
|
||||
protected:
|
||||
void run() override {
|
||||
// 这段代码在子线程执行
|
||||
// ... 耗时操作 ...
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**能跑,但容易踩坑**:很多人以为"MyThread 的所有槽都在子线程执行"——**错**。`QThread`
|
||||
对象本身(包括它的槽)依然活在**创建它的线程**(通常是主线程)。只有 `run()` 里的代码
|
||||
在子线程。这个认知错位会导致"明明 moveTo 了,槽却还在主线程跑"的困惑。
|
||||
|
||||
### 方法二:Worker-Object + `moveToThread`(官方推荐)⭐
|
||||
|
||||
不继承 `QThread`,而是把耗时逻辑写成一个**普通 `QObject` 子类**(Worker)的槽函数,
|
||||
再用 `moveToThread()` 把它整个挪到子线程。此后 Worker 的槽就在子线程执行,
|
||||
通过信号槽与主线程双向通信。
|
||||
|
||||
这是 Qt 官方**强烈推荐**的写法,第三节会给完整示例。优点:槽确实在子线程、用信号槽通信
|
||||
天然线程安全、代码清晰。
|
||||
|
||||
### 方法三:`QRunnable` + `QThreadPool`(任务并行)
|
||||
|
||||
适合"**一堆独立的、可并行的小任务**"(如分块计算、批量处理)。每个任务继承 `QRunnable`,
|
||||
`run()` 写计算逻辑,丢进 `QThreadPool` 由它调度到若干线程并行执行。
|
||||
|
||||
与方法二相比:worker-object 适合"**一个长期存在的后台对象**",`QRunnable` 适合
|
||||
"**大量一次性任务**"。第三节也给出示例。
|
||||
|
||||
### 方法四(了解即可):`QtConcurrent` + `QFuture`
|
||||
|
||||
更高层、更省代码的 API。`QtConcurrent::run()` 一行就能把一个函数扔到线程池;
|
||||
`QFuture` 用来取结果/取消/等待。**适合简单的一次性并行**,缺点是控制粒度粗、调试不如
|
||||
QThread 直观。本课不展开,知道"它是更上层的封装"即可。
|
||||
|
||||
### 决策表:何时用哪种?
|
||||
|
||||
| 场景 | 推荐方法 |
|
||||
|---|---|
|
||||
| 一个长期运行的后台服务(持续监听、定期处理) | **方法二**(worker-object) |
|
||||
| 大量独立、可并行的一次性任务(批量、分块) | **方法三**(QRunnable + 线程池) |
|
||||
| 简单地把单个函数异步跑一次,不想写类 | **方法四**(QtConcurrent::run) |
|
||||
| 维护老代码、或确需完全控制线程入口 | 方法一(继承 QThread,慎用) |
|
||||
|
||||
> 给中初级学员的默认建议:**绝大多数情况用方法二(worker-object)。** 它最不易出错、最契合
|
||||
> Qt 的信号槽体系。除非任务特征明确指向方法三/四,否则一律用方法二起步。
|
||||
|
||||
---
|
||||
|
||||
## 三、如何使用多线程(18′)
|
||||
|
||||
### 3.1 完整示例:worker-object 模式(主线示例,务必看懂)
|
||||
|
||||
下面是一个**自包含、可编译、可运行**的控制台程序。它把"1 加到 5000 万"这件耗时计算
|
||||
丢到子线程,算完把结果安全地传回主线程打印。
|
||||
|
||||
```cpp
|
||||
// main.cpp —— worker-object 多线程最小完整示例(控制台,无需 GUI)
|
||||
#include <QCoreApplication>
|
||||
#include <QObject>
|
||||
#include <QThread>
|
||||
#include <QDebug>
|
||||
|
||||
// 工作对象:普通 QObject 子类。注意它「不」继承 QThread。
|
||||
class Worker : public QObject {
|
||||
Q_OBJECT
|
||||
public slots:
|
||||
void doWork() { // 这个槽将运行在子线程(因为 worker 被 moveToThread)
|
||||
// 模拟耗时计算(真实场景:解析大文件、网络请求、复杂运算……)
|
||||
// 用 qint64 防止溢出:5000 万求和 ≈ 1.25e15,远超 int 上限
|
||||
qint64 sum = 0;
|
||||
for (int i = 1; i <= 50'000'000; ++i) sum += i;
|
||||
emit resultReady(sum); // 算完发信号 → 跨线程排队连接,自动带回主线程
|
||||
emit finished(); // 通知:工作完成
|
||||
}
|
||||
signals:
|
||||
void resultReady(qint64 result); // 把结果交给主线程
|
||||
void finished(); // 通知主线程:可以结束了
|
||||
};
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QCoreApplication app(argc, argv);
|
||||
|
||||
auto* thread = new QThread; // 线程对象(本身活在主线程,管理一个子线程)
|
||||
auto* worker = new Worker; // worker 当前还在主线程
|
||||
worker->moveToThread(thread); // ★ 关键一步:把 worker 挪到子线程
|
||||
|
||||
// —— 四条核心接线(全部用函数指针语法,连接类型交由 Qt 自动判定)——
|
||||
// ① 线程一启动 → 立刻开始干活
|
||||
QObject::connect(thread, &QThread::started, worker, &Worker::doWork);
|
||||
|
||||
// ② 结果回主线程:接收者 &app 在主线程,于是这是「排队连接」,
|
||||
// 回调 lambda 必然在主线程执行(看下一行的断言验证)
|
||||
QObject::connect(worker, &Worker::resultReady, &app, [](qint64 result) {
|
||||
qDebug() << "计算结果:" << result
|
||||
<< "(回调在"
|
||||
<< (QThread::currentThread() == qApp->thread() ? "主" : "子")
|
||||
<< "线程执行)";
|
||||
});
|
||||
|
||||
// ③ 优雅退出 + 清理(Qt 官方标准四件套,照抄即可)
|
||||
QObject::connect(worker, &Worker::resultReady, thread, &QThread::quit); // 算完让子线程事件循环退出
|
||||
QObject::connect(thread, &QThread::finished, worker, &QObject::deleteLater); // 子线程结束 → 删 worker
|
||||
QObject::connect(thread, &QThread::finished, thread, &QObject::deleteLater); // → 删 thread
|
||||
QObject::connect(thread, &QThread::finished, &app, &QCoreApplication::quit);// → 退出程序
|
||||
|
||||
thread->start(); // 启动子线程事件循环
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
#include "main.moc" // Worker 定义在 .cpp 内时需要此行(拆成 .h/.cpp 则由 AUTOMOC 自动处理)
|
||||
```
|
||||
|
||||
**走读这个示例(这是本课重点)**:
|
||||
|
||||
1. **Worker 不继承 QThread**——这是方法二的精髓。耗时逻辑在 `doWork()` 槽里。
|
||||
2. **`moveToThread` 改变亲和性**:之后 `doWork` 在子线程执行。对比"方法一"的陷阱——
|
||||
那里只有 `run()` 在子线程,槽不在;这里 `doWork` 这个槽**确确实实在子线程**。
|
||||
3. **`started → doWork`**:线程的 `exec()` 一跑起来,`started` 信号触发 `doWork`,开始干活。
|
||||
4. **`resultReady → 主线程 lambda`**:因为接收者在主线程,Qt 自动用**排队连接**,
|
||||
把"调用 lambda"投递回主线程。打印里的断言会显示"主线程"——亲手验证排队连接真的把回调带回来了。
|
||||
5. **优雅退出**:`resultReady → quit()` 让子线程事件循环退出 → `finished` 触发 →
|
||||
`deleteLater` 清理 worker 和 thread,最后 `app.quit()` 结束程序。**没有内存泄漏,没有悬空。**
|
||||
|
||||
> **如何亲手验证"排队连接"?** 把第 4 条的回调想象成"更新一个 QLabel"。因为回调确定在主线程,
|
||||
> 所以可以**安全地**操作 GUI 控件——这正是 worker 模式能安全配合 GUI 的根本原因。
|
||||
|
||||
### 3.2 完整示例:`QRunnable` + `QThreadPool`(任务并行)
|
||||
|
||||
同样是"1 加到 5000 万",这次切成 4 段丢给线程池并行算,4 段结果都回到主线程汇总。
|
||||
|
||||
```cpp
|
||||
// main.cpp —— QRunnable + 线程池:任务并行最小示例
|
||||
#include <QCoreApplication>
|
||||
#include <QObject>
|
||||
#include <QRunnable>
|
||||
#include <QThreadPool>
|
||||
#include <QDebug>
|
||||
|
||||
// 一个可并行执行的「任务」。QRunnable 本身不发信号,要发结果须同时继承 QObject。
|
||||
class SumTask : public QObject, public QRunnable {
|
||||
Q_OBJECT
|
||||
int from_, to_;
|
||||
public:
|
||||
SumTask(int from, int to) : from_(from), to_(to) { setAutoDelete(true); }
|
||||
void run() override { // run() 在线程池的某个工作线程执行
|
||||
qint64 sum = 0;
|
||||
for (int i = from_; i < to_; ++i) sum += i;
|
||||
emit done(sum); // 跨线程排队连接,把结果带回主线程
|
||||
}
|
||||
signals:
|
||||
void done(qint64 sum);
|
||||
};
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QCoreApplication app(argc, argv);
|
||||
|
||||
const int N = 4; // 切 4 段并行
|
||||
const int total = 20'000'000;
|
||||
const int step = total / N;
|
||||
auto* results = new qint64[N]{}; // 存每段结果
|
||||
int got = 0; // 已返回的段数
|
||||
|
||||
for (int i = 0; i < N; ++i) {
|
||||
auto* task = new SumTask(i * step, (i + 1) * step);
|
||||
// 接收者 lambda 在主线程 → done 经排队连接带回主线程,回调串行执行,天然无锁
|
||||
QObject::connect(task, &SumTask::done, &app, [&, i](qint64 sum) {
|
||||
results[i] = sum;
|
||||
if (++got == N) { // 4 段全部返回
|
||||
qDebug() << "总和:" << results[0] + results[1] + results[2] + results[3];
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
QThreadPool::globalInstance()->start(task); // 丢进全局线程池
|
||||
}
|
||||
|
||||
return app.exec();
|
||||
// 注:results 是堆对象,示例为简洁未 delete;真实程序用智能指针或栈上容器管理
|
||||
}
|
||||
|
||||
#include "main.moc"
|
||||
```
|
||||
|
||||
**两个示例对照,记住要点**:
|
||||
|
||||
- **示例 3.1**(worker-object):一个 Worker 长期住在一个子线程里,适合"后台服务"。
|
||||
- **示例 3.2**(QRunnable):多个一次性任务被线程池调度,适合"批量并行"。
|
||||
- **两者都靠排队连接把结果安全带回主线程**——这就是贯穿所有 Qt 多线程的统一范式:
|
||||
**子线程只管算,结果用信号发给主线程,主线程负责一切后续(更新界面/汇总/落盘)。**
|
||||
|
||||
---
|
||||
|
||||
## 四、最佳实践与常见坑(8′)
|
||||
|
||||
每条都对应一个**真实会崩或会卡**的坑,逐条记住可省去项目期大量排查时间。
|
||||
|
||||
### ① 绝不在工作线程里访问 GUI 控件
|
||||
|
||||
`QWidget` 及其子类**不是线程安全**的,且只能由主线程操作。在工作线程里直接调
|
||||
`label->setText(...)` 是**未定义行为**——可能崩溃、可能乱码、可能偶发花屏。
|
||||
|
||||
**正解**:子线程算出数据后 `emit` 一个信号,由主线程的槽(排队连接)去更新控件。
|
||||
(回顾示例 3.1 的回调——它确认在主线程,所以那里才是碰 GUI 的合法位置。)
|
||||
|
||||
### ② 优先用信号槽传数据,退而求其次才用 `QMutex`
|
||||
|
||||
子线程之间、子线程与主线程之间,**优先选择"通过信号槽传递数据的所有权"**(一个线程
|
||||
算完、发出去、就不再碰它),而不是"多个线程共享一块内存 + 加锁"。
|
||||
|
||||
只有当确实**无法避免共享同一块可变数据**时(如多个写线程更新同一个计数器),才用
|
||||
`QMutex`(互斥锁)/ `QReadWriteLock`。锁是最后的手段,不是首选——它带来死锁、
|
||||
性能下降、调试困难。
|
||||
|
||||
### ③ `moveToThread` 的两个时序要点
|
||||
|
||||
- **`worker` 不能有 parent**。`new Worker(parent)` 之后 `moveToThread` 会失败。务必
|
||||
`new Worker`(无 parent),由 `deleteLater` 负责它的生命周期。
|
||||
- **先 `connect`,再 `start`**。确保线程一启动,信号槽已就绪,避免"线程已跑、接线还没好"
|
||||
的竞态。
|
||||
|
||||
### ④ 优雅退出:`quit()` + `wait()`,别暴力 `terminate()`
|
||||
|
||||
- 让线程的事件循环自然结束:`thread->quit()`(请求退出)配 `deleteLater` 清理(见示例)。
|
||||
- 若是 `QThread` 作为类成员:在析构里 `quit()` + `wait()`,确保线程真正停下再销毁对象。
|
||||
- **绝不轻易用 `terminate()`**:它强行杀线程,可能导致资源未释放、锁未解开、数据损坏。
|
||||
|
||||
### ⑤ 亲和性决定槽的执行线程(再强调一次)
|
||||
|
||||
`connect` 里**接收者所在线程**决定了槽在哪执行(排队连接时)。当你疑惑"这个槽为什么
|
||||
在主线程跑"——去查接收者的 `thread()`,答案就在那里。这是排查 Qt 线程问题的第一招。
|
||||
|
||||
### ⑥ 调试利器:`QThread::currentThread()`
|
||||
|
||||
任何时候想确认"现在我在哪个线程",打印 `QThread::currentThread()` 或与 `qApp->thread()`
|
||||
比较。示例 3.1 就用它验证了回调确实在主线程。**排查多线程问题的第一行代码就是它。**
|
||||
|
||||
### 常见坑速览表
|
||||
|
||||
| 现象 | 根因 | 对应条目 |
|
||||
|---|---|---|
|
||||
| 界面卡死、"未响应" | 耗时操作在主线程 | 引言 / ① |
|
||||
| 工作线程更新控件崩溃 | 子线程碰了 GUI | ① |
|
||||
| moveToThread 后槽还在主线程跑 | 误用继承 QThread(方法一陷阱) | 二·方法一 / ⑤ |
|
||||
| 程序退出时崩溃/卡死 | 线程未优雅退出 | ④ |
|
||||
| 偶发数据错乱 | 多线程共享可变数据未加锁 | ② |
|
||||
|
||||
---
|
||||
|
||||
## 小结:一页速查表(2′)
|
||||
|
||||
**Qt 多线程的三条心法**:
|
||||
|
||||
1. **动机**:多线程的首要目的是**界面不卡**(把耗时操作搬出主线程),其次是多核加速。
|
||||
2. **核心抽象**:**线程亲和性**决定槽在哪个线程跑;**排队连接**自动把跨线程调用安全调度到目标线程。
|
||||
3. **统一范式**:**子线程只算,结果用信号发回主线程,主线程负责一切后续(含更新 GUI)。**
|
||||
|
||||
**默认选型**:长期后台服务 → worker-object(方法二);批量并行任务 → QRunnable + 线程池(方法三)。
|
||||
|
||||
**四条铁律**:① 子线程不碰 GUI;② 优先信号槽传数据、慎用锁;③ moveToThread 先 connect 再 start、worker 无 parent;④ 优雅 quit+wait、禁用 terminate。
|
||||
|
||||
---
|
||||
|
||||
## 附:构建运行(一行说明)
|
||||
|
||||
上述示例为单 `.cpp` 控台程序。最小 CMake 片段:
|
||||
|
||||
```cmake
|
||||
find_package(Qt5 COMPONENTS Core REQUIRED)
|
||||
add_executable(thread_demo main.cpp)
|
||||
target_link_libraries(thread_demo PRIVATE Qt5::Core)
|
||||
set_target_properties(thread_demo PROPERTIES AUTOMOC ON) # 自动处理 moc,无需手写 main.moc
|
||||
```
|
||||
|
||||
开启 `AUTOMOC` 后,示例末尾的 `#include "main.moc"` 可去掉,CMake 会自动生成元对象代码。
|
||||
若把 Worker/SumTask 拆到独立的 `.h`,则无论哪种构建方式都无需手动处理 moc。
|
||||
|
||||
> **运行提示(看不到 qDebug 输出?)**:在 Windows + 官方**发行版(release)** Qt 下,
|
||||
> `qDebug` 默认走系统调试输出(`OutputDebugString`)而非终端,控制台程序可能看不到打印。
|
||||
> 这**不是程序没执行**——线程照常运行、结果照常计算。验证手段任选其一:① 用 Debug 版 Qt;
|
||||
> ② 临时改用 `std::cerr << ... << std::flush;` 打印关键路径;③ 在 IDE/调试器输出窗口查看。
|
||||
> 示例逻辑的正确性不依赖此输出(上述三种示例的运行行为均已在 Qt 5.14.2 上端到端验证通过)。
|
||||
@@ -0,0 +1,659 @@
|
||||
# 实训项目集深度解析(按学生关注度:04 / 11 / 02 / 05 / 06 / 08)
|
||||
|
||||
> 题目来源:wiki「02. Qt方向 → 04. 项目集」(pageId 183664900,16 个项目)。
|
||||
> 本文对其中学生关注度较高的 6 个项目做深入拆解:题目重点是什么、技术难点在哪、
|
||||
> 哪些地方容易卡住、卡住了往哪个方向查。目标是让学生顺利启动、按里程碑推进,
|
||||
> 不在环境和 API 细节上消耗整周时间。
|
||||
>
|
||||
> 每个项目按固定结构展开:**定位 → 题目重点 → 技术难点拆解 →
|
||||
> 高频坑与排查 → 建议启动路径(里程碑)**。里程碑设计原则与
|
||||
> [`ASSIGNMENTS.md`](ASSIGNMENTS.md) 相同:先保最小可运行闭环,再逐层加功能。
|
||||
|
||||
## 六个项目总览
|
||||
|
||||
| 项目 | 难度 | 核心考点 | 最容易卡住的点 |
|
||||
|---|---|---|---|
|
||||
| 04 简易本地音乐播放器 | ⭐⭐⭐⭐⭐ | QtMultimedia、Model/View、异步元数据 | 元数据是异步到达的,setMedia 后立即读为空 |
|
||||
| 11 OpenCV 面部识别系统 | ⭐⭐⭐⭐⭐⭐ | OpenCV+Qt 集成、图像格式转换、多线程 | 环境搭建:MinGW 的 Qt 链不上 MSVC 编译的 OpenCV |
|
||||
| 02 基于QT的自动售卖机 | ⭐⭐⭐⭐⭐ | QStateMachine、Model/View、互斥锁 | 带条件的状态跳转(守卫)Qt 没有现成 API |
|
||||
| 05 个人相册管理与智能标签 | ⭐⭐⭐⭐⭐ | 缩略图性能、多线程加载、SQLite 索引 | 主线程同步加载大图,几十张照片就把界面卡死 |
|
||||
| 06 多功能抽奖与随机点名 | ⭐⭐⭐⭐ | 随机算法正确性、定时器动画 | CSV 导入中文乱码(Excel 导出的是 GBK) |
|
||||
| 08 双人飞机大战 | ⭐⭐⭐⭐⭐ | QGraphicsView、游戏循环、碰撞检测 | 键盘多键同时按:直接在 keyPressEvent 里移动会卡顿、双人抢键 |
|
||||
|
||||
## 开工前的三条通用提醒(六个项目都适用)
|
||||
|
||||
1. **先确认 Qt 版本再写第一行代码。** 课程环境是 Qt 5.14.2。自己电脑装了 Qt 6 的
|
||||
同学注意:这批题目里 04(`QMediaPlaylist` 在 Qt6 被删除)和 02(`QStateMachine`
|
||||
在 Qt6 挪进了独立的 StateMachine 模块)受 Qt5/Qt6 差异影响最大,各项目小节末尾
|
||||
有版本说明。网上的 Qt5 和 Qt6 示例代码混着抄,是编译报错查半天的常见原因。
|
||||
2. **资源路径不要用裸相对路径。** 双击 exe 和从 IDE 里运行,"当前目录"不一样。
|
||||
图片、音效等只读资源进 `.qrc`(`:/imgs/xxx.png`);存档、配置等可写数据用
|
||||
`QCoreApplication::applicationDirPath()` 或 `QStandardPaths` 拼绝对路径。
|
||||
"IDE 里正常、双击就白屏/没声音"多数是这个原因。
|
||||
3. **每完成一个里程碑就 git commit。** 这批题目都要在一份代码上连续叠功能,
|
||||
没有提交点,一次重构失败就退不回去。验收时 git log 也能说明你是按里程碑推进的。
|
||||
|
||||
---
|
||||
|
||||
## 04. 简易本地音乐播放器 ⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
用 QtMultimedia 做一个能实际使用的播放器:播放控制链路(QMediaPlayer)、
|
||||
播放列表(Model/View)、元数据展示三块拼起来。没有算法难度,考的是
|
||||
把 Qt 各模块正确地连在一起,六个项目里属于投入产出比较高的一个。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 这道题的骨架是两条方向相反的状态同步链:
|
||||
1. UI → 播放器:按钮/滑块操作调用 `play()/pause()/setPosition()/setVolume()`;
|
||||
2. 播放器 → UI:`positionChanged`/`durationChanged`/`stateChanged`/
|
||||
`metaDataChanged` 等信号回写界面。
|
||||
两条链各自单向、靠信号槽解耦,这正是题面"各UI组件间通过信号与槽高效解耦通信"
|
||||
要考的。写成"按钮槽里既改播放器又顺手改十个控件",这部分分就没有了。
|
||||
- 播放列表用 Model/View(`QAbstractTableModel` 或至少 `QStandardItemModel` +
|
||||
`QTableView`),不要用 `QTableWidget` 逐格塞字符串,题面明确点了 Model/View。
|
||||
- 播放模式(列表循环/单曲循环/随机)在 Qt 5.14 里有现成 API:
|
||||
`QMediaPlaylist::setPlaybackMode(Loop / CurrentItemInLoop / Random)`,一行完成。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:元数据(ID3 标签)是异步到达的。** `setMedia()` 之后媒体要经历加载
|
||||
过程,立即调 `metaData()` 拿到的是空值。正确做法:连接
|
||||
`QMediaPlayer::metaDataAvailableChanged` 或 `mediaStatusChanged`
|
||||
(等 `LoadedMedia`)信号,在槽里再读
|
||||
`metaData(QMediaMetaData::Title / ContributingArtist / AlbumTitle)`。
|
||||
标题一直显示空白的,基本都是在 `setMedia` 后下一行就去读了。
|
||||
|
||||
**难点 2:进度条的拖动回环。** 直观的写法是把 `positionChanged` 连到
|
||||
`slider->setValue()`、把 `slider->valueChanged` 连到 `player->setPosition()`,
|
||||
结果两个信号互相触发,拖动时来回跳动甚至卡顿。标准解法:
|
||||
|
||||
- 播放器 → 滑块用 `setValue`(程序触发的 `valueChanged` 不做 seek);
|
||||
- 用户 → 播放器只用 `sliderMoved`(仅用户拖动才发出),或
|
||||
`sliderPressed` 时暂停回写、`sliderReleased` 时一次性 `setPosition()`。
|
||||
|
||||
**难点 3:专辑封面在很多平台后端拿不到。** `QMediaMetaData::CoverArtImage`
|
||||
依赖系统解码后端,Windows/Linux 上对 MP3 内嵌封面经常返回空。题面已经给了
|
||||
降级路径,按题面做即可:拿不到就在歌曲同目录找 `cover.jpg`/`folder.jpg`,
|
||||
再拿不到显示默认占位图。想真正解析 ID3 内嵌封面(APIC 帧)可引入 TagLib,
|
||||
算加分项,不是及格线。
|
||||
|
||||
**难点 4:解码后端决定能不能出声。** QMediaPlayer 自己不解码,依赖系统后端:
|
||||
Windows(DirectShow/WMF)放 MP3/WAV 没问题,FLAC 未必;Linux 走 GStreamer,
|
||||
缺 `gstreamer1.0-plugins-good/bad/ugly、gst-libav` 就没有声音。
|
||||
建议先用 WAV 文件打通全链路,再测 MP3/FLAC,把"代码错"和"后端缺解码器"
|
||||
两类问题分开。
|
||||
|
||||
**难点 5(可选算法项):LRC 歌词同步。** 题面点了二分查找:把 LRC 解析成
|
||||
按时间戳排序的 `QVector<QPair<qint64, QString>>`,`positionChanged` 里用
|
||||
`std::upper_bound` 定位当前行。这是题目"算法与数据结构优化"一节里
|
||||
最容易落地的一项(Fisher-Yates 随机也是,见项目 06 的展开)。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 编译报 `QMediaPlayer: No such file` | 没链 multimedia 模块 | qmake: `QT += multimedia`;CMake: `find_package(... Multimedia)` + `target_link_libraries(... Qt5::Multimedia)` |
|
||||
| 不报错但没声音 | ① 后端缺解码器 ② 文件路径错(相对路径) ③ 音量为 0 | 先播 WAV;`QMediaPlayer::error` 信号连上打印;路径用 `QUrl::fromLocalFile(绝对路径)`,不要手拼 `file://` |
|
||||
| 歌曲标题/歌手一直空白 | 元数据异步,读早了 | 挪进 `metaDataAvailableChanged` 槽里读 |
|
||||
| 拖进度条时来回跳动 | 双向信号回环 | 用 `sliderMoved`/`sliderReleased`,见难点 2 |
|
||||
| 显示时间是一串大数字 | `position()` 返回毫秒(qint64) | `QTime(0,0).addMSecs(pos).toString("mm:ss")` |
|
||||
| 关闭再打开,列表没了 | 只存了内存 | 关闭时(`closeEvent`)把路径列表写 `QSettings` 或 JSON,启动时读回;注意存绝对路径 |
|
||||
| 随机模式来回就那两首 | 用 `rand()%n` 且每次独立随机 | 用 `QMediaPlaylist::Random`,或自己实现 Fisher-Yates 洗牌序列 |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(半天)**:一个按钮 + 一个 `QMediaPlayer`,点击播放一个写死路径的 WAV。
|
||||
打通"模块链接 + 能出声",这一步没过不要写任何 UI。
|
||||
2. **M2(1 天)**:`QFileDialog` 多选添加 → `QMediaPlaylist` + Model/View 列表 →
|
||||
双击列表任意行播放。
|
||||
3. **M3(1 天)**:播放/暂停/上下曲按钮、进度条(含拖动 seek)、音量条、
|
||||
时间标签。到这里已经可以日常使用。
|
||||
4. **M4(1 天)**:三种播放模式切换 + 元数据/封面展示(含降级链)+ 列表持久化。
|
||||
5. **M5(加分,量力)**:LRC 歌词二分同步、系统托盘、QSS 明暗双主题、LRU 最近播放。
|
||||
|
||||
### Qt5 / Qt6 版本说明(本项目受影响最大)
|
||||
|
||||
- `QMediaPlaylist` 在 Qt6 被删除,没有替代类。用 Qt6 就得自己维护
|
||||
`QList<QUrl>` + 当前索引 + 播放模式逻辑(`mediaStatusChanged` 收到
|
||||
`EndOfMedia` 时按模式算下一首),工作量不小,选 Qt6 前想清楚。
|
||||
- Qt6 里 `setMedia()` 改为 `setSource()`,且必须 `setAudioOutput(new QAudioOutput)`
|
||||
才有声音——Qt6 下没有声音,先查这一条。
|
||||
- Qt6 的 `metaData()` 返回 `QMediaMetaData` 对象,取值用
|
||||
`.value(QMediaMetaData::Title)`,与 Qt5 的 API 不同。
|
||||
- 结论:此题用课程环境 Qt 5.14.2 做,问题最少。
|
||||
|
||||
---
|
||||
|
||||
## 11. (OpenCV)基于 OpenCV 的面部识别系统 ⭐⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
六个里唯一的六星题。难度不在识别算法(模型是现成的),而在工程集成:
|
||||
OpenCV 环境搭建、`cv::Mat` 与 `QImage` 互转、视频流不阻塞 UI、
|
||||
"检测→特征→比对"流水线的正确串联。选这题前要有准备:第一周必须把环境跑通,
|
||||
跑不通后面无从谈起。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 先分清两个概念,题目是两级流水线,不要混为一谈:
|
||||
1. **人脸检测(detection)**:找出画面里哪里有脸(矩形框)。传统方案
|
||||
`cv::CascadeClassifier`(Haar),现代方案 DNN(如 YuNet / ResNet-SSD)。
|
||||
2. **人脸识别(recognition)**:判断这张脸是谁。用预训练模型
|
||||
(SFace/FaceNet/ArcFace 的 ONNX)把人脸编码成 128/512 维特征向量,
|
||||
与注册库中向量算余弦相似度,超过阈值即判定为该人。
|
||||
- 识别不是训练神经网络。模型是下载来的 `.onnx`/`.xml` 文件,
|
||||
用 `cv::dnn::readNet` 或 `cv::FaceRecognizerSF` 加载。在想"要不要自己训练模型"
|
||||
的同学可以打住:那不是本题要求,实训周期内也做不完。
|
||||
- 题面明说"可选只通过图片识别",这是官方给的降级出口。摄像头/实时流
|
||||
做不下来时,"导入图片→检测→识别→标注展示"的静态版本仍是一个完整、
|
||||
可以及格的系统。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 0(第一关):环境。** OpenCV 官网给 Windows 的预编译包是 MSVC 编译的,
|
||||
MinGW 版 Qt 链不上(C++ ABI 不兼容,症状是链接期大量 `undefined reference`)。
|
||||
三条出路,按推荐排序:
|
||||
|
||||
1. Qt 用 MSVC kit(装 Visual Studio Community / Build Tools,Qt 安装时勾选
|
||||
MSVC 组件),直接用官方 OpenCV 包,最省事;
|
||||
2. 坚持 MinGW,就要用 CMake + 同版本 MinGW 自己编译 OpenCV(预留一个晚上);
|
||||
3. Linux/WSL:`apt install libopencv-dev`,基本没有这个问题。
|
||||
|
||||
第一周内跑不通"读一张图并显示",就应该向老师求助或换项目。环境是本题
|
||||
最大的淘汰因素,及时求助不丢人。
|
||||
|
||||
**难点 1:`cv::Mat` → `QImage` 的三个常见错误。**
|
||||
|
||||
- 通道序:OpenCV 是 BGR,Qt 是 RGB。直接
|
||||
`QImage(mat.data, w, h, QImage::Format_RGB888)` 得到的画面偏蓝。
|
||||
用 `QImage::Format_BGR888`(Qt 5.14 起可用),或先 `cv::cvtColor(BGR2RGB)`。
|
||||
- 行对齐:`QImage` 构造必须显式传 `mat.step` 作 bytesPerLine,
|
||||
否则某些分辨率下图像出现斜纹/错位(Mat 行末可能有 padding)。
|
||||
- 生命周期:这种构造是浅拷贝,`QImage` 借用 Mat 的内存。Mat 先析构
|
||||
(比如函数局部变量)就会花屏或崩溃。跨函数传递前 `.copy()` 一次。
|
||||
|
||||
三个问题合起来,建议直接采用这个写法:
|
||||
|
||||
```cpp
|
||||
QImage matToQImage(const cv::Mat &bgr) {
|
||||
return QImage(bgr.data, bgr.cols, bgr.rows,
|
||||
static_cast<int>(bgr.step),
|
||||
QImage::Format_BGR888).copy(); // copy() 断开内存借用
|
||||
}
|
||||
```
|
||||
|
||||
**难点 2:视频流驱动方式。** 入门版:`QTimer` 每 33ms 触发
|
||||
"`cap.read()` → 检测 → 转 QImage → `label->setPixmap()`"。能跑,但检测一慢
|
||||
(DNN 单帧可能超过 100ms)整个 UI 就冻住。正式版:把 `VideoCapture` + 检测识别
|
||||
放进 `QThread` 工作线程,处理完一帧发 `frameReady(QImage)` 信号回主线程
|
||||
(QImage 隐式共享,跨线程信号传值安全)。注意子线程里不能直接操作任何 QWidget。
|
||||
建议先用 QTimer 版打通流程,再整体搬进线程,一步到位容易两头出错。
|
||||
|
||||
**难点 3:识别流水线的数据设计。** 注册(录入)与识别共用前半段:
|
||||
|
||||
```
|
||||
帧/图片 → 检测得人脸框 → 裁剪+对齐 → 特征提取模型 → 128/512维向量
|
||||
注册:向量 + 姓名 存库(多张照片可求平均向量,题面提示了)
|
||||
识别:向量与库中所有向量算余弦相似度 → 最高分且超阈值 → 判定身份
|
||||
```
|
||||
|
||||
数据库不必上 SQL:人数少时 `QVector<Person{name, QVector<float> feat}>` +
|
||||
JSON 持久化完全够用(题面也允许)。余弦相似度手写只有几行(点积/模长),
|
||||
阈值从 0.36(SFace 官方推荐值附近)起调。建议做一个滑块让阈值可调,
|
||||
演示时展示"调阈值看误识率变化",本身就是加分点。
|
||||
|
||||
**难点 4:级联检测器文件路径。** `CascadeClassifier::load("haarcascade_frontalface_default.xml")`
|
||||
返回 false 不抛异常,不检查返回值的话,程序照常运行但永远检测不到脸。
|
||||
xml 在 OpenCV 安装目录 `etc/haarcascades/` 下,复制到程序目录并用绝对路径加载,
|
||||
load 后一定要检查返回值。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 链接期大量 `undefined reference to cv::...` | MinGW Qt 配 MSVC OpenCV | 见难点 0,换 MSVC kit 或自编译 |
|
||||
| 程序一启动就闪退 | 运行时找不到 opencv_worldXXX.dll | 把 dll 拷到 exe 旁,或把 OpenCV bin 目录加进 PATH |
|
||||
| 画面人脸偏蓝 | BGR/RGB 通道序 | `Format_BGR888` 或 cvtColor |
|
||||
| 图像斜纹/花屏/随机崩溃 | 没传 `mat.step` / QImage 借用已析构的 Mat | 用上面 matToQImage 的写法 |
|
||||
| 摄像头打不开 | 索引不对 / 被其它程序占用 / 权限 | `VideoCapture cap(0)` 换 1、2 试;检查 `cap.isOpened()` |
|
||||
| 检测一直没结果 | 级联 xml 没加载成功 | 检查 `load()` 返回值 |
|
||||
| 检测有框但识别乱认 | 没做人脸对齐/裁剪就送识别模型;阈值太松 | 用检测框裁剪(DNN 方案用 `FaceRecognizerSF::alignCrop`);调高阈值 |
|
||||
| 一开检测 UI 就明显卡顿 | 检测在主线程 | 降低检测频率(每 3 帧检测 1 次,框沿用上一次)或移到工作线程 |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(第一周内必须完成)**:环境验证——Qt 程序里用 `cv::imread` 读一张图、
|
||||
转 QImage 显示在 QLabel。这是本题第一个必须通过的节点。
|
||||
2. **M2**:QTimer + VideoCapture 实时显示摄像头画面(不检测)。
|
||||
3. **M3**:加 Haar 级联检测,画面上实时画人脸框(`cv::rectangle` 或转 QPixmap 后
|
||||
QPainter 画)。
|
||||
4. **M4**:接入特征提取模型(推荐 OpenCV 4.5.4+ 自带的 YuNet 检测 + SFace 识别,
|
||||
API 最顺);做注册界面:拍照/导入 → 提特征 → 存 JSON。
|
||||
5. **M5**:实时比对 + 画面标注姓名/相似度 + 识别日志列表。到此即完整达标。
|
||||
6. **M6(加分,量力)**:多张照片平均特征、阈值滑块、识别事件抓拍存盘。
|
||||
跟踪/活体检测等题面"提升"项投入大、产出小,谨慎选择。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- OpenCV 用 4.x(推荐 ≥4.5.4,才有 `FaceDetectorYN`/`FaceRecognizerSF`)。
|
||||
网上大量教程是 3.x 的 `cv::face::LBPHFaceRecognizer`(在 contrib 模块里,
|
||||
官方预编译包不含),照抄编译不过,而且 LBPH 效果也远不如 DNN 方案。
|
||||
- `QImage::Format_BGR888` 需要 Qt ≥ 5.14,课程环境刚好够用;更老的 Qt 只能 cvtColor。
|
||||
|
||||
---
|
||||
|
||||
## 02. 基于 QT 的自动售卖机 ⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
题面是售卖机,考的其实是软件设计。没有硬技术门槛(不需要多媒体、不需要
|
||||
第三方库),看的是状态机建模、Model/View 数据流、并发保护、JSON 持久化
|
||||
四个工程概念用得对不对。它是六个项目里最不容易被环境卡住的选择;
|
||||
全用 if-else 也能跑起来,但设计分就没有了。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 核心是用状态机管交易流程:`待机 → 已投币 → 已选商品 → 出货中 → 找零中 → 完成`。
|
||||
题面点名 `QStateMachine`。关键在于:"当前允许做什么"由状态决定,而不是由
|
||||
散落各处的 if 判断决定——待机态投币按钮可用、选货按钮禁用;已投币态反之。
|
||||
评分时首先看的就是这一点。
|
||||
- 第二个重心是数据与界面分离:商品/库存/零钱集中在一个数据管理类
|
||||
(单例或注入均可),前台购买界面和后台管理界面共享同一个模型——
|
||||
前台买走一瓶,后台表格里的库存立刻变。这是 Model/View 真正的考点:
|
||||
一份 `QAbstractTableModel`,两处视图。
|
||||
- `QMutex` 那条要求,本质是要求把"扣库存+扣零钱"做成一个原子操作:
|
||||
`deductStock()` 里 `QMutexLocker locker(&mutex);` 检查+扣减一起完成。
|
||||
加分做法:开一个 `QThread` 模拟并发购买,演示"不加锁会超卖、加锁不会"。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:QStateMachine 是事件驱动异步的,和直觉不同。**
|
||||
|
||||
- `machine.start()` 之后状态并不立即进入,要等事件循环跑起来。
|
||||
start 后下一行断言当前状态,必然失败。
|
||||
- 状态迁移用 `state->addTransition(sender, &Sender::signal, targetState)` 声明,
|
||||
UI 更新挂在 `QState::entered`/`exited` 信号上,或用
|
||||
`state->assignProperty(widget, "enabled", true)` 让状态机自动改属性。
|
||||
这套接对了,代码里基本不需要手写"if 当前状态是 X"。
|
||||
|
||||
**难点 2:带条件的迁移(守卫条件)没有现成 API。** "投币额 ≥ 商品价才允许出货"
|
||||
这种条件迁移,Qt 的标准做法是自定义 Transition:继承 `QSignalTransition`
|
||||
重写 `eventTest()`,不满足条件返回 false(迁移不发生)。嫌重的话有个务实变通:
|
||||
在槽里判断条件、满足才 `emit sufficientPayment()`,让状态机只监听这个信号。
|
||||
两种都算对,后者更容易讲清楚。这是本题最隐蔽的设计问题,动手前先想好,
|
||||
不要写到一半才发现 addTransition 不支持带条件,回头改架构。
|
||||
|
||||
**难点 3:找零算法的正确性边界。** 贪心(从大面额往小配)对常规币制
|
||||
{50,20,10,5,1} 是最优的,但零钱库存有限时贪心会失败:库存没有 5 元、有 3 张
|
||||
2 元时找 6 元,贪心先拿 5 就走进死路。及格线:贪心 + 失败时整单退币(题面明确
|
||||
允许"无法找零则退币");加分线:库存受限的找零本质是完全背包可行性问题,
|
||||
用 DP 或回溯保证"能找开就一定找得开"。答辩时能说清贪心什么时候会错,
|
||||
比闷头写 DP 更有价值。
|
||||
|
||||
**难点 4:持久化的时机与原子性。** "数据变更时自动保存"不要真的每次变更都
|
||||
全量写盘(快速连点可能写出半截 JSON)。简单可靠的做法:变更只置脏标记 +
|
||||
`QTimer` 500ms 防抖落盘;写盘用 `QSaveFile` 替代 `QFile`(写完才原子替换,
|
||||
崩溃不损坏存档)。`QSaveFile` 知道的学生不多,用上可以作为一个细节亮点。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| `machine.start()` 后状态没进入 | 状态机异步启动 | 初始化逻辑挂到 `initialState` 的 `entered` 信号,不要 start 后同步读状态 |
|
||||
| 迁移不触发 | 信号签名不匹配 / 当前不在源状态 | 迁移只在源状态激活时监听;连 `QState::entered` 打日志看状态流 |
|
||||
| 忘了 `setInitialState` | 状态机启动即静默停止 | `machine.setErrorState`/日志;new 完状态第一件事设初始态 |
|
||||
| 后台改库存前台不动 | 两界面各持一份数据副本 | 共享同一个 model 实例;改数据走 `setData` 或发 `dataChanged` |
|
||||
| 表格改了数字界面不刷 | 自定义 model 改数据没 emit `dataChanged` | 所有修改路径统一收口到一个函数,里面负责 emit |
|
||||
| 偶发库存变负数 | 检查与扣减分离,中间被插队 | "检查+扣减"放进同一个 `QMutexLocker` 作用域 |
|
||||
| JSON 读回来全是 0 | `QJsonObject` 取值类型不对(如 `toInt()` 用在 double 上)/ 键名拼写 | 先 `qDebug() << doc.toJson()` 核对;封装 load/save 对称函数 |
|
||||
| 程序异常退出后存档损坏 | 直接 QFile 覆盖写 | 换 `QSaveFile` |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(1 天)**:不带界面,先写 `VendingData` 数据类(商品表、零钱表、
|
||||
加锁的 deduct/refund 接口)+ JSON load/save,用 qDebug 自测。数据层立住,
|
||||
后面基本是接线工作。
|
||||
2. **M2(1 天)**:前台购买界面(商品按钮阵列 + 投币按钮 + 信息提示区),
|
||||
先用普通槽函数把"投币→选货→出货→找零"跑通(暂时用 if-else 没关系)。
|
||||
3. **M3(1–2 天)**:引入 QStateMachine 重构 M2 的流程控制——按钮使能交给
|
||||
`assignProperty`,流程迁移交给 transition。先能跑再重构,比一步到位稳。
|
||||
4. **M4(1 天)**:管理员后台(密码 QDialog + QTableView 库存表,共享模型)+
|
||||
售罄置灰、无法找零拦截两条异常流。
|
||||
5. **M5(加分)**:交易流水 + 报表(QSortFilterProxyModel 排序即可,QtCharts 可选)、
|
||||
多线程购买压测演示、折扣规则引擎。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- Qt6 中 `QStateMachine` 被移出 QtCore,进了独立的 statemachine 模块
|
||||
(`QT += statemachine` / `find_package(Qt6 COMPONENTS StateMachine)`)。
|
||||
Qt 5.14 下在 QtCore 里直接用,无需额外配置。
|
||||
- QtCharts 在 Qt 5.14 是独立模块(`QT += charts`,且要在安装器里勾选过)。
|
||||
没装的话报表用 QTableView 呈现即可,不必为一张柱状图重装 Qt。
|
||||
|
||||
---
|
||||
|
||||
## 05. 个人相册管理与智能标签应用 ⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
一道性能敏感的 Model/View 大题:核心挑战不是显示图片(一行 setPixmap 的事),
|
||||
而是几百上千张照片下界面依然流畅——缩略图异步加载、元数据索引、组合筛选。
|
||||
"智能标签"名字唬人,实际是可裁剪的加分层:题目允许从"手动标签 + 搜索"
|
||||
起步,ONNX 智能识别放在最后做。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 本题真正的关键是索引:把图片路径、拍摄日期、评分、标签、收藏这些
|
||||
元数据放进一个独立于文件系统的库(SQLite 或 JSON 起步),所有浏览、搜索、
|
||||
筛选都查库,不实时扫磁盘。想通这一点,"虚拟相册"(同一张图归入多个相册
|
||||
而不复制文件)、组合筛选、倒排索引都是顺理成章的查询而已。
|
||||
- 第二个关键是缩略图网格的性能。评分演示时老师多半会导入一大批照片,
|
||||
滚动一卡,前面做得再多也减分。这是本题技术含量最高的部分(见难点 1)。
|
||||
- 智能标签的合理做法:先把手动标签的完整链路做穿(加标签→存库→按标签搜索),
|
||||
智能标签只是标签的另一个生产者——接 ONNX 后生成的标签走完全相同的
|
||||
存储/搜索链路。架构对了,"智能"是插件;架构不对,"智能"意味着重写。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:缩略图异步加载。** 直观的写法:遍历文件夹,每张
|
||||
`QPixmap(path).scaled(...)` 塞进 `QListWidget`。一张 12MP 的 JPEG 解码要
|
||||
几十上百毫秒,100 张照片主线程就冻结十几秒。正确做法分三层:
|
||||
|
||||
1. 解码离开主线程:用 `QThreadPool` + `QRunnable`(或 `QtConcurrent::run`)
|
||||
在后台解码并缩放,完成后发信号把缩略图(QImage,跨线程传值安全)交回主线程。
|
||||
注意 QPixmap 不能在非 GUI 线程创建,后台一律用 QImage,回主线程再转。
|
||||
2. 读小不读大:`QImageReader reader(path); reader.setScaledSize(QSize(200,200));`
|
||||
——JPEG 可以按比例降采样解码,比"解全图再 scaled"快一个数量级、省数倍内存。
|
||||
这是本题最值得记住的一个 API。
|
||||
3. 缩略图磁盘缓存:首次生成后存到缓存目录(文件名用路径哈希),
|
||||
第二次打开应用直接读缓存。加分项。
|
||||
|
||||
视图层用 `QListView::IconMode` + 自定义 model(配合按需供数),
|
||||
不要用 `QListWidget` 一次性塞几千个 item。
|
||||
|
||||
**难点 2:SQLite 表设计(标签是多对多)。** 三张表就够:
|
||||
`photos(id, path, date, rating, favorite)`、`tags(id, name)`、
|
||||
`photo_tags(photo_id, tag_id)`。"按标签找图"就是一个 JOIN,这本身就是
|
||||
题面"倒排索引"的数据库实现,答辩时可以直接这样讲。组合筛选(日期范围 +
|
||||
评分 + 标签)就是动态拼 WHERE 子句。不想上 QtSql 的同学用 JSON + 内存
|
||||
`QHash<QString标签, QSet<int>图片id>` 也能达标,但数据量大后启动变慢。
|
||||
|
||||
**难点 3:ONNX Runtime 集成(可选层,放最后做)。** 流程:下载 squeezenet /
|
||||
mobilenet 分类 ONNX 模型 → C++ 里 `Ort::Session` 加载 → 图片缩放到 224×224、
|
||||
归一化成 float tensor → 推理 → top-5 类别按 ImageNet 标签表映射成中文标签存库。
|
||||
坑主要在预处理:通道序(NCHW vs NHWC)、归一化均值方差要和模型训练时一致,
|
||||
不一致不报错但输出全错。人脸分组(聚类)工作量再上一个台阶,
|
||||
建议只有已完成全部基础功能、确有余力的同学做。
|
||||
|
||||
**难点 4:非破坏性编辑 + 撤销。** 题面点了命令模式:每个编辑操作
|
||||
(旋转/裁剪/调色)封装成命令对象压栈,undo 出栈反做。Qt 有现成的
|
||||
`QUndoStack`/`QUndoCommand`,不必自己实现栈管理。"非破坏性"最省事的实现:
|
||||
不覆盖原图,编辑结果另存副本,或只存操作参数列表、显示时重放。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 导入文件夹时界面冻死 | 主线程同步解码大图 | QImageReader::setScaledSize + 线程池,见难点 1 |
|
||||
| 后台线程崩溃:`QPixmap: It is not safe...` | 子线程创建了 QPixmap | 子线程只用 QImage |
|
||||
| 内存涨到几个 GB | 缓存了全尺寸 QImage | 只留缩略图;看大图时才解码全图,且用 `QPixmapCache` 限额 |
|
||||
| 部分照片没有拍摄日期 | 截图/微信图片本无 EXIF | 降级链:EXIF DateTimeOriginal → 文件修改时间;Qt 自带 EXIF 读取很弱,完整 EXIF 建议引入 TinyEXIF/easyexif(单头文件) |
|
||||
| 手机竖拍照片横过来了 | 没处理 EXIF Orientation | `QImageReader::setAutoTransform(true)`,一行解决 |
|
||||
| 搜索一次卡一秒 | 每次搜索全量扫描 | 元数据进 SQLite 建索引,或内存哈希 |
|
||||
| 删除照片后列表错乱 | 直接操作文件没同步库 | 所有增删走数据层统一入口,UI 只发请求 |
|
||||
| 中文路径图片读不出 | 用了 `std::string`/`fopen` 链路 | 全程用 QString/QFile;给 OpenCV/stb 传路径时注意 UTF-8 转换 |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(1 天)**:选文件夹 → 列出所有图片文件 → 同步加载 20 张的缩略图网格 +
|
||||
双击全屏看图(先不管性能,打通视觉闭环)。
|
||||
2. **M2(1–2 天)**:把 M1 的加载改成 QImageReader 降采样 + 线程池异步。
|
||||
验收标准:导入 500 张照片,滚动不卡。这是本题技术难度最高的一步,值得花足时间。
|
||||
3. **M3(1 天)**:元数据层——SQLite/JSON 三表结构,手动标签、星级、收藏的
|
||||
增删改查 + 持久化。
|
||||
4. **M4(1 天)**:搜索框(文件名/标签实时过滤)+ 组合筛选面板(日期范围/评分/
|
||||
收藏)。到此为完整达标的相册管理器。
|
||||
5. **M5(加分)**:虚拟相册、旋转/裁剪/亮度对比度编辑 + QUndoStack 撤销、
|
||||
缩略图磁盘缓存。
|
||||
6. **M6(大加分,量力)**:ONNX 自动标签;人脸检测分组(可参考项目 11 的
|
||||
OpenCV 部分,环境成本也相同)。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- `QImageReader::setAutoTransform` 在 Qt5 默认 false(Qt6 默认 true)。
|
||||
课程环境下不手动打开,竖拍照片就会横着显示。
|
||||
- QtSql 的 SQLite 驱动随 Qt 发行版自带,无需另装 SQLite;MySQL 等驱动不自带,
|
||||
不要把时间花在配数据库服务上,本题 SQLite 文件库就够了。
|
||||
|
||||
---
|
||||
|
||||
## 06. 多功能抽奖与随机点名程序 ⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
六个里唯一的四星题,功能面窄,但考正确性:随机算法是否真的公平、
|
||||
不重复抽取是否真的不重复、权重抽奖概率是否真的和权重成正比。
|
||||
适合想稳妥完成、把精力花在细节上的同学。注意题面的算法项
|
||||
(Fisher-Yates、轮盘赌)是答辩必问点,只写一个 `rand()%n` 过不了关。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- UI 上的"滚动"只是演出,抽取结果在点"开始"那一刻就该定了。合理的架构:
|
||||
按下开始 → 先用算法抽出结果 → QTimer 驱动名字快速轮播制造悬念 →
|
||||
按下停止 → 动画减速定格到预定结果。让"停止那一刻的随机数"决定中奖者,
|
||||
公平性就依赖用户手速,答辩说不清。这一条想通了,逻辑层(抽取)和
|
||||
表现层(动画)自然就分开了。
|
||||
- 三个算法是题目的核心,各 20 行以内,务必自己写、写到能白板复现:
|
||||
1. Fisher-Yates 洗牌(从 N 人不重复抽 M 人:洗牌取前 M);
|
||||
2. 轮盘赌选择(权重抽奖:随机数落在累计权重区间上);
|
||||
3. 随机数源:`QRandomGenerator::global()->bounded(n)`,自动用系统熵播种。
|
||||
`rand()` 不 srand 的话每次运行结果相同,演示时会当场暴露。
|
||||
- 名单管理(增删改、txt/CSV 导入、持久化)是体力活,但中文编码有一个
|
||||
几乎必踩的坑,见下。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:CSV/文本导入的编码。** Windows Excel 导出的 CSV 默认 GBK/ANSI
|
||||
编码,`QTextStream` 按默认设置读(Qt5 是 Locale,而记事本另存的 UTF-8 又带
|
||||
BOM),中文名很容易乱码。稳妥做法:读入字节后探测——有 UTF-8 BOM 按 UTF-8,
|
||||
否则先按 UTF-8 严格解码、失败再退 GBK(`QTextCodec::codecForName("GBK")`)。
|
||||
另外 CSV 字段可能带引号包裹和内嵌逗号,课程场景用 `line.split(',')` 可以接受,
|
||||
但至少要 `trimmed()` 并跳过空行。(xlsx 是压缩包格式,Qt 原生读不了,
|
||||
题面说"CSV 或 xlsx",选 CSV 即可,不必为 xlsx 引第三方库。)
|
||||
|
||||
**难点 2:滚动动画的两种实现,选简单的那种。** 题面提示了 QPropertyAnimation,
|
||||
但对"名字快速切换"这个效果,QTimer + 每 tick 换 QLabel 文本才是对口工具
|
||||
(30–50ms 一换足够快)。QPropertyAnimation 适合做结果揭晓的放大/滑入动画
|
||||
(配合 `QGraphicsOpacityEffect` 或对 geometry 做动画)。"减速定格"用变周期
|
||||
QTimer:每次 tick 后 `interval *= 1.15`,超过 300ms 时停在预定结果,
|
||||
二十行代码就有不错的现场效果。
|
||||
|
||||
**难点 3:不重复抽取的状态一致性。** "抽过的人从池子里移除"说起来简单,
|
||||
坑在多处状态要同步:候选池、已中奖列表、UI 上两个列表控件。要先定好
|
||||
数据以谁为准:数据层一个 `QList<Person>` + 一个 `QSet<int> drawnIds`,
|
||||
两个视图都从它渲染。再叠加"中途重置""切换可重复模式""删除已中奖的人"
|
||||
这些操作时才不会乱。先想清数据结构,UI 只是它的投影。
|
||||
|
||||
**难点 4:权重抽奖 + 不重复的组合。** 各自单独都好写,组合时注意:抽走一人后
|
||||
总权重要重算(或轮盘赌遍历时跳过已中者)。验证方法值得写进报告:
|
||||
循环抽 10 万次统计频率,和权重比例对照,这个小实验就是公平性的答辩证据。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 导入名单中文乱码 | Excel CSV 是 GBK | 编码探测降级链,见难点 1 |
|
||||
| 每次运行抽中的人一样 | `rand()` 没播种 | 换 `QRandomGenerator::global()` |
|
||||
| "不重复"偶尔重复 | 从 UI 控件读名单,删除项后索引错位 | 状态收口到数据层,UI 只渲染 |
|
||||
| 滚动动画卡顿 | tick 里做了重活(如重排全列表) | tick 只改一个 QLabel 文本 |
|
||||
| 多人抽取结果有重复 | 循环 M 次独立随机 | Fisher-Yates 洗牌取前 M |
|
||||
| 全屏后布局错乱 | 固定坐标写死 | 全程用布局管理器;全屏用 `showFullScreen()`,记得处理 Esc 退出 |
|
||||
| 音效不响 | 路径/模块问题 | `QSoundEffect` 只支持 WAV,不解码 MP3;`QT += multimedia`;资源走 qrc |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(半天)**:控制台级验证——名单硬编码,Fisher-Yates 抽 M 人、轮盘赌
|
||||
带权抽 1 人,qDebug 打印 + 万次频率统计自检。算法先于界面。
|
||||
2. **M2(1 天)**:主界面:名单列表(增删改)+ txt/CSV 导入(含编码处理)+
|
||||
JSON 持久化。
|
||||
3. **M3(1 天)**:单人抽取全流程:开始滚动 → 停止减速定格 → 结果进
|
||||
"已中奖"列表;可重复/不可重复开关。
|
||||
4. **M4(半天)**:多人抽取模式 + 中奖记录导出 txt + 抽奖/点名模式切换文案。
|
||||
5. **M5(加分)**:权重列 + 轮盘赌抽奖、分组抽奖、结果揭晓 QPropertyAnimation
|
||||
动画、背景音乐音效、全屏模式、自定义背景图。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- `QTextCodec` 在 Qt6 移入 core5compat 兼容模块(Qt6 正统是
|
||||
`QStringConverter`/`QStringDecoder`)。课程环境 Qt 5.14 直接用 QTextCodec 即可。
|
||||
- `QRandomGenerator` 需要 Qt ≥ 5.10,课程环境没问题;教程里出现
|
||||
`qsrand/qrand`(已废弃)的可以跳过不看。
|
||||
|
||||
---
|
||||
|
||||
## 08. 双人飞机大战 ⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
一道实时程序架构题:游戏循环、大量对象的生命周期管理、键盘多键并发、
|
||||
碰撞检测,和前五个事件驱动的表单程序是完全不同的思维方式。
|
||||
它是六个里代码量最大、最容易烂尾的:功能清单(双人、三模式、道具、
|
||||
BOSS、弹幕)是开放的,没有里程碑约束很容易把时间耗在素材和关卡设计上。
|
||||
另一面是演示效果最好,适合愿意投入的同学。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 先选定框架路线,两条路二选一,不要混:
|
||||
- **路线 A:QGraphicsView 框架**(题面推荐)——`QGraphicsScene` 管对象、
|
||||
`QGraphicsItem` 派生飞机/子弹、场景自带 `collidingItems()` 碰撞检测。
|
||||
省很多底层代码,但要理解 item 坐标系和场景坐标系的区别。
|
||||
- **路线 B:QWidget + QPainter 手绘**——一个大 widget,`paintEvent` 里画所有
|
||||
对象,自己维护对象列表和矩形碰撞。原始一些,但每一步都在自己掌控里,
|
||||
配套教材 ch10 的绘图知识直接够用。
|
||||
- 建议:跟着题面走 A;如果 QGraphicsView 两天内没找到感觉,及时退到 B。
|
||||
B 路线做完,好过 A 路线做一半。
|
||||
- 游戏循环是全部逻辑的驱动源:一个 `QTimer`(16ms ≈ 60FPS)驱动每帧
|
||||
`处理输入 → 移动所有对象 → 生成敌机/子弹 → 碰撞检测 → 清理死亡对象 → 重绘`。
|
||||
所有会动的东西都由这一个 timer 统一驱动。给每个子弹/敌机各开一个 QTimer
|
||||
是常见错误,几百个 timer 会把事件循环拖垮,而且相位不齐。
|
||||
- 对象生命周期是本题隐含的 C++ 考点:子弹飞出屏幕、敌机被击落,都要从
|
||||
场景移除并 delete。忘删,内存与遍历量持续膨胀,越玩越卡;
|
||||
删早了(还在容器遍历中),程序崩溃。见难点 3。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:键盘多键同时按(双人模式的关键)。** 直接在 `keyPressEvent` 里
|
||||
`player.move()` 有两个问题:一是依赖系统键盘重复,第一下和连发之间有停顿,
|
||||
手感卡;二是玩家1 按住 W 时玩家2 再按方向键,系统键盘重复只服务最后按下的键,
|
||||
另一个玩家会直接失控。标准解法是按键状态表 + 游戏循环采样:
|
||||
|
||||
```cpp
|
||||
QSet<int> pressedKeys;
|
||||
void keyPressEvent(QKeyEvent *e) override {
|
||||
if (e->isAutoRepeat()) return; // 屏蔽系统自动重复
|
||||
pressedKeys.insert(e->key());
|
||||
}
|
||||
void keyReleaseEvent(QKeyEvent *e) override {
|
||||
if (e->isAutoRepeat()) return;
|
||||
pressedKeys.remove(e->key());
|
||||
}
|
||||
// 游戏循环每帧:
|
||||
if (pressedKeys.contains(Qt::Key_W)) p1.moveUp();
|
||||
if (pressedKeys.contains(Qt::Key_Up)) p2.moveUp(); // 互不干扰,天然支持斜向
|
||||
```
|
||||
|
||||
双人模式能不能玩取决于这一段,建议一开始就按这个写法来。另外游戏主
|
||||
widget 要 `setFocusPolicy(Qt::StrongFocus)`,否则按键事件不会分发到它。
|
||||
|
||||
**难点 2:碰撞检测的量级与精度。**
|
||||
|
||||
- 起步:矩形相交(`QRect::intersects` 或 QGraphicsView 的 `collidingItems`)
|
||||
完全够用。QGraphicsItem 默认按 `boundingRect`+`shape()` 判定;飞机贴图
|
||||
四角是透明的,会出现"看着没碰到就爆了"——重写 `shape()` 返回缩小的
|
||||
`QPainterPath`,或直接把判定框缩成机身核心(弹幕游戏的通行做法,
|
||||
玩家只会觉得手感变好)。
|
||||
- 量级:每帧"所有子弹 × 所有敌机"是 O(N²),课程规模(500 个对象以内)
|
||||
暴力检测没有问题——先暴力,卡了再优化。题面的四叉树是加分项,报告里
|
||||
给出"对象数-帧耗时"对比数据,比只谈复杂度有说服力。
|
||||
- 帧率稳定性:`QTimer` 精度默认约 15ms 且会漂移,位移量写死"每帧 5 像素"
|
||||
在卡顿时会变慢。进阶写法:用 `QElapsedTimer` 测真实帧间隔 dt,
|
||||
位移 = 速度 × dt。及格线不强求,答辩能说出这个问题在哪就是加分。
|
||||
|
||||
**难点 3:容器遍历中删除对象。** 每帧要清理出界子弹/死亡敌机,
|
||||
`for (auto *b : bullets) { if (出界) { bullets.removeOne(b); delete b; } }`
|
||||
是遍历中修改容器,未定义行为,迟早崩溃。两个安全做法:
|
||||
一,标记-清扫:遍历只打 `dead` 标记,遍历结束后统一
|
||||
`removeIf + delete`(或 `QMutableListIterator`);
|
||||
二,碰撞回调里不直接 delete 对方,只标记。另外 QGraphicsScene 删 item
|
||||
要 `scene->removeItem(item); delete item;`,只 delete 的话场景还留着悬空指针。
|
||||
这是本题最常见的崩溃原因,写清理代码前先把这段看明白。
|
||||
|
||||
**难点 4:对象设计与生成节奏。** 建一个基类(如 `Entity`:位置/速度/血量/
|
||||
`advance()`/`rect()`),玩家机、敌机、子弹、道具全部派生。多态在这里不是
|
||||
为了展示技巧,是实际需要:游戏循环只面对 `QList<Entity*>`。敌机生成用
|
||||
"生成计时器 + 关卡脚本"(第几秒出什么阵型),数据驱动(写在表/JSON 里)
|
||||
比硬编码 if 好调平衡。关于素材:飞机/子弹贴图用开源素材包(如 Kenney.nl
|
||||
免费素材),自己画像素图非常耗时;实在没素材,纯色几何图形(QPainter
|
||||
画三角形飞机)完全不影响技术评分。
|
||||
|
||||
**难点 5:双人对战模式的裁剪。** 题面三模式(单人/合作/对战)全做工作量很大。
|
||||
架构上留好口子即可:碰撞规则表里"玩家子弹是否伤害另一玩家"是一个 bool,
|
||||
合作为 false、对战为 true——对战模式本质是碰撞规则的一次配置变更。
|
||||
想通这层,第三模式的边际成本很低;想不通就先做单人+合作。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 按方向键飞机不动 | 焦点不在游戏 widget 上 | `setFocusPolicy(Qt::StrongFocus)`;场景/视图结构下注意谁拿焦点 |
|
||||
| 移动一顿一顿 / 双人互相抢键 | 直接在 keyPressEvent 移动 | 按键状态表 + 循环采样,见难点 1 |
|
||||
| 玩一会儿越来越卡 | 出界子弹没清理,对象只增不减 | 每帧清理;调试时在窗口标题实时显示对象数,方便发现泄漏 |
|
||||
| 随机崩溃在碰撞/清理附近 | 遍历中删除 / 双重 delete | 标记-清扫,见难点 3 |
|
||||
| 子弹出生位置不对 | item 坐标系与场景坐标系混淆 | 生成子弹用 `mapToScene()`/`item->scenePos()` 换算 |
|
||||
| 看着没碰到就判死 | 判定框是含透明边的整图矩形 | 重写 `shape()` 或缩小判定矩形 |
|
||||
| 背景滚动有裂缝 | 两张背景图衔接坐标差 1 像素 / 非整数位移 | 两张图首尾相接循环复位;位移取整 |
|
||||
| 全屏炸弹一放帧率骤降 | 一帧内 delete 几百对象 + 特效 | 分帧清理,或炸弹只做标记、由正常清理流程消化 |
|
||||
| CPU 占一核 100% | timer 间隔设 0 或忙循环 | 16ms 间隔足够;`QGraphicsView` 的 `setViewportUpdateMode` 用默认值即可 |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(1 天)**:窗口 + 游戏循环 timer + 一架能被 WASD 流畅操控的飞机
|
||||
(按键状态表一步到位)。这一步的手感决定整个项目的手感。
|
||||
2. **M2(1 天)**:玩家子弹(按住 J 连发、出界清理)+ 敌机从顶部随机生成
|
||||
直线下落 + 子弹打敌机的碰撞与爆炸消失。到这里游戏已经成立。
|
||||
3. **M3(1 天)**:敌机撞玩家/敌弹打玩家 → 生命值与无敌闪烁 → 得分显示 →
|
||||
Game Over 判定。加第二名玩家(方向键 + 小键盘0),合作模式成立。
|
||||
4. **M4(1 天)**:滚动背景、开始菜单/暂停/结束三场景切换(简单 FSM:
|
||||
一个 enum + QStackedWidget 或场景切换)、最高分持久化(QSettings)。
|
||||
5. **M5(加分)**:道具掉落、BOSS(多阶段血条 + 正弦/极坐标弹幕)、
|
||||
对战模式(碰撞规则开关)、音效(QSoundEffect + WAV)。
|
||||
6. 提醒一条执行纪律:M2 没完成前不做素材、不写 BOSS。本题烂尾的原因
|
||||
几乎都是顺序反了。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- 本题用到的 QGraphicsView/QPainter/事件系统在 Qt5/Qt6 间差异很小,
|
||||
是六题中版本风险最低的。注意音效走 `QSoundEffect`(只支持 WAV),
|
||||
背景音乐才用 QMediaPlayer——后者在 Qt6 的差异见项目 04 的版本说明。
|
||||
- 高分屏(Windows 缩放 150%)下坐标可能被缩放干扰,演示机若有此问题,
|
||||
入口处 `QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)`(Qt 5.14)
|
||||
或环境变量 `QT_SCALE_FACTOR=1` 兜底。
|
||||
|
||||
---
|
||||
|
||||
## 怎么选、怎么起步
|
||||
|
||||
- 想稳妥拿分、不被环境问题拖住:选 **02 售卖机** 或 **06 抽奖点名**。
|
||||
纯 Qt 标准模块,问题集中在设计和细节,本文的排查表能覆盖大部分。
|
||||
- 想做完自己还能用:选 **04 音乐播放器** 或 **05 相册管理**。
|
||||
多媒体和性能两个方向各有一个硬难点(异步元数据 / 异步缩略图),
|
||||
啃下来收获扎实。
|
||||
- 接受环境风险、想上技术高度:选 **11 面部识别**。第一周先过环境验证(M1),
|
||||
过不了及时降级到图片识别版或换题。
|
||||
- 看重答辩演示效果:选 **08 飞机大战**。用里程碑约束进度,M2 前不碰素材。
|
||||
|
||||
无论选哪个:第一周结束时必须有一个能运行的 M1。这批项目的失败方式高度一致:
|
||||
前两周画界面调样式,最后一周才发现核心链路(能出声/能链上库/状态机能转/
|
||||
键盘不卡)有硬问题。先做最难的部分。
|
||||
@@ -99,15 +99,47 @@ add_executable(qt_calculator_mainwindow_qss
|
||||
calculator_mainwindow_qss/mainwindow.cpp
|
||||
calculator_mainwindow_qss/calculatordesigner.cpp
|
||||
calculator_mainwindow_qss/expressionevaluator.cpp
|
||||
calculator_mainwindow_qss/widget.ui)
|
||||
calculator_mainwindow_qss/widget.ui
|
||||
calculator_mainwindow_qss/themes.qrc)
|
||||
target_include_directories(qt_calculator_mainwindow_qss PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/calculator_mainwindow_qss)
|
||||
set_target_properties(qt_calculator_mainwindow_qss PROPERTIES
|
||||
CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
|
||||
AUTOMOC ON AUTOUIC ON)
|
||||
AUTOMOC ON AUTOUIC ON AUTORCC ON)
|
||||
target_link_libraries(qt_calculator_mainwindow_qss PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_calculator_mainwindow_qss PRIVATE -Wall -Wextra)
|
||||
|
||||
# QDialog 教学综合示例(wiki「6 对话框 QDialog」pageId 183664881 的 6.2–6.5):
|
||||
# QWidget 布满 pushbutton,每个按钮演示一种 QDialog 行为。纯代码(无 .ui),
|
||||
# 单文件,走 add_teaching_example。
|
||||
add_teaching_example(qt_dialog_showcase qdialog_showcase)
|
||||
|
||||
# QLineEdit 自绘小眼睛 / echo mode 切换(Day4 上午:组件绘制 + 事件捕捉 + clear button 避让):
|
||||
# 子类化 QLineEdit,paintEvent 自绘眼睛、mouseEvent 命中切换 echoMode,与内置 clear button
|
||||
# 动态避让(读 objectName=="clearButton" 子 widget 几何)。子目录另有独立 CMakeLists.txt
|
||||
# 供单独打开(不走这里:顶层 add_subdirectory 不递归)。
|
||||
add_teaching_example(qt_lineedit_eye_toggle lineedit_eye_toggle)
|
||||
|
||||
# Day4 综合演示示例(事件机制 + QPainter 绘图 + 多线程):Mandelbrot 分形渲染器。
|
||||
# 三段式对比(主线程卡死 / 后台单线程 / 后台多线程),滚轮缩放 + 拖拽平移 + 分块并行。
|
||||
# 多源文件(无 .ui/.qrc),单独写 target;子目录另有独立 CMakeLists.txt 供单独打开
|
||||
# (不走这里:顶层 add_subdirectory 不递归),与 lineedit_eye_toggle 同模式。
|
||||
add_executable(qt_mandelbrot_renderer
|
||||
mandelbrot_renderer/main.cpp
|
||||
mandelbrot_renderer/mandelbrot_widget.cpp
|
||||
mandelbrot_renderer/mandelbrot_strip_task.cpp)
|
||||
target_include_directories(qt_mandelbrot_renderer PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/mandelbrot_renderer)
|
||||
set_target_properties(qt_mandelbrot_renderer PROPERTIES
|
||||
CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF AUTOMOC ON)
|
||||
target_link_libraries(qt_mandelbrot_renderer PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_mandelbrot_renderer PRIVATE -Wall -Wextra)
|
||||
|
||||
# Qt5 文件操作教学综合示例(QFile/QTextStream/QDataStream/QFileInfo/QDir/QTextCodec/QJson):
|
||||
# 一个 QWidget 布满按钮,5 个主题(基础IO/编码/文本vs二进制/异常处理/QJson),每个按钮
|
||||
# 产生一个文件落到 demo_output/(可执行文件同目录),方便课堂手动打开查看。纯代码单文件。
|
||||
add_teaching_example(qt_file_io_demo file_io_demo)
|
||||
|
||||
add_custom_target(all_teaching_examples DEPENDS
|
||||
qt_qss_styling
|
||||
qt_model_view_basics
|
||||
@@ -125,4 +157,8 @@ add_custom_target(all_teaching_examples DEPENDS
|
||||
qt_student_manager
|
||||
qt_mainwindow_showcase
|
||||
qt_calculator_mainwindow
|
||||
qt_calculator_mainwindow_qss)
|
||||
qt_calculator_mainwindow_qss
|
||||
qt_dialog_showcase
|
||||
qt_lineedit_eye_toggle
|
||||
qt_mandelbrot_renderer
|
||||
qt_file_io_demo)
|
||||
|
||||
@@ -5,38 +5,55 @@
|
||||
|
||||
## 定位:上午版 + QSS 美化
|
||||
|
||||
以 [`../calculator_mainwindow/`](../calculator_mainwindow/README.md)(Day3 上午版)为
|
||||
起点,**只加 QSS**:按键按功能分色、`:hover`/`:pressed` 视觉反馈、深/浅双主题切换。
|
||||
以 [`../calculator_mainwindow/`](../calculator_mainwindow/README.md)(Day3 上午版)为起点,
|
||||
**只加 QSS**:按键按功能分色、`:hover`/`:pressed` 视觉反馈、深/浅双主题切换。
|
||||
菜单/停靠窗/状态栏/计算逻辑与上午版完全一致——**QSS 与逻辑正交**,这正是 Day3 下午
|
||||
「美化」环节要传达的核心。
|
||||
|
||||
## 与上午版的差异(集中在 `mainwindow.{h,cpp}`)
|
||||
## 与上午版的差异(集中在 `mainwindow.{h,cpp}` + `themes/`)
|
||||
|
||||
| 改动 | 位置 | 内容 |
|
||||
|---|---|---|
|
||||
| 双主题 QSS 字符串 | `mainwindow.cpp` 顶部 `kLightQss` / `kDarkQss` | 按键分色 + 伪状态 |
|
||||
| 视图菜单「深色主题」 | `buildMenu()` | `checkable` QAction,`toggled` → `onToggleTheme` |
|
||||
| `applyStyle(bool dark)` | `mainwindow.cpp` | `qApp->setStyleSheet(...)` 全局切换 |
|
||||
| 自测加主题断言 | `runSelfTest()` | 验证切换后 `styleSheet()` 含对应主题标识 |
|
||||
| 双主题样式表(**外部 `.qss` 文件**) | `themes/light.qss`、`themes/dark.qss` | 按键分色 + 伪状态 |
|
||||
| 资源清单 | `themes.qrc`(`AUTORCC`) | 嵌入 `:/themes/light.qss`、`:/themes/dark.qss` |
|
||||
| 视图菜单「深色主题」 | `buildMenu()` | `checkable` QAction,`toggled → onToggleTheme` |
|
||||
| `applyStyle(bool dark)` | `mainwindow.cpp` | `QFile` 读 `.qss` → `qApp->setStyleSheet` 全局切换 |
|
||||
| 自测加主题断言 | `runSelfTest()` | 切换后 `styleSheet()` 含对应主题标识 |
|
||||
|
||||
`calculatordesigner.{h,cpp}` / `expressionevaluator.{h,cpp}` / `widget.ui` 与上午版
|
||||
完全一致(自包含复制)。
|
||||
`calculatordesigner.{h,cpp}` / `expressionevaluator.{h,cpp}` / `widget.ui` 与上午版完全一致。
|
||||
|
||||
## QSS 教学要点(对应 OUTLINE_4DAY.md Day3 下午)
|
||||
## QSS 最佳实践(本示例的刻意设计)
|
||||
|
||||
1. **ID 选择器认 objectName**:`QPushButton#btnEquals { ... }` 命中的前提是控件
|
||||
`setObjectName("btnEquals")`。本例的 objectName 在 `widget.ui` 里已定义
|
||||
(`<widget class="QPushButton" name="btnEquals">`),无需再 `setObjectName`——
|
||||
这是「样式写了没生效」头号原因(忘设 objectName)的反面教材。
|
||||
2. **按键分色用功能归类**:数字键(`btn0`–`btn9`,`btnDot`)浅蓝、运算键
|
||||
(`btnPlus/Minus/Mul/Div`)橙、等号(`btnEquals`)绿、括号灰、清空/退格红——
|
||||
用 ID 选择器分组,比给每个按钮单独写样式更易维护。
|
||||
3. **伪状态**:`:hover` / `:pressed` 不用手写鼠标事件就能做交互反馈。
|
||||
4. **`qApp->setStyleSheet` 全局 vs `widget->setStyleSheet` 局部**:本例用全局,
|
||||
一次切换全部生效。坑卡:全局样式会被 widget 级样式覆盖,混用易级联失控
|
||||
(OUTLINE_4DAY.md Day3 下午「QSS 级联失控」卡)。
|
||||
5. **主题切换不碰逻辑**:`applyStyle` 只改 `styleSheet`,不动任何业务代码——
|
||||
QSS 与逻辑正交的可维护性演示。
|
||||
1. **样式抽成外部 `.qss` 文件**(而非硬编码 C++ 字符串):`light.qss` / `dark.qss` 是纯样式,
|
||||
由 `themes.qrc` 编译进可执行文件,`mainwindow.cpp` 用 `QFile` 读取后 `qApp->setStyleSheet`
|
||||
应用。好处:
|
||||
- 设计师/非程序员可直接编辑 `.qss`(不用碰 C++);
|
||||
- 开发期改 `.qss` 重跑即生效(不必改 C++ 重编译);
|
||||
- 多主题物理分文件,编辑器对 `.qss` 有语法高亮。
|
||||
2. **ID 选择器认 objectName**:`QPushButton#btnEquals { ... }` 命中的前提是控件
|
||||
`setObjectName("btnEquals")`——本例 objectName 在 `widget.ui` 定义
|
||||
(`<widget class="QPushButton" name="btnEquals">`)。这是「样式写了没生效」头号原因
|
||||
(忘设 objectName)的反面教材。
|
||||
3. **按键分色用功能归类**(DRY):数字键(`btn0`–`btn9`,`btnDot`)浅蓝、运算键橙、等号绿、
|
||||
括号灰、清空退格红——用 ID 选择器分组,比给每个按钮单独写样式更易维护。
|
||||
4. **伪状态**:`:hover` / `:pressed` 不用手写鼠标事件就能做交互反馈。
|
||||
5. **`qApp->setStyleSheet` 全局 vs `widget->setStyleSheet` 局部**:本例用全局,一次切换全部
|
||||
生效。坑卡:全局样式会被 widget 级样式覆盖,混用易级联失控——故本例只用全局。
|
||||
6. **主题切换不碰逻辑**:`applyStyle` 只改 `styleSheet`,不动任何业务代码。
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
calculator_mainwindow_qss/
|
||||
├── mainwindow.{h,cpp} # 上午版 + applyStyle(loadQss) 主题切换
|
||||
├── calculatordesigner.{h,cpp} # 计算器面板(与上午版一致)
|
||||
├── expressionevaluator.{h,cpp} # 表达式求值(与上午版一致)
|
||||
├── widget.ui # 面板表单(objectName 在此定义)
|
||||
├── themes.qrc # 资源清单:嵌入两个 .qss
|
||||
├── themes/
|
||||
│ ├── light.qss # 浅色主题样式
|
||||
│ └── dark.qss # 深色主题样式
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 构建与运行
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QDockWidget>
|
||||
#include <QFile>
|
||||
#include <QKeySequence>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
@@ -19,84 +20,22 @@
|
||||
#include <QMessageBox>
|
||||
#include <QStatusBar>
|
||||
|
||||
// ---- QSS:按按键功能分色,ID 选择器精确定位(widget.ui 里已设好 objectName)----
|
||||
// 教学要点:QSS 选择器认的是 objectName——setObjectName("btnEquals") 之后才能用
|
||||
// #btnEquals{...}。本例的 objectName 在 widget.ui 里已定义,无需再 setObjectName。
|
||||
// ---- QSS:从外部 .qss 资源文件加载(themes/light.qss / themes/dark.qss)----
|
||||
// 教学要点(QSS 最佳实践):把样式抽成独立 .qss 文件用 QFile 读取,而非硬编码
|
||||
// C++ 字符串——① 样式与逻辑彻底分离(设计师可独立编辑 .qss);② 开发期改 .qss
|
||||
// 重跑即生效,不必改 C++ 重编译;③ 多主题物理分文件,编辑器对 .qss 有语法高亮。
|
||||
// 资源路径 :/themes/*.qss 由 themes.qrc(AUTORCC)编译进可执行文件。
|
||||
namespace {
|
||||
|
||||
// 浅色主题:数字=浅蓝、运算=橙、等号=绿、括号=灰、清空/退格=红、显示区=米黄
|
||||
const char* kLightQss = R"(
|
||||
QMainWindow { background: #f5f5f5; }
|
||||
QLineEdit#display, QTextEdit#expressionEdit {
|
||||
background: #fffbe6; color: #222;
|
||||
border: 1px solid #d0c8a0; border-radius: 4px;
|
||||
font-size: 18px; padding: 4px;
|
||||
// 从嵌入资源读取样式表。读取失败返回空串(setStyleSheet 空串即清空样式)。
|
||||
QString loadQss(const QString& resourcePath) {
|
||||
QFile f(resourcePath);
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
qWarning() << "无法打开样式资源" << resourcePath;
|
||||
return {};
|
||||
}
|
||||
QPushButton {
|
||||
border: 1px solid #bbb; border-radius: 4px;
|
||||
padding: 8px; font-size: 15px; min-height: 28px;
|
||||
return QString::fromUtf8(f.readAll());
|
||||
}
|
||||
QPushButton#btn0, QPushButton#btn1, QPushButton#btn2, QPushButton#btn3,
|
||||
QPushButton#btn4, QPushButton#btn5, QPushButton#btn6, QPushButton#btn7,
|
||||
QPushButton#btn8, QPushButton#btn9, QPushButton#btnDot {
|
||||
background: #e3f2fd; border-color: #90caf9; color: #0d47a1;
|
||||
}
|
||||
QPushButton#btnPlus, QPushButton#btnMinus, QPushButton#btnMul, QPushButton#btnDiv {
|
||||
background: #ffe0b2; border-color: #ffb74d; color: #e65100;
|
||||
}
|
||||
QPushButton#btnEquals {
|
||||
background: #c8e6c9; border-color: #81c784; color: #1b5e20; font-weight: bold;
|
||||
}
|
||||
QPushButton#btnLeftParen, QPushButton#btnRightParen {
|
||||
background: #eceff1; border-color: #b0bec5; color: #37474f;
|
||||
}
|
||||
QPushButton#btnClear, QPushButton#btnBackspace {
|
||||
background: #ffcdd2; border-color: #e57373; color: #b71c1c;
|
||||
}
|
||||
QPushButton:hover { background: #ddd; }
|
||||
QPushButton:pressed { background: #bbb; }
|
||||
QListWidget { background: #fafafa; alternate-background-color: #eeeeee; }
|
||||
QStatusBar { background: #e0e0e0; }
|
||||
QMenuBar { background: #eceff1; }
|
||||
QDockWidget { color: #333; }
|
||||
)";
|
||||
|
||||
// 深色主题:整体深底浅字,功能分色保留但调暗,便于和浅色对照验证「切换生效」
|
||||
const char* kDarkQss = R"(
|
||||
QMainWindow { background: #2b2b2b; color: #eee; }
|
||||
QLineEdit#display, QTextEdit#expressionEdit {
|
||||
background: #3a3a2a; color: #fffbe6;
|
||||
border: 1px solid #6b6b4a; border-radius: 4px;
|
||||
font-size: 18px; padding: 4px;
|
||||
}
|
||||
QPushButton {
|
||||
border: 1px solid #555; border-radius: 4px;
|
||||
padding: 8px; font-size: 15px; min-height: 28px; color: #eee;
|
||||
}
|
||||
QPushButton#btn0, QPushButton#btn1, QPushButton#btn2, QPushButton#btn3,
|
||||
QPushButton#btn4, QPushButton#btn5, QPushButton#btn6, QPushButton#btn7,
|
||||
QPushButton#btn8, QPushButton#btn9, QPushButton#btnDot {
|
||||
background: #1565c0; border-color: #42a5f5; color: #e3f2fd;
|
||||
}
|
||||
QPushButton#btnPlus, QPushButton#btnMinus, QPushButton#btnMul, QPushButton#btnDiv {
|
||||
background: #d84315; border-color: #ff7043; color: #ffe0b2;
|
||||
}
|
||||
QPushButton#btnEquals {
|
||||
background: #2e7d32; border-color: #66bb6a; color: #c8e6c9; font-weight: bold;
|
||||
}
|
||||
QPushButton#btnLeftParen, QPushButton#btnRightParen {
|
||||
background: #455a64; border-color: #78909c; color: #eceff1;
|
||||
}
|
||||
QPushButton#btnClear, QPushButton#btnBackspace {
|
||||
background: #c62828; border-color: #ef5350; color: #ffcdd2;
|
||||
}
|
||||
QPushButton:hover { background: #666; }
|
||||
QPushButton:pressed { background: #888; }
|
||||
QListWidget { background: #333; alternate-background-color: #3d3d3d; color: #eee; }
|
||||
QStatusBar { background: #1f1f1f; color: #eee; }
|
||||
QMenuBar { background: #37474f; color: #eee; }
|
||||
QDockWidget { color: #eee; }
|
||||
)";
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -168,7 +107,9 @@ void MainWindow::refreshStatus(const QString& msg) {
|
||||
// 避免和未来某控件局部样式打架(Day3 下午坑卡:setStyleSheet 级联失控)。
|
||||
void MainWindow::applyStyle(bool dark) {
|
||||
m_dark = dark;
|
||||
qApp->setStyleSheet(QString::fromUtf8(dark ? kDarkQss : kLightQss));
|
||||
const QString path = dark ? QStringLiteral(":/themes/dark.qss")
|
||||
: QStringLiteral(":/themes/light.qss");
|
||||
qApp->setStyleSheet(loadQss(path));
|
||||
}
|
||||
|
||||
void MainWindow::onToggleTheme(bool dark) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>themes/light.qss</file>
|
||||
<file>themes/dark.qss</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -0,0 +1,38 @@
|
||||
/* ============================================================================
|
||||
* 深色主题样式表 —— qt_calculator_mainwindow_qss(Day3 下午)
|
||||
* 整体深底浅字,功能分色保留但调暗,便于和浅色对照验证「切换生效」。
|
||||
* 资源路径 :/themes/dark.qss(themes.qrc 嵌入)。
|
||||
* ============================================================================ */
|
||||
QMainWindow { background: #2b2b2b; color: #eee; }
|
||||
QLineEdit#display, QTextEdit#expressionEdit {
|
||||
background: #3a3a2a; color: #fffbe6;
|
||||
border: 1px solid #6b6b4a; border-radius: 4px;
|
||||
font-size: 18px; padding: 4px;
|
||||
}
|
||||
QPushButton {
|
||||
border: 1px solid #555; border-radius: 4px;
|
||||
padding: 8px; font-size: 15px; min-height: 28px; color: #eee;
|
||||
}
|
||||
QPushButton#btn0, QPushButton#btn1, QPushButton#btn2, QPushButton#btn3,
|
||||
QPushButton#btn4, QPushButton#btn5, QPushButton#btn6, QPushButton#btn7,
|
||||
QPushButton#btn8, QPushButton#btn9, QPushButton#btnDot {
|
||||
background: #1565c0; border-color: #42a5f5; color: #e3f2fd;
|
||||
}
|
||||
QPushButton#btnPlus, QPushButton#btnMinus, QPushButton#btnMul, QPushButton#btnDiv {
|
||||
background: #d84315; border-color: #ff7043; color: #ffe0b2;
|
||||
}
|
||||
QPushButton#btnEquals {
|
||||
background: #2e7d32; border-color: #66bb6a; color: #c8e6c9; font-weight: bold;
|
||||
}
|
||||
QPushButton#btnLeftParen, QPushButton#btnRightParen {
|
||||
background: #455a64; border-color: #78909c; color: #eceff1;
|
||||
}
|
||||
QPushButton#btnClear, QPushButton#btnBackspace {
|
||||
background: #c62828; border-color: #ef5350; color: #ffcdd2;
|
||||
}
|
||||
QPushButton:hover { background: #666; }
|
||||
QPushButton:pressed { background: #888; }
|
||||
QListWidget { background: #333; alternate-background-color: #3d3d3d; color: #eee; }
|
||||
QStatusBar { background: #1f1f1f; color: #eee; }
|
||||
QMenuBar { background: #37474f; color: #eee; }
|
||||
QDockWidget { color: #eee; }
|
||||
@@ -0,0 +1,40 @@
|
||||
/* ============================================================================
|
||||
* 浅色主题样式表 —— qt_calculator_mainwindow_qss(Day3 下午)
|
||||
* 按按键功能分色:数字=浅蓝、运算=橙、等号=绿、括号=灰、清空/退格=红、显示区=米黄。
|
||||
* 选择器认 objectName(btn0/btnEquals/… 在 widget.ui 定义)。
|
||||
* 由 themes.qrc 嵌入为资源 :/themes/light.qss;mainwindow.cpp 用 QFile 读取后
|
||||
* qApp->setStyleSheet 全局应用——把样式与 C++ 逻辑分离,便于独立编辑、热重载。
|
||||
* ============================================================================ */
|
||||
QMainWindow { background: #f5f5f5; }
|
||||
QLineEdit#display, QTextEdit#expressionEdit {
|
||||
background: #fffbe6; color: #222;
|
||||
border: 1px solid #d0c8a0; border-radius: 4px;
|
||||
font-size: 18px; padding: 4px;
|
||||
}
|
||||
QPushButton {
|
||||
border: 1px solid #bbb; border-radius: 4px;
|
||||
padding: 8px; font-size: 15px; min-height: 28px;
|
||||
}
|
||||
QPushButton#btn0, QPushButton#btn1, QPushButton#btn2, QPushButton#btn3,
|
||||
QPushButton#btn4, QPushButton#btn5, QPushButton#btn6, QPushButton#btn7,
|
||||
QPushButton#btn8, QPushButton#btn9, QPushButton#btnDot {
|
||||
background: #e3f2fd; border-color: #90caf9; color: #0d47a1;
|
||||
}
|
||||
QPushButton#btnPlus, QPushButton#btnMinus, QPushButton#btnMul, QPushButton#btnDiv {
|
||||
background: #ffe0b2; border-color: #ffb74d; color: #e65100;
|
||||
}
|
||||
QPushButton#btnEquals {
|
||||
background: #c8e6c9; border-color: #81c784; color: #1b5e20; font-weight: bold;
|
||||
}
|
||||
QPushButton#btnLeftParen, QPushButton#btnRightParen {
|
||||
background: #eceff1; border-color: #b0bec5; color: #37474f;
|
||||
}
|
||||
QPushButton#btnClear, QPushButton#btnBackspace {
|
||||
background: #ffcdd2; border-color: #e57373; color: #b71c1c;
|
||||
}
|
||||
QPushButton:hover { background: #ddd; }
|
||||
QPushButton:pressed { background: #bbb; }
|
||||
QListWidget { background: #fafafa; alternate-background-color: #eeeeee; }
|
||||
QStatusBar { background: #e0e0e0; }
|
||||
QMenuBar { background: #eceff1; }
|
||||
QDockWidget { color: #333; }
|
||||
@@ -0,0 +1,30 @@
|
||||
# =============================================================================
|
||||
# Qt5 文件操作教学示例 —— 独立 CMake 工程
|
||||
#
|
||||
# 本文件供「单独打开 / 独立构建」使用:
|
||||
# cmake -S . -B build -DCMAKE_PREFIX_PATH=<Qt5.14.2> -DCMAKE_CXX_COMPILER=<g++>
|
||||
# cmake --build build
|
||||
#
|
||||
# 仓库统一构建【不走】这里:顶层 CMakeLists.txt 只
|
||||
# add_subdirectory(docs/teaching/examples) 且不递归,统一构建走
|
||||
# examples/CMakeLists.txt 的 add_teaching_example,源文件同为
|
||||
# qt_file_io_demo.cpp(零重复)。本目录不计入仓库「173 目标」计数。
|
||||
#
|
||||
# 说明:本工程为手写规范版(C++17 / cmake 3.16 / 无 ANDROID 噪音),
|
||||
# 区别于其它子目录里 Qt Creator 自动导出的低质量模板。
|
||||
# =============================================================================
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(qt_file_io_demo LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED)
|
||||
message(STATUS "Found Qt5 ${Qt5Core_VERSION_STRING} at ${Qt5Core_DIR}")
|
||||
|
||||
add_executable(qt_file_io_demo qt_file_io_demo.cpp)
|
||||
target_link_libraries(qt_file_io_demo PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_file_io_demo PRIVATE -Wall -Wextra)
|
||||
@@ -0,0 +1,108 @@
|
||||
# qt_file_io_demo —— Qt5 文件操作教学示例
|
||||
|
||||
一个 `QWidget` 布满按钮,分 5 个主题演示 Qt5 文件操作。每个按钮产生一个文件,落到
|
||||
**可执行文件同目录的 `demo_output/`** 子目录下,界面顶部 `statusLabel` 始终显示绝对路径,
|
||||
方便课堂逐个手动打开查看文件内容。
|
||||
|
||||
> 这是对仓库 `json_io`(控制台、仅内存序列化)的 GUI 补充:把 **QJson + 真实文件 I/O**
|
||||
> 与其它文件操作主题一起,放进一个可点击演示的界面。
|
||||
|
||||
## 构建
|
||||
|
||||
仓库统一构建(走 `examples/CMakeLists.txt` 的 `add_teaching_example`):
|
||||
|
||||
```bash
|
||||
cmake -S . -B build -G Ninja -DBUILD_QT_PART=ON \
|
||||
-DCMAKE_PREFIX_PATH=$PWD/.qt514/5.14.2/gcc_64 -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build --target qt_file_io_demo
|
||||
tools/run_qt.sh qt_file_io_demo
|
||||
```
|
||||
|
||||
单独打开(用本目录的独立 `CMakeLists.txt`):
|
||||
|
||||
```bash
|
||||
cmake -S . -B build -DCMAKE_PREFIX_PATH=<Qt5.14.2> -DCMAKE_CXX_COMPILER=<g++>
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
离屏自动化验证(仿 p03 风格,200ms 自动退出、退出码 0):
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_file_io_demo
|
||||
```
|
||||
|
||||
## 界面布局
|
||||
|
||||
```
|
||||
┌─ statusLabel(显示绝对路径 + 每个演示的结果摘要)─────────────┐
|
||||
├─ ① 基础文件操作 QFile 写/读/追加、QFileInfo、QDir │
|
||||
├─ ② 字符集编码 UTF-8 / Latin-1 / GBK / UTF-16LE / 乱码对比 │
|
||||
├─ ③ 文本vs二进制 QTextStream / QDataStream / QIODevice::Text │
|
||||
├─ ④ 异常处理 Qt 返回值式 | C++ try/catch(对照) │
|
||||
├─ ⑤ QJson 文件 写 / 读 / 改写 │
|
||||
├─ [在文件管理器中打开 demo_output] [清空 demo_output] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 各主题要点与产出文件
|
||||
|
||||
### ① 基础文件操作
|
||||
| 按钮 | 产出文件 | 要点 |
|
||||
|---|---|---|
|
||||
| QFile 写文本 | `01_qfile_write.txt` | `QTextStream` 写;**坑点:Qt5 默认 codec 是系统区域编码(中文 Windows 常为 GBK),写中文必须显式 `setCodec("UTF-8")`** |
|
||||
| QFile 读文本 | (读上一个) | 读端同样要 `setCodec("UTF-8")`,否则跨平台乱码 |
|
||||
| 追加 vs 覆盖 | `01_append.txt` | `WriteOnly` 每次清空;`Append` 在末尾追加 |
|
||||
| QFileInfo 信息 | `01_fileinfo.txt` | `exists / size / absoluteFilePath / absolutePath` |
|
||||
| QDir 列目录 | — | `entryList(QDir::Files)` 列出 `demo_output` 内文件 |
|
||||
|
||||
### ② 字符集编码
|
||||
| 按钮 | 产出文件 | 要点 |
|
||||
|---|---|---|
|
||||
| 写 UTF-8 | `02_utf8.txt` | `QString::toUtf8()`:中文每字 3 字节、ASCII 每字 1 字节 |
|
||||
| 写 UTF-8 + BOM | `02_utf8_bom.txt` | 头部 `EF BB BF`(十六进制可见) |
|
||||
| 写 Latin-1 | `02_latin1.txt` | `toLatin1()` 只能表 `U+0000~U+00FF`,中文超出变 `?` |
|
||||
| 写 GBK | `02_gbk.txt` | `QTextCodec::codecForName("GBK")`;**未装 CJK codec 插件时返回 `nullptr`,界面列出当前可用编码并给出提示**。本仓库 MinGW Qt 未含 `plugins/codecs/`,故此按钮走降级提示(这本身就是教学点);如需真正写 GBK,用 Qt Maintenance Tool 勾选安装 codecs 插件,或把 `plugins/codecs/` 拷到 Qt 的 plugins 目录 |
|
||||
| 写 UTF-16LE | `02_utf16le.txt` | 手动写 `FF FE` BOM + 每字符固定 2 字节(低字节在前) |
|
||||
| 乱码:错码读回 | (读 `02_utf8.txt`) | UTF-8 字节用 Latin-1 强解 → mojibake,说明「读写编码须一致」 |
|
||||
|
||||
> 用编辑器(VS Code / Notepad++)切换编码打开这些文件,可直观看到「同一段中文在不同编码下的字节差异」与乱码成因。
|
||||
|
||||
### ③ 文本方式 vs 二进制方式
|
||||
| 按钮 | 产出文件 | 要点 |
|
||||
|---|---|---|
|
||||
| 文本写结构 | `03_struct_text.txt` | `QTextStream` 写 `name=张三 / age=20 / score=95.5`:可读但格式松散 |
|
||||
| 二进制写结构 | `03_struct_bin.dat` | `QDataStream` 写 `qint32 + double + QString`:紧凑、带类型、可精确还原(程序内立即读回验证) |
|
||||
| 对比大小 | — | `QFileInfo` 报告两文件字节数;用十六进制工具看 `.dat` 内部布局 |
|
||||
| QIODevice::Text 换行 | `03_nl_text.txt` / `03_nl_bin.txt` | Windows 下 `Text` 标志把 `\n` 转 `\r\n`,不加则原样 `\n`;Linux 下两者相同 |
|
||||
|
||||
> `QDataStream` **务必 `setVersion(Qt_5_14)`**,否则 Qt 升级后二进制格式可能不兼容。
|
||||
|
||||
### ④ 异常与错误处理(两范式对照)
|
||||
| 按钮 | 要点 |
|
||||
|---|---|
|
||||
| Qt: 打开不存在文件 | **Qt 范式**:`open()` 返回 `false` + `error()` / `errorString()`,不抛异常 |
|
||||
| Qt: 只读模式写 | 只读 `QFile` 调 `write()` 返回 `-1` + `errorString()` |
|
||||
| C++: ifstream 异常 | **C++ 范式**:`in.exceptions(failbit\|badbit)` 后打开失败抛 `std::ios_base::failure`,`try/catch` 捕获 |
|
||||
| C++: throw + catch | 自定义异常 + `catch` 顺序(派生类在前、基类在后) |
|
||||
| JSON: 损坏解析 | `04_broken.json`:`QJsonDocument::fromJson` 不抛异常,用 `QJsonParseError` 拿出错偏移与描述 |
|
||||
|
||||
> 核心对照:**Qt 几乎不用 C++ 异常**,错误经返回值/API 暴露;标准库流默认也不抛,需显式开启。
|
||||
> 教学 LLVM 式「异常 vs 返回值」两种错误处理哲学的绝佳切入点。
|
||||
|
||||
### ⑤ QJson 文件读写
|
||||
| 按钮 | 产出文件 | 要点 |
|
||||
|---|---|---|
|
||||
| 写 JSON 文件 | `05_student.json` | `QJsonObject` + `QJsonArray` → `QJsonDocument::toJson(Indented)` → 写文件 |
|
||||
| 读 JSON 文件 | (读上一个) | `fromJson` → `object()` → 按键取值,`toArray()` 遍历数组 |
|
||||
| 修改后回写 | (覆盖上一个) | 改 `score`、增 `grade`、删 `enrolled`,覆盖回写 |
|
||||
|
||||
## demo_output 在哪?
|
||||
|
||||
`QCoreApplication::applicationDirPath() + "/demo_output"` —— 即**可执行文件旁边**。
|
||||
`tools/run_qt.sh` 运行时通常在 `build/` 下,所以路径形如 `build/qt_file_io_demo` 同级。
|
||||
界面底部「在文件管理器中打开 demo_output」按钮可一键定位。
|
||||
|
||||
## 相关课程点
|
||||
|
||||
对应「C++ 方向 Day3」文件与数据格式章节;与 `json_io`(控制台 QJson 内存演示)、
|
||||
`p03/ch11`(wiki 的 QFile/QTextStream/QDataStream)互为补充。
|
||||
@@ -0,0 +1,500 @@
|
||||
// ============================================================================
|
||||
// qt_file_io_demo —— Qt5 文件操作教学综合示例(非 wiki 生成,手工维护)
|
||||
//
|
||||
// 覆盖 5 个主题,每组按钮对应一个场景;所有演示文件落到可执行文件同目录的
|
||||
// demo_output/ 子目录,statusLabel 始终显示绝对路径,方便课堂手动打开查看:
|
||||
// ① 基础文件操作:QFile 写/读/追加、QFileInfo、QDir 列目录
|
||||
// ② 字符集编码:UTF-8 / Latin-1 / GBK / UTF-16LE + BOM + 「错码读回」乱码对比
|
||||
// ③ 文本方式 vs 二进制方式:QTextStream 文本、QDataStream 二进制、QIODevice::Text
|
||||
// ④ 异常与错误处理:Qt 返回值式(open/errorString)+ C++ try/catch(ifstream 异常)两范式
|
||||
// ⑤ QJson 文件读写:构造→落盘 / 读盘→解析 / 改写 / 损坏解析
|
||||
//
|
||||
// 形态:一个 QWidget 布满按钮(仿 qt_dialog_showcase),纯代码、单文件。
|
||||
// 离屏验证:QT_QPA_PLATFORM=offscreen 时 200ms 自动退出(仿 p03 风格,不点按钮
|
||||
// 不触发任何文件写入)。
|
||||
//
|
||||
// 编码演示设计要点:优先用 QString::toUtf8()/toLatin1()(Qt 内置、不依赖 CJK codec
|
||||
// 插件),GBK 通过 QTextCodec 尝试,未安装 codec 则给出明确提示并优雅降级 —— 这
|
||||
// 本身就是 Linux 上精简 Qt 安装的真实坑点。
|
||||
// ============================================================================
|
||||
#include <utility>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QGridLayout>
|
||||
#include <QPushButton>
|
||||
#include <QLabel>
|
||||
#include <QFrame>
|
||||
#include <QFile>
|
||||
#include <QTextStream>
|
||||
#include <QDataStream>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QList>
|
||||
#include <QDateTime>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonValue>
|
||||
#include <QJsonParseError>
|
||||
#include <QTextCodec>
|
||||
#include <QDesktopServices>
|
||||
#include <QUrl>
|
||||
#include <QTimer>
|
||||
|
||||
#include <fstream> // std::ifstream(C++ 异常演示)
|
||||
#include <ios> // std::ios_base::failure
|
||||
#include <stdexcept> // std::runtime_error
|
||||
#include <exception>
|
||||
|
||||
class FileIoDemo : public QWidget {
|
||||
public:
|
||||
explicit FileIoDemo(QWidget *parent = nullptr) : QWidget(parent) {
|
||||
// demo_output 放在可执行文件同目录,路径稳定可预测;statusLabel 始终显示绝对路径。
|
||||
m_dir = QCoreApplication::applicationDirPath() + QStringLiteral("/demo_output");
|
||||
QDir().mkpath(m_dir);
|
||||
|
||||
auto *root = new QVBoxLayout(this);
|
||||
|
||||
m_status = new QLabel(
|
||||
QStringLiteral("演示文件目录:%1\n点击下方按钮,每个按钮产生一个文件,可手动打开查看。")
|
||||
.arg(m_dir));
|
||||
m_status->setWordWrap(true);
|
||||
m_status->setMinimumHeight(54);
|
||||
m_status->setFrameShape(QFrame::Box);
|
||||
root->addWidget(m_status);
|
||||
|
||||
buildBasicGroup(root); // ①
|
||||
buildEncodingGroup(root); // ②
|
||||
buildTextBinGroup(root); // ③
|
||||
buildErrorGroup(root); // ④
|
||||
buildJsonGroup(root); // ⑤
|
||||
|
||||
// 底部:打开 / 清空 demo_output
|
||||
auto *bottom = new QHBoxLayout;
|
||||
auto *btnOpen = new QPushButton(QStringLiteral("在文件管理器中打开 demo_output"), this);
|
||||
auto *btnClear = new QPushButton(QStringLiteral("清空 demo_output"), this);
|
||||
bottom->addWidget(btnOpen);
|
||||
bottom->addWidget(btnClear);
|
||||
root->addLayout(bottom);
|
||||
connect(btnOpen, &QPushButton::clicked, this, &FileIoDemo::openDemoDir);
|
||||
connect(btnClear, &QPushButton::clicked, this, &FileIoDemo::clearDemoDir);
|
||||
|
||||
setWindowTitle(QStringLiteral("Qt5 文件操作教学示例(5 主题 × 多场景)"));
|
||||
resize(700, 760);
|
||||
}
|
||||
|
||||
private:
|
||||
QLabel *m_status = nullptr;
|
||||
QString m_dir; // demo_output 绝对路径
|
||||
|
||||
// ---- 通用 helpers --------------------------------------------------------
|
||||
template <typename F>
|
||||
void addBtn(QGridLayout *g, int row, int col, const QString &text, F &&slot) {
|
||||
auto *btn = new QPushButton(text, this);
|
||||
g->addWidget(btn, row, col);
|
||||
connect(btn, &QPushButton::clicked, this, std::forward<F>(slot));
|
||||
}
|
||||
|
||||
void setStatus(const QString &s) { m_status->setText(s); }
|
||||
|
||||
QString path(const QString &fname) const { return m_dir + QLatin1Char('/') + fname; }
|
||||
|
||||
// 写原始字节;extra 可叠加 QIODevice::Text / QIODevice::Append
|
||||
bool writeBytes(const QString &fname, const QByteArray &bytes,
|
||||
QIODevice::OpenMode extra = QIODevice::NotOpen) {
|
||||
QFile f(path(fname));
|
||||
if (!f.open(QIODevice::WriteOnly | extra)) {
|
||||
setStatus(QStringLiteral("写入失败:%1\n错误:%2").arg(path(fname), f.errorString()));
|
||||
return false;
|
||||
}
|
||||
f.write(bytes);
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 读原始字节
|
||||
bool readBytes(const QString &fname, QByteArray *out,
|
||||
QIODevice::OpenMode extra = QIODevice::NotOpen) {
|
||||
QFile f(path(fname));
|
||||
if (!f.open(QIODevice::ReadOnly | extra)) {
|
||||
setStatus(QStringLiteral("读取失败:%1\n错误:%2").arg(path(fname), f.errorString()));
|
||||
return false;
|
||||
}
|
||||
*out = f.readAll();
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 汇报某文件信息(名称/大小/绝对路径),可选附注,用于 statusLabel
|
||||
QString report(const QString &fname, const QString ¬e = QString()) {
|
||||
QFileInfo fi(path(fname));
|
||||
const QString base = QStringLiteral("✅ %1(%2 字节)\n绝对路径:%3")
|
||||
.arg(fname).arg(fi.size()).arg(fi.absoluteFilePath());
|
||||
return note.isEmpty() ? base : base + QStringLiteral("\n") + note;
|
||||
}
|
||||
|
||||
// ---- ① 基础文件操作 ------------------------------------------------------
|
||||
void buildBasicGroup(QVBoxLayout *root) {
|
||||
auto *g = new QGroupBox(QStringLiteral("① 基础文件操作(QFile / QTextStream / QFileInfo / QDir)"));
|
||||
auto *l = new QGridLayout(g);
|
||||
addBtn(l, 0, 0, QStringLiteral("QFile 写文本"), [this] { basicWriteText(); });
|
||||
addBtn(l, 0, 1, QStringLiteral("QFile 读文本"), [this] { basicReadText(); });
|
||||
addBtn(l, 0, 2, QStringLiteral("追加 vs 覆盖"), [this] { basicAppend(); });
|
||||
addBtn(l, 1, 0, QStringLiteral("QFileInfo 信息"), [this] { basicFileInfo(); });
|
||||
addBtn(l, 1, 1, QStringLiteral("QDir 列目录"), [this] { basicDirList(); });
|
||||
root->addWidget(g);
|
||||
}
|
||||
void basicWriteText() {
|
||||
// 坑点:Qt5 的 QTextStream 默认 codec 是「系统区域编码」(中文 Windows 常为 GBK),
|
||||
// 不是 UTF-8。写中文必须显式 setCodec("UTF-8"),否则文件编码随系统而变。
|
||||
QFile f(path("01_qfile_write.txt"));
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
setStatus(QStringLiteral("打开失败:%1").arg(f.errorString())); return;
|
||||
}
|
||||
QTextStream out(&f);
|
||||
out.setCodec(QTextCodec::codecForName("UTF-8"));
|
||||
out << QStringLiteral("第一行:Qt5 文件操作\n");
|
||||
out << QStringLiteral("第二行:当前时间 ") << QDateTime::currentDateTime().toString(Qt::ISODate) << '\n';
|
||||
out << QStringLiteral("第三行:中文测试 —— 你好,世界\n");
|
||||
f.close();
|
||||
setStatus(report("01_qfile_write.txt", QStringLiteral("用 QTextStream 写,显式 setCodec UTF-8。")));
|
||||
}
|
||||
void basicReadText() {
|
||||
QFile f(path("01_qfile_write.txt"));
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
setStatus(QStringLiteral("读取失败(请先点「QFile 写文本」):%1").arg(f.errorString())); return;
|
||||
}
|
||||
QTextStream in(&f);
|
||||
in.setCodec(QTextCodec::codecForName("UTF-8"));
|
||||
const QString all = in.readAll();
|
||||
f.close();
|
||||
setStatus(QStringLiteral("读到内容(%1 个字符):\n%2").arg(all.length()).arg(all));
|
||||
}
|
||||
void basicAppend() {
|
||||
// WriteOnly 每次清空覆盖;Append 在末尾追加、保留原内容
|
||||
QFile f(path("01_append.txt"));
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Append)) { setStatus(f.errorString()); return; }
|
||||
QTextStream out(&f);
|
||||
out.setCodec(QTextCodec::codecForName("UTF-8"));
|
||||
out << QStringLiteral("append @ ")
|
||||
<< QDateTime::currentDateTime().toString(Qt::ISODateWithMs) << '\n';
|
||||
f.close();
|
||||
|
||||
QFile r(path("01_append.txt"));
|
||||
r.open(QIODevice::ReadOnly | QIODevice::Text);
|
||||
setStatus(report("01_append.txt",
|
||||
QStringLiteral("当前全部内容:\n") + QString::fromUtf8(r.readAll())));
|
||||
}
|
||||
void basicFileInfo() {
|
||||
writeBytes("01_fileinfo.txt", "hello\n");
|
||||
const QFileInfo fi(path("01_fileinfo.txt"));
|
||||
setStatus(QStringLiteral("文件名:%1\n存在:%2 大小:%3 字节\n绝对路径:%4\n所在目录:%5")
|
||||
.arg(fi.fileName())
|
||||
.arg(fi.exists() ? QStringLiteral("是") : QStringLiteral("否"))
|
||||
.arg(fi.size()).arg(fi.absoluteFilePath()).arg(fi.absolutePath()));
|
||||
}
|
||||
void basicDirList() {
|
||||
const QDir d(m_dir);
|
||||
const QStringList files = d.entryList(QDir::Files, QDir::Name);
|
||||
setStatus(QStringLiteral("demo_output 内文件数:%1\n%2")
|
||||
.arg(files.size()).arg(files.join('\n')));
|
||||
}
|
||||
|
||||
// ---- ② 字符集编码 --------------------------------------------------------
|
||||
void buildEncodingGroup(QVBoxLayout *root) {
|
||||
auto *g = new QGroupBox(QStringLiteral("② 字符集编码(UTF-8 / Latin-1 / GBK / UTF-16LE + BOM + 乱码)"));
|
||||
auto *l = new QGridLayout(g);
|
||||
addBtn(l, 0, 0, QStringLiteral("写 UTF-8"), [this] { encUtf8(); });
|
||||
addBtn(l, 0, 1, QStringLiteral("写 UTF-8 + BOM"), [this] { encUtf8Bom(); });
|
||||
addBtn(l, 0, 2, QStringLiteral("写 Latin-1"), [this] { encLatin1(); });
|
||||
addBtn(l, 1, 0, QStringLiteral("写 GBK"), [this] { encGbk(); });
|
||||
addBtn(l, 1, 1, QStringLiteral("写 UTF-16LE"), [this] { encUtf16Le(); });
|
||||
addBtn(l, 1, 2, QStringLiteral("乱码:错码读回"), [this] { encMismatch(); });
|
||||
root->addWidget(g);
|
||||
}
|
||||
void encUtf8() {
|
||||
const QString s = QStringLiteral("你好,Qt\tASCII: Hello\n"); // 含中文 + ASCII + 制表符
|
||||
writeBytes("02_utf8.txt", s.toUtf8());
|
||||
setStatus(report("02_utf8.txt", QStringLiteral("toUtf8():中文每字 3 字节,ASCII 每字 1 字节。")));
|
||||
}
|
||||
void encUtf8Bom() {
|
||||
QByteArray b;
|
||||
b.append((char)0xEF).append((char)0xBB).append((char)0xBF); // UTF-8 BOM = EF BB BF
|
||||
b.append(QStringLiteral("带 BOM 的 UTF-8\n").toUtf8());
|
||||
writeBytes("02_utf8_bom.txt", b);
|
||||
setStatus(report("02_utf8_bom.txt", QStringLiteral("头部 3 字节 EF BB BF 即 UTF-8 BOM(十六进制可见)。")));
|
||||
}
|
||||
void encLatin1() {
|
||||
// Latin-1(ISO-8859-1)只能表示 U+0000~U+00FF;中文超出范围,toLatin1() 会替换成 '?'
|
||||
const QString s = QStringLiteral("Hello 世界");
|
||||
writeBytes("02_latin1.txt", s.toLatin1());
|
||||
setStatus(report("02_latin1.txt",
|
||||
QStringLiteral("toLatin1() 把超出 0xFF 的「世界」替换成 '?',文件里只剩 ASCII 部分。")));
|
||||
}
|
||||
void encGbk() {
|
||||
// GBK 是中文 Windows 默认编码,需 Qt 的 CJK codec 插件(plugins/codecs)。
|
||||
// 精简 Qt 安装常不含该插件,codecForName 返回 nullptr —— 这里优雅降级,
|
||||
// 并列出当前实际可用的编码,让学生看清「为什么 GBK 用不了」。
|
||||
QTextCodec *gbk = QTextCodec::codecForName("GBK");
|
||||
if (!gbk) {
|
||||
const QList<QByteArray> codecs = QTextCodec::availableCodecs();
|
||||
QStringList names;
|
||||
for (const QByteArray &c : codecs) names << QString::fromLatin1(c);
|
||||
setStatus(QStringLiteral("⚠️ 当前 Qt 构建未提供 GBK codec 插件\n"
|
||||
"(QTextCodec::codecForName(\"GBK\") 返回 nullptr)。\n"
|
||||
"这是真实坑点:精简 Qt 安装常不含 CJK codec 插件。\n"
|
||||
"当前可用编码(%1 个):%2\n"
|
||||
"可改用 toUtf8()/toLatin1()(内置)演示编码原理,见其余按钮。")
|
||||
.arg(names.size()).arg(names.join(QStringLiteral(", "))));
|
||||
return;
|
||||
}
|
||||
const QString s = QStringLiteral("你好,GBK 编码\n");
|
||||
writeBytes("02_gbk.txt", gbk->fromUnicode(s));
|
||||
setStatus(report("02_gbk.txt",
|
||||
QStringLiteral("GBK:中文每字 2 字节。用编辑器以 GBK 打开才正常,以 UTF-8 打开会乱码。")));
|
||||
}
|
||||
void encUtf16Le() {
|
||||
const QString s = QStringLiteral("UTF-16LE 你好\n");
|
||||
QByteArray b;
|
||||
b.append((char)0xFF).append((char)0xFE); // UTF-16LE BOM = FF FE
|
||||
const QChar *p = s.unicode();
|
||||
for (int i = 0; i < s.length(); ++i) {
|
||||
const ushort u = p[i].unicode();
|
||||
b.append((char)(u & 0xFF)); // 低字节在前 = Little Endian
|
||||
b.append((char)((u >> 8) & 0xFF));
|
||||
}
|
||||
writeBytes("02_utf16le.txt", b);
|
||||
setStatus(report("02_utf16le.txt", QStringLiteral("FF FE 为 UTF-16LE BOM;之后每字符固定 2 字节。")));
|
||||
}
|
||||
void encMismatch() {
|
||||
if (!QFile::exists(path("02_utf8.txt"))) encUtf8();
|
||||
QByteArray raw;
|
||||
if (!readBytes("02_utf8.txt", &raw)) return;
|
||||
// 故意用 Latin-1 解码 UTF-8 字节:多字节中文被拆成多个单字节字符 = 乱码(mojibake)
|
||||
const QString mojibake = QString::fromLatin1(raw);
|
||||
setStatus(QStringLiteral("02_utf8.txt 用 Latin-1 强行解码(编码不匹配)→ 乱码:\n%1\n"
|
||||
"结论:读文件必须用与写入一致的编码。").arg(mojibake));
|
||||
}
|
||||
|
||||
// ---- ③ 文本方式 vs 二进制方式 -------------------------------------------
|
||||
void buildTextBinGroup(QVBoxLayout *root) {
|
||||
auto *g = new QGroupBox(
|
||||
QStringLiteral("③ 文本方式 vs 二进制方式(QTextStream / QIODevice::Text / QDataStream)"));
|
||||
auto *l = new QGridLayout(g);
|
||||
addBtn(l, 0, 0, QStringLiteral("文本写结构"), [this] { tbStructText(); });
|
||||
addBtn(l, 0, 1, QStringLiteral("二进制写结构"), [this] { tbStructBin(); });
|
||||
addBtn(l, 0, 2, QStringLiteral("对比大小"), [this] { tbCompare(); });
|
||||
addBtn(l, 1, 0, QStringLiteral("QIODevice::Text 换行"), [this] { tbNewline(); });
|
||||
root->addWidget(g);
|
||||
}
|
||||
void tbStructText() {
|
||||
// 文本方式:人可读,但格式松散、解析需约定分隔符、浮点精度受 << 默认值影响
|
||||
QFile f(path("03_struct_text.txt"));
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { setStatus(f.errorString()); return; }
|
||||
QTextStream out(&f);
|
||||
out.setCodec(QTextCodec::codecForName("UTF-8"));
|
||||
out << "name=张三\nage=20\nscore=95.5\n";
|
||||
f.close();
|
||||
setStatus(report("03_struct_text.txt", QStringLiteral("文本:可读,但程序解析需自己处理格式。")));
|
||||
}
|
||||
void tbStructBin() {
|
||||
// 二进制方式(QDataStream):紧凑、带类型长度、可精确还原;是 Qt 推荐的跨平台二进制格式
|
||||
QFile f(path("03_struct_bin.dat"));
|
||||
if (!f.open(QIODevice::WriteOnly)) { setStatus(f.errorString()); return; }
|
||||
QDataStream out(&f);
|
||||
out.setVersion(QDataStream::Qt_5_14); // 固定版本,避免 Qt 升级后格式不兼容
|
||||
out.setByteOrder(QDataStream::LittleEndian);
|
||||
out << qint32(20) << double(95.5) << QStringLiteral("张三");
|
||||
f.close();
|
||||
|
||||
// 立即读回验证二进制可精确还原
|
||||
QFile r(path("03_struct_bin.dat"));
|
||||
r.open(QIODevice::ReadOnly);
|
||||
QDataStream in(&r);
|
||||
in.setVersion(QDataStream::Qt_5_14);
|
||||
in.setByteOrder(QDataStream::LittleEndian);
|
||||
qint32 age = 0; double score = 0; QString rname;
|
||||
in >> age >> score >> rname;
|
||||
r.close();
|
||||
setStatus(report("03_struct_bin.dat",
|
||||
QStringLiteral("读回验证 → age=%1 score=%2 name=%3(二进制可精确还原)")
|
||||
.arg(age).arg(score, 0, 'f', 1).arg(rname)));
|
||||
}
|
||||
void tbCompare() {
|
||||
if (!QFile::exists(path("03_struct_text.txt"))) tbStructText();
|
||||
if (!QFile::exists(path("03_struct_bin.dat"))) tbStructBin();
|
||||
const QFileInfo ft(path("03_struct_text.txt")), fb(path("03_struct_bin.dat"));
|
||||
setStatus(QStringLiteral("文本 03_struct_text.txt:%1 字节\n二进制 03_struct_bin.dat:%2 字节\n"
|
||||
"用十六进制工具看 .dat:int=4B、double=8B、QString 带长度前缀 + UTF-16。")
|
||||
.arg(ft.size()).arg(fb.size()));
|
||||
}
|
||||
void tbNewline() {
|
||||
// QIODevice::Text:Windows 上写 '\n' 会被转成 "\r\n";Linux 上不转换。
|
||||
// 不加 Text 标志(纯二进制写法)则始终原样 '\n'。这是跨平台文本文件的经典差异。
|
||||
const QByteArray body = "AAAA\nBBBB\nCCCC\n";
|
||||
writeBytes("03_nl_text.txt", body, QIODevice::Text); // 文本模式
|
||||
writeBytes("03_nl_bin.txt", body); // 二进制模式(无转换)
|
||||
setStatus(QStringLiteral("已写 03_nl_text.txt(QIODevice::Text)与 03_nl_bin.txt(无 Text)。\n"
|
||||
"Windows 下前者含 \\r\\n、后者只 \\n;Linux 下两者相同(平台差异,见注释)。"));
|
||||
}
|
||||
|
||||
// ---- ④ 异常与错误处理 ----------------------------------------------------
|
||||
void buildErrorGroup(QVBoxLayout *root) {
|
||||
auto *g = new QGroupBox(
|
||||
QStringLiteral("④ 异常与错误处理(Qt 返回值式 | C++ try/catch 两范式对照)"));
|
||||
auto *l = new QGridLayout(g);
|
||||
addBtn(l, 0, 0, QStringLiteral("Qt: 打开不存在文件"), [this] { errQtOpenMissing(); });
|
||||
addBtn(l, 0, 1, QStringLiteral("Qt: 只读模式写"), [this] { errQtReadOnlyWrite(); });
|
||||
addBtn(l, 0, 2, QStringLiteral("C++: ifstream 异常"), [this] { errCppIfstream(); });
|
||||
addBtn(l, 1, 0, QStringLiteral("C++: throw + catch"), [this] { errCppThrow(); });
|
||||
addBtn(l, 1, 1, QStringLiteral("JSON: 损坏解析"), [this] { errJsonBroken(); });
|
||||
root->addWidget(g);
|
||||
}
|
||||
void errQtOpenMissing() {
|
||||
// Qt 范式:不抛异常,靠 open() 返回值 + errorString()
|
||||
QFile f(path("这个文件不存在.txt"));
|
||||
if (f.open(QIODevice::ReadOnly)) {
|
||||
f.close();
|
||||
setStatus(QStringLiteral("意外:文件竟存在。"));
|
||||
} else {
|
||||
setStatus(QStringLiteral("【Qt 返回值式】open() 返回 false\n错误码:%1\n错误描述:%2\n"
|
||||
"(Qt 设计上几乎不用 C++ 异常,错误经返回值/API 暴露)")
|
||||
.arg(f.error()).arg(f.errorString()));
|
||||
}
|
||||
}
|
||||
void errQtReadOnlyWrite() {
|
||||
writeBytes("04_readonly.txt", "data\n");
|
||||
QFile f(path("04_readonly.txt"));
|
||||
f.open(QIODevice::ReadOnly); // 故意只读
|
||||
const qint64 n = f.write("try overwrite"); // 只读模式下写
|
||||
const QString es = f.errorString();
|
||||
f.close();
|
||||
setStatus(QStringLiteral("【Qt 返回值式】只读模式下 write() 返回 %1(-1 表示失败)\nerrorString():%2")
|
||||
.arg(n).arg(es));
|
||||
}
|
||||
void errCppIfstream() {
|
||||
// C++ 范式:标准库流默认不抛异常;调用 exceptions() 开启后,打开失败抛 std::ios_base::failure
|
||||
std::ifstream in;
|
||||
in.exceptions(std::ios::failbit | std::ios::badbit);
|
||||
try {
|
||||
in.open(path("这个文件不存在.txt").toStdString()); // 不存在 → failbit → 抛异常
|
||||
setStatus(QStringLiteral("意外:文件竟存在。"));
|
||||
} catch (const std::ios_base::failure &e) {
|
||||
setStatus(QStringLiteral("【C++ 异常式】捕获 std::ios_base::failure\nwhat():%1\n"
|
||||
"(对比:Qt 的 QFile 同样情况只返回 false,不抛异常)")
|
||||
.arg(QString::fromStdString(e.what())));
|
||||
} catch (const std::exception &e) {
|
||||
setStatus(QStringLiteral("捕获 std::exception:%1").arg(QString::fromStdString(e.what())));
|
||||
}
|
||||
}
|
||||
void errCppThrow() {
|
||||
// 演示自定义异常 + 多层 catch 顺序(派生类在前、基类在后)
|
||||
try {
|
||||
throw std::runtime_error("演示:文件内容校验失败,主动抛出");
|
||||
} catch (const std::runtime_error &e) {
|
||||
setStatus(QStringLiteral("【C++ 异常式】捕获 std::runtime_error:\n%1\n"
|
||||
"要点:catch 顺序从派生类到基类,否则被基类提前吞掉。")
|
||||
.arg(QString::fromStdString(e.what())));
|
||||
}
|
||||
}
|
||||
void errJsonBroken() {
|
||||
// 先写一段语法错误的 JSON(键未加引号、对象未闭合)
|
||||
writeBytes("04_broken.json", "{ name: \"张三\", score: 95 ");
|
||||
QByteArray raw;
|
||||
if (!readBytes("04_broken.json", &raw)) return;
|
||||
QJsonParseError err;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &err);
|
||||
if (doc.isNull()) {
|
||||
setStatus(QStringLiteral("【JSON 解析】失败(QJsonDocument 不抛异常)。\n"
|
||||
"错误偏移:%1\n错误描述:%2\n"
|
||||
"(JSON 解析用 QJsonParseError 拿精确出错位置,而非 try/catch)")
|
||||
.arg(err.offset).arg(err.errorString()));
|
||||
} else {
|
||||
setStatus(QStringLiteral("意外:JSON 竟合法。"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ⑤ QJson 文件读写 ----------------------------------------------------
|
||||
void buildJsonGroup(QVBoxLayout *root) {
|
||||
auto *g = new QGroupBox(QStringLiteral("⑤ QJson 文件读写(QJsonDocument / Object / Array)"));
|
||||
auto *l = new QGridLayout(g);
|
||||
addBtn(l, 0, 0, QStringLiteral("写 JSON 文件"), [this] { jsonWrite(); });
|
||||
addBtn(l, 0, 1, QStringLiteral("读 JSON 文件"), [this] { jsonRead(); });
|
||||
addBtn(l, 1, 0, QStringLiteral("修改后回写"), [this] { jsonModify(); });
|
||||
root->addWidget(g);
|
||||
}
|
||||
void jsonWrite() {
|
||||
QJsonObject student;
|
||||
student["name"] = QStringLiteral("张三");
|
||||
student["score"] = 92;
|
||||
student["enrolled"] = true;
|
||||
QJsonArray tags;
|
||||
tags.append(QStringLiteral("Qt"));
|
||||
tags.append(QStringLiteral("C++"));
|
||||
tags.append(QStringLiteral("多线程"));
|
||||
student["tags"] = tags;
|
||||
const QJsonDocument doc(student);
|
||||
writeBytes("05_student.json", doc.toJson(QJsonDocument::Indented));
|
||||
setStatus(report("05_student.json",
|
||||
QStringLiteral("QJsonObject → QJsonDocument → toJson(Indented) 缩进美化。")));
|
||||
}
|
||||
void jsonRead() {
|
||||
if (!QFile::exists(path("05_student.json"))) jsonWrite();
|
||||
QByteArray raw;
|
||||
if (!readBytes("05_student.json", &raw)) return;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw);
|
||||
if (doc.isNull() || !doc.isObject()) {
|
||||
setStatus(QStringLiteral("解析失败,请先点「写 JSON 文件」。")); return;
|
||||
}
|
||||
const QJsonObject obj = doc.object();
|
||||
const QJsonArray tags = obj["tags"].toArray();
|
||||
QStringList tagList;
|
||||
for (const QJsonValue &v : tags) tagList << v.toString();
|
||||
setStatus(QStringLiteral("读 05_student.json:\nname = %1\nscore = %2\nenrolled = %3\ntags = [%4]")
|
||||
.arg(obj["name"].toString())
|
||||
.arg(obj["score"].toInt())
|
||||
.arg(obj["enrolled"].toBool() ? QStringLiteral("true") : QStringLiteral("false"))
|
||||
.arg(tagList.join(", ")));
|
||||
}
|
||||
void jsonModify() {
|
||||
if (!QFile::exists(path("05_student.json"))) jsonWrite();
|
||||
QByteArray raw;
|
||||
if (!readBytes("05_student.json", &raw)) return;
|
||||
QJsonObject obj = QJsonDocument::fromJson(raw).object();
|
||||
obj["score"] = 100; // 改
|
||||
obj["grade"] = QStringLiteral("A+"); // 增
|
||||
obj.remove("enrolled"); // 删
|
||||
writeBytes("05_student.json", QJsonDocument(obj).toJson(QJsonDocument::Indented));
|
||||
setStatus(report("05_student.json",
|
||||
QStringLiteral("已改 score=100、新增 grade=A+、删除 enrolled,覆盖回写。")));
|
||||
}
|
||||
|
||||
// ---- 底部按钮 ------------------------------------------------------------
|
||||
void openDemoDir() {
|
||||
QDesktopServices::openUrl(QUrl::fromLocalFile(m_dir));
|
||||
}
|
||||
void clearDemoDir() {
|
||||
const QDir d(m_dir);
|
||||
int n = 0;
|
||||
for (const QFileInfo &fi : d.entryInfoList(QDir::Files)) {
|
||||
if (QFile::remove(fi.absoluteFilePath())) ++n;
|
||||
}
|
||||
setStatus(QStringLiteral("已清空 demo_output,删除 %1 个文件。").arg(n));
|
||||
}
|
||||
};
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
FileIoDemo w;
|
||||
w.show();
|
||||
// 离屏自动化验证时 200ms 后自动退出(仿 p03 风格);
|
||||
// 正常运行窗口保持、不自动退出;不点按钮则不触发任何文件写入。
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(200, &app, &QApplication::quit);
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# =============================================================================
|
||||
# QLineEdit 自绘小眼睛 / echo mode 切换 —— 独立 CMake 工程
|
||||
#
|
||||
# 本文件供「单独打开 / 独立构建」使用:
|
||||
# cmake -S . -B build -DCMAKE_PREFIX_PATH=<Qt5.14.2> -DCMAKE_CXX_COMPILER=<g++>
|
||||
# cmake --build build
|
||||
#
|
||||
# 仓库统一构建【不走】这里:顶层 CMakeLists.txt 只
|
||||
# add_subdirectory(docs/teaching/examples) 且不递归,统一构建走
|
||||
# examples/CMakeLists.txt 的 add_teaching_example,源文件同为
|
||||
# qt_lineedit_eye_toggle.cpp(零重复)。本目录不计入仓库「173 目标」计数。
|
||||
#
|
||||
# 说明:本工程为手写规范版(C++17 / cmake 3.16 / 无 ANDROID 噪音),
|
||||
# 区别于其它子目录里 Qt Creator 自动导出的低质量模板。
|
||||
# =============================================================================
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(qt_lineedit_eye_toggle LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED)
|
||||
message(STATUS "Found Qt5 ${Qt5Core_VERSION_STRING} at ${Qt5Core_DIR}")
|
||||
|
||||
add_executable(qt_lineedit_eye_toggle qt_lineedit_eye_toggle.cpp)
|
||||
target_link_libraries(qt_lineedit_eye_toggle PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_lineedit_eye_toggle PRIVATE -Wall -Wextra)
|
||||
@@ -0,0 +1,101 @@
|
||||
# QLineEdit 自绘小眼睛 / echo mode 切换(qt_lineedit_eye_toggle)
|
||||
|
||||
Day4 上午教学示例(OUTLINE_4DAY.md Day4 上午「消息机制和事件 + 绘图事件」),**同时覆盖两个主题**:
|
||||
|
||||
- **组件绘制**——覆写 `paintEvent`,用 QPainter 在 QLineEdit 右侧自绘眼睛图标;
|
||||
- **事件捕捉**——覆写 `mousePressEvent` / `mouseMoveEvent`,几何 hit-test 命中眼睛时切换
|
||||
`echoMode`,且不阻断基类的光标定位 / 拖选。
|
||||
|
||||
载体是 QLineEdit 密码框:右侧一个自绘小眼睛,点击在 `Password` ↔ `Normal` 间切换,并显式
|
||||
处理与 QLineEdit 内置 clear button 的位置冲突。
|
||||
|
||||
## 三区对照(先简后深)
|
||||
|
||||
| 区 | 内容 | 教学作用 |
|
||||
|---|---|---|
|
||||
| ① echo mode 四态 | Normal / NoEcho / Password / PasswordEchoOnEdit 静态对照 | 先给 echo mode 全貌,眼睛只是切换载体 |
|
||||
| ② addAction 一行版 | `addAction(QIcon, TrailingPosition)` 加眼睛,点击切换 | Qt 内置、1 行实现——「为什么还要自绘」的引子 |
|
||||
| ③ 自绘版主秀 | `PasswordLineEdit` 自绘眼睛 + 「启用 clear button」复选框 | 主题落地 + clear button 避让 |
|
||||
|
||||
## 关键教学点
|
||||
|
||||
### 1. 组件绘制
|
||||
- `paintEvent` **先调基类**画原生内容(文本 / 边框 / clear button),再用 QPainter 叠加眼睛。
|
||||
顺序不能反——基类画完后 clear button 几何才最终确定,眼睛避让才准。
|
||||
- 眼睛用 QPainter 纯代码绘制(眼眶椭圆 + 瞳孔 / 斜杠两态),无外部资源。绘制逻辑抽成自由函数
|
||||
`paintEye()`,③区自绘与②区 `QIcon` pixmap 共用(DRY,且对照更直观)。
|
||||
|
||||
### 2. 事件捕捉
|
||||
- `mousePressEvent`:命中眼睛矩形 → 切 `echoMode` + `e->accept()` + `return`(**不**调基类,
|
||||
否则 QLineEdit 把这次点击当光标定位);非命中 → 调基类(光标定位 / 双击选词 / 右键菜单照常)。
|
||||
这是「自绘交互区与原生编辑行为共存」的核心。
|
||||
- `mouseMoveEvent` + `setMouseTracking(true)`:实现 hover 高亮。**坑卡**:`mouseTracking` 默认关,
|
||||
不按鼠标键就收不到 `mouseMoveEvent`,hover 高亮出不来。
|
||||
|
||||
### 3. clear button 避让(读 clearButton 子 widget 几何)
|
||||
QLineEdit 内置 clear button 的宽度随主题 / DPI / 字号变化,**不能硬编码偏移**(硬编码是反面教材,
|
||||
换主题即错位)。正确做法:clear button 是 QLineEdit 的一个 `objectName=="clearButton"` 直接子 widget
|
||||
(Qt5 私有 `QLineEditIconButton`),用 `findChild` 读它的真实几何:
|
||||
|
||||
```cpp
|
||||
if (QWidget* cb = findChild<QWidget*>(QStringLiteral("clearButton"),
|
||||
Qt::FindDirectChildrenOnly)) {
|
||||
rightEdge = cb->x() - 4; // 眼睛贴 clear button 左侧,留 4px 缝
|
||||
}
|
||||
```
|
||||
|
||||
眼睛紧贴其左侧;未启用 clear button 或无文本(clear button 不占位)时贴右边。勾选③区复选框即可实时
|
||||
观察眼睛从「贴右」移到「贴 clear button 左侧」、两者不重叠。
|
||||
|
||||
> 为什么不用 `QStyle::subControlRect`?QLineEdit 不经 QStyle 的 complex-control 绘制,
|
||||
> `QStyle` 没有 `CC_LineEdit` / `SC_LineEditClearButton`,无对应 subcontrol 可查——读子 widget 是唯一可靠途径。
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
lineedit_eye_toggle/
|
||||
├── qt_lineedit_eye_toggle.cpp # 唯一源文件(paintEye + PasswordLineEdit + ShowcaseWidget + main)
|
||||
├── CMakeLists.txt # 独立顶层工程(可单独 cmake -S . -B build,不走仓库统一构建)
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 构建与运行(Windows + MinGW 7.3.0 + Qt 5.14.2)
|
||||
|
||||
> 必须用与 Qt 5.14.2 ABI 匹配的 mingw730_64,不可用 PATH 中更高版本的 g++。
|
||||
|
||||
### A. 集成构建(仓库统一构建树)
|
||||
```bash
|
||||
# 配置(在仓库根)
|
||||
cmake -S . -B build_mw -G "MinGW Makefiles" \
|
||||
-DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64 \
|
||||
-DCMAKE_CXX_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/g++.exe \
|
||||
-DCMAKE_C_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/gcc.exe
|
||||
|
||||
cmake --build build_mw --target qt_lineedit_eye_toggle
|
||||
|
||||
# 运行(GUI,点 ②③ 的眼睛、勾选 clear button 体验)
|
||||
PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
|
||||
QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \
|
||||
./build_mw/docs/teaching/examples/qt_lineedit_eye_toggle.exe
|
||||
|
||||
# 离屏自动退出验证(不交互,退出码应为 0)
|
||||
QT_QPA_PLATFORM=offscreen \
|
||||
QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \
|
||||
PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
|
||||
./build_mw/docs/teaching/examples/qt_lineedit_eye_toggle.exe; echo "exit=$?"
|
||||
```
|
||||
|
||||
### B. 独立构建(单独 cmake 工程)
|
||||
```bash
|
||||
cd docs/teaching/examples/lineedit_eye_toggle
|
||||
cmake -S . -B build -G "MinGW Makefiles" \
|
||||
-DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64 \
|
||||
-DCMAKE_CXX_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/g++.exe \
|
||||
-DCMAKE_C_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/gcc.exe
|
||||
cmake --build build
|
||||
PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
|
||||
QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \
|
||||
./build/qt_lineedit_eye_toggle.exe
|
||||
```
|
||||
|
||||
> 两条路径编译同一份 `qt_lineedit_eye_toggle.cpp`,行为一致。集成路径进入仓库统一构建;
|
||||
> 独立路径可把这个目录单独拷走即用(仅需本目录三文件 + 一个 Qt 5.14.x)。
|
||||
@@ -0,0 +1,304 @@
|
||||
// =============================================================================
|
||||
// QLineEdit 自绘小眼睛 / echo mode 切换 —— Day4 上午教学示例
|
||||
// 对应课程:OUTLINE_4DAY.md Day4 上午「消息机制和事件 + 绘图事件」
|
||||
//
|
||||
// 覆盖两个主题:
|
||||
// 1) 组件绘制 —— 覆写 paintEvent,用 QPainter 在 QLineEdit 右侧自绘眼睛图标
|
||||
// 2) 事件捕捉 —— 覆写 mousePressEvent / mouseMoveEvent,几何 hit-test 命中眼睛
|
||||
// 时切换 echoMode,且不阻断基类的光标定位/拖选行为
|
||||
//
|
||||
// 三区对照(先简后深):
|
||||
// ① echo mode 四态静态对照(Normal / NoEcho / Password / PasswordEchoOnEdit)
|
||||
// ② addAction 一行版(Qt 内置,作「为什么还要自绘」的引子)
|
||||
// ③ 自绘版主秀:PasswordLineEdit 自绘眼睛 + clear button 避让(读 clearButton 子 widget 几何)
|
||||
// =============================================================================
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QCheckBox>
|
||||
#include <QEvent>
|
||||
#include <QFormLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QIcon>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMouseEvent>
|
||||
#include <QPaintEvent>
|
||||
#include <QPainter>
|
||||
#include <QPixmap>
|
||||
#include <QRect>
|
||||
#include <QStyle>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QDebug>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
using namespace std;
|
||||
// -----------------------------------------------------------------------------
|
||||
// 眼睛图标绘制:PasswordLineEdit::paintEvent 与 ②区 QIcon pixmap 共用(DRY)。
|
||||
// closed=true → 当前隐藏(密码)态:眼眶椭圆 + 斜杠(「看不见」)
|
||||
// closed=false → 当前明文态:眼眶椭圆 + 实心瞳孔(「能看见」)
|
||||
// hover=true → 描边加深为蓝色,给即时反馈
|
||||
// -----------------------------------------------------------------------------
|
||||
static void paintEye(QPainter* p, const QRect& r, bool closed, bool hover)
|
||||
{
|
||||
p->setRenderHint(QPainter::Antialiasing, true);
|
||||
const QColor stroke = hover ? QColor(QStringLiteral("#0078D7"))
|
||||
: QColor(QStringLiteral("#5a5a5a"));
|
||||
// 眼眶:左右各留 10%、上下各留 20% 的内边距,画一个扁椭圆(杏仁眼简化形)
|
||||
const QRect eye = r.adjusted(r.width() * 10 / 100, r.height() * 20 / 100,
|
||||
-(r.width() * 10 / 100), -(r.height() * 20 / 100));
|
||||
p->setPen(QPen(stroke, hover ? 2.0 : 1.6));
|
||||
p->setBrush(Qt::NoBrush);
|
||||
p->drawEllipse(eye);
|
||||
|
||||
if (closed) {
|
||||
// 密码态:一条斜杠盖住眼睛,直观表示「内容隐藏中」
|
||||
p->drawLine(eye.topLeft(), eye.bottomRight());
|
||||
} else {
|
||||
// 明文态:实心瞳孔,直观表示「内容可见」
|
||||
p->setPen(Qt::NoPen);
|
||||
p->setBrush(stroke);
|
||||
const QPoint c = eye.center();
|
||||
const int pr = eye.height() / 4;
|
||||
p->drawEllipse(c, pr, pr);
|
||||
}
|
||||
}
|
||||
|
||||
// 用同一套绘制逻辑生成 QIcon(供 ②区 addAction 用,与 ③区自绘像素一致)
|
||||
static QIcon makeEyeIcon(const QStyle* st, bool closed)
|
||||
{
|
||||
const int sz = st->pixelMetric(QStyle::PM_SmallIconSize);
|
||||
QPixmap pm(sz, sz);
|
||||
pm.fill(Qt::transparent);
|
||||
QPainter p(&pm);
|
||||
paintEye(&p, QRect(0, 0, sz, sz), closed, /*hover=*/false);
|
||||
return QIcon(pm);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PasswordLineEdit:自绘版主秀
|
||||
// =============================================================================
|
||||
class PasswordLineEdit : public QLineEdit {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit PasswordLineEdit(QWidget* parent = nullptr) : QLineEdit(parent)
|
||||
{
|
||||
setEchoMode(QLineEdit::Password);
|
||||
// ★ mouseTracking 默认关:不按鼠标键收不到 mouseMoveEvent → hover 高亮出不来。
|
||||
setMouseTracking(true);
|
||||
// 右侧给眼睛预留宽度,避免文字被眼睛遮挡(clear button 的文本避让由 Qt 内部处理)。
|
||||
const int sz = style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, this);
|
||||
setTextMargins(0, 0, sz + 8, 0);
|
||||
}
|
||||
|
||||
bool passwordMode() const { return m_passwordMode; }
|
||||
|
||||
signals:
|
||||
void passwordModeChanged(bool password);
|
||||
|
||||
protected:
|
||||
// 【组件绘制】先让基类画原生内容(文本 / 边框 / clear button),再叠加自绘的眼睛。
|
||||
// 顺序关键:必须先画基类,paint 后 clear button 几何才最终确定,recomputeEyeRect 才准。
|
||||
void paintEvent(QPaintEvent* e) override
|
||||
{
|
||||
QLineEdit::paintEvent(e);
|
||||
recomputeEyeRect();
|
||||
QPainter p(this);
|
||||
paintEye(&p, m_eyeRect, /*closed=*/m_passwordMode, m_eyeHover);
|
||||
}
|
||||
|
||||
// 【事件捕捉】命中眼睛 → 切 echoMode 并 accept 阻断;否则交基类保留编辑行为。
|
||||
void mousePressEvent(QMouseEvent* e) override
|
||||
{
|
||||
recomputeEyeRect();
|
||||
if (e->button() == Qt::LeftButton && m_eyeRect.contains(e->pos())) {
|
||||
m_passwordMode = !m_passwordMode;
|
||||
setEchoMode(m_passwordMode ? QLineEdit::Password : QLineEdit::Normal);
|
||||
emit passwordModeChanged(m_passwordMode);
|
||||
update();
|
||||
e->accept(); // ★ accept + return:不让基类把这次点击当光标定位
|
||||
return;
|
||||
}
|
||||
QLineEdit::mousePressEvent(e); // 非命中:光标定位 / 双击选词 / 右键菜单照常
|
||||
}
|
||||
|
||||
// hover 高亮:命中眼睛变手势指针,离开变文本光标
|
||||
void mouseMoveEvent(QMouseEvent* e) override
|
||||
{
|
||||
recomputeEyeRect();
|
||||
const bool over = m_eyeRect.contains(e->pos());
|
||||
if (over != m_eyeHover) {
|
||||
m_eyeHover = over;
|
||||
update(m_eyeRect); // 局部刷新,避免整控件重绘
|
||||
}
|
||||
setCursor(over ? Qt::PointingHandCursor : Qt::IBeamCursor);
|
||||
QLineEdit::mouseMoveEvent(e); // 保留文本拖选
|
||||
}
|
||||
|
||||
void leaveEvent(QEvent* e) override
|
||||
{
|
||||
if (m_eyeHover) {
|
||||
m_eyeHover = false;
|
||||
update(m_eyeRect);
|
||||
}
|
||||
QLineEdit::leaveEvent(e);
|
||||
}
|
||||
|
||||
private:
|
||||
// 【clear button 避让】读 QLineEdit 内置 clear button 子 widget 的真实几何,
|
||||
// 眼睛贴其左侧;未启用时贴右边。【注】QStyle 没有 CC_LineEdit / SC_LineEditClearButton
|
||||
// ——QLineEdit 不经 QStyle 的 complex control 绘制,无对应 subcontrol 可查,故改读子 widget。
|
||||
// clear button 的宽度随主题/DPI/字号变化,且仅在有文本时显示占位,故同时判文本。
|
||||
void recomputeEyeRect()
|
||||
{
|
||||
const int sz = style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, this);
|
||||
int rightEdge = width() - 6; // 默认:贴右边内边距
|
||||
if (!text().isEmpty()) {
|
||||
const auto& children = this->children();
|
||||
auto cbFound = false;
|
||||
QWidget *cb = nullptr;
|
||||
for (auto & child : findChildren<QWidget *>("")) {
|
||||
|
||||
;
|
||||
}
|
||||
for (auto& child : children) {
|
||||
qDebug() << child->objectName() << child->metaObject()->className() << endl;
|
||||
// if (child->objectName().endsWith("clearaction")) {
|
||||
// cbFound = true;
|
||||
// cb = qobject_cast<QWidget *>(child);
|
||||
// if (cbFound) {
|
||||
// rightEdge = rightEdge - sz - 4; // 有 clear button:眼睛贴其左侧,留 4px 缝
|
||||
// } else {
|
||||
|
||||
// qDebug() << "cast failed" << endl;
|
||||
// }
|
||||
// break;
|
||||
// }
|
||||
}
|
||||
}
|
||||
m_eyeRect = QRect(rightEdge - sz, (height() - sz) / 2, sz, sz);
|
||||
}
|
||||
|
||||
bool m_passwordMode = true;
|
||||
bool m_eyeHover = false;
|
||||
QRect m_eyeRect;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ShowcaseWidget:三区对照窗口
|
||||
// =============================================================================
|
||||
class ShowcaseWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ShowcaseWidget(QWidget* parent = nullptr) : QWidget(parent)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("QLineEdit 自绘小眼睛 / echo mode 切换"));
|
||||
auto* root = new QVBoxLayout(this);
|
||||
const QString sample = QStringLiteral("hello123");
|
||||
|
||||
// ---- ① echo mode 四态静态对照 -------------------------------------------------
|
||||
{
|
||||
auto* g = new QGroupBox(
|
||||
QStringLiteral("① echo mode 四态对照(文本 \"hello123\")"), this);
|
||||
auto* f = new QFormLayout(g);
|
||||
auto* eNormal = makeRO(sample, QLineEdit::Normal);
|
||||
auto* eNoEcho = makeRO(sample, QLineEdit::NoEcho);
|
||||
auto* ePwd = makeRO(sample, QLineEdit::Password);
|
||||
auto* ePwdOnEdit = makeRO(sample, QLineEdit::PasswordEchoOnEdit);
|
||||
f->addRow(QStringLiteral("Normal(明文)"), eNormal);
|
||||
f->addRow(QStringLiteral("NoEcho(完全无显示,光标仍可移动)"), eNoEcho);
|
||||
f->addRow(QStringLiteral("Password(密文圆点)"), ePwd);
|
||||
// PasswordEchoOnEdit 的「编辑时明文」需取消只读后键入才显现(见 README)。
|
||||
f->addRow(QStringLiteral("PasswordEchoOnEdit(失焦密文/编辑明文)"), ePwdOnEdit);
|
||||
root->addWidget(g);
|
||||
}
|
||||
|
||||
// ---- ② addAction 一行版(Qt 内置,对照) -------------------------------------
|
||||
{
|
||||
auto* g = new QGroupBox(
|
||||
QStringLiteral("② addAction 一行版(Qt 内置,对照「为什么还要自绘」)"), this);
|
||||
auto* f = new QFormLayout(g);
|
||||
auto* edit = new QLineEdit(this);
|
||||
edit->setEchoMode(QLineEdit::Password);
|
||||
edit->setText(sample);
|
||||
// 一行即可:Qt 自动处理位置 / 点击 / 与 clear button 的避让。但它碰不到「绘制/事件」主题。
|
||||
QAction* eyeAct = edit->addAction(makeEyeIcon(style(), /*closed=*/true),
|
||||
QLineEdit::TrailingPosition);
|
||||
eyeAct->setToolTip(QStringLiteral("点击切换显示"));
|
||||
connect(eyeAct, &QAction::triggered, this, [this, edit, eyeAct]() {
|
||||
const bool toNormal = edit->echoMode() == QLineEdit::Password;
|
||||
edit->setEchoMode(toNormal ? QLineEdit::Normal : QLineEdit::Password);
|
||||
eyeAct->setIcon(makeEyeIcon(style(), /*closed=*/!toNormal));
|
||||
log(QStringLiteral("② addAction:echoMode → %1")
|
||||
.arg(toNormal ? QStringLiteral("Normal") : QStringLiteral("Password")));
|
||||
});
|
||||
f->addRow(QStringLiteral("密码框 + 眼睛(内置 action)"), edit);
|
||||
root->addWidget(g);
|
||||
}
|
||||
|
||||
// ---- ③ 自绘版主秀 -------------------------------------------------------------
|
||||
{
|
||||
auto* g = new QGroupBox(
|
||||
QStringLiteral("③ 自绘版主秀:paintEvent 自绘 + mouseEvent 捕获 + clear button 避让"),
|
||||
this);
|
||||
auto* f = new QFormLayout(g);
|
||||
m_pwdEdit = new PasswordLineEdit(this);
|
||||
m_pwdEdit->setText(sample);
|
||||
connect(m_pwdEdit, &PasswordLineEdit::passwordModeChanged, this, [this](bool pwd) {
|
||||
log(QStringLiteral("③ 自绘:echoMode → %1")
|
||||
.arg(pwd ? QStringLiteral("Password") : QStringLiteral("Normal")));
|
||||
});
|
||||
auto* cbClear = new QCheckBox(
|
||||
QStringLiteral("启用 QLineEdit 内置 clear button(观察眼睛如何避让到其左侧)"), this);
|
||||
cbClear->setChecked(false);
|
||||
connect(cbClear, &QCheckBox::toggled, m_pwdEdit, &QLineEdit::setClearButtonEnabled);
|
||||
connect(cbClear, &QCheckBox::toggled, this, [this](bool on) {
|
||||
log(QStringLiteral("③ clear button %1")
|
||||
.arg(on ? QStringLiteral("开启") : QStringLiteral("关闭")));
|
||||
});
|
||||
f->addRow(QStringLiteral("密码框 + 自绘眼睛"), m_pwdEdit);
|
||||
f->addRow(QString(), cbClear);
|
||||
root->addWidget(g);
|
||||
}
|
||||
|
||||
m_status = new QLabel(QStringLiteral("事件日志:就绪。点击 ②③ 的眼睛,或勾选 clear button。"), this);
|
||||
m_status->setStyleSheet(QStringLiteral("padding:4px; color:#333;"));
|
||||
m_status->setWordWrap(true);
|
||||
root->addWidget(m_status);
|
||||
root->addStretch();
|
||||
}
|
||||
|
||||
private:
|
||||
void log(const QString& s) { m_status->setText(QStringLiteral("事件日志:%1").arg(s)); }
|
||||
|
||||
static QLineEdit* makeRO(const QString& text, QLineEdit::EchoMode mode)
|
||||
{
|
||||
auto* e = new QLineEdit(text);
|
||||
e->setEchoMode(mode);
|
||||
e->setReadOnly(true);
|
||||
return e;
|
||||
}
|
||||
|
||||
PasswordLineEdit* m_pwdEdit = nullptr;
|
||||
QLabel* m_status = nullptr;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// main
|
||||
// =============================================================================
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
ShowcaseWidget w;
|
||||
w.resize(540, 440);
|
||||
w.show();
|
||||
|
||||
// 离屏自动化验证:200ms 后自动退出,退出码 0 表示窗口正常构建与渲染。
|
||||
if (qEnvironmentVariable("QT_QPA_PLATFORM") == QStringLiteral("offscreen")) {
|
||||
QTimer::singleShot(200, qApp, &QCoreApplication::quit);
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
#include "qt_lineedit_eye_toggle.moc"
|
||||
@@ -0,0 +1,51 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
|
||||
project(qt_mainwindow_showcase LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 11)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# QtCreator supports the following variables for Android, which are identical to qmake Android variables.
|
||||
# Check http://doc.qt.io/qt-5/deployment-android.html for more information.
|
||||
# They need to be set before the find_package(Qt5 ...) call.
|
||||
|
||||
#if(ANDROID)
|
||||
# set(ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android")
|
||||
# if (ANDROID_ABI STREQUAL "armeabi-v7a")
|
||||
# set(ANDROID_EXTRA_LIBS
|
||||
# ${CMAKE_CURRENT_SOURCE_DIR}/path/to/libcrypto.so
|
||||
# ${CMAKE_CURRENT_SOURCE_DIR}/path/to/libssl.so)
|
||||
# endif()
|
||||
#endif()
|
||||
|
||||
find_package(Qt5 COMPONENTS Widgets REQUIRED)
|
||||
|
||||
if(ANDROID)
|
||||
add_library(qt_mainwindow_showcase
|
||||
main.cpp
|
||||
mainwindow_showcase.cpp
|
||||
mainwindow_showcase.h
|
||||
mainwindow2.cpp
|
||||
mainwindow2.h
|
||||
mainwindow2.ui
|
||||
mainwindow.ui
|
||||
)
|
||||
else()
|
||||
add_executable(qt_mainwindow_showcase
|
||||
main.cpp
|
||||
mainwindow_showcase.cpp
|
||||
mainwindow_showcase.h
|
||||
mainwindow2.cpp
|
||||
mainwindow2.h
|
||||
mainwindow2.ui
|
||||
mainwindow.ui
|
||||
mainwindow.qrc)
|
||||
endif()
|
||||
|
||||
target_link_libraries(qt_mainwindow_showcase PRIVATE Qt5::Widgets)
|
||||
@@ -6,4 +6,8 @@
|
||||
<file>icons/about.png</file>
|
||||
<file>icons/dock.png</file>
|
||||
</qresource>
|
||||
<qresource prefix="/new/prefix1">
|
||||
<file>icons/about.png</file>
|
||||
</qresource>
|
||||
<qresource prefix="/new/prefix2"/>
|
||||
</RCC>
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
<string>QMainWindow 教学综合示例</string>
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<iconset>
|
||||
<normaloff>:/icons/app.png</normaloff>:/icons/app.png</iconset>
|
||||
</property>
|
||||
<!-- 5.5 中心部件:QTextEdit(编辑器主体) -->
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="centralLayout">
|
||||
<property name="spacing">
|
||||
@@ -46,14 +45,13 @@
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<!-- 5.1 菜单栏:文件 / 视图 / 帮助 -->
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>720</width>
|
||||
<height>22</height>
|
||||
<height>21</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
@@ -95,9 +93,7 @@
|
||||
<addaction name="menuView"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<!-- 5.3 状态栏(.ui 占位,永久标签在 .cpp 用 addPermanentWidget 添加) -->
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<!-- 5.2 工具栏:复用同一批 QAction(QAction 是菜单项与工具按钮的公共抽象) -->
|
||||
<widget class="QToolBar" name="mainToolBar">
|
||||
<property name="windowTitle">
|
||||
<string>主工具栏</string>
|
||||
@@ -113,7 +109,6 @@
|
||||
<addaction name="actionToggleDockFloating"/>
|
||||
<addaction name="actionAbout"/>
|
||||
</widget>
|
||||
<!-- 5.4 铆接部件(浮动窗口):可拖出浮动、停靠到边缘 -->
|
||||
<widget class="QDockWidget" name="dockWidget">
|
||||
<property name="windowTitle">
|
||||
<string>浮动窗口</string>
|
||||
@@ -138,36 +133,35 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<!-- 5.1 QAction:菜单项与工具按钮的公共抽象。图标取自 5.6 资源文件 -->
|
||||
<action name="actionNew">
|
||||
<property name="icon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<iconset>
|
||||
<normaloff>:/icons/new.png</normaloff>:/icons/new.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>新建</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+N</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>清空编辑区(5.5 中心部件)</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+N</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionQuit">
|
||||
<property name="icon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<iconset>
|
||||
<normaloff>:/icons/quit.png</normaloff>:/icons/quit.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>退出</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Q</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>退出程序</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Q</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToggleToolBar">
|
||||
<property name="checkable">
|
||||
@@ -213,7 +207,7 @@
|
||||
</action>
|
||||
<action name="actionToggleDockFloating">
|
||||
<property name="icon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<iconset>
|
||||
<normaloff>:/icons/dock.png</normaloff>:/icons/dock.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
@@ -281,7 +275,7 @@
|
||||
</action>
|
||||
<action name="actionAbout">
|
||||
<property name="icon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<iconset>
|
||||
<normaloff>:/icons/about.png</normaloff>:/icons/about.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
@@ -292,8 +286,6 @@
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="mainwindow.qrc"/>
|
||||
</resources>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#include "mainwindow2.h"
|
||||
#include "ui_mainwindow2.h"
|
||||
|
||||
MainWindow2::MainWindow2(QWidget *parent) :
|
||||
QMainWindow(parent),
|
||||
ui(new Ui::MainWindow2)
|
||||
{
|
||||
ui->setupUi(this);
|
||||
}
|
||||
|
||||
MainWindow2::~MainWindow2()
|
||||
{
|
||||
delete ui;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#ifndef MAINWINDOW2_H
|
||||
#define MAINWINDOW2_H
|
||||
|
||||
#include <QMainWindow>
|
||||
|
||||
namespace Ui {
|
||||
class MainWindow2;
|
||||
}
|
||||
|
||||
class MainWindow2 : public QMainWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainWindow2(QWidget *parent = nullptr);
|
||||
~MainWindow2();
|
||||
|
||||
private:
|
||||
Ui::MainWindow2 *ui;
|
||||
};
|
||||
|
||||
#endif // MAINWINDOW2_H
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow2</class>
|
||||
<widget class="QMainWindow" name="MainWindow2">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>640</width>
|
||||
<height>480</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget"/>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>640</width>
|
||||
<height>21</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>&File</string>
|
||||
</property>
|
||||
<addaction name="actionOpen"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuEdit">
|
||||
<property name="title">
|
||||
<string>Edit</string>
|
||||
</property>
|
||||
<addaction name="actionOpen"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuAbout">
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<addaction name="actionOpen"/>
|
||||
<addaction name="actionAbout_Me"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuEdit"/>
|
||||
<addaction name="menuAbout"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<action name="actionOpen">
|
||||
<property name="text">
|
||||
<string>&Open</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Alt+O</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout_Me">
|
||||
<property name="text">
|
||||
<string>About Me</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -21,6 +21,12 @@
|
||||
|
||||
三段对比让两个结论同时成立:**耗时任务必须离开主线程**(否则冻结);**多线程进一步压榨多核**(加速比)。
|
||||
|
||||
> **关于「主线程渲染」的冻结时长**:单帧 820×640@256 单线程仅约 **70ms**,远低于人眼可感阈值,
|
||||
> 直接算一帧看不出「冻结」。故该按钮**自适应循环渲染直到累计 ≥ 1500ms**(实测约 23 帧 / 1506ms),
|
||||
> 保证在任何 CPU 上冻结都肉眼可感。冻结的本质是「事件循环被占用」,与算什么无关——循环只是模拟一个
|
||||
> 持续 ~1.5s 的耗时操作。**演示技巧**:按下该按钮后立即拖动窗口或点击其它按钮,会发现整 1.5s 内
|
||||
> 完全无响应,这就是事件循环停摆。
|
||||
|
||||
## 概念映射
|
||||
|
||||
| Day4 知识点 | 本示例实现 |
|
||||
|
||||
@@ -143,15 +143,23 @@ void MandelbrotWidget::onRenderOnMainThread() {
|
||||
pixmap_ = QImage(vp_.width, vp_.height, QImage::Format_RGB32);
|
||||
const int H = vp_.height;
|
||||
const int chunk = 24; // 分块调 renderStrip 只为复用同一纯函数
|
||||
// 自适应加重:单帧仅约几十毫秒(实测 820x640@256 ≈ 66ms),不足以让「冻结」肉眼可感。
|
||||
// 故循环渲染直到累计 ≥ 1500ms——冻结的本质是事件循环被占用,与「算什么」无关,
|
||||
// 这模拟任意一个「占着 GUI 线程不返回事件循环」的耗时操作(解析大文件、复杂运算……)。
|
||||
// 上限 100 轮防止极慢机器卡死过久。
|
||||
int frames = 0;
|
||||
do {
|
||||
for (int y = 0; y < H; y += chunk) {
|
||||
const QImage s = renderStrip(vp_, pal_, y, qMin(y + chunk, H));
|
||||
QPainter p(&pixmap_);
|
||||
p.drawImage(0, y, s);
|
||||
}
|
||||
++frames;
|
||||
} while (timer_.elapsed() < 1500 && frames < 100);
|
||||
// 注意:本函数返回前事件循环不跑——期间按钮无响应、窗口拖不动、paintEvent 排队等待。
|
||||
update();
|
||||
label_->setText(QString(QStringLiteral("主线程渲染 (界面冻结) · %1 ms"))
|
||||
.arg(int(timer_.elapsed())));
|
||||
label_->setText(QString(QStringLiteral("主线程渲染 (界面冻结) · %1 ms · %2 轮"))
|
||||
.arg(int(timer_.elapsed())).arg(frames));
|
||||
}
|
||||
|
||||
// ---- 单/多线程切换:复用同一套分块逻辑,仅切线程池大小 ----
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 649 KiB |
@@ -0,0 +1,45 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
|
||||
project(qt_dialog_showcase LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 11)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# QtCreator supports the following variables for Android, which are identical to qmake Android variables.
|
||||
# Check http://doc.qt.io/qt-5/deployment-android.html for more information.
|
||||
# They need to be set before the find_package(Qt5 ...) call.
|
||||
|
||||
#if(ANDROID)
|
||||
# set(ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android")
|
||||
# if (ANDROID_ABI STREQUAL "armeabi-v7a")
|
||||
# set(ANDROID_EXTRA_LIBS
|
||||
# ${CMAKE_CURRENT_SOURCE_DIR}/path/to/libcrypto.so
|
||||
# ${CMAKE_CURRENT_SOURCE_DIR}/path/to/libssl.so)
|
||||
# endif()
|
||||
#endif()
|
||||
|
||||
find_package(Qt5 COMPONENTS Widgets REQUIRED)
|
||||
|
||||
if(ANDROID)
|
||||
add_library(qt_dialog_showcase
|
||||
qt_dialog_showcase.cpp
|
||||
dialog.cpp
|
||||
dialog.h
|
||||
dialog.ui
|
||||
)
|
||||
else()
|
||||
add_executable(qt_dialog_showcase
|
||||
dialog.cpp
|
||||
dialog.h
|
||||
dialog.ui
|
||||
resources.qrc
|
||||
qt_dialog_showcase.cpp)
|
||||
endif()
|
||||
|
||||
target_link_libraries(qt_dialog_showcase PRIVATE Qt5::Widgets)
|
||||
@@ -0,0 +1,78 @@
|
||||
# QDialog 教学综合示例(qt_dialog_showcase)
|
||||
|
||||
参考川大 wiki「6 对话框 QDialog」(pageId 183664881) 的 **6.2–6.5**,形态为一个
|
||||
**QWidget 布满 pushbutton**:每个按钮触发一种 `QDialog` 行为。纯代码(无 `.ui`),主类不
|
||||
定义信号槽,全部用 lambda 连接 `QPushButton::clicked`。
|
||||
|
||||
> 仓库 `p03/ch06` 已有零散单行为示例(modal_dialog / modeless_dialog_* / message_box_* /
|
||||
> file_dialog);本示例是**综合演示**,把页面的全部行为聚到一个窗口对照体验。
|
||||
|
||||
## 16 个按钮 ↔ 页面行为
|
||||
|
||||
### ① 模态 / 非模态(6.3)
|
||||
| 按钮 | 行为 | 关键 API / 要点 |
|
||||
|---|---|---|
|
||||
| 模态 exec | `QDialog::exec()` | 应用级模态,阻塞整个应用直到关闭 |
|
||||
| 窗口模态 open | `QDialog::open()` | 窗口级模态,仅阻塞父窗口 |
|
||||
| 非模态 show | `new QDialog->show()` | 非模态,立即返回;**栈对象会一闪而过**(6.3 的坑) |
|
||||
| 非模态+删除关闭 | `setAttribute(WA_DeleteOnClose)+show()` | 关闭即 delete,避免无 parent 堆对象泄漏 |
|
||||
|
||||
### ② QMessageBox(6.4)
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| about / aboutQt / critical / information / question / warning | 六个 static 函数 |
|
||||
| 自定义消息框 | 实例 API:`setIcon / setText / setInformativeText / setDetailedText / setStandardButtons / setDefaultButton` + `exec` |
|
||||
|
||||
### ③ 标准对话框(6.2 / 6.5)
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 打开文件 | `QFileDialog::getOpenFileName` |
|
||||
| 保存文件 | `QFileDialog::getSaveFileName` |
|
||||
| 选颜色 | `QColorDialog::getColor` |
|
||||
| 选字体 | `QFontDialog::getFont` |
|
||||
| 输入文字 | `QInputDialog::getText` |
|
||||
|
||||
底部 `QLabel` 显示标准对话框 / question / 自定义消息框的返回值。
|
||||
|
||||
## 关键教学点
|
||||
- **exec vs open vs show**:三种模态/非模态实现,对应 6.3。`exec()` 阻塞整个应用;
|
||||
`open()` 阻塞父窗口;`show()` 不阻塞。
|
||||
- **栈对象坑(6.3)**:`QDialog d; d.show();` 在函数返回时 `d` 析构,对话框立即消失 →
|
||||
必须用堆对象(`new`)。
|
||||
- **WA_DeleteOnClose(6.3)**:无 parent 的堆对象靠它在关闭时自动 `delete`,避免内存泄漏。
|
||||
- **QMessageBox static vs 实例(6.4)**:static(about / question / …)调用最简;实例 API
|
||||
可设详细文本(`setDetailedText` 出现「显示详情...」按钮)与任意按钮组合。
|
||||
- **标准对话框统一风格(6.2/6.5)**:`QFileDialog / QColorDialog / QFontDialog / QInputDialog`
|
||||
都是 `get...` static,返回用户选择,`isEmpty()`/`isValid()`/`&ok` 判断是否取消。
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
qdialog_showcase/
|
||||
├── qt_dialog_showcase.cpp # DialogShowcase : QWidget(无 Q_OBJECT,纯 lambda)+ main
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 构建与运行(Windows + MinGW 7.3.0 + Qt 5.14.2)
|
||||
> 必须用与 Qt 5.14.2 ABI 匹配的 mingw730_64,不可用 PATH 中更高版本的 g++。
|
||||
|
||||
```bash
|
||||
# 配置(在仓库根)
|
||||
cmake -S . -B build_mw -G "MinGW Makefiles" \
|
||||
-DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64 \
|
||||
-DCMAKE_CXX_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/g++.exe \
|
||||
-DCMAKE_C_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/gcc.exe
|
||||
|
||||
# 构建单目标
|
||||
cmake --build build_mw --target qt_dialog_showcase
|
||||
|
||||
# 运行(GUI,逐个点按钮体验)
|
||||
PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
|
||||
QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \
|
||||
./build_mw/docs/teaching/examples/qt_dialog_showcase.exe
|
||||
|
||||
# 离屏自动退出验证(不点按钮,退出码应为 0)
|
||||
QT_QPA_PLATFORM=offscreen \
|
||||
QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \
|
||||
PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
|
||||
./build_mw/docs/teaching/examples/qt_dialog_showcase.exe; echo "exit=$?"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
#include "dialog.h"
|
||||
#include "ui_dialog.h"
|
||||
#include <QMovie>
|
||||
#include <QDebug>
|
||||
|
||||
Dialog::Dialog(QWidget *parent) :
|
||||
QDialog(parent),
|
||||
ui(new Ui::Dialog)
|
||||
{
|
||||
ui->setupUi(this);
|
||||
this->pMovie = new QMovie(":/source.gif");
|
||||
this->pMovie->start();
|
||||
ui->label->setMovie(this->pMovie);
|
||||
}
|
||||
|
||||
Dialog::~Dialog()
|
||||
{
|
||||
delete this->pMovie;
|
||||
delete ui;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef DIALOG_H
|
||||
#define DIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
namespace Ui {
|
||||
class Dialog;
|
||||
}
|
||||
|
||||
class Dialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit Dialog(QWidget *parent = nullptr);
|
||||
~Dialog();
|
||||
|
||||
private:
|
||||
Ui::Dialog *ui;
|
||||
QMovie *pMovie;
|
||||
};
|
||||
|
||||
#endif // DIALOG_H
|
||||
@@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Dialog</class>
|
||||
<widget class="QDialog" name="Dialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>640</width>
|
||||
<height>480</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Dialog</string>
|
||||
</property>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>10</x>
|
||||
<y>440</y>
|
||||
<width>621</width>
|
||||
<height>32</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QLabel" name="label2">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>120</x>
|
||||
<y>70</y>
|
||||
<width>100</width>
|
||||
<height>100</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>100</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::NoFrame</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="pixmap">
|
||||
<pixmap resource="resources.qrc">:/source.gif</pixmap>
|
||||
</property>
|
||||
<property name="scaledContents">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>140</x>
|
||||
<y>190</y>
|
||||
<width>200</width>
|
||||
<height>241</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>200</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>MarioGif</string>
|
||||
</property>
|
||||
<property name="textFormat">
|
||||
<enum>Qt::PlainText</enum>
|
||||
</property>
|
||||
<property name="scaledContents">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="resources.qrc"/>
|
||||
</resources>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>Dialog</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>248</x>
|
||||
<y>254</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>157</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>Dialog</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>316</x>
|
||||
<y>260</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>286</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -0,0 +1,188 @@
|
||||
// ============================================================================
|
||||
// qdialog_showcase —— QDialog 教学综合示例
|
||||
// 参考川大 wiki「6 对话框 QDialog」(pageId 183664881) 的 6.2–6.5:
|
||||
// 6.3 模态/非模态(exec / open / show + WA_DeleteOnClose)
|
||||
// 6.4 QMessageBox(六个 static + 实例 API)
|
||||
// 6.2/6.5 标准对话框(QFileDialog / QColorDialog / QFontDialog / QInputDialog)
|
||||
// 形态:一个 QWidget 布满 pushbutton,每个按钮触发一种 QDialog 行为。
|
||||
// 纯代码(无 .ui);主类不定义信号槽,全部用 lambda 连接 QPushButton::clicked。
|
||||
// ============================================================================
|
||||
#include <utility>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QGridLayout>
|
||||
#include <QPushButton>
|
||||
#include <QLabel>
|
||||
#include <QDialog>
|
||||
#include <QMessageBox>
|
||||
#include <QFileDialog>
|
||||
#include <QColorDialog>
|
||||
#include <QFontDialog>
|
||||
#include <QInputDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QTimer>
|
||||
#include "dialog.h"
|
||||
|
||||
class DialogShowcase : public QWidget {
|
||||
public:
|
||||
explicit DialogShowcase(QWidget *parent = nullptr) : QWidget(parent) {
|
||||
auto *root = new QVBoxLayout(this);
|
||||
|
||||
// 反馈标签:显示标准对话框 / question 的返回值
|
||||
statusLabel = new QLabel(QStringLiteral("点击下方按钮,体验不同的 QDialog 行为。"));
|
||||
statusLabel->setWordWrap(true);
|
||||
statusLabel->setMinimumHeight(36);
|
||||
|
||||
// ① 模态/非模态(6.3) -------------------------------------------------
|
||||
auto *g1 = new QGroupBox(QStringLiteral("① 模态/非模态(6.3)"));
|
||||
auto *l1 = new QGridLayout(g1);
|
||||
addBtn(l1, 0, 0, QStringLiteral("模态 exec"), [this] {
|
||||
QDialog d(this);
|
||||
d.setWindowTitle(QStringLiteral("模态对话框(exec)"));
|
||||
d.exec(); // 应用级模态:阻塞整个应用,直到关闭
|
||||
setStatus(QStringLiteral("exec() 返回 —— 应用级模态已关闭"));
|
||||
});
|
||||
addBtn(l1, 0, 1, QStringLiteral("窗口模态 open"), [this] {
|
||||
auto *d = new QDialog(this);
|
||||
d->setWindowTitle(QStringLiteral("窗口级模态(open)"));
|
||||
d->setAttribute(Qt::WA_DeleteOnClose);
|
||||
d->open(); // 窗口级模态:仅阻塞父窗口,其余窗口仍可交互
|
||||
setStatus(QStringLiteral("open() 弹出窗口级模态(仅阻塞父窗口)"));
|
||||
});
|
||||
addBtn(l1, 1, 0, QStringLiteral("非模态 show"), [this] {
|
||||
// 必须用堆对象:栈对象在 show() 返回后随作用域结束而析构,对话框一闪而过
|
||||
// (页面 6.3 强调的坑)。这里给 parent this,由 Qt 对象树托管生命周期。
|
||||
auto *d = new QDialog(this);
|
||||
d->setWindowTitle(QStringLiteral("非模态对话框(show)"));
|
||||
d->show(); // 非模态:立即返回、不阻塞,可与主窗口同时操作
|
||||
setStatus(QStringLiteral("show() 非模态弹出(不阻塞)"));
|
||||
});
|
||||
addBtn(l1, 1, 1, QStringLiteral("非模态+删除关闭"), [this] {
|
||||
// 无 parent 的堆对象:靠 WA_DeleteOnClose 在关闭时自动 delete,避免泄漏
|
||||
auto *d = new QDialog;
|
||||
d->setWindowTitle(QStringLiteral("WA_DeleteOnClose"));
|
||||
d->setAttribute(Qt::WA_DeleteOnClose);
|
||||
d->show();
|
||||
setStatus(QStringLiteral("show() + WA_DeleteOnClose:关闭即销毁"));
|
||||
});
|
||||
|
||||
// ② QMessageBox(6.4) -------------------------------------------------
|
||||
auto *g2 = new QGroupBox(QStringLiteral("② QMessageBox(6.4)"));
|
||||
auto *l2 = new QGridLayout(g2);
|
||||
addBtn(l2, 0, 0, QStringLiteral("about"), [this] {
|
||||
QMessageBox::about(this, QStringLiteral("about"),
|
||||
QStringLiteral("这是 about 对话框(只有一个 OK)。"));
|
||||
});
|
||||
addBtn(l2, 0, 1, QStringLiteral("aboutQt"), [this] {
|
||||
QMessageBox::aboutQt(this, QStringLiteral("aboutQt"));
|
||||
});
|
||||
addBtn(l2, 0, 2, QStringLiteral("critical"), [this] {
|
||||
QMessageBox::critical(this, QStringLiteral("critical"), QStringLiteral("严重错误!"));
|
||||
});
|
||||
addBtn(l2, 1, 0, QStringLiteral("information"), [this] {
|
||||
QMessageBox::information(this, QStringLiteral("information"), QStringLiteral("提示信息。"));
|
||||
});
|
||||
addBtn(l2, 1, 1, QStringLiteral("question"), [this] {
|
||||
const auto r = QMessageBox::question(this, QStringLiteral("question"),
|
||||
QStringLiteral("你确定吗?"),
|
||||
QMessageBox::Yes | QMessageBox::No,
|
||||
QMessageBox::Yes);
|
||||
setStatus(QStringLiteral("question 返回:%1")
|
||||
.arg(r == QMessageBox::Yes ? QStringLiteral("Yes") : QStringLiteral("No")));
|
||||
});
|
||||
addBtn(l2, 1, 2, QStringLiteral("warning"), [this] {
|
||||
QMessageBox::warning(this, QStringLiteral("warning"), QStringLiteral("警告!"));
|
||||
});
|
||||
addBtn(l2, 2, 0, QStringLiteral("自定义消息框"), [this] {
|
||||
QMessageBox box(QMessageBox::Question, QStringLiteral("自定义消息框"),
|
||||
QStringLiteral("文档已被修改。"),
|
||||
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, this);
|
||||
box.setInformativeText(QStringLiteral("是否保存修改?"));
|
||||
box.setDetailedText(QStringLiteral("这里是详细差异信息……"));
|
||||
box.setDefaultButton(QMessageBox::Save);
|
||||
const int r = box.exec();
|
||||
const QString name =
|
||||
(r == QMessageBox::Save) ? QStringLiteral("保存") :
|
||||
(r == QMessageBox::Discard) ? QStringLiteral("丢弃") :
|
||||
QStringLiteral("取消");
|
||||
setStatus(QStringLiteral("自定义消息框返回:%1").arg(name));
|
||||
});
|
||||
|
||||
// ③ 标准对话框(6.2 / 6.5) -------------------------------------------
|
||||
auto *g3 = new QGroupBox(QStringLiteral("③ 标准对话框(6.2 / 6.5)"));
|
||||
auto *l3 = new QGridLayout(g3);
|
||||
addBtn(l3, 0, 0, QStringLiteral("打开文件"), [this] {
|
||||
const QString f = QFileDialog::getOpenFileName(this, QStringLiteral("打开文件"));
|
||||
if (!f.isEmpty()) setStatus(QStringLiteral("打开文件:%1").arg(f));
|
||||
});
|
||||
addBtn(l3, 0, 1, QStringLiteral("保存文件"), [this] {
|
||||
const QString f = QFileDialog::getSaveFileName(this, QStringLiteral("保存文件"));
|
||||
if (!f.isEmpty()) setStatus(QStringLiteral("保存文件:%1").arg(f));
|
||||
});
|
||||
addBtn(l3, 1, 0, QStringLiteral("选颜色"), [this] {
|
||||
const QColor c = QColorDialog::getColor(Qt::white, this, QStringLiteral("选颜色"));
|
||||
if (c.isValid())
|
||||
setStatus(QStringLiteral("选中颜色 RGB(%1,%2,%3)")
|
||||
.arg(c.red()).arg(c.green()).arg(c.blue()));
|
||||
});
|
||||
addBtn(l3, 1, 1, QStringLiteral("选字体"), [this] {
|
||||
bool ok = false;
|
||||
const QFont f = QFontDialog::getFont(&ok, font(), this, QStringLiteral("选字体"));
|
||||
if (ok) setStatus(QStringLiteral("选中字体:%1").arg(f.family()));
|
||||
});
|
||||
addBtn(l3, 2, 0, QStringLiteral("输入文字"), [this] {
|
||||
bool ok = false;
|
||||
const QString s = QInputDialog::getText(this, QStringLiteral("输入文字"),
|
||||
QStringLiteral("请输入:"),
|
||||
QLineEdit::Normal, QString(), &ok);
|
||||
if (ok) setStatus(QStringLiteral("输入:%1").arg(s));
|
||||
});
|
||||
addBtn(l3, 2, 0, QStringLiteral("输入文字"), [this] {
|
||||
bool ok = false;
|
||||
const QString s = QInputDialog::getText(this, QStringLiteral("输入文字"),
|
||||
QStringLiteral("请输入:"),
|
||||
QLineEdit::Normal, QString(), &ok);
|
||||
if (ok) setStatus(QStringLiteral("输入:%1").arg(s));
|
||||
});
|
||||
addBtn(l3, 2, 1, QStringLiteral("自定义对话框"), [] {
|
||||
QDialog *customDialog = new Dialog();
|
||||
customDialog->setAttribute(Qt::WA_DeleteOnClose);
|
||||
customDialog->exec();
|
||||
});
|
||||
root->addWidget(g1);
|
||||
root->addWidget(g2);
|
||||
root->addWidget(g3);
|
||||
root->addWidget(statusLabel);
|
||||
|
||||
setWindowTitle(QStringLiteral("QDialog 教学综合示例"));
|
||||
resize(520, 430);
|
||||
}
|
||||
|
||||
private:
|
||||
QLabel *statusLabel = nullptr;
|
||||
|
||||
// 在 QGridLayout(row, col) 放一个按钮,clicked 连到 lambda
|
||||
template <typename F>
|
||||
void addBtn(QGridLayout *layout, int row, int col, const QString &text, F &&slot) {
|
||||
auto *btn = new QPushButton(text, this);
|
||||
layout->addWidget(btn, row, col);
|
||||
connect(btn, &QPushButton::clicked, this, std::forward<F>(slot));
|
||||
}
|
||||
|
||||
void setStatus(const QString &s) { statusLabel->setText(s); }
|
||||
};
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
DialogShowcase w;
|
||||
w.show();
|
||||
// 仅在离屏自动化验证时 200ms 后自动退出(仿 p03 风格);
|
||||
// 正常运行窗口保持,不自动退出。不点按钮则不触发任何对话框。
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(200, &app, &QApplication::quit);
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>source.gif</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 649 KiB |
@@ -122,6 +122,7 @@ public:
|
||||
auto* actAdd = new QAction(QStringLiteral("添加记录"), this);
|
||||
auto* actSave = new QAction(QStringLiteral("保存"), this);
|
||||
auto* actLoad = new QAction(QStringLiteral("读取"), this);
|
||||
auto* actRemoveSelected = new QAction(QStringLiteral("删除选中"), this);
|
||||
m_actImport = new QAction(QStringLiteral("后台导入"), this);
|
||||
auto* actAbout = new QAction(QStringLiteral("关于"), this);
|
||||
|
||||
@@ -131,12 +132,14 @@ public:
|
||||
QMenu* recordMenu = menuBar()->addMenu(QStringLiteral("记录(&R)"));
|
||||
recordMenu->addAction(actAdd);
|
||||
recordMenu->addAction(m_actImport);
|
||||
recordMenu->addAction(actRemoveSelected);
|
||||
menuBar()->addMenu(QStringLiteral("帮助(&H)"))->addAction(actAbout);
|
||||
|
||||
QToolBar* bar = addToolBar(QStringLiteral("main"));
|
||||
bar->addAction(actAdd);
|
||||
bar->addAction(actSave);
|
||||
bar->addAction(actLoad);
|
||||
bar->addAction(actRemoveSelected);
|
||||
bar->addAction(m_actImport);
|
||||
|
||||
// QAction 显示出来 ≠ 生效:忘 connect 就是「菜单点了没反应」(Day3 坑卡)
|
||||
@@ -144,6 +147,7 @@ public:
|
||||
connect(actSave, &QAction::triggered, this, [this] { saveTo(m_dataPath); });
|
||||
connect(actLoad, &QAction::triggered, this, [this] { loadFrom(m_dataPath); });
|
||||
connect(m_actImport, &QAction::triggered, this, &MainWindow::onImport);
|
||||
connect(actRemoveSelected, &QAction::triggered, this, &MainWindow::onRemoveSelected);
|
||||
connect(actAbout, &QAction::triggered, this, [this] {
|
||||
QMessageBox::about(this, QStringLiteral("关于"),
|
||||
QStringLiteral("Day5 综合案例参考实现:Day1–4 全部知识点的收束。"));
|
||||
@@ -165,6 +169,12 @@ public:
|
||||
m_table->setItem(row, 2, new QTableWidgetItem(s.tag));
|
||||
}
|
||||
|
||||
void onRemoveSelected() {
|
||||
// for (int row = m_table->selectedRows().size() - 1; row >= 0; --row) {
|
||||
// m_table->removeRow(m_table->selectedRows().at(row).row());
|
||||
// }
|
||||
}
|
||||
|
||||
bool saveTo(const QString& path) {
|
||||
QJsonArray arr;
|
||||
for (int row = 0; row < m_table->rowCount(); ++row) {
|
||||
|
||||
Binary file not shown.
@@ -267,11 +267,49 @@ grid->addWidget(btn9, 0, 2); grid->addWidget(btnDiv, 0, 3); // 行、列坐标
|
||||
<div class="concept-box">
|
||||
<h4>组装思路</h4>
|
||||
<ul>
|
||||
<li>数字/运算符按钮(上午的 <code>button_creation</code>)+ 点击信号槽(刚学的三种写法)
|
||||
+ <code>QGridLayout</code> 脚手架排键盘 + <code>QLabel</code>/<code>QLineEdit</code> 显示结果。</li>
|
||||
<li>数字/运算符按钮(上午的 <code>button_creation</code>)+ 点击信号槽(刚学的两种写法:
|
||||
lambda+context 批量绑定数字/运算符键,成员函数指针绑定等号/清空/退格)
|
||||
+ <code>QGridLayout</code> 脚手架排键盘 + <code>QLineEdit</code> 显示结果。</li>
|
||||
<li>项目 = 已学知识点的组装——这是 Day5 综合案例和 Day6–10 项目的预演。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p><small>教师完整参考实现(现场先跑成品,再拆代码):
|
||||
<a class="filelink" target="_blank" href="../examples/calculator/qt_calculator.cpp">examples/calculator/qt_calculator.cpp</a>
|
||||
(<a class="filelink" target="_blank" href="../examples/calculator/README.md">README</a>,
|
||||
<code>QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator</code> 自测 5 组用例)。
|
||||
<b>课上不要直接展示源码给学生抄</b>——讲思路,学生自己实现(任务卡 2)。</small></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>★ 番外:Qt Designer 表单版对照 <span class="tag tag-lecture">讲</span></h3>
|
||||
<div class="concept-box">
|
||||
<h4>核心概念</h4>
|
||||
<ul>
|
||||
<li>界面还能用 <b>Qt Designer</b> 拖控件生成 <code>.ui</code> 表单(纯 XML),
|
||||
<code>uic</code> 编译期翻译成 <code>ui_xxx.h</code>,<code>setupUi(this)</code>
|
||||
一次建好控件树——业务逻辑(<code>onDigit</code>/<code>onOperator</code>……)
|
||||
与代码布局版<b>一字不差</b>,换的只是"界面从哪来"。</li>
|
||||
<li><code>setupUi()</code> 内部会 <code>connectSlotsByName(this)</code>:类里若有
|
||||
<code>on_<objectName>_<signal>()</code> 槽会被<b>自动连接</b>——这是
|
||||
信号槽之外、Designer 工作流特有的第三种"连接方式"。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pitfall-box">
|
||||
<h4>常见的坑</h4>
|
||||
<ul>
|
||||
<li>把表单里按钮 <code>objectName</code> 从 <code>btnClear</code> 改成
|
||||
<code>btnReset</code>:<code>on_btnClear_clicked()</code> 不报编译错误、不报运行时
|
||||
错误,只是从此再也不会被调用——静默失败坑点家族的第三个成员(前两个:旧
|
||||
<code>SIGNAL()/SLOT()</code> 字符串语法、重复 <code>connect</code>)。
|
||||
本参考实现故意改用显式 <code>connect()</code> 规避这个坑。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p><small>对照实现:<a class="filelink" target="_blank"
|
||||
href="../examples/calculator_designer/qt_calculator_designer.cpp">examples/calculator_designer/qt_calculator_designer.cpp</a>
|
||||
+ <a class="filelink" target="_blank" href="../examples/calculator_designer/calculator_designer.ui">calculator_designer.ui</a>
|
||||
(<a class="filelink" target="_blank" href="../examples/calculator_designer/README.md">README</a>)——
|
||||
快速原型/界面调整用 Designer 更直观;控件数量随数据动态变化(如 Day5 表格行)
|
||||
必须手写代码搭建,两条路线按场景选,不是谁取代谁。</small></p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -294,6 +332,9 @@ grid->addWidget(btn9, 0, 2); grid->addWidget(btnDiv, 0, 3); // 行、列坐标
|
||||
<dd><a class="filelink" target="_blank" href="../../../p03/ch03/button_creation.cpp">p03/ch03/button_creation.cpp</a> ·
|
||||
<a class="filelink" target="_blank" href="../../../p03/ch04/custom_signal_slot.cpp">p03/ch04/custom_signal_slot.cpp</a> ·
|
||||
<a class="filelink" target="_blank" href="../../../p03/ch08/custom_widget.cpp">p03/ch08/custom_widget.cpp</a>(布局参考)</dd>
|
||||
<dt>教师答案(不下发)</dt>
|
||||
<dd><a class="filelink" target="_blank" href="../examples/calculator/qt_calculator.cpp">examples/calculator/</a> ——
|
||||
验收时用同一组用例(7+3、6÷0、4.5+0.5、连续按运算符)现场提问对照</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user