Windows_面向对象程序设计

目的

把原先集中在wWinMain函数的繁重流程拆分为面向对象模式。
wWinMain的任务:Windows_wchar和wmain

  1. 初始化窗口类对象WNDCLASSEX wcex的各种成员值。最重要的一步是lpfnWndProc绑定回调函数。
  2. 注册该窗口类对象。
  3. 创建窗口,返回HWND。
  4. 显示窗口
  5. 更新窗口
  6. 消息循环

以上是窗口类的启动职责。
除此之外就是消息处理职责了。
总体上,使用模板方法设计模式进行相应的消息处理的方法封装

准备项目

编译之前,要配置好VS项目的属性,右键项目选择Properties,左边栏选择Linker,然后选择System。右边详细条目"SubSystem"需下拉选择Windows。

在Header Files中创建Window.h,代表一个窗口。因为本文编写的应用程序是简化的项目,只包含一个进程,代表整个应用程序,所以生命周期和这个窗口是绑定的。目的是为了学习如何封装一个窗口程序的技术。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Window.h

#include <Windows.h>
#include <string>

#pragma once

namespace afk
{
class Window
{
// ...
};
}

Window类设计

需要封装什么属性?

  1. app_name
  2. HWND窗口对象

需要封装什么行为?

  1. 窗口的创建(构造函数)
  2. 显示窗口
  3. 消息循环

Window类初步定义

1
2
3
4
5
6
7
8
9
class Window
{
public:
Window(std::wstring const & app_name);
void show_window(bool show = true);
int run(void);
protected:
HWND _wnd{ nullptr };
};

Window类初步实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Window.cpp
#include "Window.h"
afk::Window::Window(std::wstring const & app_name)
{
WNDCLASSEX wcex = { 0 };
wcex.cbSize = sizeof(wcex);
wcex.style = CS_HREDRAW | CS_VREDRAW;
// 绑定CALLBACK,见后面解释
wcex.lpfnWndProc = &window_procedure;
// wcex.... = ...;
wcex.lpszClassName = app_name.c_str();
// wcex.... = ...;

if (!::RegisterClassEx(&wcex))
return;

// 定义一个窗口对象的句柄
// HWND: 窗口类型的句柄,也叫内核对象
_wnd = ::CreateWindowEx(
WS_EX_OVERLAPPEDWINDOW,
app_name.c_str(),
L"App",
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, 0, CW_USEDEFAULT, 0,
nullptr,
nullptr,
hInstance,
nullptr);
if (!_wnd)
{
int i = ::GetLastError();
return;
}
}

show_window实现

1
2
3
4
5
6
7
8
9
10
11
void afk::Window::show_window(bool show /*= true*/)
{
if(_wnd)
{
int nShowCmd = show ? SW_NORMAL : SW_HIDE;
// 显示窗口,nShowCmd可以指定窗口以最小/最大/正常状态显示
::ShowWindow(_wnd, nShowCmd);
// 更新窗口,绘制窗口。刚显示出来可能是无效的,需要在显示后绘制。
::UpdateWindow(_wnd);
}
}

run实现

1
2
3
4
5
6
7
8
9
10
11
12
int afk::Window::run(void)
{
MSG msg;
while (::GetMessage(&msg, nullptr, 0, 0))
{
// 翻译消息,比如输入法通过‘wo’生成‘我’
::TranslateMessage(&msg);
// 发送消息,调用刚才注册的callback窗口处理函数
::DispatchMessage(&msg);
}
return 0;
}

还有一些问题需要解决:
hInstance暂无;回调函数需要自定义;注册时,需要进行异常处理。

CALLBACK写到哪?

可以写到类中,但是,CALLBACK必须符合Windows的范式,即函数签名一致。则不能有this指针,因此需要设置为static。

1
2
3
4
5
6
7
8
class Window
{
public:
// ...
static LRESULT CALLBACK window_procedure(HWND wnd, UINT message, WPARAM wparam, LPARAM lparam);
protected:
HWND _wnd{ nullptr };
};

实现:

1
2
3
4
LRESULT afk::Window::window_procedure(HWND wnd, UINT message, WPARAM wparam, LPARAM lparam)
{
return LRESULT();
}

hInstance怎么处理

hInstance需要被window_procedure访问,因此也需要设置为static的。

1
2
3
4
5
6
7
class Window
{
public:
// ...
protected:
static HINSTANCE _instance;
};

静态变量,需要在.cpp文件中,全局初始化。
最好标记为constinit,以保证其在静态编译时就初始化。

1
2
// Window.cpp
constinit HINSTANCE afk::Window::_instance{ nullptr };

如何封装消息处理方法?如何实现CALLBACK具体逻辑?

使用模板方法设计模式进行相应的消息处理的方法封装

比如,处理WM_LBUTTONDOWN消息,就为Window类增加一个on_lbtndown的成员方法,在CALLBACK中相应的case中调用该方法。
注意,考虑继承性,需要设置其为virtual。
还需要考虑,要显示出有没有经过用户重写?所以需要返回bool,false代表默认的空操作。

此项目我们需要封装的消息处理方法有:

  1. 左键按下
  2. 左键抬起
1
2
3
4
5
6
7
8
9
10
11
12
class Window
{
public:
// ...
protected:
virtual bool on_lbtndown(POINT& pt);
virtual bool on_lbtnup(POINT& pt);
// HDC本身就是指针,没必要传递引用
virtual bool on_paint(HDC dc, PAINTSTRUCT& ps);
protected:
// ...
};

此处默认返回false表示用户没有处理此消息。

on_paint的参数:

  1. HDC是BeginPaint的返回值。见:Windows_wchar和wmain中的BeginPaint
  2. PAINTSTRUCT是BeginPaint的第2个参数,本项目中实际上暂未使用ps。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool afk::Window::on_lbtndown(POINT& pt)
{
return false;
}

bool afk::Window::on_lbtnup(POINT& pt)
{
return false;
}

bool afk::Window::on_paint(HDC dc, PAINTSTRUCT& ps)
{
return false;
}

需要考虑,CALLBACK是静态方法,不能直接调用非静态的成员方法(即各种消息处理函数)。就需要定义一个静态成员变量Window* _window来间接调用,相当于自创this指针。

1
2
3
4
5
6
7
8
class Window
{
public:
// ...
protected:
// ...
static Window* _window;
};

编译时的初始化先填一个nullptr值。

1
2
// Window.cpp
constinit afk::Window* afk::Window::_window{ nullptr };

在Window构造时,创建窗口前,使用this指针进行真正的初始化。

1
2
3
4
5
afk::Window::Window(std::wstring const & app_name)
{
_window = this;
// ...
}

CALLBACK具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
LRESULT afk::Window::window_procedure(HWND wnd, UINT message, WPARAM wparam, LPARAM lparam)
{
if(!_window)
return ::DefWindowProc(wnd, message, wparam, lparam);

PAINTSTRUCT ps;

switch (message)
{
case WM_CREATE:
break;
case WM_LBUTTONDOWN:
{
// GET_X/Y_LPARAM From <windowsx.h>
int x_coord = GET_X_LPARAM(lparam);
int y_coord = GET_Y_LPARAM(lparam);
POINT pt{ x_coord, y_coord };
// true代表用户处理完毕
if (_window->on_lbtndown(pt))
return 1;
}
break;
case WM_LBUTTONUP:
{
// GET_X/Y_LPARAM From <windowsx.h>
int x_coord = GET_X_LPARAM(lparam);
int y_coord = GET_Y_LPARAM(lparam);
POINT pt{ x_coord, y_coord };
// true代表用户处理完毕
if (_window->on_lbtnup(pt))
return 1;
}
break;
case WM_PAINT:
{
HDC dc = ::BeginPaint(wnd, &ps);
bool result = _window->on_paint(dc, ps);
::EndPaint(wnd, &ps);
if (result)
return 1;
}
break;
case WM_DESTROY:
::PostQuitMessage(0);
break;
//default:
// ;
}
return ::DefWindowProc(wnd, message, wparam, lparam);
}

wWinMain放到哪里?

用户不可见,所以可以放到.cpp文件中。

涉及到一个问题:_instance即使是静态的,但其是非public成员变量,可以用友元来解决类外部函数的访问问题。

1
2
3
4
5
6
7
8
class Window
{
friend int ::wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int);
public:
// ...
protected:
// ...
};

其次,wWinMain函数比较复杂,为了便于用户操作,再封装一层main,是供用户使用的接口,即这个函数相对于Window类来讲是用户提供的外部函数,因此标注extern。用户main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Window.cpp

// ...

extern int main(std::wstring const & args);

// ...
int wWinMain(
HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPWSTR lpCmdLine,
int nShowCmd)
{
afk::Window::_instance = hInstance;

return main(lpCmdLine);
}

以上即为一个Window程序的框架。用户可以使用此框架开发自己的程序。

基于框架开发

定义MyApp类继承Window类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// MyApp.h
#pragma once
#include "Window.h"
class MyApp : public afk::Window
{
public:
MyApp(std::wstring const & name) : Window{name}
{
//
}
bool on_paint(HDC dc, PAINTSTRUCT & ps)
{
// 测试:画一个矩形
//Rectangle(dc, 100, 100, 200, 200);
return true;
}
bool on_lbtndown(POINT& pt)
{
_pt = pt;
return true;
}
bool on_lbtnup(POINT& pt)
{
HDC dc = ::GetDC(_wnd);
// 从左键按下鼠标点 画直线到 左键弹起的点
::MoveToEx(dc, _pt.x, _pt.y, nullptr);
::LineTo(dc, pt.x, pt.y);

::ReleaseDC(_wnd, dc);
return true;
}
private:
POINT _pt;
};

技术点:GetDC

由于绘制需要得到HDC才可以,而dc只传给了on_paint函数,on_lbtnup中没有dc。
可以通过HWND找到当前窗口的HDC,需要调用GetDC(HWND)方法。
但是要记得,用完之后要ReleaseDC,需要2个参数,1是窗口句柄,2是HDC。

测试

1
2
3
4
5
6
7
8
// main.cpp
#include "MyApp.h"
int main(std::wstring const & args)
{
MyApp app{L"app"};
app.show_window();
return app.run();
}

至此,可以实现左键按下、抬起画直线了。但是一旦改变窗口就导致系统WM_PAINT消息重绘窗口,因为暂时没有处理on_paint函数,所以已经画上的线条会消失。需要把线条重绘操作集成在on_paint方法中,这些线条使用数据结构进行存储。

总结:模板方法模式

以上方法是模板方法设计模式。主要思想就是围绕一个Framework引擎,本身是可以直接使用的,行为是默认的配置。可以继承以重写一些的方法来完成特定的功能。多用于游戏引擎、应用框架,在底层隐藏了很多细节、可重用的部分。

完善细节:创建窗口前用户自定义配置

我们想给用户提供一个方法,就是改变窗口的属性。
需要在wcex注册前对wcex进行更改。

在Window类中封装pre_create方法:用于在WNDCLASSEX注册之前,用户修改配置。

1
2
3
4
5
6
7
8
9
class Window
{
// ...
public:
// ...
protected:
// ...
virtual void pre_create(WNDCLASSEX& wcex);
}

默认的pre_create实现:

1
2
3
4
5
6
7
8
9
// Window.cpp

// ...
void afk::Window::pre_create(WNDCLASSEX& wcex)
{
// 默认不操作
return;
}
// ...

Window构造函数在注册wcex窗口前加入pre_create

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
afk::Window::Window(std::wstring const & app_name)
{
_window = this;

WNDCLASSEX wcex = { 0 };
wcex.cbSize = sizeof(wcex);
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpfnWndProc = &window_procedure;
wcex.cbClsExtra = 0;
wcex.cbWndExtra = 0;
wcex.hInstance = _instance;
wcex.hIcon = LoadIcon(nullptr, IDI_SHIELD);
wcex.hCursor = LoadCursor(nullptr, IDC_ARROW);
wcex.hbrBackground = (HBRUSH)(GetStockObject(LTGRAY_BRUSH));
wcex.lpszMenuName = NULL;
wcex.lpszClassName = app_name.c_str();
wcex.hIconSm = wcex.hIcon;

pre_create(wcex);

if (!::RegisterClassEx(&wcex))
return;

// ...
}

用户自定义pre_create:此例中我们修改浅灰色背景为黑色背景。

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyApp : public afk:Window
{
public:
// ...
protected:
void pre_create(WNDCLASSEX& wcex)
{
// 改变背景色
wcex.hbrBackground = (HBRUSH)(GetStockObject(BLACK_BRUSH));
}

// ...
}

问题解决:不能在构造函数中调用虚方法

此时测试,发现并没有更改成功。因为我们的设计出现了一个结构性问题:我们在构造Window函数中调用了虚函数pre_create,由于虚表是在构造对象的过程中还没被定义,所以会出现失效。所以我们要长个教训,不能在构造函数中赋予太多任务,尤其不能调用虚函数。
因此需要分解Window构造函数的任务。把其中的创建窗口和此处要做的预创建剥离出来。实际上,凡是和成员变量无关的,都要剥离。

Window构造函数剥离出create_window:

1
2
3
4
5
6
7
8
9
10
11
// Window.h
class Window
{
// ...
public:
// ...
bool create_window(void);
// ...
protected:
// ...
}

被剥离后的Window构造函数:

1
2
3
4
5
6
7
8
// Window.cpp

// ...
afk::Window::Window(std::wstring const & app_name)
{
_window = this;
// app_name ??? 见下文
}

create_window实现,在注册窗口前调用pre_create,此时this指针、虚表已经构造完毕,虚函数行为得到纠正:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
bool afk::Window::create_window(void)
{
WNDCLASSEX wcex = { 0 };
wcex.cbSize = sizeof(wcex);
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpfnWndProc = &window_procedure;
wcex.cbClsExtra = 0;
wcex.cbWndExtra = 0;
wcex.hInstance = _instance;
wcex.hIcon = LoadIcon(nullptr, IDI_SHIELD);
wcex.hCursor = LoadCursor(nullptr, IDC_ARROW);
wcex.hbrBackground = (HBRUSH)(GetStockObject(LTGRAY_BRUSH));
wcex.lpszMenuName = NULL;
wcex.lpszClassName = _app_name.c_str();
wcex.hIconSm = wcex.hIcon;

// 用户自定义配置
pre_create(wcex);

// 注册
if (!::RegisterClassEx(&wcex))
return false;

// 创建窗口
_wnd = ::CreateWindowEx(
WS_EX_OVERLAPPEDWINDOW,
_app_name.c_str(), L"App",
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, 0, CW_USEDEFAULT, 0,
nullptr, nullptr, _instance, nullptr);
if (!_wnd)
{
int i = ::GetLastError();
return false;
}
return true;
}

// ...

此时发现,create_window函数中不认识app_name。这个变量我们目前只在构造函数中作为参数传入,没有设计为成员变量,需要增加。

1
2
3
4
5
6
7
8
9
// Window.h
class Window
{
public:
// ...
protected:
// ...
std::wstring _app_name;
}

最终的Window构造函数

1
2
3
4
5
6
7
8
// Window.cpp
// ...
afk::Window::Window(std::wstring const & app_name)
{
_window = this;
_app_name = app_name;
}
// ...

用户main

用户自己编写的main如下:wWinMain放到哪里?

1
2
3
4
5
6
7
8
9
// MyApp.cpp
#include "MyApp.h"
int main()
{
MyApp app{ L"app" };
app.create_window(); // add
app.show_window();
return app.run();
}

处理重绘

至此,可以实现左键按下、抬起画直线了,并且可以在创建窗口前让用户自定义配置。

现在我们着眼于解决:改变窗口(如改变大小)后导致重绘窗口。

因为没有处理重绘,所以已经画上的线条会消失。俗称:脏矩形,指窗口被污染或被其他窗口覆盖。

Windows操作系统其实也是保留了一些东西的:鼠标箭头样式、菜单条区域。其他的图形不予保留,需要程序员自行处理。

每当改变窗口时,相当于发送了PAINT消息,我们需要把操作集成在on_paint方法中,并使用数据结构对图形进行暂存。

LineManager:单例模式

LineManager类设计:(除了和单例有关的)

  1. 成员:list容器,存放Line
  2. add_line:存储Line
  3. render:渲染Line
  4. clear_all:析构所有Line
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// LineManager.h
#include <list>
#include <Windows.h>

// 不完全类型定义
class Line;

class LineManager
{
public:
static LineManager* instance_ptr(void);
static LineManager& instance(void);
static void destroy_instance(void);

void add_line(Line* line);
void clear_all(void);
// 渲染
void render(HDC dc);
private:
LineManager(void) = delete;
LineManager(LineManager const&) = delete;
LineManager(LineManager&&) noexcept = delete;
~LineManager();
private:
static LineManager* _manager;
std::list<Line*> _line_container;
}

LineManager单例实现

以下是懒汉式单例模式,即对象的指针一开始是空的,只有在第一次获取单例对象时,才开始构造单例对象。(线程不安全,因为new是分3个步骤的,可能导致_manager是否为空判断有误)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// LineManager.cpp
#include "LineManager.cpp"
#include <algorithm>
#include <ranges>
#include "Line.h"

constinit LineManager* LineManager::_manager{ nullptr };

LineManager::LineManager(void)
{
}
LineManager::~LineManager()
{
clear_all();
}

/* inline */ LineManager* LineManager::instance_ptr(void)
{
if(!_manager)
_manager = new LineManager;
return _manager;
}

LineManager& LineManager::instance(void)
{
if(!_manager)
_manager = new LineManager;
return *_manager;
}

void LineManager::destroy_instance(void)
{
if(_manager)
{
delete _manager;
_manager = nullptr;
}
}

问题:内联函数不能访问private成员。把LineManager::instance_ptr前的inline标记去掉即可编译成功。

LineManager的add_line、clear_all实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void LineManager::add_line(Line* line)
{
if(!line)
return;
_line_container.emplace_back(line);
}

void LineManager::clear_all(void)
{
std::for_each(
_line_container.begin(),
_line_container.end(),
[](auto&& line) -> void
{
delete line;
});
_line_container.clear();
}

LineManager的render实现

需要HDC参数,即Device Context的句柄。
然后传给每一个line,让他们在该DC上各自render。

1
2
3
4
5
6
7
8
9
10
11
void LineManager::render(HDC dc)
{
if(!dc)
return;
std::ranges::for_each(
_line_container,
[&dc](auto&& line) -> void /* dc是临时变量,需要在[]中捕获 */
{
line->render(dc);
});
}

Line

Line类设计:

  1. 2个POINT成员,即两个点的坐标
  2. render渲染
1
2
3
4
5
6
7
8
9
10
11
12
// Line.h
#pragma once
#include <Windows.h>
class Line
{
public:
Line(POINT const& pt1, POINT const& pt2);
~Line();
void render(HDC dc);
private:
POINT _pt1, _pt2;
}

Line实现:

1
2
3
4
5
6
7
8
9
10
11
// Line.cpp
#include "Line.h"
Line::Line(POINT const& pt1, POINT const& pt2) :
_pt1{ pt1 }, _pt2{ pt2 }
{

}
Line::~Line()
{

}

Line的render实现

需要HDC参数,即Device Context的句柄。
是由LineManager传入的,调用MoveToEx,LineTo,在该DC上进行绘制直线。

1
2
3
4
5
void Line::render(HDC dc)
{
::MoveToEx(dc, _pt1.x, _pt1.y, nullptr);
::LineTo(dc, _pt2.x, _pt2.y);
}

MyApp支持重绘的版本

  1. on_paint使用LineManager进行render。
  2. 每次抬起左键,就存一个Line对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// MyApp.h

#include "Window.h"
#include "LineManager.h"
#include "Line.h"

class MyApp
{
public:
// ...

bool on_paint(HDC dc, PAINTSTRUCT& ps)
{
LineManager::instance().render(dc);
return true;
}

// ...

bool on_lbtnup(POINT& pt)
{
HDC dc = ::GetDC(_wnd);
LineManager::instance_ptr()->add_line(new Line{ _pt, pt });
::ReleaseDC(_wnd, dc);
::InvalidateRect(_wnd, nullptr, true);
return true;
}
private:
// ...
}

技术点:GetDC

技术点:InvalidateRect

InvalidateRect函数 - InvalidateRect function
函数的作用:在指定窗口的更新区域中添加一个矩形。更新区域代表窗口的工作区中必须重绘的部分。

  1. 参数1:已更改的窗口的句柄。如果此参数为NULL,则系统将使所有窗口无效并重新绘制,而不仅仅是此应用程序的窗口。
  2. 参数2:指向RECT结构的指针,该结构包含要添加到更新区域的矩形的工作区坐标。如果此参数为NULL,则将整个客户区添加到更新区域。
  3. 参数3:指定在处理更新区域时是否要擦除更新区域内的背景。如果此参数为TRUE,则在调用BeginPaint函数时擦除背景。如果此参数为FALSE,则背景保持不变。

这个是主动让窗口失效的函数,从而刺激系统发出WM_PAINT的消息,然后就调用on_paint,让窗口重绘。如果不加这一句的话,我们只是画完线后,窗口上不会出现东西,因为我们现在画线的逻辑只是往容器中储存了Line,真正画出来是on_paint导致LineManager调用render,从而每个Line最后render。

精细化管理:智能指针

容器内容对象的智能指针管理

目前的程序只能做成图形的clear_all,但如果当我们有精确删除某个、某些图形的需求时,甚至有撤销删除的需求,就容易忘记、遗漏delete,因此需要智能指针来做管理。

修改 LineManager 中的 list 成员的模板参数,以及add_line中参数由裸指针改为智能指针。

见旧实现:LineManager的add_line、clear_all实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// LineManager.h

#pragma once

// ...

#include <memory>

class LineManager
{
public:
// ...

void add_line(std::shared_ptr<Line> line);

// ...
private:
// ...
std::list<std::shared_ptr<Line>> _line_container;
}

修改 LineManager 中的add_line的参数类型为智能指针,函数体内容不变。

1
2
3
4
5
6
7
// LineManager.cpp
void LineManager::add_line(std::shared_ptr<Line> line)
{
if(!line)
return;
_line_container.emplace_back(line);
}

修改 LineManager 中的clear_all的函数体,不用进行遍历delete了,一键 clear 即可以完成 Line 的自动析构。

1
2
3
4
5
// LineManager.cpp
void LineManager::clear_all(void)
{
_line_container.clear();
}

修改 MyApp 中的on_lbtnup,传入智能指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// MyApp.h

// ...

class MyApp
{
public:

// ...

bool on_lbtnup(POINT& pt)
{
// ...
LineManager::instance_ptr()->add_line(std::shared_ptr<Line>(new Line{ _pt, pt }));
// ...
}
private:
// ...
}

单例对象的智能指针管理:自定义deleter

1
2
3
4
5
6
7
8
9
10
11
12
13
// LineManager.h
// ...
class LineManager
{
public:
static std::shared_ptr<LineManager> instance_ptr(void);
// ...
private:
// ...
private:
static std::shared_ptr<LineManager> _manager;
std::list<std::shared_ptr<Line>> _line_container;
};

因为改用智能指针来管理此单例对象了,所以之前其涉及到单例对象的new、delete的函数都需要修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// LineManager.cpp
constinit std::shared_ptr<LineManager> LineManager::_manager{ nullptr };

std::shared_ptr<LineManager> LineManager::instance_ptr(void)
{
if(!_manager)
// error
_manager = std::shared_ptr<LineManager>(new LineManager /* 需要一个deleter */);
return _manager;
}
LineManager& LineManager::instance(void)
{
if(!_manager)
// error
_manager = std::shared_ptr<LineManager>(new LineManager /* 需要一个deleter */);
return *_manager;
}
void LineManager::destroy_instance(void)
{
if(_manager)
{
// 无需delete
_manager = nullptr;
}
}

由于shared_ptr在销毁时,默认绑定的deleter需要访问LineManager的析构函数,而单例的析构函数是私有的,因此需要加一个自定义deleter。

1
2
3
4
5
6
7
8
9
10
11
12
13
// LineManager.cpp
std::shared_ptr<LineManager> LineManager::instance_ptr(void)
{
if(!_manager)
_manager = std::shared_ptr<LineManager>(new LineManager, [](LineManager* p) { delete p; });
return _manager;
}
LineManager& LineManager::instance(void)
{
if(!_manager)
_manager = std::shared_ptr<LineManager>(new LineManager, [](LineManager* p) { delete p; });
return *_manager;
}

更简洁的deleter

单独定义一个类中private的deleter函数。

如果不写成静态的,传deleter时还需要binder此对象的this指针,失去了简洁性。

1
2
3
4
5
6
7
8
9
10
11
12
// LineManager.h
// ...
class LineManager
{
public:
// ...
private:
// ...
static void manager_deleter(LineManager* p);
private:
// ...
};

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// LineManager.cpp
void LineManager::manager_deleter(LineManager* p)
{
if (p)
{
delete p;
}
}
std::shared_ptr<LineManager> LineManager::instance_ptr(void)
{
if(!_manager)
_manager = std::shared_ptr<LineManager>(new LineManager, &LineManager::manager_deleter);
return _manager;
}
LineManager& LineManager::instance(void)
{
if(!_manager)
_manager = std::shared_ptr<LineManager>(new LineManager, &LineManager::manager_deleter);
return *_manager;
}

destroy_instance 和 deleter 的关系是什么?

我们发现,单例类自己也有一个 destroy_instance ,这个是用户自己调用的。

deleter是智能指针管理的,在new的时候,就要同时绑定此deleter,当时deleter已经记录下来new返回的裸指针p了,在没有任何一个智能指针引用此对象的情况下,就会通过delete p来进行对象的析构。
在智能指针接管之前,delete _manager的操作是我们用户自己在destroy_instance中进行的,而后还要置空_manager = nullptr

现在智能指针接管之后,是否可以把destroy_instance 方法删除呢?
不可以。
虽然我们无需自己delete _manager,但仍需要_manager = nullptr,因此 destroy_instance 还是有用的,即指针置空操作。

Windows_wchar和wmain

字符集

Character Set

char - 1 Byte - 8 bit - ASCII

参考:ASCII和ANSI Ascii Codes - C++ Tutorials (cplusplus.com)

ANSI码

ANSI码仅在前128(0-127)个与ASCII码相同,之后的字符全是某个国家语言的所有字符。值得注意的是,两个字节最多可以存储的字符数目是2的16次方,即65536个字符,这对于一个语言的字符来说,绝对够了。还有ANSI编码其实包括很多编码:中国制定了GB2312编码,用来把中文编进去另外,日本把日文编到Shift_JIS里,韩国把韩文编到Euc-kr里,各国有各国的标准。受制于当时的条件,不同语言之间的ANSI码之间不能互相转换,这就会导致在多语言混合的文本中会有乱码。

Unicode编码

为了解决不同国家ANSI编码的冲突问题,Unicode应运而生:如果全世界每一个符号都给予一个独一无二的编码,那么乱码问题就会消失。这就是Unicode,就像它的名字都表示的,这是一种所有符号的编码。

Unicode标准也在不断发展,但最常用的是用两个字节表示一个字符(如果要用到非常偏僻的字符,就需要4个字节)。现代操作系统和大多数编程语言都直接支持Unicode。

但是问题在于,原本可以用一个字节存储的英文字母在Unicode里面必须存两个字节(规则就是在原来英文字母对应ASCII码前面补0),这就产生了浪费。那么有没有一种既能消除乱码,又能避免浪费的编码方式呢?答案就是UTF-8!

UTF-8编码

这是一种变长的编码方式:它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度,当字符在ASCII码的范围时,就用一个字节表示,保留了ASCII字符一个字节的编码做为它的一部分,如此一来UTF-8编码也可以是为视为一种对ASCII码的拓展。值得注意的是unicode编码中一个中文字符占2个字节,而UTF-8一个中文字符占3个字节。从unicode到uft-8并不是直接的对应,而是要过一些算法和规则来转换。

在计算机内存中,统一使用Unicode编码,当需要保存到硬盘或者需要传输的时候,就转换为UTF-8编码。

用记事本编辑的时候,从文件读取的UTF-8字符被转换为Unicode字符到内存里,编辑完成后,保存的时候再把Unicode转换为UTF-8保存到文件。

Unicode对应的字符类型 - wchar

Unicode对应的字符类型为wchar_t。打印sizeof为2,即占大小为2字节、16位。

1
2
3
4
5
6
#include<iostream>
int main()
{
    std::cout << sizeof(wchar_t);
    return 0;
}
  1. 初始化一个Unicode字符串。需要在"双引号"前加L,表示使用Unicode字符集串。
  2. 如果要正常打印Unicode字符串,需要使用std::wcout
  3. Cpp中也有Unicode字符串对应的类,是std::wstring
1
2
3
4
5
6
7
8
9
10
11
12
#include<iostream>
int main()
{
    // wchar_t str[] = "Hello"; // 错误,"Hello"默认为ANSI字符集串
    wchar_t str[] = L"Hello";
    std::cout << sizeof str << std::endl; // 输出 12. Hello 5个 加 1个哨兵位,乘以2
    std::wcout << str; // 输出 Hello
   
    // Cpp中的Unicode字符串
    std::wstring test = str;
    std::wcout << test;
}

C中的wchar库

新标准写法为cwchar
cwchar (wchar.h) - C++ Reference (cplusplus.com)

C语言标准库中也提供了对wchar的支持,输出用wprintf。区别为"双引号"前要加L
相应于普通的strcat、strcmp、strcpy、strlen等,都可用wcs开头的wcscat等代替。

Cpp中的宽字符输出流为:std::wcout

1
2
3
4
5
6
#include<cwchar>
int main()
{
wchar_t str[] = L"Hello";
wprintf(L"%s\n", str); // 记得加L. 输出Hello
}

wmain

1
2
3
4
5
6
7
int main(int ac, char * av[]) // main入口 + 普通的char * []
{
for(int i = 0; i < ac; ++i)
{
std::cout << av[i] << std::endl; // cout
}
}
1
2
3
4
5
6
7
int wmain(int ac, wchar_t * av[]) // wmain入口 + wchar_t * []
{
for(int i = 0; i < ac; ++i)
{
std::wcout << av[i] << std::endl;// wcout
}
}

第一个Windows程序

不遵循ANSI、ISO标准,是Windows平台下程序的入口。
使用Windows.h库,是Windows系统自带的SDK。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <Windows.h>

int wWinMain(
HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPWSTR lpCmdLine,
int nShowCmd)
{
return 0;
}

编译之前,要配置好VS项目的属性,右键项目选择Properties,左边栏选择Linker,然后选择System。右边详细条目"SubSystem"需下拉选择Windows。

如果直接return 0,不会出现窗口,程序直接退出。
如果想要出现窗口,则需要加语句:

1
2
3
{
::MessageBox(nullptr, L"First App", L"cap", MB_OK);
}

MessageBox API:
int MessageBoxW(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType)
hWnd表示窗口句柄。
lpText表示显示文本。
lpCaption表示窗口标题。
uType表示显示的按钮是什么类型。我们在此例中使用MB_OK这个值。有关于此的参考文档,可以进入learn.microsoft.com,在输入框搜索"messagebox"。搜索结果查看MessageBox function (winuser.h) - Win32 apps

LPCWSTR表示Long Pointer Const Wide String。
关于LP:Windows 3.2版本以前由于内存分段的原因,要区分长短指针。但是现在内存不分段了,是平坦模式,因此长短指针不区分了。
因此只需要关注CWSTR:比如L"Hello First Windows Program"

运行效果:出现一个如下的窗口,程序会阻塞于此,当点击OK按钮时,程序才会继续执行。

窗口程序

多个程序共享一个窗口。

弱化流程——基于消息驱动

解决抢资源的问题,以前是锁,是资源抢占形式,不太好。现在,让多个人共享同一种资源,最好的办法说白了就是排队。排队需要优先级,比如时间先后顺序。那就需要一个消息队列(消息循环)。
这些消息可能是并行的,需要串行化后进入消息队列,从队列中不断取消息,把消息一个一个地投放到目标窗口。

Windows下的面向对象

Windows编程,Windows SDK或Win32,是一个面向对象的程序设计方法。虽然1985年C++面向对象语言才出来,在这之前大多数是用C语言开发,但这不是说用不了面向对象的思想。

假设整个应用程序抽象为一个对象,应用程序可能有多个窗口,每个窗口是一个窗口对象,此窗口对象的数据结构存在于操作系统的内部(因此也叫内核对象 (Kernel Object)),每个窗口对象有一个对应的Handle,即句柄,字面意义上是把手,形容可以用此把手提起一个箱子。

与Linux思想不同,Linux是一切皆文件,而Windows是一切皆窗口,在其中,窗口也视为一种内核对象。

权威教材描述:

  1. Programming Windows - Charles Petzold
  2. Windows核心编程(Windows via C/C++

wWinMain解析

  1. HINSTANCE:应用程序的句柄类型,H代表句柄。
  2. nShowCmd:预设的值以及含义见ShowWindow function (winuser.h) - Win32 apps | Microsoft Learn中的Parameters。可以用于控制窗口的显示、隐藏等。

应用程序创建窗口首先需要一个对象。

WNDCLASSEX是一个结构体,用于定义窗口类。因为当时没有面向对象语言,需要把该类注册给操作系统,让操作系统在内部生成内核对象。
About Window Classes - Win32 apps | Microsoft Learn

WNDCLASSEXA (winuser.h) - Win32 apps | Microsoft Learn

  1. 第一步先初始化窗口类对象的成员值。
    1. lpfnWndProc:是WNDPROC类型,需要指定一个函数指针。WNDPROC - Win32 apps | Microsoft Learn
  2. 注册该窗口类对象。之后操作系统就知道了该窗口类的存在。
    1. RegisterClassExW function (winuser.h) - Win32 apps | Microsoft Learn
    2. 如果注册成功,返回一个非零的类原子值,唯一标识该类。If the function succeeds, the return value is a class atom that uniquely identifies the class being registered. This atom can only be used by the CreateWindowCreateWindowExGetClassInfoGetClassInfoExFindWindowFindWindowEx, and UnregisterClass functions and the IActiveIMMap::FilterClientWindows method.
    3. If the function fails, the return value is zero. To get extended error information, call GetLastError.
  3. 操作系统以名字——lpszClassName访问,如果没有定义该变量,就不认识了。
  4. 用注册成功的类来创建窗口,调用CreateWindowEx,返回窗口句柄。
    1. CreateWindowEx的参数来定义创建的窗口的属性。
      1. CreateWindowExW function (winuser.h) - Win32 apps | Microsoft Learn
      2. 第一个参数dwExStyle:Extended Window Styles (Winuser.h) - Win32 apps | Microsoft Learn。表示样式。我们暂用WS_EX_OVERLAPPEDWINDOW
      3. 第二个参数lpClassName:窗口类名。指定用哪个窗口类来创建窗口的实例,用窗口类注册的名字索引。
      4. 第三个参数lpWindowName:窗口的标题。
      5. 第四个参数dwStyle:和第一个参数dwExStyle相比是基础的,Ex表示拓展。我们暂用WS_OVERLAPPEDWINDOW。具体值为:WS_OVERLAPPED | WS_CAPTION(标题栏) | WS_SYSMENU(菜单) | WS_THICKFRAME | WS_MINIMIZEBOX(最小化按钮) | WS_MAXIMIZEBOX(最大化按钮)
      6. 第5、6个参数X、Y:表示窗口的起始坐标。
      7. 第7、8个参数nWidth、nHeight:分别表示宽度、高度。
      8. 第9个参数hWndParent:表示父窗口句柄,若无则填NULL或nullptr。
      9. 第10个参数hMenu:Menu栏句柄,若无则填NULL或nullptr。
      10. 第11个参数hInstance:整个应用程序的句柄。
      11. 第12个参数lpParam:特殊、额外的参数。若无则填NULL或nullptr。
  5. 显示窗口、更新窗口。创建完窗口后,就交由Windows系统管理了。
  6. 开始消息循环。调用GetMessage从当前的应用程序队列中获得消息,队列是操作系统内部提供的。GetMessage function (winuser.h) - Win32 apps | Microsoft Learn
    1. 第一个参数lpMsg,放入一个MSG类型的指针,用于获取消息后填充给它。MSG是个结构体。
      1. MSG结构体成员:hwnd、message、wParam、lParam、time、pt
    2. 第二个参数hWnd,指定应用程序的某个窗口。如果填nullptr表示指定所有窗口。
    3. 第三、四参数wMsgFilterMin、wMsgFilterMax,指定关注消息的号段。如果全填0,表示关注所有消息。
    4. 返回BOOL。正常则返回非零,继续循环;如果收到的消息是WM_QUIT(0x0012),则返回0退出循环。
  7. 循环退出则程序结束。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
int wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nShowCmd)
{
std::wstring app_name{ L"First App" };

WNDCLASSEX wcex = { 0 };
wcex.cbSize = sizeof(wcex);
wcex.style = CS_HREDRAW | CS_VREDRAW;
// 绑定CALLBACK,见后面解释
wcex.lpfnWndProc = &window_procedure;
// wcex.... = ...;
wcex.lpszClassName = app_name.c_str();
// wcex.... = ...;


if (!::RegisterClassEx(&wcex))
{
return -1;
}
// 定义一个窗口对象的句柄
// HWND: 窗口类型的句柄,也叫内核对象
HWND hWnd = ::CreateWindowEx(
WS_EX_OVERLAPPEDWINDOW,
app_name.c_str(),
L"App",
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, 0, CW_USEDEFAULT, 0,
nullptr,
nullptr,
hInstance,
nullptr);
if (!hWnd)
{
int i = ::GetLastError();
return -1;
}
// 显示窗口,nShowCmd可以指定窗口以最小/最大/正常状态显示
ShowWindow(hWnd, nShowCmd);
// 更新窗口,绘制窗口。刚显示出来可能是无效的,需要在显示后绘制。
UpdateWindow(hWnd);

MSG msg;
while (::GetMessage(&msg, nullptr, 0, 0))
{
// 翻译消息,比如输入法通过‘wo’生成‘我’
::TranslateMessage(&msg);
// 发送消息,调用刚才注册的callback窗口处理函数
::DispatchMessage(&msg);
}

return 0;
}

其中,CALLBACK函数如下:message是消息类型,为unsigned

1
2
3
4
5
// 按照WNDPROC的原型定义,返回默认的DefWindowProc
LRESULT CALLBACK window_procedure(HWND wnd, UINT message, WPARAM wparam, LPARAM lparam)
{
return ::DefWindowProc(wnd, message, wparam, lparam);
}

以上这个是什么也不处理,直接返回一个默认的操作。
以下使用switch进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
LRESULT CALLBACK window_procedure(HWND wnd, UINT message, WPARAM wparam, LPARAM lparam)
{
PAINTSTRUCT ps;
switch (message)
{
case WM_CREATE: // 相当于constructor,往往先于消息循环。
break;
case WM_PAINT:
// Handle of Device Context
// case语句中不能直接声明、定义变量,因为case中的语句会影响程序编译的行为,而程序不确定会运行到定义变量语句,case是没有对应的栈的,没有了确定性,因此需要在此段语句外加大括号,形成局部的栈,就可以存放临时变量了。相当于把这几条语句用函数包装了。
{
HDC hdc = ::BeginPaint(wnd, &ps);
::Rectangle(hdc, 100, 100, 200, 200); // 四个数字是坐标参数
::EndPaint(wnd, &ps);
}
break;
case WM_CLOSE:
::DestroyWindow(wnd);
return 0; // 返回的LRESULT,表示WM_CLOSE消息已处理
break;
case WM_DESTORY: // 相当于destructor
::PostQuitMessage(0); // 执行后会向消息队列投入WM_QUIT消息,下次GetMessage得到WM_QUIT将返回0,while循环退出。
break;
default:
break;
}
return ::DefWindowProc(wnd, message, wparam, lparam);
}

BeginPaint

BeginPaint 函数准备指定的窗口进行绘画,并使用有关绘画的信息填充PAINTSTRUCT结构。

参数:

  1. hWnd,HWND类,要重绘的窗口的句柄。
  2. lpPaint,LPPAINTSTRUCT类,指向将接收绘画信息的PAINTSTRUCT结构的Long指针。

返回值:

  1. 如果成功,返回值是指定窗口的DC的句柄。
  2. 如果失败,返回NULL,表示没有可用的显示设备上下文(Display Device Context)。

总结

以上虽然是对Windows系统中的窗口程序进行了解析,但是其实其他的操作系统中的窗口UI程序也是同样的流程和道理。

总之,不同的应用程序抢相同的资源时,程序范式有两种:

  1. 红绿灯,等别人做完再做。锁机制,在多任务系统,尤其是带UI的系统会有卡顿感。
  2. 基于消息循环,从消息发送到消息处理也就是100ms之内,本质上也是一种同步,但粒度更小,就可以等效于一段时间内大家都在同时做。

再记几个消息类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
// ...
case WM_LBUTTONDOWN:
{
int x_coord = lparam & 0xffff;
int y_coord = lparam >> 16;
wchar_t text[50] = L"";
// 打印到text字符数组中
swprintf(text, 50, L"%i, %i", x_coord, y_coord);
::MessageBox(nullptr, text, L"BtnTest", MB_OK);
}
break;
// ...
}
  1. WM_LBUTTONDOWN:鼠标左键按钮按下动作。WM_LBUTTONDOWN message (Winuser.h) - Win32 apps | Microsoft Learn
    1. 消息里的属性有:wparam,lparam。w代表字,l代表long,现代已经全部升级为32位的long了。lparam中,低16位是X坐标,高16位是Y坐标。如果要从32位数中取出16位,需要做与掩码的按位与运算。比如取X:int x_coord = lparam & 0xffff;,取Y:int y_coord = lparam >> 16;

自定义消息类型

Windows中默认的message都是有相应预定义好的值的。比如在WinUser.h中:

1
2
3
#define WM_MOUSEMOVE      0x0200
#define WM_LBUTTONDOWN 0x0201
#define WM_LBUTTONUP 0x0202

我们也可以根据需要自定义一个消息类型,在WinUser.h中,又一个给出的WM_USER值,它的注释说明了,小于0x0400的值已被系统占用,自定义类型值需要从0x0400开始:

1
2
3
4
5
/* 
* NOTE: All Message Numbers below 0x0400 are RESERVED.
* Private Window Messages Start Here:
*/
#define WM_USER 0x0400

所以,比较简便的自定义消息类型的预定义语句可以这么写:

1
#define MYMSG            WM_USER + 10

使用:通过左击鼠标,用PostMessage来发送MYMSG到wnd的消息队列。下次取出MYMSG消息后做相应的动作。

1
2
3
4
5
6
7
8
9
10
{
// ...
case WM_LBUTTONDOWN:
::PostMessage(wnd, MYMSG, wparam, lparam);
break;
case MYMSG:
::MessageBox(nullptr, L"My Message", L"MyMsg", MB_OK);
break;
// ...
}

也可以用SendMessage,区别是不经过消息队列,直接发送消息。效果是在SendMessage后,立即调用回调函数,处理完毕后,再回到原先的SendMessage下一条位置继续。

1
2
3
4
5
6
7
8
9
10
{
// ...
case WM_LBUTTONDOWN:
::SendMessage(wnd, MYMSG, wparam, lparam);
break;
case MYMSG:
::MessageBox(nullptr, L"My Message", L"MyMsg", MB_OK);
break;
// ...
}

带参数

利用SendMessage、PostMessage中可以带wparam、lparam,回调函数可以使用这些参数。

发送消息时携带参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct MyParam
{
int x_coord;
int y_coord;
}
// ...
{
// ...
case WM_LBUTTONDOWN:
{
int x_coord = lparam & 0xffff;
int y_coord = lparam >> 16;
MyParam* param = new MyParam{ x_coord, y_coord };
::SendMessage(wnd, MYMSG, reinterpret_cast<WPARAM>(param), lparam);
}
break;
// ...
}

处理消息时解析参数:

1
2
3
4
5
6
7
8
9
10
11
12
{
// ...
case MYMSG:
{
MyParam* param = reinterpret_cast<MyParam*>(wparam);
std::wstring text = std::format(L"{0}, {1}", x_coord, y_coord);
::MessageBox(nullptr, text.c_str(), L"Param Test", MB_OK);
delete param;
}
break;
// ...
}

总结

Windows SDK的开发,聚焦的点主要就在于window_procedure里的代码逻辑和消息的处理。