發(fā):QPainter繪圖與撤銷重做實(shí)現(xiàn))
簡(jiǎn)介一套適合畢業(yè)設(shè)計(jì)、課程設(shè)計(jì)或課程作業(yè)也適合C初學(xué)者進(jìn)階的Qt繪畫板項(xiàng)目源碼基于Qt框架實(shí)現(xiàn)重點(diǎn)解決圖形繪制、文件存儲(chǔ)與交互操作等常見(jiàn)桌面應(yīng)用開(kāi)發(fā)問(wèn)題。壓縮包共27個(gè)文件其中包含8個(gè)cpp與7個(gè)h源文件、9個(gè)png圖標(biāo)資源、1個(gè)Qt工程文件及1份項(xiàng)目說(shuō)明文檔整體僅42KB代碼量精簡(jiǎn)且結(jié)構(gòu)清晰便于閱讀和二次擴(kuò)展。項(xiàng)目支持繪制點(diǎn)、直線、橢圓、矩形等基本幾何圖形具備繪圖文件存取、撤回與重做、線寬調(diào)節(jié)、顏色選擇以及鼠標(biāo)坐標(biāo)實(shí)時(shí)顯示等功能能夠完整覆蓋簡(jiǎn)單繪圖工具的核心交互流程。目前已有358人學(xué)習(xí)下載工程內(nèi)還附帶res圖標(biāo)資源文件夾程序采用絕對(duì)路徑加載圖標(biāo)二次部署時(shí)稍作調(diào)整即可。整個(gè)項(xiàng)目模塊劃分明確適合作為C/Qt入門練手、課程設(shè)計(jì)或初期項(xiàng)目演示的實(shí)用參考。1. 為什么用 Qt 寫一個(gè)繪畫板而不是直接調(diào) Windows GDI一個(gè)基于 Qt 的 C 繪畫板程序壓縮包十幾個(gè)文件拆開(kāi)后幾乎是 Qt 客戶端開(kāi)發(fā)的迷你樣板間Shape 基類、QPainter 繪制、鼠標(biāo)事件狀態(tài)機(jī)、撤銷重做棧和自定義文件序列化全都有。如果你正找課程設(shè)計(jì)源碼或者第一次用 C 接觸 Qt這個(gè)項(xiàng)目很適合。剛學(xué)完 C 語(yǔ)法的人能在這里看到虛函數(shù)和設(shè)計(jì)模式怎么落到界面上工作后跳過(guò)來(lái)寫 Qt 的工程師也能快速補(bǔ)上事件循環(huán)和繪圖管線這一課。表面上是畫點(diǎn)、畫線、畫矩形的玩具實(shí)際上把 Qt Widget 應(yīng)用的主干都串起來(lái)了。2. QPainter 與 Shape 類先把圖形抽象成可繪制對(duì)象2.1 Shape 基類設(shè)計(jì)為什么一定要有虛函數(shù)繪畫板要支持點(diǎn)、直線、橢圓、矩形四種圖元。最直白的做法是在鼠標(biāo)事件里寫一個(gè) switch根據(jù)當(dāng)前工具類型調(diào)用 QPainter 的對(duì)應(yīng)方法。但這樣每加一種圖形就要改三處創(chuàng)建、繪制、文件讀寫。項(xiàng)目里的解法是定義一個(gè) Shape 抽象基類把 draw、save、load 都設(shè)成純虛函數(shù)讓每種圖元自己負(fù)責(zé)繪制和持久化。先看核心頭文件// shape.h #pragma once #include QPainter #include QColor #include QDataStream class Shape { public: enum Type { PointShape, Line, Rectangle, Ellipse }; Shape(QPoint start, QPoint end, QColor color, int penWidth) : startPoint(start), endPoint(end), color(color), penWidth(penWidth) {} virtual ~Shape() default; virtual void draw(QPainter painter) const 0; virtual void save(QDataStream stream) const 0; virtual void load(QDataStream stream) 0; virtual Type type() const 0; protected: QPoint startPoint; QPoint endPoint; QColor color; int penWidth; };注意這里我把點(diǎn)圖元類命名為PointShape避免和 Qt 的QPoint混淆。每個(gè)派生類只需要實(shí)現(xiàn)三個(gè)接口例如直線// line.cpp class Line : public Shape { public: using Shape::Shape; void draw(QPainter painter) const override { QPen pen(color, penWidth); painter.setPen(pen); painter.drawLine(startPoint, endPoint); } Type type() const override { return Line; } };這段代碼的關(guān)鍵在于把“怎么畫”下沉到每個(gè)類內(nèi)部paintEvent 里只需要遍歷圖形列表挨個(gè)調(diào)用draw(painter)完全不用知道當(dāng)前圖元是哪一種。這就是面向?qū)ο罄镩_(kāi)放封閉原則的體現(xiàn)以后要加箭頭、曲線新增一個(gè)子類即可原有代碼一行都不用改。2.2 鼠標(biāo)拖拽的三段式處理畫板交互的核心在centerwidget里按下鼠標(biāo)左鍵記錄起點(diǎn)移動(dòng)時(shí)更新終點(diǎn)并刷新界面松開(kāi)時(shí)把臨時(shí)圖元提交到列表。事件處理可以拆成三段void CenterWidget::mousePressEvent(QMouseEvent *event) { if (event-button() ! Qt::LeftButton) return; startPoint event-pos(); drawing true; } void CenterWidget::mouseMoveEvent(QMouseEvent *event) { if (drawing) { endPoint event-pos(); update(); } } void CenterWidget::mouseReleaseEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton drawing) { drawing false; Shape *shape createShape(); shapes.append(shape); update(); } }createShape()會(huì)根據(jù)當(dāng)前工具欄選中的圖元類型用 startPoint、endPoint、當(dāng)前顏色和線寬構(gòu)造對(duì)應(yīng)的 Line、Rectangle、Ellipse 或 PointShape。注意mouseMoveEvent里那一句update()不能省否則拖拽過(guò)程中屏幕上看不到橡皮筋效果要等松開(kāi)左鍵才一次性畫出體驗(yàn)非常奇怪。event-pos()返回的是 QWidget 本地坐標(biāo)如果沒(méi)有滾動(dòng)條和縮放可以直接用在 QPainter 上一旦畫布需要滾動(dòng)或縮放就得用mapToScene之類的方式轉(zhuǎn)換這是從簡(jiǎn)單畫板走向?qū)I(yè)繪圖工具時(shí)最容易踩的坐標(biāo)坑。2.3 重繪策略全量重繪與 QPixmap 緩沖圖元列表建好之后重繪有兩種常見(jiàn)方案。第一種是在paintEvent里清空背景再遍歷所有 shape 繪制實(shí)現(xiàn)最簡(jiǎn)單但圖元超過(guò)幾百個(gè)后每次 update 都會(huì)卡頓而且閃屏明顯。第二種是維護(hù)一張 QPixmap 作為緩沖畫布鼠標(biāo)釋放時(shí)把成品畫上去paintEvent只執(zhí)行一次drawPixmap拖動(dòng)過(guò)程中的臨時(shí)圖形另畫一層。兩種方案對(duì)比如下策略內(nèi)存占用拖拽流暢度實(shí)現(xiàn)復(fù)雜度全量重繪 遍歷列表極低圖元多時(shí)肉眼可見(jiàn)卡頓最簡(jiǎn)單QPixmap 緩沖畫布畫布面積 x 4 字節(jié)左右流暢但撤銷時(shí)要重放歷史中等QGraphicsView 場(chǎng)景中等高自帶局部更新首次學(xué)習(xí)成本高這個(gè)項(xiàng)目用的就是第二種思路。緩沖畫布的核心代碼集中在撤銷重做之后的重放里下一章會(huì)把這個(gè)棧講透。3. 撤銷重做與 QDataStream 文件存儲(chǔ)別讓畫布只是一張位圖3.1 用圖元列表做撤銷重做而不是位圖快照如果每次操作都保存一張 800x600 的 QPixmap 快照內(nèi)存會(huì)迅速膨脹畫幾十筆就吃掉幾十 MB完全不可持續(xù)。常見(jiàn)做法是維護(hù)一個(gè)QVectorShape*和一個(gè)“已提交數(shù)量”索引currentIndex。每次新增圖元時(shí)先把末尾可能存在的殘留刪除再 push 到列表并把索引指向列表末尾。撤銷只需把索引減一重做加一然后重放整個(gè)列表到緩沖畫布。void PaintModel::undo() { if (currentIndex 0) return; --currentIndex; redrawAll(); } void PaintModel::redo() { if (currentIndex shapes.size()) return; currentIndex; redrawAll(); } void PaintModel::redrawAll() { bufferPixmap.fill(Qt::white); QPainter painter(bufferPixmap); for (int i 0; i currentIndex; i) { shapes[i]-draw(painter); } }這里的currentIndex表示“已提交的圖元數(shù)量”不是最后一個(gè)元素下標(biāo)。撤銷一次后shapes里可能還有后續(xù)圖元重做時(shí)要把它們重新顯示出來(lái)如果此時(shí)畫了新圖形就必須先銷毀索引之后的殘留對(duì)象否則內(nèi)存泄漏且重做語(yǔ)義會(huì)亂。這種索引棧是 Qt 相關(guān)面試?yán)锝?jīng)常問(wèn)到的 C 八股為什么撤銷用棧重做用索引而不是真的把元素 pop 出來(lái)。理解了這段代碼再去看 QUndoStack 就覺(jué)得沒(méi)什么神秘了。3.2 用 QDataStream 讀寫 .draw 文件保存畫布不能只截一張圖還要能重新編輯所以要持久化圖元數(shù)據(jù)。QDataStream 是 Qt 自帶的二進(jìn)制序列化工具能自動(dòng)處理字節(jié)序比手寫 fread/fwrite 安全得多。保存代碼void PaintModel::save(const QString path) { QFile file(path); if (!file.open(QIODevice::WriteOnly)) { qWarning() 無(wú)法打開(kāi)文件 path; return; } QDataStream out(file); out.setVersion(QDataStream::Qt_5_15); out quint32(0x44724157); // 魔數(shù) Draw out qint32(1); // 格式版本號(hào) out qint32(shapes.size()); for (const Shape *s : shapes) { out qint32(s-type()); s-save(out); } }魔數(shù)0x44724157是 ASCII 字符串 “Draw” 的十六進(jìn)制值用來(lái)識(shí)別文件是不是本程序畫的。格式版本號(hào)是為將來(lái)功能擴(kuò)展預(yù)留的如果以后增加箭頭圖元老閱讀器讀到版本號(hào) 2 時(shí)可以明確提示“文件格式過(guò)新”。每個(gè)圖元保存前先寫入類型枚舉讀取時(shí)根據(jù)類型 new 出對(duì)應(yīng)對(duì)象再調(diào)用它的 load。要注意out.setVersion必須和讀取端一致Qt 5.15 寫出的數(shù)據(jù)如果用 Qt 6.4 默認(rèn)版本讀取可能因?yàn)楦↑c(diǎn)或字符串編碼變化導(dǎo)致錯(cuò)位。讓版本號(hào)統(tǒng)一寫在文件頭里就是最便宜的容錯(cuò)手段。3.3 擴(kuò)展格式新圖元類型的兼容設(shè)計(jì)之前的枚舉類型就是文件里的“身份證”類型枚舉值圖元說(shuō)明0PointShape點(diǎn)1Line直線2Rectangle矩形3Ellipse橢圓以后加箭頭、貝塞爾曲線只要枚舉值往后排讀取端根據(jù) type 分派構(gòu)造。如果需要兼容舊文件可以在 Shape::save 里先寫一個(gè)數(shù)據(jù)長(zhǎng)度字段load 時(shí)先讀長(zhǎng)度遇到未知類型就 skipRawData 跳過(guò)去。這是為課程設(shè)計(jì)加分的細(xì)節(jié)也能讓指導(dǎo)老師看到你對(duì)二進(jìn)制格式有設(shè)計(jì)意識(shí)。4. 工具欄、QColorDialog 與坐標(biāo)狀態(tài)欄把畫板做成正經(jīng)程序4.1 用 QToolBar 和 QActionGroup 管理圖元工具主窗口 QMainWindow 里放一個(gè) QToolBar把新建、打開(kāi)、保存、撤銷、重做和四種圖元都做成 QAction。圖元工具之間必須互斥用 QActionGroup 就可以自動(dòng)完成不需要手動(dòng)維護(hù)狀態(tài)auto *group new QActionGroup(this); QAction *lineAction new QAction(QIcon(:/res/line.png), 直線, this); lineAction-setCheckable(true); lineAction-setChecked(true); group-addAction(lineAction); toolBar-addAction(lineAction);把 lineAction 設(shè)置為 checkable 后用戶點(diǎn)擊時(shí)按鈕保持按下?tīng)顟B(tài)。group 默認(rèn) exclusive 為 true同一時(shí)間只能有一個(gè)圖元工具處于激活狀態(tài)。connect 時(shí)只需要把 toolbar 上的 action 信號(hào)映射到 centerwidget 的 setTool 方法例如lineAction觸發(fā)時(shí)把當(dāng)前工具枚舉改為Shape::Line。注意這里setShortcut也可以直接加比如按 L 切換到直線、按 R 切換到矩形對(duì)提高演示效果幫助很大。4.2 圖標(biāo)絕對(duì)路徑的坑res 目錄為什么會(huì)失蹤項(xiàng)目說(shuō)明里特意寫了“程序圖標(biāo)使用絕對(duì)路徑圖標(biāo)資源在 res 文件夾里”這說(shuō)明代碼里很可能出現(xiàn)了QIcon(D:/some/path/res/line.png)這種寫法。在自己的機(jī)器上編譯運(yùn)行沒(méi)問(wèn)題把項(xiàng)目拷給別人或換一臺(tái)電腦圖標(biāo)立刻全部消失。我不建議繼續(xù)維護(hù)這種硬編碼路徑有兩個(gè)更穩(wěn)的替代方案方案優(yōu)點(diǎn)缺點(diǎn)把 res 加進(jìn) .qrc 資源文件可執(zhí)行文件自包含不易丟替換圖標(biāo)需要重新編譯用applicationDirPath()拼相對(duì)路徑圖標(biāo)可外部替換需要保證 exe 同級(jí)有 res 目錄保留絕對(duì)路徑本機(jī)最快換機(jī)器就崩不推薦如果不想改 UI 代碼最小改動(dòng)是把圖標(biāo)路徑統(tǒng)一成一句QString iconPath QCoreApplication::applicationDirPath() /res/line.png; QAction *lineAction new QAction(QIcon(iconPath), 直線, this);開(kāi)發(fā)調(diào)試時(shí)applicationDirPath()指向 build 目錄所以需要在 .pro 里加一句QMAKE_POST_LINK xcopy /Y res $$OUT_PWD讓編譯器每次構(gòu)建后自動(dòng)復(fù)制資源目錄。否則 IDE 里跑起來(lái)圖標(biāo)正常直接雙擊 build 目錄里的 exe 又找不到資源這個(gè)問(wèn)題在 Qt 教程里經(jīng)常被一筆帶過(guò)實(shí)際遇到會(huì)卡半天。4.3 狀態(tài)欄實(shí)時(shí)顯示鼠標(biāo)坐標(biāo)坐標(biāo)顯示雖然簡(jiǎn)單但有一個(gè)隱藏開(kāi)關(guān)只有調(diào)用setMouseTracking(true)Qt 才會(huì)在鼠標(biāo)不按下按鍵的情況下持續(xù)發(fā)送 mouseMoveEvent。很多新手直接重寫mouseMoveEvent結(jié)果發(fā)現(xiàn)只有拖拽時(shí)才有坐標(biāo)變化就是因?yàn)闆](méi)開(kāi)鼠標(biāo)追蹤。在CenterWidget構(gòu)造函數(shù)里加一行setMouseTracking(true)然后通過(guò)信號(hào)把坐標(biāo)傳給主窗口statusLabel new QLabel(坐標(biāo): (0, 0), this); statusBar()-addPermanentWidget(statusLabel); connect(centerWidget, CenterWidget::mouseMoved, this, [this](QPoint p){ statusLabel-setText(QString(坐標(biāo): (%1, %2)).arg(p.x()).arg(p.y())); });信號(hào)mouseMoved在mouseMoveEvent里發(fā)送注意這里不要再判斷drawing否則松開(kāi)鼠標(biāo)后坐標(biāo)顯示就凍結(jié)了。把狀態(tài)欄放在永久區(qū)域窗口縮放時(shí)不會(huì)擠掉它。調(diào)試坐標(biāo)錯(cuò)位時(shí)先在狀態(tài)欄驗(yàn)證鼠標(biāo)點(diǎn)位置和 QPainter 繪制點(diǎn)是否一致比打印日志更直觀。5. 進(jìn)階從 QWidget 遷移到 QGraphicsView并完成打包驗(yàn)證5.1 QGraphicsScene 帶來(lái)的范式變化當(dāng)圖元數(shù)量超過(guò)一千繼續(xù)在 QWidget 里全量重繪會(huì)明顯掉幀。這個(gè)時(shí)候應(yīng)該考慮把畫板遷移到 QGraphicsView 框架。遷移的關(guān)鍵是讓每種圖元變成 QGraphicsItem 子類例如class LineItem : public QGraphicsItem { public: QRectF boundingRect() const override { return QRectF(startPoint, endPoint).normalized(); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) override { painter-drawLine(startPoint, endPoint); } private: QPoint startPoint, endPoint; };boundingRect()必須返回一個(gè)正確的矩形區(qū)域QGraphicsView 要靠它決定哪些區(qū)域需要重繪。如果返回的矩形太小圖元會(huì)被相鄰區(qū)域裁掉出現(xiàn)“畫的線斷了一截”的詭異現(xiàn)象。這是從 QWidget 方式遷移過(guò)來(lái)最容易犯的錯(cuò)。遷移后原來(lái)的撤銷重做棧仍然有效只是redrawAll可以簡(jiǎn)化成對(duì) scene 里 item 的顯隱操作性能顯著提升。5.2 用 windeployqt 打包驗(yàn)證無(wú) Qt 環(huán)境可運(yùn)行課程設(shè)計(jì)提交時(shí)一個(gè)常見(jiàn)成績(jī)點(diǎn)是程序能否在他人的電腦上運(yùn)行。Qt 程序默認(rèn)靜態(tài)鏈接 Qt DLL拷貝 exe 不夠還要帶上插件目錄。Windows 下用 Qt 自帶的 windeployqt 工具C:\Qt\5.15.2\mingw81_64\bin\windeployqt.exe --release --no-translations release\SimpleDraw.exe--no-translations可以跳過(guò) qtbase 的翻譯文件縮小發(fā)布體積。如果程序里用到 Qt 圖像格式插件比如讀取 jpg 圖標(biāo)還需要檢查發(fā)布目錄里有沒(méi)有imageformats文件夾里的相應(yīng) DLL。部署完成后把整個(gè)發(fā)布目錄打成 zip里面應(yīng)該包含 exe、platforms、styles、iconengines 等目錄。驗(yàn)證時(shí)找一臺(tái)完全沒(méi)有 Qt 的機(jī)器雙擊 exe畫一條線、保存、重新打開(kāi)確認(rèn)圖元不丟狀態(tài)欄坐標(biāo)有變化工具欄圖標(biāo)正常。如果在無(wú) Qt 環(huán)境里圖標(biāo)消失優(yōu)先檢查 res 目錄是否與 exe 同級(jí)。本文還有配套的精品資源點(diǎn)擊獲取