您现在的位置是:首页 >学无止境 >C++跨平台“点绘机” 代码解读网站首页学无止境

C++跨平台“点绘机” 代码解读

barbyQAQ 2024-06-14 12:01:02
简介C++跨平台“点绘机” 代码解读

前言

球球大作战可以自定义皮肤,用画刷绘制。
想着用软件来绘制。

初次尝试,没有达成最终目的,不过也有很大收获。

仓库链接:https://github.com/sixsixQAQ/dolphin

问题

这个半成品,已经有了基本结构了,而且做了跨平台处理。就当作代码案例来讲了,记录自己的思考。 太完美的东西反而无迹可寻。

后面准备第二代“行绘机”,不再更新它,因为“点绘”的出发点就是错的:

设计时是“点绘”实现的,忽略了实际的画笔粗细,当绘图板提供的画笔较粗时,根本无法完成精细绘制。(实际上,应该采用“行绘”来实现。)

但是如果画笔可以很细,比如windows自带绘图板mspaint,仍然可以使用。

效果

在这里插入图片描述
请添加图片描述

功能实现 && 代码解读

C++代码600 ~ 700行。

主要用Qt框架、平台API、pthread线程来实现。
由于对Qt不是不是很熟悉,很多地方可能设计/实现不是最佳。

实现划分

$ ls include
Image.h  MainWindow.h  Mouse.h  api.h

头文件反映了实现上的划分。

1. 伪API设计

首先,因为要跨平台,所以封装了特定平台的API,声明在api.h中。

api.h:

#ifndef API_H
#define API_H

void init_API();

void API_mouse_down();

void API_mouse_up();

void API_get_mouse_pos(int *x, int*y);

void API_set_mouse (int x, int y);

void destroy_API();

#endif

(Linux的API并未完全封装,因为主要在Windows上测试,频繁切换系统很费时间,也不打算再封装了。)

我们且称这些封装过后的API为伪API,目的就是提供统一的接口。

一看名字你就知道伪API怎么用了。
可是你理解为什么要有某个伪API,参数为什么要那样设置、功能为什么要那样划分吗?

(1) void API_mouse_down()和void API_mouse_up()

一个模拟鼠标点击的伪API。

 void API_mouse_click()

为什么要有这个伪API?
因为Qt、标准库都没有为我们提供模拟鼠标点击的操作。

Qt确实有模拟鼠标单击的,但是只能作用于Qt的组件,而我们需要点击非Qt组件。


如何设计这个接口?

我们很容易这样想:void API_mouse_click()

还有人可能:void API_mouse_click(int delay),给点击之间加上间隔。
要不要这样这样?我觉得不要,因为delay完全可以由库使用者在外部分设置,他可以在调用之间写上sleep()usleep(),多一个参数就是多余,对大家都是负担。C语言的重要特点就是精简,只提供一个达到目的的方式,重复并不好。

然而,click这个语义本身就不好,它不够细化。
假设库使用者要实现按下鼠标后持续一会儿呢?比如实现拖拽什么的。

哦,你或许会想,
设计成这样:void API_mouse_click(int up_down_delay),然后让使用者传一个参数。
首先,多了一个参数,加重了实现和调用的负担;
最致命的,调用者可能无法提前知道这个参数的值——比如要将左上角的图标拖到右下角,我怎么知道要拖多久,从而给出一个参数?


所以如果你不想重复实现某些东西,而且让接口更精简,就要将click分成两段,最终声明如下:

void API_mouse_down();

void API_mouse_up();

你可能要问我如何区分左键、右键、中间键?
然而我自始至终都只需要左键点击,让不需要的功能见鬼去吧,你不会需要它的,它只会白白让你的代码变复杂。

即便以后真的需要,再加上也不困难。

(2)void void API_set_mouse (int x, int y)

为什么要有这个伪API?

确实,Qt的QCursoe::setPos()足以设置鼠标到桌面任何位置。
它在Linux上工作很好。
然而在Windows下,当它和鼠标点击API(windows API)一起工作时,总是看起来丢掉了一些调用,绘制的图形会少了些许点,也就是进行了缩放。
然而像素数量并没有变多。后面会提及缩放的问题。

在这里插入图片描述
理想的绘制如下:
在这里插入图片描述

(3) void API_get_mouse (int *x, int *y);

为什么要有这个伪API?

确实,Qt的QCursoe::pos()足以获取桌面上的鼠标坐标。
它在Linux上1920 *1080的分辨率下工作很好;

然而在Windows下,最大可达位置只有1536 *864,少掉的数字去哪里了?

我发现是QCursoe::setPos()会受到桌面缩放的的影响。
在这里插入图片描述
当你将缩放设置100%时,它将看起来和预期一般。

这意味着什么?
这意味着QCursor::pos()的坐标是相对于虚拟的缩放后的桌面,而不是相对于真实的分辨率。

即便是受缩放影响也无妨,可是由于前面的API_mouse_pos()使用了平台的API,而Windows平台的API将不受缩放影响,

这会导致虽然Qcursoe::pos()告诉我们鼠标在(1534, 864)——在虚拟桌面上代表着最右下角的坐标,但是用Windows API去将鼠标设置到(1534, 854)却无法设置到桌面右下角,因为它不考虑缩放,会将(1534, 854)视作真实的分辨率。

这无疑是一种欺骗,而且用户也不会喜欢你告诉他一个虚拟的坐标,而是真实的坐标。

另外,void API_get_mouse (int *x, int *y),通过指针回传坐标,而非返回值,因为返回值只能有一个。
我知道你要说结构体了,这样引入了新的结构,对大家都是一种负担。传两个坐标毕竟不是那么复杂。
没有使用int&来引用,这样C、C++都使用。

(4)void init_API()和void destroy_API()

为什么要有这两个伪API?

起因是Linux下的桌面API调用都需要这样:

 Display * display = XOpenDisplay(NULL);
	/*
	 *	利用display完成一些API调用
	 *	...
	*/
XCloseDisplay (display)

这看起来不会是一笔小的开销,特别是对于鼠标移动、点击、位置获取这样的超频繁调用。

我们希望只打开次、关闭一次,重复使用同一个变量。
gcc的__attribute__((constructor))__attribute__((destructor))很好,可是它们依赖编译器。

C语言的实现风格,就是设置初始化函数,别无他法。

另外,即使是Windows也能从中获益,下面是windows下的实现:

static INPUT G_mouse_down;
static INPUT G_mouse_up;

void init_API()
{
    G_mouse_down.type = INPUT_MOUSE;
    G_mouse_down.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;

    G_mouse_up.type = INPUT_MOUSE;
    G_mouse_up.mi.dwFlags = MOUSEEVENTF_LEFTUP;
}

void API_mouse_down()
{
    SendInput(1, &G_mouse_down, sizeof(INPUT));
}
void API_mouse_up()
{
    SendInput(1, &G_mouse_up, sizeof(INPUT));
}

有了init就给了我们一个便利:重复的东西只用初始化一次。

虽然这里的INPUT类型直接静态初始化也可完成,但是如果以后有需要动态初始化的类型,也能很方便地实现。

2. Mouse.h

这是Mouse类的实现。

class Mouse final {
public:
	static Mouse &get_instance();
	//获取鼠标位置
	QPoint get_pos() const;
	//移动鼠标
	void move_to (const QPoint &pos);
	//单击鼠标左键
	void click (int down_up_delay=0);
private:
	Mouse();
	~Mouse();
	Mouse (const Mouse &) = delete;
	Mouse &operator= (const Mouse &) = delete;
};

前面已经封装好API接口了。
为什么要有这个类?
两点原因:

  1. 屏蔽实现,可能并不需要调用API,比如可以调用Qt库的函数。
  2. 直接调用API不好用,我们更希望以面向对象的方式。

如何设计这个类?
很显然鼠标是唯一资源,典型的单例模式适用者。

有关C++的单例模式,请参考另一篇博客:4. 单例模式(Singleton)

既然是单例模式,就无需过多考虑继承、虚函数的问题了,因为这个模式的一个特点就是不适合继承。

只需简简单单地再封装一层接口,调用底层伪API / Qt函数即可。

3. Image.h

class RGB {
public:
	RGB (uint8_t r, uint8_t g, uint8_t b);
	uint8_t get_red() const;
	uint8_t get_green() const;
	uint8_t get_blue() const;
private:
	uint8_t m_red, m_green, m_blue;
};

class fileOpenError : runtime_error {
public:
	fileOpenError (const string &msg);
};

class Image {
public:
	Image (const string &file);
	RGB pixel_at (int x, int y);
	int get_width() const;
	int get_height() const;
private:
	QImage m_image;
};

很明显RGB类是服务于Image类的,不必说它。

为什么要有Image类?
老实说,起初我是用OpenCV来实现这个类,OpenCV中获取像素需要手动的方式。而且OpenCV的效率理应比QImage更高,

封装这个类用来屏蔽掉底层操作。

后来发现,始终无法在Windows上完美地用MinGW编译OpenCV,总是缺东西、缺特性,心里不安,索性不用它了。

底层便重新用QImage来实现了,使用者无需改变,只需要将Image实现改写即可。

很明显这个类降低了接口使用和接口实现的耦合度,体现了面向对象的优点。

4. MainWindow.h

下来是重活了,

#ifndef MAIN_WINDOW_H
#define MAIN_WINDOW_H

#include <QWidget>
#include <memory>
#include <pthread.h>

class QLabel;
class QPushButton;
class QLineEdit;
class QSpinBox;
class QVBoxLayout;
class QHBoxLayout;

namespace YQ {

using std::unique_ptr;
using std::shared_ptr;
using std::make_unique;
using std::make_shared;

class Image;

class MainWindow : public QWidget {
	Q_OBJECT
public:
	MainWindow();
	~MainWindow();
private:
	shared_ptr<Image> m_image;

    bool m_Signal_terminate_draw;
    void keyPressEvent(QKeyEvent *e)override;

    pthread_mutex_t m_Mutex_update_pos;
	bool m_Signal_terminate_update_pos;
    bool m_Signal_terminate_check_hotkey;
	pthread_t m_Thread_update_pos;
    pthread_t m_Thread_check_hotkey;

	static void *update_pos (void *instance);
    static void *check_hotkey(void *instance);
private:
	QLabel *m_Label_image_wrapper;
	QPushButton *m_Button_start;
	QPushButton *m_Button_choose_image;
	
	QLineEdit *m_LineEdit_img_size;
	QLineEdit *m_LineEdit_current_x;
	QLineEdit *m_LineEdit_current_y;
	QLineEdit *m_LineEdit_draw_center_x;
	QLineEdit *m_LineEdit_draw_center_y;
	QLineEdit *m_LineEdit_draw_radius;
	QLineEdit *m_LineEdit_rgb_begin;
	QLineEdit *m_LineEdit_rgb_end;
	QLineEdit *m_LineEdit_click_delay;//毫秒数
    QLineEdit *m_LineEdit_down_up_delay;
    QLineEdit *m_LineEdit_row_dilute_ratio;//无损绘制的稀散程度,拉伸率
    QLineEdit *m_LineEdit_column_dilute_ratio;
    QSpinBox *m_SpinBox_row_pixel_step;//有损绘制的步长,像素数为单位
    QSpinBox *m_SpinBox_column_pixel_step;
    bool all_args_filled();
private slots:
	void connect_all_slots();
	void on_Button_start_clicked();
	void on_Button_choose_image_clicked();
	
	void on_MainWindow_request_to_update_cursor_pos (int x, int y);//更新控件
signals:
	void request_to_update_cursor_pos (int x, int y);//子线程请求主线程更新控件的信号
};

}

#endif // MAIN_WINDOW_H

首先,下面的这些前置声明:

class QLabel;
class QPushButton;
class QLineEdit;
class QSpinBox;
class QVBoxLayout;
class QHBoxLayout;

这是《Effiective C++》中推荐的做法,用前置声明代替引入头文件,可以加快编译速度,减少重复编译。
同时,成员得用指针 / 引用 / shared_ptr<>

但是不能是unique_ptr<>,这好像是因为unique_ptr<>需要在展开的地方就要获取类型大小(用于析构)。

然后,一个Q_OBJECT,因为我们用到了Qt的信号和槽机制。

关于Q_OBJECT,见另一篇博客:Qt核心特点

下来的成员:

private:
	shared_ptr<Image> m_image;

    bool m_Signal_terminate_draw;
    void keyPressEvent(QKeyEvent *e)override;

    pthread_mutex_t m_Mutex_update_pos;
	bool m_Signal_terminate_update_pos;
    bool m_Signal_terminate_check_hotkey;
	pthread_t m_Thread_update_pos;
    pthread_t m_Thread_check_hotkey;

	static void *update_pos (void *instance);
    static void *check_hotkey(void *instance);

除了m_Image外,其他都是线程的。

先简单说下,后面实现详细说:

  • bool m_Signal_terminate_draw;
    • 如其名,Signal,用来终止绘制过程的信号,其实就是个flag,主线程在绘制之前会将它设为false,然后开始绘制循环。循环中会不断检测这个flag,如果为true(被其他人设置了),就会停止绘制。
  • void keyPressEvent(QKeyEvent *e)override;
    • 覆写父类的按键事件处理程序。当按ESC时会将m_Signal_terminate_draw设置为true,导致绘制循环的终止。
    • 仅作为对照:这个函数其实没用,因为当焦点不在Qt组件上时, Qt程序不会收到事件,就不会触发这个函数。所以后面用平台API实现了按键检测。
  • pthread_mutex_t m_Mutex_update_pos;
    • 互斥元,有个线程用于更新当前鼠标位置,更新之前它需要lock这个锁。
    • 它的作用就是当开始绘制时,集中CPU去绘制。绘制线程(主线程)会lock这个锁,阻止鼠标位置更新线程使用CPU(浪费资源)。
  • bool m_Signal_terminate_update_pos;
    • 和前面类似,用于终止鼠标位置更新线程。
  • bool m_Signal_terminate_check_hotkey;
    • 用于终止按键检测线程。
  • pthread_t m_Thread_update_pos;
    • 鼠标位置更新线程。
  • pthread_t m_Thread_check_hotkey;
    • 按键检测线程
  • static void *update_pos (void *instance);
    • 鼠标位置更新线程的入口函数,参数为MainWindow *
  • static void *check_hotkey(void *instance);
    • 按键检测线程的入口函数,参数为MainWindow *
(1)鼠标位置获取线程
/**
 * 子线程,用来更新当前鼠标位置。
 * 一定不要直接操作主线程的控件,会有bug。
 * 用Qt的信号与槽机制,发送信号,请求主线程去更新自己的控件。
 */
void *MainWindow::update_pos (void *instance_)
{
	auto instance = static_cast<MainWindow *> (instance_);
	for (; !instance->m_Signal_terminate_update_pos;) {
        pthread_mutex_lock(&instance->m_Mutex_update_pos);
		auto pos = Mouse::get_instance().get_pos();
		emit instance->request_to_update_cursor_pos (pos.x(), pos.y());
        pthread_mutex_unlock(&instance->m_Mutex_update_pos);
        usleep(10000);
	}
	return NULL;
}

这就是鼠标位置获取线程的全部了。
一个很重要的就是不要直接操作主线程的控件,不然会跑着跑着就报“段错误”,导致程序终止。

这里采用官方比较推荐的做法,利用Qt的信号与槽机制,发射信号,主线程接收到信号后调用槽函数,完成更新。

这里加上usleep(10000),不让它太快,够用就行了,在你的视觉效果看起来没什么区别,可是对CPU来说,却轻松了10000倍!!!
你可以打开任务管理器看看,如果没有这一行,CPU占用会相当高,少则20%,多则50%。而有了这一行后,只有百分之零点几,撑死2%。

提醒:这里用了usleep(),已经破坏跨平台性了,MSVC不能用,但是MinGW仍然能用,赶时间就没有进一步处理,知道就好。

(2) 按键检测线程

#ifdef WIN32
#include <windows.h>
namespace YQ{

void *MainWindow::check_hotkey(void *instance_) {
    auto instance = static_cast<MainWindow*>(instance_);
    if(!RegisterHotKey(NULL,1,0,VK_ESCAPE)) {
        QMessageBox::warning(instance, "错误", "ESC热键注册失败");
        return NULL;
    }
    MSG msg;
    while(GetMessage(&msg,NULL,0,0) &&!instance->m_Signal_terminate_check_hotkey){
        if(msg.message == WM_HOTKEY){
            if(msg.wParam == 1){
                instance->m_Signal_terminate_draw = true;
            }
        }
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    UnregisterHotKey(NULL,1);
    return NULL;
}

}
#endif

赶进度,就没有进行API的细分,直接一整个条件编译写了进去,其实这也不太好,Linux的版本也没写。

这个线程的业务就一行:

 instance->m_Signal_terminate_draw = true;

当接收到VK_ESCAPE(ESC)键后,将这个flag位设置为true,这样,绘制循环下一波就会因为检测到这个flag为true而提前退出。

其他都是常规代码。

(3)小结

还有很多说不完的细节。
比如构造函数里面这个:

m_Signal_terminate_update_pos = false;
m_Signal_terminate_check_hotkey= false;
pthread_create (&m_Thread_update_pos, NULL, MainWindow::update_pos, this);
pthread_create(&m_Thread_check_hotkey, NULL, MainWindow::check_hotkey,this);

这个顺序就不能变,flag变量得再线程创建之前就设置好,不然线程刚一创建就可能检测到flag为true从而退出了。

错误&&解决思路

这个过程中,遇到过两个大问题。

  1. 内存泄漏 / 二次释放。
  2. windows下内存占用巨高。

1. 内存泄漏 / 二次释放

这个问题,主要是因为没有注意组件的parent

ainWindow::MainWindow()
{
	auto *Label_show = new QLabel (this);
	auto *Label_current_img_size = new QLabel (this);
	auto *Label_input_center = new QLabel (this);
	auto *Label_input_radius = new QLabel (this);
	auto *Label_input_rgb_interval = new QLabel (this);
	auto *Label_input_click_delay = new QLabel (this);
    auto *Label_input_down_up_delay = new QLabel(this);
    auto *Label_input_pixel_degree = new QLabel(this);
    auto *Label_input_dilute_ratio = new QLabel(this);
	auto *VBox_top = new QVBoxLayout();
	auto *Grid_labels_and_blanks = new QGridLayout();
	auto *HBox_buttons = new QHBoxLayout();
......

看上面的例子,这些标签、布局组件,我并没有把他们放到成员变量里,是因为不能放吗?
当然可以作为成员,只是没必要。

一个布局组件、一个指示性标签,有必要作为成员吗?我们又不需要在运行时读取/设置它们。

成员越多,我们要管理的就越多,完全可以让它们放野。

可是这些类谁来管?

答案是让parent接管。

继承自QObjectQWidget的类,都会接管它们的孩子的内存管理任务。只需要在孩子中调用child->setParent()来设置parent即可。

设置过parent的组件,会由parent来释放内存,此时如果再释放就二次释放了。

QGridLayoutQHBoxLayout等布局组件,不会负责子组件的内存释放。但是它会负责子布局的内存释放。——这就是说,addLayout()方法添加的布局,由父布局管理,addWidget()方法添加的组件,不由该布局管理。

如果你对组件之间的关系感到疑惑,可以调用QObject::dumpObjectTree()来看看关系树。

2. Windows内存占用巨高

这个程序刚在Linux下测试好时,在linux下占用也就十几二十MB的样子,我还拿valgrind进行了完整的检查,没有确定的内存泄漏。

拿到Windows下时,直接炸了——内存占用直飙8G、9G,CPU也占了50%,甚至屏闪、宕机。

人都傻了,我还以为是极其隐蔽的内存泄漏。
拿工具测了又测,都没有检测到泄露。

最终慢慢测试了出来,问题有两点,

  1. 鼠标位置更新线程中,位置获取API调用太频繁了,这导致CPU占用很高。。
  2. Windows鼠标点击API的封装不太好。

起初我是这么封装的:

void API_mouse_click()
{
	INPUT input[2];
    input[0].type = INPUT_MOUSE;
    input[0].mi.dwFlags = MOUSEEVENTF_LEFTDOWN;

    input[1].type = INPUT_MOUSE;
    input[1].mi.dwFlags = MOUSEEVENTF_LEFTUP;
    SendInput(2, &input, sizeof(INPUT));
}

当鼠标快速点击时,内存直接爆满。

虽然我初步推断是:每次调用都会创建栈变量INPUT,有不必要的开销。

但也不尽合理,因为栈变量,栈帧剥离也就随之消亡了,难道函数调用会堆积到如此地步?

未解之谜,所幸问题都平息,程序内存占用只有十几二十MB,CPU也最多2%。

总之,“点绘”不好,有空第二代“行绘机”了。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。