前言
球球大作战可以自定义皮肤,用画刷绘制。
想着用软件来绘制。
初次尝试,没有达成最终目的,不过也有很大收获。
仓库链接: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接口了。
为什么要有这个类?
两点原因:
- 屏蔽实现,可能并不需要调用API,比如可以调用Qt库的函数。
- 直接调用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
(被其他人设置了),就会停止绘制。
- 如其名,Signal,用来终止绘制过程的信号,其实就是个flag,主线程在绘制之前会将它设为false,然后开始绘制循环。循环中会不断检测这个flag,如果为
void keyPressEvent(QKeyEvent *e)override;
- 覆写父类的按键事件处理程序。当按ESC时会将
m_Signal_terminate_draw
设置为true
,导致绘制循环的终止。 - 仅作为对照:这个函数其实没用,因为当焦点不在Qt组件上时, Qt程序不会收到事件,就不会触发这个函数。所以后面用平台API实现了按键检测。
- 覆写父类的按键事件处理程序。当按ESC时会将
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
从而退出了。
错误&&解决思路
这个过程中,遇到过两个大问题。
- 内存泄漏 / 二次释放。
- 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接管。
继承自QObject
和QWidget
的类,都会接管它们的孩子的内存管理任务。只需要在孩子中调用child->setParent()
来设置parent
即可。
设置过parent
的组件,会由parent
来释放内存,此时如果再释放就二次释放了。
QGridLayout
、QHBoxLayout
等布局组件,不会负责子组件的内存释放。但是它会负责子布局的内存释放。——这就是说,addLayout()
方法添加的布局,由父布局管理,addWidget()
方法添加的组件,不由该布局管理。
如果你对组件之间的关系感到疑惑,可以调用QObject::dumpObjectTree()
来看看关系树。
2. Windows内存占用巨高
这个程序刚在Linux下测试好时,在linux下占用也就十几二十MB的样子,我还拿valgrind进行了完整的检查,没有确定的内存泄漏。
拿到Windows下时,直接炸了——内存占用直飙8G、9G,CPU也占了50%,甚至屏闪、宕机。
人都傻了,我还以为是极其隐蔽的内存泄漏。
拿工具测了又测,都说没有。
最终慢慢测试了出来,问题有两点,
- 鼠标位置更新线程中,位置获取API调用太频繁了,这导致CPU占用很高。。
- 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%。
总之,“点绘”不好,有空第二代“行绘机”了。