wiki「04. 项目集」(pageId 183664900)中学生关注度较高的六个项目, 每个按「定位 → 题目重点 → 技术难点拆解 → 高频坑与排查 → 里程碑启动路径」展开,并附 Qt5/Qt6 版本差异说明; 分层原则与 ASSIGNMENTS.md 一致(先保最小可运行闭环,再逐层加分)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
43 KiB
实训项目集深度解析(按学生关注度:04 / 11 / 02 / 05 / 06 / 08)
题目来源:wiki「02. Qt方向 → 04. 项目集」(pageId 183664900,16 个项目)。 本文对其中学生关注度较高的 6 个项目做深入拆解:题目重点是什么、技术难点在哪、 哪些地方容易卡住、卡住了往哪个方向查。目标是让学生顺利启动、按里程碑推进, 不在环境和 API 细节上消耗整周时间。
每个项目按固定结构展开:定位 → 题目重点 → 技术难点拆解 → 高频坑与排查 → 建议启动路径(里程碑)。里程碑设计原则与
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 里移动会卡顿、双人抢键 |
开工前的三条通用提醒(六个项目都适用)
- 先确认 Qt 版本再写第一行代码。 课程环境是 Qt 5.14.2。自己电脑装了 Qt 6 的
同学注意:这批题目里 04(
QMediaPlaylist在 Qt6 被删除)和 02(QStateMachine在 Qt6 挪进了独立的 StateMachine 模块)受 Qt5/Qt6 差异影响最大,各项目小节末尾 有版本说明。网上的 Qt5 和 Qt6 示例代码混着抄,是编译报错查半天的常见原因。 - 资源路径不要用裸相对路径。 双击 exe 和从 IDE 里运行,"当前目录"不一样。
图片、音效等只读资源进
.qrc(:/imgs/xxx.png);存档、配置等可写数据用QCoreApplication::applicationDirPath()或QStandardPaths拼绝对路径。 "IDE 里正常、双击就白屏/没声音"多数是这个原因。 - 每完成一个里程碑就 git commit。 这批题目都要在一份代码上连续叠功能, 没有提交点,一次重构失败就退不回去。验收时 git log 也能说明你是按里程碑推进的。
04. 简易本地音乐播放器 ⭐⭐⭐⭐⭐
定位
用 QtMultimedia 做一个能实际使用的播放器:播放控制链路(QMediaPlayer)、 播放列表(Model/View)、元数据展示三块拼起来。没有算法难度,考的是 把 Qt 各模块正确地连在一起,六个项目里属于投入产出比较高的一个。
题目重点
- 这道题的骨架是两条方向相反的状态同步链:
- UI → 播放器:按钮/滑块操作调用
play()/pause()/setPosition()/setVolume(); - 播放器 → UI:
positionChanged/durationChanged/stateChanged/metaDataChanged等信号回写界面。 两条链各自单向、靠信号槽解耦,这正是题面"各UI组件间通过信号与槽高效解耦通信" 要考的。写成"按钮槽里既改播放器又顺手改十个控件",这部分分就没有了。
- 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 洗牌序列 |
建议启动路径(里程碑)
- M1(半天):一个按钮 + 一个
QMediaPlayer,点击播放一个写死路径的 WAV。 打通"模块链接 + 能出声",这一步没过不要写任何 UI。 - M2(1 天):
QFileDialog多选添加 →QMediaPlaylist+ Model/View 列表 → 双击列表任意行播放。 - M3(1 天):播放/暂停/上下曲按钮、进度条(含拖动 seek)、音量条、 时间标签。到这里已经可以日常使用。
- M4(1 天):三种播放模式切换 + 元数据/封面展示(含降级链)+ 列表持久化。
- 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、
"检测→特征→比对"流水线的正确串联。选这题前要有准备:第一周必须把环境跑通,
跑不通后面无从谈起。
题目重点
- 先分清两个概念,题目是两级流水线,不要混为一谈:
- 人脸检测(detection):找出画面里哪里有脸(矩形框)。传统方案
cv::CascadeClassifier(Haar),现代方案 DNN(如 YuNet / ResNet-SSD)。 - 人脸识别(recognition):判断这张脸是谁。用预训练模型 (SFace/FaceNet/ArcFace 的 ONNX)把人脸编码成 128/512 维特征向量, 与注册库中向量算余弦相似度,超过阈值即判定为该人。
- 人脸检测(detection):找出画面里哪里有脸(矩形框)。传统方案
- 识别不是训练神经网络。模型是下载来的
.onnx/.xml文件, 用cv::dnn::readNet或cv::FaceRecognizerSF加载。在想"要不要自己训练模型" 的同学可以打住:那不是本题要求,实训周期内也做不完。 - 题面明说"可选只通过图片识别",这是官方给的降级出口。摄像头/实时流 做不下来时,"导入图片→检测→识别→标注展示"的静态版本仍是一个完整、 可以及格的系统。
技术难点拆解
难点 0(第一关):环境。 OpenCV 官网给 Windows 的预编译包是 MSVC 编译的,
MinGW 版 Qt 链不上(C++ ABI 不兼容,症状是链接期大量 undefined reference)。
三条出路,按推荐排序:
- Qt 用 MSVC kit(装 Visual Studio Community / Build Tools,Qt 安装时勾选 MSVC 组件),直接用官方 OpenCV 包,最省事;
- 坚持 MinGW,就要用 CMake + 同版本 MinGW 自己编译 OpenCV(预留一个晚上);
- 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()一次。
三个问题合起来,建议直接采用这个写法:
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 次,框沿用上一次)或移到工作线程 |
建议启动路径(里程碑)
- M1(第一周内必须完成):环境验证——Qt 程序里用
cv::imread读一张图、 转 QImage 显示在 QLabel。这是本题第一个必须通过的节点。 - M2:QTimer + VideoCapture 实时显示摄像头画面(不检测)。
- M3:加 Haar 级联检测,画面上实时画人脸框(
cv::rectangle或转 QPixmap 后 QPainter 画)。 - M4:接入特征提取模型(推荐 OpenCV 4.5.4+ 自带的 YuNet 检测 + SFace 识别, API 最顺);做注册界面:拍照/导入 → 提特征 → 存 JSON。
- M5:实时比对 + 画面标注姓名/相似度 + 识别日志列表。到此即完整达标。
- 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 |
建议启动路径(里程碑)
- M1(1 天):不带界面,先写
VendingData数据类(商品表、零钱表、 加锁的 deduct/refund 接口)+ JSON load/save,用 qDebug 自测。数据层立住, 后面基本是接线工作。 - M2(1 天):前台购买界面(商品按钮阵列 + 投币按钮 + 信息提示区), 先用普通槽函数把"投币→选货→出货→找零"跑通(暂时用 if-else 没关系)。
- M3(1–2 天):引入 QStateMachine 重构 M2 的流程控制——按钮使能交给
assignProperty,流程迁移交给 transition。先能跑再重构,比一步到位稳。 - M4(1 天):管理员后台(密码 QDialog + QTableView 库存表,共享模型)+ 售罄置灰、无法找零拦截两条异常流。
- 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 张照片主线程就冻结十几秒。正确做法分三层:
- 解码离开主线程:用
QThreadPool+QRunnable(或QtConcurrent::run) 在后台解码并缩放,完成后发信号把缩略图(QImage,跨线程传值安全)交回主线程。 注意 QPixmap 不能在非 GUI 线程创建,后台一律用 QImage,回主线程再转。 - 读小不读大:
QImageReader reader(path); reader.setScaledSize(QSize(200,200));——JPEG 可以按比例降采样解码,比"解全图再 scaled"快一个数量级、省数倍内存。 这是本题最值得记住的一个 API。 - 缩略图磁盘缓存:首次生成后存到缓存目录(文件名用路径哈希), 第二次打开应用直接读缓存。加分项。
视图层用 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 转换 |
建议启动路径(里程碑)
- M1(1 天):选文件夹 → 列出所有图片文件 → 同步加载 20 张的缩略图网格 + 双击全屏看图(先不管性能,打通视觉闭环)。
- M2(1–2 天):把 M1 的加载改成 QImageReader 降采样 + 线程池异步。 验收标准:导入 500 张照片,滚动不卡。这是本题技术难度最高的一步,值得花足时间。
- M3(1 天):元数据层——SQLite/JSON 三表结构,手动标签、星级、收藏的 增删改查 + 持久化。
- M4(1 天):搜索框(文件名/标签实时过滤)+ 组合筛选面板(日期范围/评分/ 收藏)。到此为完整达标的相册管理器。
- M5(加分):虚拟相册、旋转/裁剪/亮度对比度编辑 + QUndoStack 撤销、 缩略图磁盘缓存。
- M6(大加分,量力):ONNX 自动标签;人脸检测分组(可参考项目 11 的 OpenCV 部分,环境成本也相同)。
版本说明
QImageReader::setAutoTransform在 Qt5 默认 false(Qt6 默认 true)。 课程环境下不手动打开,竖拍照片就会横着显示。- QtSql 的 SQLite 驱动随 Qt 发行版自带,无需另装 SQLite;MySQL 等驱动不自带, 不要把时间花在配数据库服务上,本题 SQLite 文件库就够了。
06. 多功能抽奖与随机点名程序 ⭐⭐⭐⭐
定位
六个里唯一的四星题,功能面窄,但考正确性:随机算法是否真的公平、
不重复抽取是否真的不重复、权重抽奖概率是否真的和权重成正比。
适合想稳妥完成、把精力花在细节上的同学。注意题面的算法项
(Fisher-Yates、轮盘赌)是答辩必问点,只写一个 rand()%n 过不了关。
题目重点
- UI 上的"滚动"只是演出,抽取结果在点"开始"那一刻就该定了。合理的架构: 按下开始 → 先用算法抽出结果 → QTimer 驱动名字快速轮播制造悬念 → 按下停止 → 动画减速定格到预定结果。让"停止那一刻的随机数"决定中奖者, 公平性就依赖用户手速,答辩说不清。这一条想通了,逻辑层(抽取)和 表现层(动画)自然就分开了。
- 三个算法是题目的核心,各 20 行以内,务必自己写、写到能白板复现:
- Fisher-Yates 洗牌(从 N 人不重复抽 M 人:洗牌取前 M);
- 轮盘赌选择(权重抽奖:随机数落在累计权重区间上);
- 随机数源:
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 |
建议启动路径(里程碑)
- M1(半天):控制台级验证——名单硬编码,Fisher-Yates 抽 M 人、轮盘赌 带权抽 1 人,qDebug 打印 + 万次频率统计自检。算法先于界面。
- M2(1 天):主界面:名单列表(增删改)+ txt/CSV 导入(含编码处理)+ JSON 持久化。
- M3(1 天):单人抽取全流程:开始滚动 → 停止减速定格 → 结果进 "已中奖"列表;可重复/不可重复开关。
- M4(半天):多人抽取模式 + 中奖记录导出 txt + 抽奖/点名模式切换文案。
- 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 路线做一半。
- 路线 A:QGraphicsView 框架(题面推荐)——
- 游戏循环是全部逻辑的驱动源:一个
QTimer(16ms ≈ 60FPS)驱动每帧处理输入 → 移动所有对象 → 生成敌机/子弹 → 碰撞检测 → 清理死亡对象 → 重绘。 所有会动的东西都由这一个 timer 统一驱动。给每个子弹/敌机各开一个 QTimer 是常见错误,几百个 timer 会把事件循环拖垮,而且相位不齐。 - 对象生命周期是本题隐含的 C++ 考点:子弹飞出屏幕、敌机被击落,都要从 场景移除并 delete。忘删,内存与遍历量持续膨胀,越玩越卡; 删早了(还在容器遍历中),程序崩溃。见难点 3。
技术难点拆解
难点 1:键盘多键同时按(双人模式的关键)。 直接在 keyPressEvent 里
player.move() 有两个问题:一是依赖系统键盘重复,第一下和连发之间有停顿,
手感卡;二是玩家1 按住 W 时玩家2 再按方向键,系统键盘重复只服务最后按下的键,
另一个玩家会直接失控。标准解法是按键状态表 + 游戏循环采样:
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 用默认值即可 |
建议启动路径(里程碑)
- M1(1 天):窗口 + 游戏循环 timer + 一架能被 WASD 流畅操控的飞机 (按键状态表一步到位)。这一步的手感决定整个项目的手感。
- M2(1 天):玩家子弹(按住 J 连发、出界清理)+ 敌机从顶部随机生成 直线下落 + 子弹打敌机的碰撞与爆炸消失。到这里游戏已经成立。
- M3(1 天):敌机撞玩家/敌弹打玩家 → 生命值与无敌闪烁 → 得分显示 → Game Over 判定。加第二名玩家(方向键 + 小键盘0),合作模式成立。
- M4(1 天):滚动背景、开始菜单/暂停/结束三场景切换(简单 FSM: 一个 enum + QStackedWidget 或场景切换)、最高分持久化(QSettings)。
- M5(加分):道具掉落、BOSS(多阶段血条 + 正弦/极坐标弹幕)、 对战模式(碰撞规则开关)、音效(QSoundEffect + WAV)。
- 提醒一条执行纪律: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。这批项目的失败方式高度一致: 前两周画界面调样式,最后一周才发现核心链路(能出声/能链上库/状态机能转/ 键盘不卡)有硬问题。先做最难的部分。