这篇文章本来只是想介绍一下子类化和超类化这两个比较“生僻”的名词为了叙述的完整 性而讨论了 Windows的窗口和消息,也简要讨论了进程和线程子类化(Subclassing)和 超类化(Superclassing)是伴随Windows窗口机制而产生的两个复用代码的方法不要把“子 类化、超类化”与面向对象语言中的派生类、基类混淆起来子类化、超类化”中的“类” 是指Windows的窗口类0运行程序希望读者在阅读本节前先看看"谈谈Windows程序中的字符编码"开头的第0节和附录0第 0节介绍了 Windows系统的几个重要模块附录0概述了 Windows的启动过程,从上电到 启动Explorer.exe本节介绍的是运行程序时发生的事情0.1程序的启动当我们通过Explorer.exe运行一个程序时,Explorer.exe会调用CreateProcess函数请求系统 为这个程序创建进程当然,其它程序也可以调用CreateProcess函数创建进程系统在为进程分配内部资源,建立独立的地址空间后,会为进程创建一个主线程我们可以 把进程看作单位,把线程看作员工进程拥有资源,但真正在CPU上运行和调度的是线程。
系统以挂起状态创建主线程,即主线程创建好,不会立即运行,而是等待系统调度系统向 Win32子系统的管理员csrss.exe登记新创建的进程和线程登记结束后,系统通知挂起的主 线程可以运行,新程序才开始运行这时,在创建进程中CreateProcess函数返回;在被创建进程中,主线程在完成最后的初始 化后进入程序的入口函数(Entry-point)创建进程与被创建进程在各自的地址空间独立运 行这时,即使我们结束创建进程,也不会影响被创建进程0.2程序的执行可执行文件(PE文件)的文件头结构包含入口函数的地址入口函数一般是Windows在运 行时库中提供的,我们在编译时可以根据程序类型设定在VC中编译、运行程序的小知识 点讨论了 Entry-point,读者可以参考入口函数前的过程可以被看作程序的装载过程在装载时,系统已经做过全局和静态变量(在 编译时可以确定地址)的初始化,有初值的全局变量拥有了它们的初值,没有初值的变量被 设为0,我们可以在入口函数处设置断点确认这一点进入入口函数后,程序继续运行环境的建立,例如调用所有全局对象的构造函数在一切就 绪后,程序调用我们提供的主函数主函数名是入口函数决定的,例如main或WinMain。
如果我们没有提供入口函数要求的主函数,编译时就会产生链接错误0.3进程和线程我们通常把存储介质(例如硬盘)上的可执行文件称作程序程序被装载、运行后就成为进 程系统会为每个进程创建一个主线程,主线程通过入口函数进入我们提供的主函数我们 可以在程序中创建其它线程线程可以创建一个或多个窗口,也可以不创建窗口系统会为有窗口的线程建立消息队列 有消息队列的线程就可以接收消息,例如我们可以用PostThreadMessage函数向线程发送消 息没有窗口的线程只要调用了 PeekMessage或GetMessage,系统也会为它创建消息队列1窗口和消息1.1线程的消息队列每个运行的程序就是一个进程每个进程有一个或多个线程有的线程没有窗口,有的线程 有一个或多个窗口我们可以向线程发送消息,但大多数消息都是发给窗口的发给窗口的消息同样放程的 消息队列中我们可以把线程的消息队列看作信箱,把窗口看作收信人我们在向指定窗口 发送消息时,系统会找到该窗口所属的线程,然后把消息放到该线程的消息队列中线程消息队列是系统内部的数据结构,我们在程序中看不到这个结构但我们可以通过 Windows的API向消息队列发送、投递消息;从消息队列接收消息;转换和分派接收到的 消息。
1.2最小的Windows程序Windows的程序员大概都看过这么一个最小的Windows程序://例程1#include "windows.h"static const char m_szName[]="窗口 ";//////////////////////////////////////////////////////////////////////////////////////////////////////主窗口回调函数 如果直接用DefWindowProc,关闭窗口时不会结束消息循环static LRESULT CALLBACK WindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM IParam){switch (uMsg) {case WM_DESTROY:PostQuitMessage(O); //关闭窗口时发送WM_QUIT消息结束消息循环break;default:return DefWindowProc(hWnd, uMsg, wParam, lParam);}return 0;}//////////////////////////////////////////////////////////////////////////////////////////////////////主函数int _stdcall WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,LPSTR lpCmdLine, int nCmdShow){WNDCLASS wc;memset(&wc, 0, sizeof(WNDCLASS));wc.style = CS_VREDRAWICS_HREDRAW; wc.lpfnWndProc = (WNDPROC)WindowProc;wc.hCursor = LoadCursor(NULL, IDC_ARROW);wc.hbrBackground = (HBRUSH)(COLOR_WINDOW); wc.lpszClassName = m_szName;RegisterClass(&wc); // 登记窗口类HWND hWnd;hWnd =CreateWindow(m_szName,m_szName,WS_OVERLAPPEDWINDOW,100,100,320,240,NULL,NULL,hInstance,NULL); // 创建窗口ShowWindow(hWnd, nCmdShow); // 显示窗口MSG sMsg;while (int ret=GetMessage(&sMsg, NULL, 0, 0)) { // 消息循环if (ret != -1) {TranslateMessage (&sMsg); DispatchMessage (&sMsg);}}return 0;}这个程序虽然只显示一个窗口,但经常被用来说明Windows程序的基本结构。
在MFC框架 内部我们同样可以找到类似的程序结构这个程序包含以下基本概念:窗口类、窗口和窗口过程消息循环下面分别介绍1.3窗口类、窗口和窗口过程创建窗口时要提供窗口类的名字窗口类相当于窗口的模板,我们可以基于同一个窗口类创 建多个窗口我们可以使用Windows预先登记好的窗口类但在更多的情况下,我们要登 记自己的窗口类在登记窗口类时,我们要登记名称、风格、图标、光标、菜单等项,其中 最重要的就是窗口过程的地址窗口过程是一个函数窗口收到的所有消息都会被送到这个函数处理那么,发到线程消息 队列的消息是怎么被送到窗口的呢?1.4消息循环熟悉嵌入式多任务程序的程序员,都知道任务(相当于Windows的线程)的结构基本上都 是:while (1) {等待信号;处理信号;}任务收到信号就处理,否则就挂起,让其它任务运行这就是消息驱动程序的基本结构 Windows程序通常也是这样:while (int ret=GetMessage(&sMsg, NULL, 0, 0)) { // 消息循环if (ret != -1) {TranslateMessage (&sMsg);DispatchMessage (&sMsg);}}GetMessage从消息队列接收消息;TranslateMessage根据按键产生WM_CHAR消息,放入 消息队列;DispatchMessage根据消息中的窗口句柄将消息分发到窗口,即调用窗口过程函 数处理消息。
1.5通过消息通信创建窗口的函数会返回一个窗口句柄窗口句柄在系统范围内(不是进程范围)标识一个唯 一的窗口实例通过向窗口发送消息,我们可以实现进程内和进程间的通信我们可以用SendMessage或PostMessage向窗口发送或投递消息SendMessage必须等到目 标窗口处理过消息才会返回我试过:如果向一个没有消息循环的窗口 SendMessage,SendMessage函数永远不会返回PostMessage在把消息放入线程的消息队列后立即返回其实只有投递的消息才是通过DispatchMessage分派到窗口过程的通过SendMessage发送 的消息,程GetMessage时,就已经被分派到窗口过程了,不经过DispatchMessage1.5.1窗口程序与控制台程序的通信实例大家是不是觉得“例程1”没什么意思,让我们用它来做个小游戏:让“例程1”和一个控 制台程序做一次亲密接触我们首先将“例程1”的窗口过程修改为:static LRESULT CALLBACK WindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam){static DWORD tid = 0;switch (uMsg) {case WM_DESTROY:PostQuitMessage(O); //关闭窗口时发送WM_QUIT消息结束消息循环 break;case WM_USER:tid = wParam; //保存控制台程序的线程IDSetWindowText(hWnd,,收到”);break;case WM_CHAR:if (tid) {switch(wParam) {case T:PostThreadMessage(tid, WM_USER+1, 0, 0); // 向控制台程序发送消息 1 break;case 2:PostThreadMessage(tid, WM_USER+2, 0, 0); // 向控制台程序发送消息 2 break;} }break;default:return DefWindowProc(hWnd, uMsg, wParam, lParam);}return 0;}然后,我们创建一个控制台程序,代码如下:#include "windows.h"#include "stdio.h"static HWND m_hWnd = 0;void process_msg(UINT msg, WPARAM wp, LPARAM lp){char buf[100];static int i = 1;if (!m_hWnd) {return;}switch (msg) {case WM_USER+1:SendMessage(m_hWnd, WM_GETTEXT, sizeof(buf), (LPARAM)buf);printf("你现在叫:%s\n\n", buf); //读取、显示对方的名字 break;case WM_USER+2:sprintf(buf,"我是窗口 %d", i++);SendMessage(m_hWnd, WM_SETTEXT, sizeof(buf), (LPARAM)buf); // 修改对方名字 printf("给你改名 \n\n");break;}}int main(){MSG sMsg;printf("Start with thread id %d\n", GetCurrentThreadId());m_hWnd = FindWindow(NULL,"窗口");if (m_hWnd) {printf("找到窗口 %x\n\n", m_hWnd);SendMessage(m_hWnd, WM_USER, GetCurrentThreadId(), 0);}else {printf(” 没有找到窗口 \n\n");}while (int ret=GetMessage(&sMsg, NULL, 0, 0)) { // 消息循环if (ret != -1) {process_msg(sMsg.message, sMsg.wParam, sMsg.lParam);}}return 0;}大家能看懂这游戏怎么玩吗?首先运行“例程1” wnd,然后运行控制台程序msg。
msg会 找到wnd的窗口,并将自己的主线程ID发给wndwnd收到msg的消息后,会显示收到 这时,wnd和msg已经建立了通信的渠道:wnd可以向msg的主线程发消息,msg可以向 wnd的窗口发消息我们如果在wnd窗口按下键T,wnd会向msg发送消息1,msg收到后会通过WM_GETTEXT 消息获得wnd的窗口名称并显示我们如果在wnd窗口按下键'2',wnd会向msg发送消息 2,msg收到后会通过WM_SETTEXT消息修改wnd的窗口名称这个小例子演示了控制台程序的消息循环,向线程发消息,以及进程间的消息通信1.5.2地址空间的问题不同的进程拥有独立的地址空间,如果我们在消息参数中包含一个进程A的地址,然后发 送到进程B进程B如果在自己的地址空间里操作这个地址,就会发生错误那么,为什 么上例中的 WM_GETTEXT和 WM_SETEXT可以正常工作?这是因为 WM_GETTEXT和 WM_SETEXT都是 Windows自己定义的消息,Windows知道 参数的含义,并作了特殊的处理,即在进程B的空间分配一块内存作为中转,并在进程A 和进程B的缓冲区之间复制数据例如:在1.5.1节的例子中,如果我们设置断点观察,就 会发现msg发送的WM_SETTEXT消息中的lParam不等于wnd接收到的WM_SETTEXT 消息中的lParam o如果我们在自己定义的消息中传递内存地址,系统不会做任何特殊处理,所以必然发生错误。
Windows提供了 WM_COPYDATA消息用来向窗口传递数据,Windows同样会为这个消息 作特殊处理在进程间发送这些需要额外分配内存的消息时,我们应该用 SendMessage,而不是PostMessage因为SendMessage会等待接收方处理完后再返回,这样系统才有机会额外释 放分配的内存在这种场合使用PostMessage,系统会忽略要求投递的消息,读者可以在msg 程序中试验一下2子类化和超类化窗口类是窗口的模板,窗口是窗口类的实例窗口类和每个窗口实例都有自己的内部数据结 构Windows虽然没有公开这些数据结构,但提供了读写这些数据的API例如:用GetClassLong和SetClassLong函数可以读写窗口类的数据;用GetWindowLong和 SetWindowLong可以读写指定窗口实例的数据使用这些接口,可以在运行时读取或修改窗 口类或窗口实例的窗口过程地址这些接口是子类化的实现基础2.1子类化子类化的目的是在不修改现有代码的前提下,扩展现有窗口的功能它的思路很简单,就是 将窗口过程地址修改为一个新函数地址,新的窗口过程函数处理自己感兴趣的消息,将其它 消息传递给原窗口过程。
通过子类化,我们不需要现有窗口的源代码,就可以定制窗口功能子类化可以分为实例子类化和全局子类化实例子类化就是修改窗口实例的窗口过程地址, 全局子类化就是修改窗口类的窗口过程地址实例子类化只影响被修改的窗口全局子类化 会影响在修改之后,按照该窗口类创建的所有窗口显然,全局子类化不会影响修改前已经 创建的窗口子类化方法虽然是二十年前的概念,却很好地实践了面向对象技术的开闭原则(OCP: The Open-Closed Principle):对扩展开放,对修改关闭2.2超类化超类化的概念更简单,就是读取现有窗口类的数据,保存窗口过程函数地址对窗口类数据 作必要的修改,设置新窗口过程,再换一个名称后登记一个新窗口类新窗口类的窗口过程 函数还是仅处理自己感兴趣的消息,而将其它消息传递给原窗口过程函数处理使用 GetClassInfo函数可以读取现有窗口类的数据3 MFC中的消息循环和子类化MFC将子类化方法应用得淋漓尽致,是一个不错的例子候捷先生的《深入浅出MFC》已 经将MFC的主要框架分析得很透彻了,本节只是看看MFC的消息循环,简单分析MFC对 子类化的应用3.1消息循环随便建立一个MFC单文档程序,在视图类中添加WM_RBUTTONDOWN的处理函数,并 在该处理函数中设置断点。
运行,断下后,查看调用堆栈:CHelloView::OnRButtonDown(unsigned int, CPoint)CWnd::OnWndMsg(unsigned int, unsigned int, long, long *)CWnd::WindowProc(unsigned int, unsigned int, long)AfxCallWndProc(CWnd *, HWND__ *, unsigned int, unsigned int, long)AfxWndProc(HWND__ *, unsigned int, unsigned int, long)AfxWndProcBase(HWND__ *, unsigned int, unsigned int, long)USER32! 7e418734()USER32! 7e418816()USER32! 7e4189cd()USER32! 7e4196c7()CWinThread::PumpMessage()CWinThread::Run()CWinApp::Run()AfxWinMain(HINSTANCE__ *, HINSTANCE__ *, char *, int)WinMain(HINSTANCE__ *, HINSTANCE__ *, char *, int)WinMainCRTStartup()KERNEL32! 7c816fd7()WinMainCRTStartup是这个程序的入口函数。
候捷先生已经详细介绍过AfxWinMain我们 就看看CWinThread::PumpMessage中的消息循环:BOOL CWinThread::PumpMessage(){if (!::GetMessage(&m_msgCur, NULL, NULL, NULL)) {return FALSE;}if (m_msgCur.message != WM_KICKIDLE && !PreTranslateMessage(&m _msgCur)){::TranslateMessage(&m _msgCur);::DispatchMessage(&m _msgCur);}return TRUE;}这就是MFC程序主线程中的消息循环,它把发送到线程消息队列的消息分派到线程的窗口3.2子类化CWnd::CreateEx在创建窗口前调用SetWindowsHookEx函数安装了 一个钩子函数 _AfxCbtFilterHook窗口刚创建好,钩子函数_AfxCbtFilterHook 就被调用AfxCbtFilterHook 调用SetWindowLong将窗口过程替换为AfxWndProcBase,并将SetWindowLong返回的原 窗口地址保存到成员变量oldWndProc。
上节调用堆栈中的AfxWndProcBase就是由此而来可见,通过CWnd::CreateEx创建的所有窗口都会被子类化,即它们的窗口过程都会被替换 为AfxWndProcBaseMFC为什么要这样做? 让我们再看看调用堆栈中的CWnd::WindowProc函数: LRESULT CWnd::WindowProc(UINT message, WPARAM wParam, LPARAM IParam){LRESULT IResult = 0;if (!OnWndMsg(message, wParam, lParam, &l Result))lResult = DefWindowProc(message, wParam, lParam);return lResult;}按照侯捷先生的介绍,CWnd::OnWndMsg就是“MFC消息泵”的入口,消息通过这个入口 流入MFC消息映射中的消息处理函数消息泵只会处理我们定制过的消息,我们没有添加 过处理的消息会原封不动地流过"消息泵",进入DefWindowProc函数在DefWindowProc 函数中,消息会传给子类化时保存的原窗口地址oldWndProc。
CWnd::CreateEx里的钩子会子类化所有窗口吗?其实不尽然的确,MFC所有窗口相关的 类都是从CWnd派生的,这些类的实例在创建窗口时都会调用CWnd::CreateEx,都会被子 类化但是,通过对话框模板创建的窗口是通过 CreateDlgIndirect创建的,不经过CWnd::CreateEx 函数但这点其实也不是问题,因为如果我们想通过MFC定制一个控件的消息映射,就必须先子 类化这个控件,MFC还是有机会将窗口过程替换成自己的AfxWndProcBase下一节将介绍 对话框控件的子类化4子类化和超类化的例子我写了一个很简单的对话框程序,用来演示子类化和超类化这个对话框程序有两个编辑框, 我将编辑框的右键菜单换成了一个消息框两个编辑框的定制分别采用了子类化和超类化技 术:4.1子类化的例子首先从CEdit派生出CMyEditl,定制WM_RBUTTONDOWN的处理很多文章都建议我们 在对话框的OnInitDialog中用SubclassDlgItem实现子类化:m_edit1.SubclassDlgItem(IDC_EDIT1, this);这样做当然可以其实如果我们已经为IDC_EDIT1添加过CMyEdit1对象:void CSubclassingDlg::DoDataExchange(CDataExchange* pDX){CDialog::DoDataExchange(pDX);〃{{AFX_DATA_MAP(CSubclassingDlg)DDX_Control(pDX, IDC_EDIT1, m_edit1);〃}}AFX_DATA_MAP}DDX_Control会自动帮我们完成子类化,没有必要手工调用SubclassDlgItem。
大家可以通 过在PreSubclassWindow中设置断点看看通过DDX_Control或者SubclassDlgltem子类化控件的效果是一样的,MFC都是把窗口过程 替换成AfxWndProcBase用户添加过处理函数的消息通过MFC消息泵流入用户的处理函 数4.2 必经之路:PreSubclassWindowPreSubclassWindow是一个很好的定制控件的位置如果我们通过重载 CWnd::PreCreateWindow定制控件,而用户在对话框中使用控件由于对话框中的控件窗口 是通过 CreateDlgIndirect 创建,不经过 CWnd::CreateEx 函数,PreCreateWindow 函数不会被 调用其实,用户要在对话框中使用定制控件,必须用DDX或者SubclassDlgItem函数子类化控件, 这时PreSubclassWindow 一定会被调用如果用户直接创建定制控件窗口,CWnd::CreateEx函数就一定会被调用,控件窗口一定会 被子类化以安装MFC消息泵所以在MFC 中, PreSubclassWindow是创建窗口的必经之路4.3超类化的例子我很少看到超类化的例子(除了罗云彬的Win32汇编),在大多数应用中,子类化技术已经 足够了。
但我还是写了一个例子:CMyEdit2从CEdit派生CMyEdit2::RegisterMe获取窗口 类Edit的信息,保存原窗口过程,设置新窗口过程MyWndProc和新名称MyEdit,登记一 个新窗口类新窗口过程MyWndProc定制自己需要处理的消息,将其它消息送回原窗口过 程我在对话框的OnInitDialog中先调用CMyEdit2::RegisterMe登记新窗口类,然后创建窗口 这样创建窗口必须经过CWnd::CreateEx,所以MFC还是会把窗口过程换成 AfxWndProcBase没有被MFC消息映射拦截的消息才会流入MyWndProc5结束语 这篇文章介。