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

43 KiB
Raw Blame History

实训项目集深度解析(按学生关注度: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 里移动会卡顿、双人抢键

开工前的三条通用提醒(六个项目都适用)

  1. 先确认 Qt 版本再写第一行代码。 课程环境是 Qt 5.14.2。自己电脑装了 Qt 6 的 同学注意:这批题目里 04(QMediaPlaylist 在 Qt6 被删除)和 02QStateMachine 在 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. 播放器 → UIpositionChanged/durationChanged/stateChanged/ metaDataChanged 等信号回写界面。 两条链各自单向、靠信号槽解耦,这正是题面"各UI组件间通过信号与槽高效解耦通信" 要考的。写成"按钮槽里既改播放器又顺手改十个控件",这部分分就没有了。
  • 播放列表用 Model/ViewQAbstractTableModel 或至少 QStandardItemModel + QTableView),不要用 QTableWidget 逐格塞字符串,题面明确点了 Model/View。
  • 播放模式(列表循环/单曲循环/随机)在 Qt 5.14 里有现成 API QMediaPlaylist::setPlaybackMode(Loop / CurrentItemInLoop / Random),一行完成。

技术难点拆解

难点 1:元数据(ID3 标签)是异步到达的。 setMedia() 之后媒体要经历加载 过程,立即调 metaData() 拿到的是空值。正确做法:连接 QMediaPlayer::metaDataAvailableChangedmediaStatusChanged (等 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 走 GStreamergstreamer1.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 += multimediaCMake: find_package(... Multimedia) + target_link_libraries(... Qt5::Multimedia)
不报错但没声音 ① 后端缺解码器 ② 文件路径错(相对路径) ③ 音量为 0 先播 WAVQMediaPlayer::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::MatQImage 互转、视频流不阻塞 UI、 "检测→特征→比对"流水线的正确串联。选这题前要有准备:第一周必须把环境跑通, 跑不通后面无从谈起。

题目重点

  • 先分清两个概念,题目是两级流水线,不要混为一谈:
    1. 人脸检测(detection:找出画面里哪里有脸(矩形框)。传统方案 cv::CascadeClassifier(Haar),现代方案 DNN(如 YuNet / ResNet-SSD)。
    2. 人脸识别(recognition:判断这张脸是谁。用预训练模型 SFace/FaceNet/ArcFace 的 ONNX)把人脸编码成 128/512 维特征向量, 与注册库中向量算余弦相似度,超过阈值即判定为该人。
  • 识别不是训练神经网络。模型是下载来的 .onnx/.xml 文件, 用 cv::dnn::readNetcv::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/WSLapt install libopencv-dev,基本没有这个问题。

第一周内跑不通"读一张图并显示",就应该向老师求助或换项目。环境是本题 最大的淘汰因素,及时求助不丢人。

难点 1cv::MatQImage 的三个常见错误。

  • 通道序:OpenCV 是 BGRQt 是 RGB。直接 QImage(mat.data, w, h, QImage::Format_RGB888) 得到的画面偏蓝。 用 QImage::Format_BGR888Qt 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 次,框沿用上一次)或移到工作线程

建议启动路径(里程碑)

  1. M1(第一周内必须完成):环境验证——Qt 程序里用 cv::imread 读一张图、 转 QImage 显示在 QLabel。这是本题第一个必须通过的节点。
  2. M2QTimer + 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() 后状态没进入 状态机异步启动 初始化逻辑挂到 initialStateentered 信号,不要 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,不解码 MP3QT += 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 手绘——一个大 widgetpaintEvent 里画所有 对象,自己维护对象列表和矩形碰撞。原始一些,但每一步都在自己掌控里, 配套教材 ch10 的绘图知识直接够用。
    • 建议:跟着题面走 A;如果 QGraphicsView 两天内没找到感觉,及时退到 B。 B 路线做完,好过 A 路线做一半。
  • 游戏循环是全部逻辑的驱动源:一个 QTimer16ms ≈ 60FPS)驱动每帧 处理输入 → 移动所有对象 → 生成敌机/子弹 → 碰撞检测 → 清理死亡对象 → 重绘。 所有会动的东西都由这一个 timer 统一驱动。给每个子弹/敌机各开一个 QTimer 是常见错误,几百个 timer 会把事件循环拖垮,而且相位不齐。
  • 对象生命周期是本题隐含的 C++ 考点:子弹飞出屏幕、敌机被击落,都要从 场景移除并 delete。忘删,内存与遍历量持续膨胀,越玩越卡; 删早了(还在容器遍历中),程序崩溃。见难点 3。

技术难点拆解

难点 1:键盘多键同时按(双人模式的关键)。 直接在 keyPressEventplayer.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 间隔足够;QGraphicsViewsetViewportUpdateMode 用默认值即可

建议启动路径(里程碑)

  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。这批项目的失败方式高度一致: 前两周画界面调样式,最后一周才发现核心链路(能出声/能链上库/状态机能转/ 键盘不卡)有硬问题。先做最难的部分。