Files
scuc-qt-course/docs/teaching/PROJECT_GUIDE.md
T
张宗平 8d69cbebd5 讲义新增:实训项目集六项目深度解析(04/11/02/05/06/08)
wiki「04. 项目集」(pageId 183664900)中学生关注度较高的六个项目,
每个按「定位 → 题目重点 → 技术难点拆解 → 高频坑与排查 →
里程碑启动路径」展开,并附 Qt5/Qt6 版本差异说明;
分层原则与 ASSIGNMENTS.md 一致(先保最小可运行闭环,再逐层加分)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 03:53:10 +08:00

660 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实训项目集深度解析(按学生关注度:04 / 11 / 02 / 05 / 06 / 08
> 题目来源:wiki「02. Qt方向 → 04. 项目集」(pageId 18366490016 个项目)。
> 本文对其中学生关注度较高的 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 自己不解码,依赖系统后端:
WindowsDirectShow/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. **M21 天)**`QFileDialog` 多选添加 → `QMediaPlaylist` + Model/View 列表 →
双击列表任意行播放。
3. **M31 天)**:播放/暂停/上下曲按钮、进度条(含拖动 seek)、音量条、
时间标签。到这里已经可以日常使用。
4. **M41 天)**:三种播放模式切换 + 元数据/封面展示(含降级链)+ 列表持久化。
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 ToolsQt 安装时勾选
MSVC 组件),直接用官方 OpenCV 包,最省事;
2. 坚持 MinGW,就要用 CMake + 同版本 MinGW 自己编译 OpenCV(预留一个晚上);
3. Linux/WSL`apt install libopencv-dev`,基本没有这个问题。
第一周内跑不通"读一张图并显示",就应该向老师求助或换项目。环境是本题
最大的淘汰因素,及时求助不丢人。
**难点 1`cv::Mat` → `QImage` 的三个常见错误。**
- 通道序:OpenCV 是 BGRQt 是 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` 模拟并发购买,演示"不加锁会超卖、加锁不会"。
### 技术难点拆解
**难点 1QStateMachine 是事件驱动异步的,和直觉不同。**
- `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. **M11 天)**:不带界面,先写 `VendingData` 数据类(商品表、零钱表、
加锁的 deduct/refund 接口)+ JSON load/save,用 qDebug 自测。数据层立住,
后面基本是接线工作。
2. **M21 天)**:前台购买界面(商品按钮阵列 + 投币按钮 + 信息提示区),
先用普通槽函数把"投币→选货→出货→找零"跑通(暂时用 if-else 没关系)。
3. **M312 天)**:引入 QStateMachine 重构 M2 的流程控制——按钮使能交给
`assignProperty`,流程迁移交给 transition。先能跑再重构,比一步到位稳。
4. **M41 天)**:管理员后台(密码 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>` 也能达标,但数据量大后启动变慢。
**难点 3ONNX 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. **M11 天)**:选文件夹 → 列出所有图片文件 → 同步加载 20 张的缩略图网格 +
双击全屏看图(先不管性能,打通视觉闭环)。
2. **M212 天)**:把 M1 的加载改成 QImageReader 降采样 + 线程池异步。
验收标准:导入 500 张照片,滚动不卡。这是本题技术难度最高的一步,值得花足时间。
3. **M31 天)**:元数据层——SQLite/JSON 三表结构,手动标签、星级、收藏的
增删改查 + 持久化。
4. **M41 天)**:搜索框(文件名/标签实时过滤)+ 组合筛选面板(日期范围/评分/
收藏)。到此为完整达标的相册管理器。
5. **M5(加分)**:虚拟相册、旋转/裁剪/亮度对比度编辑 + QUndoStack 撤销、
缩略图磁盘缓存。
6. **M6(大加分,量力)**:ONNX 自动标签;人脸检测分组(可参考项目 11 的
OpenCV 部分,环境成本也相同)。
### 版本说明
- `QImageReader::setAutoTransform` 在 Qt5 默认 falseQt6 默认 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 导入、持久化)是体力活,但中文编码有一个
几乎必踩的坑,见下。
### 技术难点拆解
**难点 1CSV/文本导入的编码。** 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 文本才是对口工具
3050ms 一换足够快)。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. **M21 天)**:主界面:名单列表(增删改)+ txt/CSV 导入(含编码处理)+
JSON 持久化。
3. **M31 天)**:单人抽取全流程:开始滚动 → 停止减速定格 → 结果进
"已中奖"列表;可重复/不可重复开关。
4. **M4(半天)**:多人抽取模式 + 中奖记录导出 txt + 抽奖/点名模式切换文案。
5. **M5(加分)**:权重列 + 轮盘赌抽奖、分组抽奖、结果揭晓 QPropertyAnimation
动画、背景音乐音效、全屏模式、自定义背景图。
### 版本说明
- `QTextCodec` 在 Qt6 移入 core5compat 兼容模块(Qt6 正统是
`QStringConverter`/`QStringDecoder`)。课程环境 Qt 5.14 直接用 QTextCodec 即可。
- `QRandomGenerator` 需要 Qt ≥ 5.10,课程环境没问题;教程里出现
`qsrand/qrand`(已废弃)的可以跳过不看。
---
## 08. 双人飞机大战 ⭐⭐⭐⭐⭐
### 定位
一道实时程序架构题:游戏循环、大量对象的生命周期管理、键盘多键并发、
碰撞检测,和前五个事件驱动的表单程序是完全不同的思维方式。
它是六个里代码量最大、最容易烂尾的:功能清单(双人、三模式、道具、
BOSS、弹幕)是开放的,没有里程碑约束很容易把时间耗在素材和关卡设计上。
另一面是演示效果最好,适合愿意投入的同学。
### 题目重点
- 先选定框架路线,两条路二选一,不要混:
- **路线 AQGraphicsView 框架**(题面推荐)——`QGraphicsScene` 管对象、
`QGraphicsItem` 派生飞机/子弹、场景自带 `collidingItems()` 碰撞检测。
省很多底层代码,但要理解 item 坐标系和场景坐标系的区别。
- **路线 BQWidget + 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. **M11 天)**:窗口 + 游戏循环 timer + 一架能被 WASD 流畅操控的飞机
(按键状态表一步到位)。这一步的手感决定整个项目的手感。
2. **M21 天)**:玩家子弹(按住 J 连发、出界清理)+ 敌机从顶部随机生成
直线下落 + 子弹打敌机的碰撞与爆炸消失。到这里游戏已经成立。
3. **M31 天)**:敌机撞玩家/敌弹打玩家 → 生命值与无敌闪烁 → 得分显示 →
Game Over 判定。加第二名玩家(方向键 + 小键盘0),合作模式成立。
4. **M41 天)**:滚动背景、开始菜单/暂停/结束三场景切换(简单 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。这批项目的失败方式高度一致:
前两周画界面调样式,最后一周才发现核心链路(能出声/能链上库/状态机能转/
键盘不卡)有硬问题。先做最难的部分。