通用GUI编程技术——Win32 原生编程实战五十三——子类化与超类化仓库已经开源喜欢的话点个⭐仓库Win32和Win32图形栈的部分目前已完成教程力争做一个完备的GUI教程欢迎各位大佬前来参观https://github.com/Charliechen114514/anatomy_gui文章43的部分先短暂跳过明天发43的图形学的部分上一篇文章我们补充了高级输入消息——触控WM_TOUCH、WM_POINTER、Raw InputWM_INPUT、窗口位置管理WM_WINDOWPOSCHANGING等。这些消息让你对现代输入设备和窗口行为有了更精细的控制。但到目前为止我们创建的所有控件都是系统原样的——按钮就是按钮编辑框就是编辑框它们的行为完全由系统内置的窗口过程决定。如果你想让一个 Edit 控件在按下 Enter 时自动跳到下一个控件或者让一个 ListBox 的奇偶行显示不同颜色怎么办这就需要今天的主角——子类化Subclassing和超类化Superclassing。为什么需要子类化和超类化Win32 控件的行为是由它们的窗口过程WndProc定义的。当你创建一个EDIT控件时系统会调用 Edit 控件的内部窗口过程来处理所有消息——按键输入、文字渲染、光标移动全都是由系统代码完成的。但有时候系统默认的行为不完全满足你的需求。常见的场景包括Edit 控件按 Enter 不发出通知——标准的 Edit 控件在按下 Enter 键时只是插入一个换行符如果是多行并不通知父窗口。如果你想实现按 Enter 提交的功能需要拦截 WM_KEYDOWN。限制输入类型——你想让一个 Edit 控件只接受数字输入或者限制最大长度。自定义绘制——你想让 ListBox 的奇偶行显示不同的背景色或者在 ComboBox 的下拉列表中显示图标。统一修改同类控件——你有 20 个 Edit 控件想让它们都具备某种特殊行为比如鼠标悬停时改变边框颜色。子类化和超类化就是解决这些问题的两种方案子类化修改一个已有的控件实例的窗口过程只影响那一个控件。超类化基于现有控件类注册一个全新的窗口类之后创建的所有该类控件都具备新的行为。打个比方子类化像是给一辆现成的汽车换了一套方向盘和仪表盘超类化像是根据现有车型的图纸设计了一款新车型以后量产的都是新版。环境说明在我们正式开始之前先明确一下我们这次动手的环境平台Windows 10/11开发工具Visual Studio 2019 或更高版本Community 版本就行编程语言CC17 或更新项目类型桌面应用程序Win32 项目代码假设你已经熟悉前面文章的内容——至少知道窗口过程怎么写、WM_COMMAND 和 WM_NOTIFY 怎么处理、基本的控件使用。如果这些概念对你来说还比较陌生建议先去看看前面的笔记。第一步——子类化的原理子类化的核心思想很简单替换窗口的窗口过程指针。每个窗口在内部都有一个字段记录谁负责处理我的消息——也就是窗口过程函数指针。正常情况下Edit 控件的这个指针指向系统内部的 Edit 窗口过程。子类化就是把这个指针替换成你自己的函数让你的函数先看到所有消息。正常情况 消息 → 系统的 EditWndProc → 处理 子类化后 消息 → 你的 SubclassProc → 可以拦截/修改→ 原来的 EditWndProc → 处理你的子类过程可以选择完全处理某条消息不让原来的窗口过程看到修改消息参数后再传给原来的窗口过程先让原来的窗口过程处理再对结果做修改对大部分消息不做任何处理直接传给原来的窗口过程第二步——SetWindowSubclass推荐的子类化方式Windows 提供了两种子类化方式。老式的方式用SetWindowLongPtr(GWLP_WNDPROC)新式的方式用SetWindowSubclass。我们强烈推荐使用新式原因后面会讲。函数签名BOOLSetWindowSubclass(HWND hWnd,// 要子类化的窗口SUBCLASSPROC pfnSubclass,// 子类过程函数UINT_PTR uIdSubclass,// 子类 ID用于标识DWORD_PTR dwRefData// 传给子类过程的用户数据);子类过程的原型LRESULT CALLBACKSubclassProc(HWND hWnd,// 窗口句柄UINT uMsg,// 消息 IDWPARAM wParam,// 参数 1LPARAM lParam,// 参数 2UINT_PTR uIdSubclass,// 子类 ID你设的那个DWORD_PTR dwRefData// 用户数据你设的那个);注意子类过程比普通窗口过程多了两个参数——uIdSubclass和dwRefData。这意味着你不能直接把它当普通 WndProc 用必须遵循这个签名。核心函数DefSubclassProc在子类过程中你应该调用DefSubclassProc来把消息传给下一个处理者可能是另一个子类过程也可能是原来的窗口过程LRESULTDefSubclassProc(HWND hWnd,UINT uMsg,WPARAM wParam,LPARAM lParam);这和普通的DefWindowProc类似但它不是调用默认窗口过程而是调用子类链中的下一个处理器。移除子类化BOOLRemoveWindowSubclass(HWND hWnd,SUBCLASSPROC pfnSubclass,UINT_PTR uIdSubclass);⚠️ 注意虽然SetWindowSubclass会自动在窗口销毁时清理子类数据但最好还是养成在不需要时调用RemoveWindowSubclass的习惯。特别是如果你在dwRefData中传递了动态分配的指针必须确保在窗口销毁前释放它。第三步——实战拦截 Edit 控件的 Enter 键这是一个经典的子类化场景你想让用户在 Edit 控件中按 Enter 时不是换行而是执行某个操作比如提交搜索、发送消息。#includewindows.h#includecommctrl.h#pragmacomment(lib,comctl32.lib)#defineIDC_EDIT1001#defineIDC_SUBMIT_BTN1002#defineIDC_RESULT1003// 自定义消息Enter 键被按下#defineWM_ENTER_PRESSED(WM_APP10)HWND g_hEditNULL;HWND g_hResultNULL;// 子类过程LRESULT CALLBACKEditSubclassProc(HWND hWnd,UINT uMsg,WPARAM wParam,LPARAM lParam,UINT_PTR uIdSubclass,DWORD_PTR dwRefData){switch(uMsg){caseWM_KEYDOWN:if(wParamVK_RETURN){// 拦截 Enter 键通知父窗口HWND hParentGetParent(hWnd);SendMessage(hParent,WM_ENTER_PRESSED,0,0);return0;// 不传给原始 Edit 过程}break;caseWM_CHAR:// 拦截 Enter 的字符换行符防止多行 Edit 插入换行if(wParam\r||wParam\n)return0;break;caseWM_DESTROY:// 窗口销毁时移除子类化RemoveWindowSubclass(hWnd,EditSubclassProc,0);break;}// 其他消息交给原始窗口过程处理returnDefSubclassProc(hWnd,uMsg,wParam,lParam);}voidSubmitText(HWND hwnd){wchar_tbuf[256];GetWindowText(g_hEdit,buf,256);if(wcslen(buf)0){MessageBox(hwnd,L请输入一些文字,L提示,MB_OK);return;}// 在结果区域显示wchar_tresult[512];swprintf_s(result,L已提交%s,buf);SetWindowText(g_hResult,result);// 清空输入框SetWindowText(g_hEdit,L);SetFocus(g_hEdit);}LRESULT CALLBACKWndProc(HWND hwnd,UINT uMsg,WPARAM wParam,LPARAM lParam){switch(uMsg){caseWM_CREATE:{HINSTANCE hInst((LPCREATESTRUCT)lParam)-hInstance;// 创建标签CreateWindowEx(0,LSTATIC,L输入文字按 Enter 提交,WS_CHILD|WS_VISIBLE,20,20,300,20,hwnd,NULL,hInst,NULL);// 创建 Edit 控件g_hEditCreateWindowEx(WS_EX_CLIENTEDGE,LEDIT,L,WS_CHILD|WS_VISIBLE|ES_AUTOHSCROLL,20,50,350,28,hwnd,(HMENU)IDC_EDIT,hInst,NULL);// 子类化 Edit 控件SetWindowSubclass(g_hEdit,EditSubclassProc,0,0);// 创建提交按钮CreateWindowEx(0,LBUTTON,L提交,WS_CHILD|WS_VISIBLE|BS_PUSHBUTTON,380,50,80,28,hwnd,(HMENU)IDC_SUBMIT_BTN,hInst,NULL);// 创建结果显示g_hResultCreateWindowEx(0,LSTATIC,L,WS_CHILD|WS_VISIBLE|SS_LEFT,20,100,440,30,hwnd,(HMENU)IDC_RESULT,hInst,NULL);SetFocus(g_hEdit);return0;}caseWM_ENTER_PRESSED:SubmitText(hwnd);return0;caseWM_COMMAND:if(LOWORD(wParam)IDC_SUBMIT_BTN)SubmitText(hwnd);return0;caseWM_DESTROY:PostQuitMessage(0);return0;}returnDefWindowProc(hwnd,uMsg,wParam,lParam);}intWINAPIwWinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,PWSTR pCmdLine,intnCmdShow){WNDCLASS wc{};wc.lpfnWndProcWndProc;wc.hInstancehInstance;wc.lpszClassNameLSubclassDemo;wc.hbrBackground(HBRUSH)(COLOR_WINDOW1);wc.hCursorLoadCursor(NULL,IDC_ARROW);RegisterClass(wc);HWND hwndCreateWindowEx(0,LSubclassDemo,L子类化示例 - Enter 提交,WS_OVERLAPPED|WS_CAPTION|WS_SYSMENU,CW_USEDEFAULT,CW_USEDEFAULT,500,200,NULL,NULL,hInstance,NULL);if(hwnd){ShowWindow(hwnd,nCmdShow);UpdateWindow(hwnd);MSG msg{};while(GetMessage(msg,NULL,0,0)){TranslateMessage(msg);DispatchMessage(msg);}}return0;}代码要点解析SetWindowSubclass在 WM_CREATE 中创建 Edit 控件后立即子类化。uIdSubclass设为 0因为我们只有一个子类dwRefData也设为 0不需要额外数据。WM_KEYDOWN 拦截 VK_RETURN检测到 Enter 键后发送自定义消息WM_ENTER_PRESSED给父窗口然后返回 0 阻止原始 Edit 过程处理这条消息。WM_CHAR 拦截换行符即使拦截了 WM_KEYDOWNTranslateMessage 还会产生 WM_CHAR 消息。所以也要拦截 ‘\r’ 和 ‘\n’。DefSubclassProc所有不需要拦截的消息都交给原始窗口过程处理保证 Edit 控件的正常功能输入、选择、复制粘贴等不受影响。第四步——多级子类化SetWindowSubclass的一个强大特性是支持多级子类化——你可以对同一个窗口多次调用SetWindowSubclass注册不同的子类过程。它们会形成一个调用链。// 子类 A限制只能输入数字LRESULT CALLBACKNumericOnlyProc(HWND hWnd,UINT uMsg,WPARAM wParam,LPARAM lParam,UINT_PTR uIdSubclass,DWORD_PTR dwRefData){if(uMsgWM_CHAR){if(wParam0||wParam9)return0;// 非数字拦截}returnDefSubclassProc(hWnd,uMsg,wParam,lParam);}// 子类 B限制最大长度为 10LRESULT CALLBACKMaxLengthProc(HWND hWnd,UINT uMsg,WPARAM wParam,LPARAM lParam,UINT_PTR uIdSubclass,DWORD_PTR dwRefData){if(uMsgWM_CHARwParam ){intlenGetWindowTextLength(hWnd);if(len10)return0;// 超长拦截}returnDefSubclassProc(hWnd,uMsg,wParam,lParam);}// 注册多个子类注意注册顺序后注册的先执行SetWindowSubclass(hEdit,NumericOnlyProc,1,0);SetWindowSubclass(hEdit,MaxLengthProc,2,0);调用链消息 → MaxLengthProc → NumericOnlyProc → 原始 Edit 过程。DefSubclassProc不是调用原始窗口过程而是调用子类链中的下一个子类过程。只有最后一个子类过程的DefSubclassProc才会调用原始窗口过程。为什么推荐 SetWindowSubclass 而不是 SetWindowLongPtr老式子类化用SetWindowLongPtr(hWnd, GWLP_WNDPROC, newProc)替换窗口过程然后用CallWindowProc(oldProc, ...)调用原来的。这种方式有几个严重问题问题SetWindowLongPtrSetWindowSubclass多级子类化需要手动管理链表自动管理调用链清理必须在窗口销毁前手动恢复自动清理数据传递需要用全局变量或窗口属性内置 dwRefData 参数线程安全不保证保证SetWindowSubclass内部使用了一个引用计数机制。如果多段代码都对同一个窗口做了子类化每个都能安全地移除自己的子类不会影响其他代码。第五步——超类化Superclassing超类化和子类化的目的类似但方式不同子类化修改的是一个已有的窗口实例超类化是创建一个新的窗口类。基本步骤// 第 1 步获取原始类的信息WNDCLASSEX wc{};wc.cbSizesizeof(WNDCLASSEX);GetClassInfoEx(hInstance,LEDIT,wc);// 第 2 步保存原始窗口过程WNDPROC pfnOriginalwc.lpfnWndProc;// 第 3 步修改窗口过程为你自己的wc.lpfnWndProcMyEditProc;wc.lpszClassNameLMyNumericEdit;// 新类名wc.hInstancehInstance;// 第 4 步注册新类ATOM atomRegisterClassEx(wc);// 第 5 步用新类名创建控件HWND hEditCreateWindowEx(0,LMyNumericEdit,L,WS_CHILD|WS_VISIBLE|ES_AUTOHSCROLL,10,10,200,25,hwndParent,(HMENU)IDC_EDIT,hInstance,NULL);超类化的窗口过程// 全局保存原始窗口过程WNDPROC g_pfnOriginalEditProcNULL;LRESULT CALLBACKMyEditSuperclassProc(HWND hwnd,UINT uMsg,WPARAM wParam,LPARAM lParam){switch(uMsg){caseWM_CHAR:// 只允许数字和退格if(!((wParam0wParam9)||wParamVK_BACK)){MessageBeep(MB_ICONEXCLAMATION);return0;}break;caseWM_PASTE:{// 拦截粘贴只粘贴数字部分if(OpenClipboard(hwnd)){HANDLE hClipGetClipboardData(CF_UNICODETEXT);if(hClip){wchar_t*text(wchar_t*)GlobalLock(hClip);if(text){std::wstring digits;for(inti0;text[i];i){if(text[i]L0text[i]L9)digitstext[i];}GlobalUnlock(hClip);// 把过滤后的数字发回给编辑框for(wchar_tc:digits){SendMessage(hwnd,WM_CHAR,c,0);}}}CloseClipboard();return0;}break;}caseWM_DESTROY:// 超类化不需要手动清理break;}// 调用原始的 Edit 窗口过程returnCallWindowProc(g_pfnOriginalEditProc,hwnd,uMsg,wParam,lParam);}子类化 vs 超类化对比特性子类化超类化作用对象已有的窗口实例窗口类影响范围只影响被子类化的那个控件之后创建的所有该类控件时机控件创建之后注册新类然后创建控件适合场景修改一两个控件批量创建同类自定义控件实现复杂度简单稍复杂灵活性可动态添加/移除创建后不可修改多实例每个实例都要单独子类化创建时自动生效选择建议如果只是修改一两个控件的行为 →子类化如果需要创建大量相同行为的自定义控件 →超类化如果不确定 → 先用子类化后面有需要再重构为超类化第六步——利用 dwRefData 传递实例数据子类过程的一个常见需求是访问与特定控件关联的数据。dwRefData参数就是为了这个目的。但由于子类过程中不能直接访问父窗口的成员变量你需要通过dwRefData传递。示例带标签的 Edit 控件structEditContext{HWND hParent;intcontrolId;intmaxLength;};LRESULT CALLBACKContextEditProc(HWND hWnd,UINT uMsg,WPARAM wParam,LPARAM lParam,UINT_PTR uIdSubclass,DWORD_PTR dwRefData){EditContext*ctx(EditContext*)dwRefData;switch(uMsg){caseWM_CHAR:if(wParam GetWindowTextLength(hWnd)ctx-maxLength){MessageBeep(MB_ICONEXCLAMATION);return0;}break;caseWM_NCDESTROY:// 窗口销毁时释放上下文数据deletectx;RemoveWindowSubclass(hWnd,ContextEditProc,uIdSubclass);return0;}returnDefSubclassProc(hWnd,uMsg,wParam,lParam);}// 使用方式voidSubclassEditWithLimit(HWND hEdit,HWND hParent,intid,intmaxLen){EditContext*ctxnewEditContext{hParent,id,maxLen};SetWindowSubclass(hEdit,ContextEditProc,id,(DWORD_PTR)ctx);}⚠️ 注意生命周期管理dwRefData中的指针必须在使用期间保持有效。如果传的是栈变量指针函数返回后就成了野指针。推荐用new分配在 WM_NCDESTROY 中delete。做完上面这些您在程序中一跑看起来就是这样的输入个Hello然后点一下提交靠感觉Qt里直接能出的功能Win32很麻烦超级啊哈哈哈。常见陷阱陷阱一调用 CallWindowProc 而不是 DefSubclassProc// 错误在子类过程中调用 CallWindowProc 会跳过其他子类层LRESULT CALLBACKBadSubclassProc(...){returnCallWindowProc(g_oldProc,hWnd,uMsg,wParam,lParam);}// 正确使用 DefSubclassProcLRESULT CALLBACKGoodSubclassProc(...){returnDefSubclassProc(hWnd,uMsg,wParam,lParam);}陷阱二忘记在 WM_NCDESTROY 中清理虽然SetWindowSubclass会在窗口销毁时自动移除子类但如果你在dwRefData中传了new出来的对象必须在 WM_NCDESTROY 中手动delete否则内存泄漏。陷阱三对系统控件做过多拦截子类化的初衷是微调不是重写。如果你拦截了太多消息可能破坏控件的正常功能。比如拦截 WM_GETDLGCODE 可能导致对话框导航异常。只拦截你确实需要修改的消息其他的都交给DefSubclassProc。后续可以做什么到这里子类化和超类化的知识就讲完了。你现在应该理解了子类化的原理、SetWindowSubclass的用法、多级子类化的调用链机制、超类化的适用场景以及如何通过 dwRefData 传递实例数据。下一篇文章我们会聊一个与子类化有些关联的话题——Hook 机制。子类化只能拦截发往特定窗口的消息而 Hook 可以拦截发往整个系统或整个进程的消息。两者经常配合使用。在此之前建议你做一些练习巩固今天的知识基础练习子类化一个 Edit 控件实现只允许输入十六进制字符0-9, A-F, a-f并自动将小写字母转换为大写进阶练习子类化一个 ListBox 控件实现鼠标悬停时高亮当前项提示拦截 WM_MOUSEMOVE用LB_ITEMFROMPOINT获取当前项挑战练习用超类化创建一个MyHyperlink控件类——基于 STATIC 控件鼠标悬停时文字变蓝色并显示下划线点击时用 ShellExecute 打开链接相关资源SetWindowSubclass function (Commctrl.h) - Microsoft LearnRemoveWindowSubclass function - Microsoft LearnDefSubclassProc function - Microsoft LearnSubclassing Controls - Microsoft LearnGetClassInfoEx function - Microsoft LearnUsing Window Classes - Microsoft Learn