图片来源:电路板娘画师SYEGO
Win程序开发流程
前排提醒,这是作者阅读《深入浅出MFC》(侯俊杰,华东师大出版社)的一些笔记,有兴趣的话请读原本或者前往window官方文档细学。
Windows也是一个很古老的系统了,后续还有很多新的可视化编程界面出现,比如QT、.net。当Windows桌面程序的基本观念始终没有大变化(监听、消息、触发)。你当然可以选择你喜欢的Windows编程方式(比如python或者时HTML都可以)。我也不想参与MFC跟其他语言的撕逼大战,你能做出你想要的软件就是最棒的。
Windows程序分为[程序代码]、[UI(User Interface)资源]、[.LIB]三大部分,三部分最终由LINKER整合成为一个完整的EXE文件。
1.UI资源的二进制代码由各种工具产生,以(.ico、.bmp、.cur)等文件格式存在。程序员必须在一个所谓的资源描述档(.rc)中描述它们。RC编译器(RC.EXE)读取RC文档的描述后,将所有UI资源集中制作出一个.RES档,再与程序代码结合在一起,这才是一个完整的Windows可执行档。你可以简单理解为光标、图标、图像等的像素资源。
2.函数库(.LIB)资源。应用程序所调用的Windows API函数在[执行时期]联结上程序代码,共同驱动Windows程序。(.exe、.dll、.fon、.mod、.drv、.ocx)这些文件都是动态连结函数库文件(Dynamic Link Library)。
3.WindowsAPI由操作系统本身(主要是Windows三大模块GDI32.DLL、USER32、DLLKERNEL32.DL)提供。WindowsAPI函数与库函数(.LIB)一起作为LINKER的操作性系统函数主要来源。
4.(.DEF)文件一般为LINKER提供有关被链接程序的导出、属性及其他方面的信息。
流程总结:
从资源的角度来说,生成一个Windows程序有三类主要资源:以C编写并编译出来的程序(.OBJ二进制代码)、以RC编译器编译出来的UI资源文件(.RES二进制代码)和由(.LIB库文件)组成的操作系统有关的基础设施文件。这三类资源由LINKER联结起来之后,共同生成了一个.EXE的二进制代码文件。
所以可以说,第一步是找好这三类资源你要用到哪些,也就是找好钢筋水泥;第二步是对各类资源做粗加工,比如C文件加工成OBJ文件,UI文件加工成RES文件;第三步是使用LINKER将各部分加工好的资源整合起来,盖成一个大楼(EXE文件)。
组合与时序之辩
Windows程序的特点概括起来就是一句话:“以消息为基础、以事件驱动之”。
Windows程序的进行系统依靠外部发生的事件来驱动。换句话说,程序不断等待(利用一个while回路),等待任何可能的输入,然后做出判断,再做适当的处理。[输入]由[操作系统]捕捉到之后,以消息的形式(一种数据结构)进入程序之中。
“一个变量只能由内部产生或者由外部输入。”(一句很白痴但能帮助理解的话)
展开来说,所谓的事件,具体指的是外围设备的状态变化,比如你必须要点一下鼠标,或者是键盘,这样才能称之为是一次事件,否则不是从外围输入的信息只能是由内部产生的,那它就一定是变量,是变量就无需使用事件驱动了。(这里具体解释是,因为外围设备的状态变化往往频率很低,而内部变量变化的频率往往很高,所以内部变量沿时间的变化几乎忽略不计,故不把内部变量造成的延迟看作阻塞,而主要把外部输入的变量看为阻塞)
“在时间轴某一个点(瞬间)的变量关系,就是组合逻辑关系;沿时间轴的变量关系,就是时序逻辑关系。”
这句话看起来很绕,但本质是在说组合逻辑关系其实是一种特殊的时序逻辑关系。我们平时写的C语言函数,一旦变量的输入确定了,输出几乎立刻也就确定了。所以,我们写赋值、写声明、写引用的时候,看上去都是在写组合逻辑关系。如果一个函数的输入与输出,不需要引入时间,可以直接关联,那么我们就把这一函数称为定态函数(笔者物理学出身,这个定义情不自禁了属于是)。如果函数输入与输出需要引入时间,则为不定态函数,或者直接称函数。
你也可以理解为,有while和for等循环的,可以称之为时序函数(非定态函数),没有while和for循环的,可以称之为定态函数。
定态函数的主要作用在于简化问题,对于多变量时序变化问题,我们最好将其中定态的部分分离出来,用简单的逻辑电路直接表示(所谓简单,意为你不需要添加时间监听部件)。这样跟原本一个不定态时序函数,我们可以把它简化成两部分,定态部分和不定态部分。这样一来,我们实现了对函数的分离。
为什么要实现分离呢?因为时序逻辑函数一般比较“贵”。实现时序的办法主要就是持续性的程序记数,对CPU来说就是为了等待时序条件的变化,PC指针会一直空转。从执行时间上讲,等待时序条件变化会拖累整体进度。除此之外,为了实现时序条件判断,需要添加专门的时间判断器件。
监听函数就是总函数中时序函数部分的分离。我们可以将函数所需要的时序变量部分从整体函数中分离出来,专门放到监听函数里写,这样剩下的部分,跟事件无关的变量就可以以组合逻辑关系的形式写入定态函数之中了。这样人为的将函数分为两个部分,我们做监听的函数只需要输给定态函数少数几个变量,定态函数就能知道该如何执行了。
每一个Windows程序,都应该有如下回路:
MSG msg;
while (GetMessage(&msg,NULL,NULL,NULL))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
所谓的Windows窗口这个类,就是一个同时具备了监听、执行、回调、显示等功能的类。
Windows程序模型
讨论完时序与组合之后,我们可以开始正式的讨论一下Windows操作系统的模型了。事件,特指由其他窗口或鼠标键盘造成变量变化。操作系统会统合各类事件,将他们打包成消息发送给EXE执行文件中WndProc函数。根据消息的不同,WndProc函数会执行不同的操作,这就是Windows程序的“以消息为基础、以事件驱动之”。
为了避免代码过多引起麻烦,这里只放几个组成基本Windows程序的文件名:
(你可以通过在VS上新建Windows桌面应用程序获得一份类似的代码)
Generic.mak//makefile文件,告诉C语言编译器和rc编译器各种编译指令
Generic.h//专属头文件,用于存放基本函数、变量和宏
Generic.cpp//C语言代码主体
Generic.rc//UI资源窗口代码主体
根据之前的图可知,rc由rc编译器生成res文件;cpp由c编译器生成obj文件;这些都干完以后,由链接器链接WindowsAPI库文件,生成能被操作系统执行的统一整体。
程序进入点WinMain
WinMain函数是编译器识别的Windows程序进入点。
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
_In_opt_ HINSTANCE hPrevInstance,
_In_ LPWSTR lpCmdLine,
_In_ int nCmdShow)
{
......
......
}
你盖好楼之后,用户就是从这个函数进入房间的。用户进房间的过程是,找到要进门的位置(找到EXE文件所在位置并双击),掏出钥匙(双击后系统自动调用Windows加载器加载程序),对准门的锁眼(加载器中C Startup Code代码自动找到EXE文件中的WinMain函数),打开大门进入(WinMain被执行,程序启动)。
WinMain函数的而四个参数代表着你使用钥匙时的状态,第一个参数代表当前应用实例的句柄,第二各参数代表上一个实例的句柄(用来判断实例化了几个模块用),第三个参数时应用程序的命令行,第四个参数是窗口显示的控制int数。
窗口类注册与生成
Windows的API函数(CreateWindow)完全包办了创建一个窗口的巨大工程,你只需要在创建窗口前设定好窗口参数就行。窗口的参数设定如下代码的形式:
ATOM MyRegisterClass(HINSTANCE hInstance)
{
WNDCLASSEXW wcex;
wcex.cbSize = sizeof(WNDCLASSEX);
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpfnWndProc = WndProc;
wcex.cbClsExtra = 0;
wcex.cbWndExtra = 0;
wcex.hInstance = hInstance;
wcex.hIcon = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_WINDOWSPROJECT1));
wcex.hCursor = LoadCursor(nullptr, IDC_ARROW);
wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW+1);
wcex.lpszMenuName = MAKEINTRESOURCEW(IDC_WINDOWSPROJECT1);
wcex.lpszClassName = szWindowClass;
wcex.hIconSm = LoadIcon(wcex.hInstance, MAKEINTRESOURCE(IDI_SMALL));
return RegisterClassExW(&wcex);
}
窗口各参数设置好之后,还需要向WindowsAPI注册窗口(RegisterClassExW(&wcex))这样这个窗口的各种属性API就告诉Windows了。这里值得注意的是,参数的各变量直接跟你的UI资源文件(RC文件)挂钩,比如菜单样式等。
这里的窗口注册需要你保持参数的一致性,rc文件中定义的各类面板参数的名称,都会在你的回调函数中一一出现。
完成注册之后,还需要对窗口进行初始化:
BOOL InitInstance(HINSTANCE hInstance, int nCmdShow)
{
hInst = hInstance; // 将实例句柄存储在全局变量中
HWND hWnd = CreateWindowW(szWindowClass, szTitle, WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, nullptr, nullptr, hInstance, nullptr);
if (!hWnd)
{
return FALSE;
}
ShowWindow(hWnd, nCmdShow);
UpdateWindow(hWnd);
return TRUE;
}
在初始化中,showWindow函数与UpdateWindow函数最终告诉Windows建立窗口。
消息循环
在完成初始化工作之后,WinMain进入消息循环:
HACCEL hAccelTable = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDC_WINDOWSPROJECT1));
MSG msg;
while (GetMessage(&msg, nullptr, 0, 0))
{
if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
这里再补充一个消息体本身的结构:
typedef struct tagMSG {
HWND hwnd;
UINT message;
WPARAM wParam;
LPARAM lParam;
DWORD time;
POINT pt;
#ifdef _MAC
DWORD lPrivate;
#endif
} MSG, *PMSG, NEAR *NPMSG, FAR *LPMSG;
一个消息本身包含了其事件发生的窗口信息,消息本身的含义信息,消息可能存在的W参数与L参数信息,时间信息与指针信息。
在消息处理函数中,TranslateMessage是将消息初始化,DispatchMessage是将消息交给窗口函数。这里虽然没有直接指向回调函数,抑或是窗口类,但在msg.hwnd参数的输入下,在TranslateAccelerator函数的执行中,程序已经精准定位了接收到消息的具体是哪一个窗口了。
事件作用在窗口上,所以直接TranslateAccelerator函数调用窗口后的返回值就能告诉你现在这个事件有没有被hAccelTable代表的窗口所调用,如果有,因为事件的低频性,可以确定后续的回调与消息传递一定是相对定态的过程,所以可以直接向目前响应的窗口体发送message。当前窗口体的监听函数与回调函数也一并执行。
窗口函数
消息循环中的DispatchMessage把消息分配到哪里呢?它通过USER模块的协助,送到该窗口的窗口函数去了。窗口函数通常利用switch/case方式判断消息种类,以决定处置方式。由于它是被Windows系统所调用的,所以是一种回调函数(在你的函数中,被Windows调用的函数)。这种函数虽然由你设计,但永远也不会被你调用,因为它们是为Windows系统准备的函数。
程序进行过程中,消息由输入装置,经由消息循环的抓取,源源不断地传送给窗口并进而送到窗口函数去。窗口函数即为下列函数:
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_COMMAND:
{
int wmId = LOWORD(wParam);
// 分析菜单选择:
switch (wmId)
{
case IDM_ABOUT:
DialogBox(hInst, MAKEINTRESOURCE(IDD_ABOUTBOX), hWnd, About);
break;
case IDM_EXIT:
DestroyWindow(hWnd);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
}
break;
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
// TODO: 在此处添加使用 hdc 的任何绘图代码...
EndPaint(hWnd, &ps);
}
break;
case WM_DESTROY:
PostQuitMessage(0);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
return 0;
}
HWND表示窗口,UINT表示message,wParam和LParam表示传入参数。
注意,不论什么消息,窗口函数都必须被处理,所以switch/case指令中地default必须调用windowproc(预设消息处理函数)。窗口函数除了你会调用它,操作系统也要调用你的窗口函数,只有设计成callback的格式,才能开放出一个接口给操作系统调用。
暂时就先写这么多了,这里面回调函数的名称在注册窗口的时候会进行一次登记,因为所有的一切都源自于最开始的面板,所以系统可以只认一个回调函数,多对话框的体系,可以使用消息映射地图的办法做到,