
简介在Windows桌面应用中屏幕取词是一项典型的工程能力用户拖选文本系统即时捕获选区并返回内容。其底层依赖API Hook技术通过SetWindowsHookEx挂接鼠标钩子在事件驱动下还原按下-移动-抬起的完整语义从而可靠判断拖选动作。API Hook的常见实现路径包括消息钩子、内联钩子和DLL注入其中基于WH_MOUSE的全局钩子加DLL方案因回调运行在目标进程上下文能更直接地读取窗口文本成为屏幕取词的主流选择。该技术广泛应用于词典划词、文档阅读器和翻译工具等场景涉及鼠标状态机、窗口定位、WM_GETTEXT文本读取以及跨进程通信等关键环节。掌握这套API Hook与DLL注入的工程思路开发者可以快速搭建取词链路并进一步扩展出划词翻译、收藏等实用功能。1. 不懂 Hook 就做不了屏幕取词这套工程把整条链路摆在了你面前光标按住扫过一段英文松开时释义窗口立刻浮现——屏幕取词最经典的使用画面背后是一次 api_hook 的完整运作监听鼠标事件、判断选区、从目标窗口抠出文本。这份 API-HOOK 工程就是这条链路的最小可运行实现。主程序是 MFC 对话框取词回调封装在配套 DLL 里通过全局钩子接入系统消息流。它解决两类诉求想搞懂 Windows Hook 机制但不想只停留在文档页的开发者以及要给词典、文档工具加划词功能、正在发愁从哪下手的从业者。新手能顺着工程文件把原理和代码对上号熟手能直接拿走 DLL 与主程序的通信骨架。2. API Hook 原理与选型消息钩子、内联钩子与 DLL 注入的取舍2.1 取词的触发点为什么不能靠轮询鼠标先想一个问题不装 Hook能不能做屏幕取词理论上有条笨路每隔几十毫秒调用一次 GetCursorPos记下坐标变化再配合 GetAsyncKeyState 判断左键是否按下。这个方案能检测到鼠标动了但检测不到用户选中了一段文本——选区是按下、移动、抬起三个事件的组合结果轮询拿到的只是离散位置拼不出完整的按下-抬起语义。API Hook 的切入方式完全不同。用 SetWindowsHookEx 挂一个鼠标钩子系统会在鼠标事件产生的第一时间调用你的回调函数事件的前因后果都在参数里。按下、抬起、移动是完整的事件流而不是采样点。对屏幕取词来说只有事件驱动才能可靠还原拖选这个动作。这也是本资源把 API Hook 作为核心的原因它在这里不是用来拦截某个函数做坏事而是接管系统事件流的关键节点。2.2 三条 api_hook 实现路径为什么这套工程走 DLL 方案常见的 API Hook 途径有三类选型时各有取舍。SetWindowsHookEx 是消息级钩子。它不对函数做任何修改只是让系统在派发消息时先经过你的回调。按钩子类型不同回调可以跑在安装者线程里也可以跑在被注入的进程里。屏幕取词最常用的两个类型是 WH_MOUSE_LL 和 WH_MOUSE前者是低级鼠标钩子不需要 DLL回调在安装线程上下文执行后者是传统鼠标钩子要求回调所在的 DLL 被注入到所有 GUI 进程回调在目标进程上下文执行。Detour 是修改机器码的内联钩子。它会改写目标函数入口处的字节码插入一条跳转指令指向你的自定义函数等你的代码跑完再跳回原函数继续执行。这种方案适合拦截 CreateFileW、RegOpenKeyEx 这类明确 API 的场景但对于鼠标选中文本这种动态事件无能为力——事件不属于某个固定函数系统派发消息的路径是分散的。EasyHook 属于封装派内部用托管代码和非托管代码做了大量简化支持注入任意进程部署方便但要引入不小依赖对老 VC 工程并不友好。回到这套工程。文件列表里明摆着 ApiHook.dll 和 ApiHook.lib说明源码走的是 DLL 方案。这也符合屏幕取词的真实需求WH_MOUSE 全局钩子把 DLL 注入到各个 GUI 进程里回调直接在目标进程上下文执行。此时去读取窗口文本不用跨进程 SendMessage直接用 GetWindowText 就能拿到取词链路短可靠性高。WH_MOUSE_LL 也有自己的优势比如不需要注入、实现简单但它会全局拦截鼠标消息所有程序共用一个回调对性能影响更明显还有被系统静默移除的风险第 5 章会细讲。我在实际项目里一般优先选 WH_MOUSE 加 DLL回调跑在目标进程里后面取文本、拿选区都要顺手得多。2.3 钩子类型对比与取词选型表钩子类型是否需要 DLL回调运行位置取词场景适用性主要风险WH_MOUSE_LL不需要安装钩子所在线程可用实现快适合原型全局回调超时会被系统移除性能瓶颈明显WH_MOUSE需要被注入的 GUI 进程推荐取文本和选区信息更直接32/64 位不互通注入失败时无回调WH_KEYBOARD_LL不需要安装钩子所在线程辅助用配合快捷键取词不能独立完成选区判断WH_GETMESSAGE需要被注入的进程较少用消息已派发到目标窗口时机偏晚取词不如鼠标钩子直接选型的核心逻辑在回调跑在哪上。屏幕取词要读的是目标窗口的文本能直接在那个进程里做这件事就尽量不要隔着进程。这也是我拿到这套源码后第一个确认的点工程既然带 DLL就不要改成低级钩子那套免注入方案保留 DLL 结构才能发挥这套代码的完整价值。3. API-HOOK 工程结构拆解MFC 对话框、注入 DLL 与共享数据段3.1 文件清单与模块对应关系解压 API-HOOK.rar 之后会看到一堆后缀各异的文件。先别急着打开项目把文件按职责分个类代码读起来就顺了。文件/目录职责分类说明ApiHook.sln / ApiHook.vcproj工程文件VS 解决方案和 VC 项目文件老版工程格式ApiHook.suo / ApiHook.ncb / ApiHook.apsIDE 缓存可删除不影响编译ApiHook.cpp / ApiHook.h程序入口初始化 MFC 应用ApiHookDlg.cpp / ApiHookDlg.h主对话框提供安装/卸载钩子的操作界面ApiHook_dll.h / ApiHook.dll / ApiHook.libHook DLL取词回调所在的核心模块ApiHook.rc / Resource.h / res界面资源对话框布局、图标、字符串表stdafx.cpp / stdafx.h预编译头MFC 工程的标配ReadMe.txt工程说明原始作者的编译说明这个结构在主程序和取词逻辑之间做了清晰分层主程序负责界面和状态展示DLL 负责真正的钩子回调与文本读取。拆开看就是标准的UI 注入模块双工程结构。3.2 主程序与 DLL 的通信套路因为先装了 MFC主程序注定是个 GUI 工程。用户的日常操作就是点一个开启取词按钮然后去其他窗口划词。划词成功后文本必须从 DLL 的回调函数回到主程序的界面上。这条路走的是主程序加载 DLL、DLL 回调后向主程序窗口发消息的闭环。常见做法是主程序启动后调用 LoadLibrary 把 ApiHook.dll 载入再用 GetProcAddress 拿到 DLL 导出的安装、卸载函数指针。安装钩子时把主程序窗口句柄传给 DLLDLL 得到取词结果后通过 SendMessage 把文本发给主窗口主窗口再更新到界面上。这套导出函数传句柄 窗口消息回报的模式比共享内存省事也比直接跨进程拿窗口句柄来得稳。3.3 典型代码段DLL 导出的安装与卸载接口下面代码展示的是 DLL 侧最核心的导出函数骨架。// ApiHookDll.cppDLL 侧核心代码 #include stdafx.h #pragma data_seg(.HOOKSHARE) HWND g_hNotifyWnd NULL; // 主程序窗口句柄由 InstallHook 传入 UINT g_uNotifyMsg 0; // 自定义通知消息号 #pragma data_seg() #pragma comment(linker, /SECTION:.HOOKSHARE,RWS) static HHOOK g_hMouseHook NULL; // 钩子回调函数的完整实现见第 4 章这里先看安装/卸载的骨架 extern C __declspec(dllexport) BOOL WINAPI InstallHook(HWND hNotifyWnd, UINT uNotifyMsg) { if (g_hMouseHook ! NULL) return TRUE; // 已安装避免重复挂钩 g_hNotifyWnd hNotifyWnd; g_uNotifyMsg uNotifyMsg; // 第 4 个参数为 0表示注入所有 GUI 进程 g_hMouseHook SetWindowsHookEx(WH_MOUSE, (HOOKPROC)MouseProc, g_hInstDll, 0); return (g_hMouseHook ! NULL); } extern C __declspec(dllexport) BOOL WINAPI UninstallHook() { if (g_hMouseHook NULL) return TRUE; // 卸载钩子解除对所有进程的注入 BOOL bOK UnhookWindowsHookEx(g_hMouseHook); g_hMouseHook NULL; return bOK; }InstallHook 的两个参数由主程序填hNotifyWnd 是主对话框的句柄DLL 回调拿到文本后就发自定义消息给这个窗口uNotifyMsg 是自定义消息号要求主程序提前用 RegisterWindowMessage 生成避免与其他消息冲突。SetWindowsHookEx 第 4 个参数传 0 表示挂全局鼠标钩子系统会把 DLL 注入到所有具备消息队列的 GUI 进程里。注意线程 ID 必须是 0如果传了主程序所在线程的 ID钩子只作用于主程序自己就失去了屏幕取词的意义。UninstallHook 做的是对称操作UnhookWindowsHookEx 传回句柄即可。这里有一个容易忽略的点卸载之前要确保没有线程正在执行回调否则会出现卸载过程中回调继续进入的竞态。真正稳妥的做法是先发一条停止取词消息给 DLL等一小段时间再卸载。3.4 共享数据段让每个注入副本共享同一份状态WH_MOUSE 全局钩子会把 DLL 复制到多个进程里。每个进程里的 DLL 都是独立副本普通 static 变量并不会互相可见。但画词过程里主程序传进来的通知窗口句柄、消息号需要所有注入副本都能看到否则在记事本里划词时DLL 不知道该把结果发给谁。解决办法是共享数据段。代码里的#pragma data_seg(.HOOKSHARE)会把 g_hNotifyWnd 和 g_uNotifyMsg 放进一个特殊节再用/SECTION:.HOOKSHARE,RWS把该节标记为可读、可写、可共享。这样所有注入副本读到的都是同一份变量。这是 Windows 全局钩子 DLL 最经典的写法把这组指令从这套工程里原样拿走脱产单写一个普通 DLL 时也能直接用。不过要知道它的小限制共享段里的变量必须初始化不能放 C 对象且所有副本共用一个值别指忘在同一进程里用它存多窗口的差异化数据。4. 屏幕取词 api 实现链路鼠标状态机、窗口定位与文本读取4.1 鼠标状态机从按下到抬起还原一次选区屏幕取词的起点是鼠标事件。钩子回调会收到大量鼠标消息但真正和划词相关的只有三个WM_LBUTTONDOWN、WM_MOUSEMOVE、WM_LBUTTONUP。按下标记起始点移动过程不做什么抬起时判断终点与起点是否拉开足够距离——如果距离足够就认为用户完成了一次拖选。下面是回调函数的核心实现。// 鼠标钩子回调还原选区并触发取词 static POINT g_ptSelStart; // 按下时的屏幕坐标 static BOOL g_bTracking FALSE; // 是否正在拖选 LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode HC_ACTION) { MOUSEHOOKSTRUCT* pData (MOUSEHOOKSTRUCT*)lParam; switch (wParam) { case WM_LBUTTONDOWN: g_ptSelStart pData-pt; // 记录拖选起点 g_bTracking TRUE; break; case WM_LBUTTONUP: if (g_bTracking) { POINT ptEnd pData-pt; // 起终点距离超过 3 个像素判定为一次选区 if (abs(ptEnd.x - g_ptSelStart.x) 3 || abs(ptEnd.y - g_ptSelStart.y) 3) { OnSelectionDone(g_ptSelStart, ptEnd); } g_bTracking FALSE; } break; } } // 必须调用 CallNextHookEx否则影响后续钩子链 return CallNextHookEx(g_hMouseHook, nCode, wParam, lParam); }判定阈值选了 3 像素原因很简单用户双击选词时按下和抬起之间的位移通常小于 3 像素而拖选一般会远超这个值。如果你的产品还要支持双击取词这个阈值就要再放宽改为 5 像素左右并在抬起事件里额外判断双击消息是否存在。回调最后必须调 CallNextHookEx把事件继续往钩子链下游传。漏掉这一句其他程序的鼠标逻辑会受影响系统行为会变得诡异。OnSelectionDone 拿到的是屏幕坐标系的起点和终点后续的窗口定位、文本读取都从这里继续。4.2 根据屏幕坐标定位目标窗口取词要读的是用户划中的那个控件的文本而不是整个最顶层窗口的文本。先用 WindowFromPoint 拿到的往往是顶层窗口比如记事本的客户区、浏览器的页面容器它们通常不直接持有文本。常见做法是先用 WindowFromPoint 命中一个窗口再尝试向它的子窗口链下层找。// 从抬起点坐标定位到文本控件 HWND FindTextWindow(HWND hwndTop, POINT pt) { // 1. 先尝试子窗口命中优先命中具体控件 HWND hwndChild ChildWindowFromPoint(hwndTop, pt); if (hwndChild ! NULL hwndChild ! hwndTop) { // 探测该控件是否可读文本 LRESULT len SendMessageW(hwndChild, WM_GETTEXTLENGTH, 0, 0); if (len 0) return hwndChild; } // 2. 子窗口探测失败改用坐标转换到客户区再命中 POINT ptClient pt; ScreenToClient(hwndTop, ptClient); HWND hwndHit ChildWindowFromPointEx(hwndTop, ptClient, CWP_SKIPINVISIBLE | CWP_SKIPTRANSPARENT); if (hwndHit ! NULL) { LRESULT len SendMessageW(hwndHit, WM_GETTEXTLENGTH, 0, 0); if (len 0) return hwndHit; } // 3. 控件都不可读时退回顶层窗口 return hwndTop; }注意第 2 步的坐标系问题。ChildWindowFromPoint 的 pt 参数是父窗口客户区坐标不能直接传 WindowFromPoint 返回的屏幕坐标必须先用 ScreenToClient 转一次。这是取词实现里最容易翻车的地方——坐标没转换命中的控件完全不对。为什么用 SendMessageW 发 WM_GETTEXTLENGTH 做探测因为它能判断目标控件是否有可读文本而 GetWindowText 对某些控件会静默失败。如果目标进程回消息超时或拒绝处理SendMessage 会一直等这也是为什么在回调里做定位时要格外小心后文避坑章会展开。4.3 读取文本WM_GETTEXT 与它的边界控件定位成功后读取文本只有一条路发 WM_GETTEXT。具体是先发 WM_GETTEXTLENGTH 拿到长度再发 WM_GETTEXT 把字符填充进缓冲区。// 从目标窗口读取完整文本Unicode 版本 std::wstring GetWindowTextString(HWND hwndTarget) { // 获取文本长度EM_GETTEXTLENGTH 返回的值不含终止符 int nLen (int)::SendMessageW(hwndTarget, WM_GETTEXTLENGTH, 0, 0); if (nLen 0) return L; // 分配缓冲区多留一个字符给空终止符 std::wstring strText; strText.resize(nLen 1); // 实际读取返回值是复制到的字符数不含终止符 int nCopied (int)::SendMessageW(hwndTarget, WM_GETTEXT, nLen 1, (LPARAM)strText.c_str()); if (nCopied 0) return L; strText.resize(nCopied); return strText; }这里有几个边界必须明确。WM_GETTEXTLENGTH 返回的是字符数不含末尾的空终止符WM_GETTEXT 的 wParam 参数必须传包含空终止符的缓冲区大小也就是 nLen 加 1。用 wstring 而非 string是为了避免中文乱码——老工程大量使用 ANSI 字符串但现代 Windows 框架的文本读取几乎都是 UTF-16用 string 接回来再转 UTF-8转换粒度不对就会花式乱码。如果你要在新编译器里复用这段逻辑建议把整个取词链路都按宽字符处理最后一步再按需转码。这个方案里控件可读是硬前提。DirectUI 自绘窗口、现代浏览器的 DOM、很多游戏界面都不响应 WM_GETTEXT要么返回 0要么返回一段空白。屏幕取词产品遇到这类窗口通常会降级处理尝试读取窗口类名匹配特定类名再做附加方案或者直接放弃。这套源码的边界也在这里。4.4 裁剪选区文本并回传主窗口拿到整段文本之后还要把用户实际拖选的那段切出来。标准编辑框控件会响应 EM_GETSEL 消息返回选区的起始和结束字符位置。// 从 Edit 控件读取选区范围并裁剪文本 std::wstring GetSelectedText(HWND hwndEdit, const std::wstring strFull) { DWORD dwSel 0; // 低 16 位是选区起点高 16 位是选区终点 LRESULT lResult ::SendMessageW(hwndEdit, EM_GETSEL, 0, 0); if (lResult -1) return strFull; // 控件不支持选区退回整段 int nStart LOWORD(lResult); int nEnd HIWORD(lResult); if (nEnd nStart) return strFull; return strFull.substr(nStart, nEnd - nStart); }EM_GETSEL 返回的 DWORD 里低 16 位是选区起点高 16 位是终点。这段代码的隐含条件是目标控件是标准 Edit 或 RichEdit。遇到不响应 EM_GETSEL 的控件就退化成返回整段文本这也是大多数入门取词工程的实际行为——宁可多给不能给漏。范围控制上要注意 EM_GETSEL 只适用单行或常规编辑框RichEdit 的 EM_EXGETSEL 是另一个接口字段结构不同想支持富文本需要单独分支。文本拿到后DLL 通过自定义消息把结果发给主程序窗口。// DLL 内部把取词结果发回主程序 void OnSelectionDone(POINT ptStart, POINT ptEnd) { HWND hwndTarget FindTextWindow(WindowFromPoint(ptEnd), ptEnd); std::wstring strFull GetWindowTextString(hwndTarget); std::wstring strSel GetSelectedText(hwndTarget, strFull); if (strSel.empty()) return; // 用 SendMessage 把结果发给主程序窗口 COPYDATASTRUCT cds {0}; cds.dwData 0; cds.cbData (DWORD)(strSel.size() * sizeof(wchar_t)); cds.lpData (LPVOID)strSel.c_str(); ::SendMessageW(g_hNotifyWnd, g_uNotifyMsg, 0, (LPARAM)cds); }这里用了 WM_COPYDATA而不是直接发字符串指针。原因是指针只在发送方进程内有意义WM_COPYDATA 会自动把数据复制到接收方进程。接收方主窗口在消息处理里把 COPYDATASTRUCT 里的字符串取走再更新到界面。凡是要跨进程传可变长度文本都用 COPYDATA不要偷懒传指针这是 Windows 跨进程通信的基本纪律。5. 避坑手册五个不解决就全靠玄学的钩子问题5.1 装了钩子64 位程序里完全没有反应现象取词在 32 位程序老版记事本、某些工具里正常在 64 位程序新版浏览器、文本编辑器、设计软件里完全无响应。原因Windows 的钩子机制不允许 32 位 DLL 注入 64 位进程。系统按目标进程的位数选择注入什么 DLL位数不匹配时直接跳过。工程里的 ApiHook.dll 如果只编译了 32 位版本64 位进程根本不会加载它回调自然不触发。解决为 DLL 单独编译一份 x64 版本。实际做法是把解决方案的 Win32 平台配置改成 x64重新编译 DLL。主程序在安装钩子前可以先判断前台的窗口进程位数位数不匹配时提示用户或改用 WH_MOUSE_LL 的低级钩子做降级。这个判断可以用 IsWow64Process2 实现主程序里做一次即可不用每条消息都去查。5.2 SetWindowsHookEx 返回成功回调却一次都不触发现象InstallHook 返回 TRUE但把日志写到回调里文件始终是空的。原因回调所在的线程没有进入消息循环。SetWindowsHookEx 的钩子回调依赖消息泵来派发事件。MFC 对话框在 DoModal 运行时自带消息循环但如果在某个辅助线程里调用 InstallHook该线程没有 GetMessage/PeekMessage 循环钩子就只是挂在那里回调一直不执行。解决确认 InstallHook 是从 UI 线程调用的并且该线程正在跑消息循环。如果是辅助线程安装的低级钩子线程函数末尾要有完整的消息循环。低级钩子 WH_MOUSE_LL 对这个点尤其敏感因为它严格依赖安装线程的消息队列。5.3 在回调里弹 MessageBox界面卡死甚至系统异常现象调试时为了方便在回调里加了个 MessageBox 或者 AfxMessageBox结果程序卡死鼠标行为也变得怪异。原因鼠标钩子回调运行在系统事件派发路径上。弹窗会阻塞当前线程的事件处理鼠标消息积压目标窗口和安装者窗口都被堵住。严重时系统判定回调失控会把整个钩子移除。解决回调里绝对不做 UI 操作、不弹窗、不 Sleep、不发需要对方响应的同步消息。调试信息一律写到日志文件或 OutputDebugString。真的要看取词结果就用自定义消息把文本投递到主窗口的消息循环里让 UI 线程去更新界面。这个铁律对任何钩子都成立。5.4 取词跑了几分钟后失效没有任何报错现象功能刚装好时一切正常划几下之后取词消失进程里钩子似乎还在。原因WH_MOUSE_LL 这类低级钩子有超时限制。Windows 会定期检查低级钩子回调的响应时间默认约 3 秒。回调里任何慢操作比如同步 SendMessage 等待一个忙碌的窗口都可能导致超时系统直接把钩子移除。解决回调保持轻量把耗时操作全部异步化。如果用了 SendMessage 向目标窗口读文本目标窗口忙时会阻塞回调这时应当给发送加超时容忍或者把取词逻辑放到单独线程。安装钩子的代码要额外监听系统注册表里的 LowLevelHooksTimeout被移除后能自动重新安装。5.5 取到的是整个段落而不是划中的那一个词现象在网页或文本编辑器里明明只拖选了hello回传的却是整个段落或整篇文档。原因读到的是整个窗口文本而 EM_GETSEL 没有拿到真实选区或者目标控件本身不支持选区消息。说白了你的文本读取链路没有真正接入选区信息只是在拿整段文本充数。解决先确认目标控件类名对标准 Edit/RichEdit 用 EM_GETSEL 或 EM_EXGETSEL 拿选区。选区的起止位置是按字符数算的你截断时注意多字节字符的边界。网页类和自绘控件没有标准的选区接口这类场景要把窗口句柄、坐标、选区起点一并回传主程序再由主程序结合页面逻辑二次抠文本。不要在没有选区接口的控件上硬套 EM_GETSEL返回结果会非常不稳定。6. 验证与扩展三板斧确认 Hook 生效再把取词接进翻译流程安装钩子、划词、取词整个流程跑完之后关键动作是验证。常见的做法有两类第一类就是打开日志文件看一眼有没有写入第二类是用 Spy 类似的窗口探查工具手动取同一位置窗口的文本跟你的程序结果做比对。但这两类都有局限前者只能证明回调跑了后者只能证明文本读对了中间哪个环节出问题仍然要靠猜。我一般会强制走一遍自检清单按顺序查三个点第一板斧确认回调触发量。在 DLL 回调里加一个计数器取词成功后把计数显示在主程序界面上。鼠标每动一次计数要往上涨不动不涨。如果计数一直是零问题出在钩子安装本身和取词逻辑无关直接转向排查线程和位数。第二板斧确认目标窗口句柄有效。在 DLL 里把 WindowFromPoint 命中的窗口句柄、类名、窗口标题记录到日志里和手动探查的结果对照。句柄对不上多半是坐标转换问题。第三板斧确认文本读取正确。对目标窗口手动发一次 WM_GETTEXT和程序里取到的内容做 diff。文本一致说明读取链路没问题不一致就去检查 WM_GETTEXT 的缓冲区和字符编码。自检清单跑通后取词这个核心链路就稳了。在这个基础上做扩展方向是很自然的支持用 Ctrl 键配合鼠标取词在钩子回调里判断 GetAsyncKeyState(VK_CONTROL)按进程名做白名单自己程序的界面不触发取词避免自取词自弹窗的死循环把取词文本从 WM_COPYDATA 接到一个翻译接口上做成真正的划词翻译。最后那个扩展要注意翻译请求不能发在钩子回调里网络请求的耗时会把整个系统鼠标事件链拖住必须先把文本丢给一个后台线程等响应回来再去更新 UI。这套工程给到的是取词的地基在这之上接翻译、接收藏、接复制思路都是一样的。从那以后我每次装钩子都强制走一遍这三板斧改一行代码就重新验一遍不再靠玄学调试。希望帮到你。本文还有配套的精品资源点击获取