淺談基於QT的截圖工具的設計與實現

2023-09-01 06:00:27

本人一直在做屬於自己的一款跨平臺的截圖軟體(w4ngzhen/capi(github.com)),在軟體編寫的過程中有一些心得體會,所以有了本文。其實這篇文章醞釀了很久,現在這款軟體有了雛形,也有空梳理並寫下這篇循序漸進的介紹截圖工具的設計與實現的文章了。

前言:QT繪圖基礎

在介紹截圖工具設計與實現前,讓我們先通過介紹QT的繪圖基礎知識,讓讀者有一個比較感性的認識。

本文理論上並非是完整的QT框架使用介紹,但是我們總是需要用一款支援繪圖的GUI框架來介紹關於截圖的知識,於是筆者就拿較為熟悉的QT框架來說明。但只要讀者理解到了截圖工具的本質,舉一反三,其它的GUI框架也能完成截圖的目的。

對於繪圖來說,我們通常遵循「資料驅動渲染的模型。具體一點,我們會圍繞資料展開繪圖,影象的繪製總是來源於資料的定義。那麼如何實現動態圖形呢?只需要通過某些操作改變資料即可。

這樣的模型,資料的修改和資料的渲染是解耦的,我們編寫處理繪圖部分的時候,只需要根據已有的資料進行繪製,可以完全不用關心資料是怎麼變化的;而當運算元據的時候,完全可以不用關心渲染部分。基於該模型,可以讓我們在開發類似於截圖軟體的時候,極大降低心智負擔。

回到實際的部分,我們先使用QT編寫一個表單widget,然後重寫表單的paintEvent方法:

class DemoWidget: public QWidget {
public:
  void paintEvent(QPaintEvent *event) override {
    QPainter painter(this);
    painter.setPen(QPen(Qt::red));
    painter.drawRect(10, 10, 100, 60);
  }
};

paintEvent函數體程式碼就三行:

  1. 使用當前表單指標構造一個QPainter(QPainter painter(this););
  2. 設定畫筆的顏色;
  3. 在座標(10, 10)處繪製一個寬100畫素,高60畫素的矩形。

然後,我們編寫main方法,建立這個DemoWidget類的範例,將它show出來:

int main(int argc, char *argv[]) {
  QApplication a(argc, argv);
  DemoWidget w;
  w.resize(200, 100);
  w.show();
  return QApplication::exec();
}

整體程式碼和執行效果如下:

沒錯,QT中在一個表單中進行繪圖就是這麼簡單。接下來讓我們更進一步,將矩形資料(x,y,w,h)提升到到類成員變數層級,並讓painter繪製矩形的時候讀取類成員變數:

class DemoWidget: public QWidget {
public:
  void paintEvent(QPaintEvent *event) override {
    QPainter painter(this);
    painter.setPen(QPen(Qt::red));
-   painter.drawRect(10, 10, 100, 60);
+   painter.drawRect(x_, y_, w_, h_); // 讀取類成員變數
  }
+ private:
+  int x_ = 10, y_ = 10, w_ = 100, h_ = 60;
};

然後,我們重寫QWidget的onKeyPress事件,程式碼如下:

void keyPressEvent(QKeyEvent *event) override {
  auto key = event->key();
  switch (key) {
    case Qt::Key_Up: y_ -= 5;
      break;
    case Qt::Key_Down: y_ += 5;
      break;
    case Qt::Key_Left: x_ -= 5;
      break;
    case Qt::Key_Right: x_ += 5;
      break;
    default:break;
  }
}

這段程式碼的作用是當我們按下方向鍵後,就能夠修改x_y_變數的值,於是矩形的xy座標會按照對應方向移動5畫素。理論上講,如果此時觸發繪圖事件,而我們使用painter又在讀取類成員變數x_y_等資料進行矩形繪製,那麼就會看到矩形跟隨方向鍵在上下左右移動。

然而,當我們操作時候卻發現無論怎麼按方向鍵介面似乎沒有任何反應:

為什麼呢?讓我們引入qdebug向控制檯輸出一些資訊一探究竟:

應用執行以後,通過QDebug,我們可以在偵錯模式下看到控制檯的輸出內容:

通過控制檯可以看到,一開始觸發了幾次繪圖事件(paintEvent)。之後,當我們按下方向鍵時,觸發了按鍵事件(keyPressEvent),此時x_y_的值的確已經發生了改變,但是控制元件上的矩形沒有任何的變化。實際上,造成這種問題的根本原因在於我們重寫的繪圖事件沒有觸發,於是導致最新的效果並沒有繪製到介面上,所以看不出效果。

那麼,QT的繪圖事件什麼時候觸發呢?大致會有一下幾種情況:

  1. 當控制元件第一次顯示時,系統會自動產生一個繪圖事件。比如上面的動圖中第一次的paintEvent
  2. 表單失去焦點,獲得焦點等,之後幾次paintEvent出發就是因此產生的。
  3. 當視窗控制元件被其他部件遮擋,然後又顯示出來時,會對隱藏的區域產生一個重繪事件。比如最小化再出現。
  4. 重新調整視窗大小時。
  5. repaint()update()函數被呼叫時。

上面的例子中,在按下方向鍵以後介面沒有效果,如果此時我們最小化它再恢復它,就會看到繪圖事件被觸發,同時介面也有所改變:

當然,我們不可能為了觸發繪圖事件而手動操作表單。為了達到觸發繪圖事件的目的,我們一般會呼叫控制元件的update方法系列方法或repaint的系列方法,來主動告訴QT需要進行控制元件的重新繪製,進而讓QT觸發paintEvent,繪製介面:

再次執行程式,並按下方向鍵,我們可以清楚的看到paintEvent在每次按下方向鍵以後都被呼叫,同時,矩形也表現出移動的效果:

這裡我們呼叫的是update方法,同時,我們還提到QT還提供一個repaint方法,二者區別在於:repaint一旦呼叫,QT內部就會立刻呼叫觸發paintEvent,而update只是將觸發繪圖事件的任務放到事件佇列,等統一事件呼叫。所以,絕對不能在paintEvent中呼叫repaint,這樣會死迴圈。

此外使用update還有一個優點在於,QT會將多個update的請求通過演演算法機制儘可能的合併為一個paintEvent,從而提高執行的效率。比如,我們可以在呼叫update的地方多賦值幾次呼叫:

在實際呼叫中,只會觸發一次paintEvent

如果換成呼叫5次repaint就會發現每呼叫一次就會觸發一次paintEvent,讀者可以自行測試。

正文:截圖思路

在介紹了QT繪圖基礎以後,我們終於可以開始討論正題了:截圖工具的設計與實現。實際上,截圖工具實現起來並不複雜。可以想象一下,我們首先通過某種API獲取到桌面螢幕的圖片,然後把這個圖片放到一個表單裡面,最後再把這個表單最大化的方式展現在螢幕上。此時就達到了我們擷取了螢幕並讓整個螢幕「凍結」,等待我們操作的效果。

此時表單全螢幕幕覆蓋,接下來我們就需要在上面進行某個區域的獲取。

PS:這個動圖使用了跨平臺視訊剪輯工具Kdenlive製作,並轉為gif,有空寫一個教學,哈哈。

區域擷取狀態

一般來說,截圖過程就是按下滑鼠,然後移動滑鼠,此時介面上會顯示整個滑鼠拖拽產生的一個區域,直到鬆開滑鼠,這個區域就被「擷取」下來了:

想要實現這樣的效果並不複雜,程式碼如下何解釋如下:

在上圖程式碼中我分別標註了兩個部分:

  1. 捕獲指定區域所需要的資料;
  2. 將指定資料轉化為圖形進行繪製。

首先講解第一部分:捕獲指定區域所需要的資料。這裡我使用了三組資料,分別是:滑鼠按下的起始位置、滑鼠當前的位置、是否處於捕獲中狀態。不難看出,只需要這三組資料,我們就可以描述這樣一個畫面:如果沒有在捕獲狀態,那麼介面上不會出現矩形;如果處於捕獲狀態,那麼我們使用起始位置和當前位置得到一個矩形:

paintEvent中的程式碼實現也正是如此:

  void paintEvent(QPaintEvent *event) override {
    if (!isCapturing) {
      return;
    }
    QPainter painter(this);
    painter.setPen(QPen(Qt::red));
    int w = abs(currX - startX);
    int h = abs(currY - startY);
    painter.drawRect(startX, startY, w, h);
  }

也就是說,按照資料驅動渲染的模型,我們完成了由資料到渲染的部分:

接下來,我們完全只需要關注如何修改資料即可。在本例中,我們的操作行為是按下滑鼠開始擷取區域,移動過程中介面繪製開始點和當前滑鼠構成的矩形,鬆開滑鼠完成區域擷取。很明顯,我們會利用到滑鼠事件。在QT中提供了三個滑鼠事件供我們使用:

  1. mousePresssEvent,滑鼠按下事件;
  2. mouseReleaseEvent,滑鼠鬆開事件;
  3. mouseMoveEvent,滑鼠移動事件。

當我們按下滑鼠的時候,就進入了「捕獲狀態」(isCapturing置為true),並且記錄滑鼠此時按下的位置(startXstartY);在滑鼠移動過程中,不斷的更新當前滑鼠位置(設定currXcurrY);鬆開滑鼠時就退出「捕獲狀態」(isCapturing置為false)。程式碼如下:

  void mousePressEvent(QMouseEvent *event) override {
    isCapturing = true;
    startX = event->pos().x();
    startY = event->pos().y();
    this->update();
  }
  void mouseReleaseEvent(QMouseEvent *event) override {
    isCapturing = false;
    this->update();
  }
  void mouseMoveEvent(QMouseEvent *event) override {
    auto pos = event->pos();
    currX = pos.x();
    currY = pos.y();
    this->update();
  }

注意事項1:這裡每一個操作都要呼叫update告訴QT需要觸發繪圖事件,否則你會發現介面上沒有任何的動靜。另外,怎麼知道什麼時候應該呼叫update方法呢?很簡單,只要在某處的程式碼修改了paintEvent中所依賴的資料,就應該在之後呼叫update

注意事項2:在QT中,mouseMoveEvent並不是隨時都在觸發,該事件預設只有在滑鼠按下以後的移動過程才會觸發,QT這樣設計考慮的點是因為滑鼠的移動是很頻繁的,隨時觸發會降低效能。如果你在某些場景下就是需要隨時出發移動事件,需要在控制元件的建構函式中呼叫"setMouseTracking(true);"(可以看程式碼清單圖中11行)。

區域捕獲到這裡就結束了嗎?非也。讓我們來演示上面程式碼的問題:

很明顯可以看到,當我們將滑鼠向右下拖動的時候,矩形很正常的在動態顯示,而向左上角拖動的時候,就出現了問題。原因在於,QT的drawRect等API繪製矩形的時候,位置引數總是矩形的左上角位置,而我們總是將滑鼠按下的位置作為左上角位置。然而,滑鼠按下的位置就應該是矩形的左上角嗎?不總是。當我們拖動滑鼠向右下角移動的時候,左上角的start位置確實是可以作為矩形的xy座標。但一旦我們將滑鼠移動到左上角,位於起始位置的左邊和上邊的時候,就應該用當前滑鼠的位置作為矩形的左上角了:

於是,我們需要適當修改以下paintEvent中的程式碼:

  void paintEvent(QPaintEvent *event) override {
    if (!isCapturing) {
      return;
    }
    QPainter painter(this);
    painter.setPen(QPen(Qt::red));
    int w = abs(currX - startX);
    int h = abs(currY - startY);
+   int left = startX < currX ? startX : currX;
+   int top = startY < currY ? startY : currY;
-   painter.drawRect(startX, startY, w, h);
+   painter.drawRect(left, top, w, h);
  }

就能看到合適的效果了:

捕獲完成狀態與整體流轉

一般截圖工具都會在我們鬆開滑鼠的時候,將被擷取的區域固定下來,然後我們可以在上面寫寫畫畫(譬如新增額外的標記、文字等)。為了達到這個目的,我們首先要考慮如何將一個區域「固定」下來。在前面,我們引入了一個狀態:「是否正在捕獲中」(使用isCapturing作為標記)。在這裡,為了描述「區域擷取完成之後」的情形,我們需要引入一個新的狀態:擷取完成。於是,在整個截圖操作的過程中,我們的狀態流轉如下:

為了後續程式碼更好的設計,我們使用列舉來表達狀態:

enum Status {
  Explore = 0,
  Capturing,
  Captured
};

這裡的Status::CapturingStatus::Captured不必多說,要單獨解釋一下Explore單詞的含義。實際上,Explore就是指上面的「預設」,只是在筆者看來,當我們還沒有進行截圖的時候,滑鼠就是在整個視窗上移動「探索」,所以筆者將這個狀態取名為Explore

然後,我們需要對現有的程式碼進行適當的修改。首先是成員變數,由於我們引入了列舉來表達截圖的狀態,所以原先isCapturing欄位就可以捨棄,取而代之的是使用列舉並預設為Status::Explore。同時,我們還需要引入一個矩形資料變數,來儲存當我們鬆開滑鼠的時候,擷取到的區域的矩形資訊。於是變動如下:

private:
  int startX = 0, startY = 0;
  int currX = 0, currY = 0;
- bool isCapturing;
+ QRect capturedRect; // 儲存擷取的區域資訊,這裡使用QT的QRect類
+ Status status = Explore; // 替換原有的bool,並預設為Explore狀態

對於資料的定義發生了變化,我們優先考慮渲染部分的變化,也就是paintEvent需要做出適配。正對不同的狀態,paintEvent會繪製不同的效果:

  1. Explore態,我們認為介面上什麼操作也沒有,所以什麼都不需要做;
  2. Capturing態,其實就是我們上面isCapturingtrue的處理;
  3. Captured態,擷取完成後,我們把擷取到的區域用藍色矩形框住,而矩形資料就是上面新增的成員變數capturedRect

於是,整個程式碼如下:

void paintEvent(QPaintEvent *event) override {
  if (status == Explore) {
    return;
  }
  if (status == Capturing) {
    QPainter painter(this);
    painter.setPen(QPen(Qt::red));
    int w = abs(currX - startX);
    int h = abs(currY - startY);
    int left = startX < currX ? startX : currX;
    int top = startY < currY ? startY : currY;
    painter.drawRect(left, top, w, h);
    return;
  }
  if (status == Captured) {
    QPainter painter(this);
    painter.setPen(QPen(Qt::blue));
    painter.drawRect(capturedRect);
    return;
  }
}

同樣的,考慮完了資料以及如何繪製以後,我們需要回到模型的「資料操作」部分,考慮這些資料是如何變化的。按照上面的"預設" -> "截圖中" -> "截圖後"狀態流轉圖,我們就可以很輕易寫出資料修改的程式碼。

首先是滑鼠按下事件。當滑鼠按下的時候,如果我們處於Explore,那麼就進入Capturing並記錄滑鼠起始位置;如果處於Captured,那麼就什麼也不幹(理論上是不會有Capturing情況下的滑鼠按下事件的),程式碼如下:

void mousePressEvent(QMouseEvent *event) override {
  switch (status) {
    case Explore: {
      status = Capturing; // 進入Capturing
      startX = event->pos().x();
      startY = event->pos().y();
      break;
    }
    default:break;  // 其餘狀態都不關心
  }
  this->update();
}

接著是滑鼠鬆開事件。當滑鼠鬆開的時候,如果是Explore(理論上是不會出現的)或Captured,就什麼也不做;如果是Capturing,則進行Captured狀態,同時要儲存下此時擷取的區域,程式碼如下:

void mouseReleaseEvent(QMouseEvent *event) override {
  switch (status) {
    case Capturing: {
      // 進入Captured態
      status = Captured;
      // 儲存區域
      int w = abs(currX - startX);
      int h = abs(currY - startY);
      int left = startX < currX ? startX : currX;
      int top = startY < currY ? startY : currY;
      capturedRect = QRect(left, top, w, h);
      break;
    }
    default: break;
  }
  this->update();
}

然後是滑鼠移動過程的狀態處理。如果是Explore或是Captured,那麼什麼也不做;如果是Capturing,那麼不斷更新當前滑鼠位置,程式碼如下:

void mouseMoveEvent(QMouseEvent *event) override {
  switch (status) {
    case Capturing: {
      auto pos = event->pos();
      currX = pos.x();
      currY = pos.y();
      break;
    }
    default:break;
  }
  this->update();
}

此時,我們還差一個將狀態從Caputred切回到Explore的處理,我們重寫keyPressEvent事件的,如果在Captured狀態按下了ECS,就進入Explore態:

void keyPressEvent(QKeyEvent *event) override {
  if (event->key() == Qt::Key_Escape) {
    status = Explore;
  }
  this->update();
}

在所有程式碼準備好以後,讓我們啟用應用看一下效果:

細心的讀者如果實踐到此處,會發現一個小問題:每一次按下ESC鍵以後,下一次進入Capturing狀態,在滑鼠拖動開始的一瞬間,會有一個矩形框閃現,原因是currXcurrY還是上一次的資料,沒有即時清理。解決辦法也比較簡單,就是在按下的一瞬間,同時更新startcurr的座標資料為同一位置即可:

void mousePressEvent(QMouseEvent *event) override {
  switch (status) {
    case Explore: {
      status = Capturing; // 進入Capturing
      startX = event->pos().x();
      startY = event->pos().y();
+     currX = startX; // 同時更新start和curr
+     currY = startY;
      break;
    }
    default:break;  // 其餘狀態都不關心
  }
  this->update();
}

完成影象擷取

終於,我們還剩最後一步了,就是擷取這個區域的影象。在之前的介紹中,我們一直在一個空白的表單上進行繪圖。在本節,我們將通過QT的API,來獲取當前滑鼠所在的螢幕影象,並把影象作為這個表單的背景圖。然後,我們照舊在上面進行區域的擷取,來達到所謂的螢幕截圖的效果。

首先,我們需要做一些準備工作:

準備工作以下幾步:

  1. DemoWidget類中定義一個QImage的指標類成員變數;
  2. 修改建構函式,讓外部傳入這個QImage範例指標並進行儲存;
  3. 呼叫如下QT提供的相關API來獲取螢幕影象:
// 獲取滑鼠所在螢幕
QScreen *screen = QApplication::screenAt(QCursor().pos());
// 獲取螢幕的影象資料
QImage screenImg = screen->grabWindow(0).toImage();
  1. 我們將screenImg的地址作為指標變數作為DemoWidget的建構函式入參傳入。

影象的獲取與儲存完成以後,我們將會在paintEvent中,優先繪製螢幕影象,然後才根據狀態來繪製對應的矩形:

於是,介面執行以後,我們就能看螢幕截圖填充在視窗裡面的效果:

接下來,我們增加一種操作:當處於螢幕擷取完成的狀態(Captured)的時候,只要按下確認鍵,就能將擷取的螢幕儲存到貼上板中,並回到Explore狀態。很自然的,我們需要在keyPressEvent新增關於該操作的程式碼:

void keyPressEvent(QKeyEvent *event) override {
+ if (event->key() == Qt::Key_Return && status == Captured) {
+   // 1. 獲取捕獲的影象區域
+   // 2. 從儲存的螢幕影象中獲取指定區域的影象資料
+   // 3. 將影象資料寫入到作業系統貼上板
+   // 4. 回到Explore
+   return;
+ }
  if (event->key() == Qt::Key_Escape) {
    status = Explore;
  }
  this->update();
}

注意,QT中確認鍵的列舉值是Key_Return,不是Key_Enter。

對於步驟1,我們在前文已經使用capturedRect類成員變數儲存了當區域擷取完成以後的區域資料;

對於步驟2,QImage有一個名為copy的方法:

[[nodiscard]] QImage copy(int x, int y, int w, int h) const;

它可以從已有的影象中複製指定區域的影象,得到一個新的影象資料;

對於步驟3,我們可以使用QT提供的QClipboard類來作業系統貼上板。於是,你可以這樣呼叫來將影象資料儲存到貼上板中:

QClipboard *clipboard = QGuiApplication::clipboard();
clipboard->setImage(/* QImage物件 */);

對於步驟4就比較簡單了,切換status的狀態為Explore即可。

按照上面的過程描述,我們編寫如下的程式碼:

void keyPressEvent(QKeyEvent *event) override {
  if (event->key() == Qt::Key_Enter && status == Captured) {
    // 1. 獲取捕獲的影象區域
    auto imgRect = this->capturedRect;
    // 2. 從儲存的螢幕影象中獲取指定區域的影象資料
    auto copiedImg = this->screenImg->copy(imgRect);
    // 3. 將影象資料寫入到作業系統貼上板
    QClipboard *clipboard = QGuiApplication::clipboard();
    clipboard->setImage(copiedImg);
    // 4. 回到Explore
    status = Explore;
    return;
  }
  // 其餘程式碼 ... ...
}

當我們興致勃勃的執行應用並進行截圖操作的時候,會發現在貼上板中的影象,和我們擷取的區域不太一致!

注意,我們擷取了右下角有紫藍色的區域,但是實際獲取的影象卻不是。這個問題的核心原因是,我們擷取的capturedRect是這個表單介面上的區域,但並不是影象真正的區域capturedRect需要進行比例轉換,才能得到實際在圖片上的區域。

也就是說,我們需要將capturedRect轉化為實際imgRect:

void keyPressEvent(QKeyEvent *event) override {
  if (event->key() == Qt::Key_Return && status == Captured) {
    // 1. 獲取捕獲的影象區域
    auto picRealSize = screenImg->size();
    auto winSize = this->size();
    // 比例計算
    int realRectX = capturedRect.x() * picRealSize.width() / winSize.width();
    int realRectY = capturedRect.y() * picRealSize.height() / winSize.height();
    int realRectW =
        capturedRect.width() * picRealSize.width() / winSize.width();
    int realRectH =
        capturedRect.height() * picRealSize.height() / winSize.height();
    // 得到實際Rect
    QRect imgRect(realRectX, realRectY, realRectW, realRectH);
    // 2. 從儲存的螢幕影象中獲取指定區域的影象資料
    auto copiedImg = this->screenImg->copy(imgRect);
    // 3. 將影象資料寫入到作業系統貼上板
    QClipboard *clipboard = QGuiApplication::clipboard();
    clipboard->setImage(copiedImg);
    // 4. 回到Explore
    status = Explore;
    return;
  }
  if (event->key() == Qt::Key_Escape) {
    status = Explore;
  }
  this->update();
}

按照比例換算以後的程式碼如上,此時我們再看效果,會發現沒有問題了:

最後

這篇文章算不上是比較深入的講解截圖工具的實現,只是通過demo來大體上講解了截圖的機制,讓讀者有一個入門的認識,像是截圖區域確定以後我們還可以在上面新增方框、圓形、文字等操作都沒有在這篇文章中體現。這篇文章只是一個入門,讀者可以在掌握了基本的開發模式以後,實現更有意思的功能。

另外,筆者自己編寫的截圖軟體capi(倉庫地址:w4ngzhen/capi)已經有了基本的雛形,後續還會持續的往裡面增加功能的,這裡厚著臉皮希望有小夥伴能給個start。值得提到的是,筆者的截圖軟體capi目前是基於QT編寫的,但是筆者正在做的是將截圖的模組和QT的模組進行完全的解耦(其實已經差不多了),使用C++17的標準實現了截圖功能核心模組的概念抽象,其目的在於筆者準備將QT換成另一個跨平臺GUI框架wxWidgets來實現,為了實現這個目的,截圖模組與具體的GUI框架解耦是十分必要的。

回到本文相關的內容,整篇文章的的demo只有一個cpp檔案(QT的環境設定請自行解決啦),我直接放到Github gist:

simple screen capture demo based Qt (github.com)/