资讯详情 C# 全局鼠标钩子 + Shell COM 实现资源管理器选中文件路径获取
📅 2026/10/17 5:29:32
“鼠标选中哪个文件就自动拿到完整路径”——这句话说出来很轻巧真写起来差点把我绕进去。最近在给一个桌面文件整理工具加触发器需求是这样的用户在资源管理器里用鼠标点选文件或文件夹工具要立刻知道选中项是谁、完整路径是什么。我一开始以为无非是拿鼠标坐标、取窗口句柄、再调个 API 把路径抠出来动手才发现Windows Explorer 里的文件列表压根不是普通控件而是 Shell 命名空间视图背后全是 COM 接口。这篇文章就把我最终采用的、能稳定工作的 C# 方案完整记录下来全局低级鼠标钩子 Shell COM 选中项查询顺带把过程中踩过的坑和几套替代方案一并写清楚。这个需求常见于桌面自动化、文件批量处理、工具栏辅助脚本、录屏标注这类工具。只要你的目标用户会在资源管理器或桌面上点选文件然后希望程序“在后台不打扰”地知道选中的路径这套思路就能直接套用。1. 需求拆解与整体思路1.1 到底要监听什么“监控鼠标选中”这个描述包含两层事实第一用户确实在资源管理器窗口或桌面上按下了鼠标第二鼠标点击之后Shell 视图里存在一个或一组高亮选中项。注意鼠标点击位置和选中项位置不是一回事。用户点中了文件列表的空白区域鼠标有坐标但没有任何选中项用户用键盘方向键加 Shift 连续选中多个文件时鼠标可能从头到尾没碰到过这些文件。所以监控的核心落点是“选中项”而不是“鼠标下面的控件”。我最初把精力全放在“鼠标坐标转文件路径”上试了 GetCursorPos、WindowFromPoint、句子指令一系列 P/Invoke很快发现这条路是死的资源管理器把一个文件渲染成某个窗口内的若干像素但普通 Win32 窗口体系里根本查不到“某个像素坐标对应哪个 ShellItem”。真正能稳定拿到路径的入口是 Shell COM 层的选中项集合。1.2 为什么选择在 WM_LBUTTONUP 里触发钩子方案里我踩过一个小坑如果在 WM_LBUTTONDOWN 这个时机去查询选中项经常拿到旧选中项或者拿到一个还没有刷新的空集合。Explorer 的选中状态是在鼠标左键抬起、并且它自己完成一次视图交互后才稳定的。所以我最终把查询触发点放在 WM_LBUTTONUP。用户按住左键拖动多选文件时选中的过程发生在 MouseMove 阶段抬起那一刻选中集合已经完整查询结果最可靠。1.3 技术选型整套方案由三个核心组件拼起来全局低级鼠标钩子WH_MOUSE_LL监听全局鼠标左键抬起。WindowFromPoint 定位鼠标点击位置对应的窗口句柄再顺着父窗口链去找 Shell 视图窗口 SHELLDLL_DefView。Shell COM 接口通过 ShellWindows 枚举资源管理器窗口调用视图对象的 SelectedItems() 方法拿到全部选中项再逐个取 Path。为什么不直接解析资源管理器内部的 ListView 控件因为传统 SysListView32 在 Win10/11 上已经被 DirectUIHWND 和 UWP XAML 视图逐步取代依赖内部窗口结构等于把自己绑死在某个版本上。Shell COM 提供的是面向业务层的抽象只要 Explorer 还在用 Shell 命名空间展示文件这层接口就是稳定的。2. 原理资源管理器窗口背后的分层结构2.1 窗口层级里藏着关键入口传统资源管理器的窗口层级长这样CabinetWClass资源管理器主窗口……SHELLDLL_DefViewShell 视图的壳SysListView32经典文件列表控件到了 Win10 之后文件列表的实际渲染交给了 DirectUIHWND甚至更内部的 XAML 岛但 SHELLDLL_DefView 这个窗口依然存在。它相当于 Shell 视图在 Win32 窗口体系中的门面承载着文件名列表以及和 Shell COM 对象之间的桥接。桌面场景也是一样。桌面图标所在窗口是 Progman 或 WorkerW它们的子级同样有 SHELLDLL_DefView下面挂文件夹视图控件。所以只要能从鼠标点击位置向上找到 SHELLDLL_DefView就拿到了 Shell 视图的窗口句柄下一步才有资格去问“你选中了谁”。2.2 Shell 命名空间与 PIDL在 Windows Explorer 里“C:\Users\用户名\Desktop\报告.docx”这个字符串并不是最底层的身份标识。文件系统里的每个对象对应到 Shell 命名空间本质是一个 PIDLPointer to IDList指向 ID 列表的指针。PIDL 可以理解成 Shell 世界里的“地址码”它既能描述真实磁盘文件C 盘某个目录下某个文件也能描述回收站、控制面板、库搜索结果这类没有真实文件系统路径的虚拟对象。Shell COM 里的每个 FolderItem 对象内部就抱着一个 PIDL。FolderItem.Path 这个属性做的事是把 PIDL 解析成人类可读的文件系统路径。对于真实磁盘文件这个解析结果是完整的全路径对于虚拟位置Path 可能返回空字符串或者一个外壳路径文本。这就是为什么我们在查询路径时必须经过 Shell 这一层而不是直接拿句柄猜文件——因为句柄能定位到窗口但定位不到 PIDL。2.3 完整调用链把整套链路串起来看是这样的全局低级鼠标钩子捕获 WM_LBUTTONUP拿到屏幕坐标。WindowFromPoint 根据坐标拿到鼠标正下方的窗口句柄。从该句柄沿父窗口链向上递归寻找类名为 SHELLDLL_DefView 的窗口。用该 SHELLDLL_DefView 句柄去枚举当前所有 Shell 窗口找到包含这个句柄的那一个资源管理器窗口。通过该窗口的 Document 对象拿到 Shell 视图对象调用 SelectedItems() 得到选中项集合。遍历集合取每个 FolderItem 的 Path 属性拼接出完整路径列表。这六步缺一不可。少全局钩子就拿不到触发时机少定位就等于盲找少 Shell COM 就只能拿到窗口拿不到文件。3. 可直接抄作业的 C# 实现3.1 环境准备我用的环境是 .NET 8 WinForms目标平台 x64。项目文件里需要启用 Windows FormsProject SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet8.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms PlatformTargetx64/PlatformTarget /PropertyGroup /Project不需要额外装 NuGet 包。Shell COM 对象这里我用的是 Type.GetTypeFromCLSID dynamic 动态调用不依赖预先生成的 Interop 程序集也不需要手动添加 COM 引用省掉很多版本兼容问题。3.2 第一步全局鼠标钩子低级鼠标钩子的核心代码不长但有三个位置必须小心委托必须用静态字段存住防止被 GC 回收钩子回调里不能干重活卸载钩子一定要在程序退出路径上。using System; using System.Diagnostics; using System.Runtime.InteropServices; public static class MouseHook { private const int WH_MOUSE_LL 14; private const int WM_LBUTTONUP 0x0202; public delegate IntPtr LowLevelHookProc(int nCode, IntPtr wParam, IntPtr lParam); [StructLayout(LayoutKind.Sequential)] public struct POINT { public int x; public int y; } [StructLayout(LayoutKind.Sequential)] public struct MSLLHOOKSTRUCT { public POINT pt; public uint mouseData; public uint flags; public uint time; public IntPtr dwExtraInfo; } [DllImport(user32.dll, SetLastError true)] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelHookProc lpfn, IntPtr hMod, uint dwThreadId); [DllImport(user32.dll, SetLastError true)] private static extern bool UnhookWindowsHookEx(IntPtr hhk); [DllImport(user32.dll)] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); [DllImport(kernel32.dll, CharSet CharSet.Auto, SetLastError true)] private static extern IntPtr GetModuleHandle(string lpModuleName); // 必须存住委托引用局部变量会被 GC 回收 private static readonly LowLevelHookProc _proc HookCallback; private static IntPtr _hookId IntPtr.Zero; public static event Actionint, int LeftButtonUp; public static void Start() { if (_hookId ! IntPtr.Zero) return; IntPtr moduleHandle IntPtr.Zero; using (Process cur Process.GetCurrentProcess()) using (ProcessModule? curModule cur.MainModule) { if (curModule ! null) { moduleHandle GetModuleHandle(curModule.ModuleName); } } _hookId SetWindowsHookEx(WH_MOUSE_LL, _proc, moduleHandle, 0); if (_hookId IntPtr.Zero) { throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); } } public static void Stop() { if (_hookId IntPtr.Zero) return; UnhookWindowsHookEx(_hookId); _hookId IntPtr.Zero; } private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode 0 (int)wParam WM_LBUTTONUP) { MSLLHOOKSTRUCT info Marshal.PtrToStructureMSLLHOOKSTRUCT(lParam); LeftButtonUp?.Invoke(info.pt.x, info.pt.y); } return CallNextHookEx(_hookId, nCode, wParam, lParam); } }这套钩子是全局的但它并不注入别的进程回调发生在安装钩子的那个线程里。所以你必须在 WinForms 消息循环里跑它Application.Run 不能省。3.3 第二步从鼠标坐标找到 Shell 视图窗口定位窗口的辅助类如下using System; using System.Text; using System.Runtime.InteropServices; public static class Win32Window { [DllImport(user32.dll)] public static extern IntPtr WindowFromPoint(POINT point); [DllImport(user32.dll, CharSet CharSet.Auto)] private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); [DllImport(user32.dll, CharSet CharSet.Auto)] private static extern IntPtr GetParent(IntPtr hWnd); [DllImport(user32.dll)] public static extern bool IsChild(IntPtr hWndParent, IntPtr hWnd); [DllImport(user32.dll)] private static extern IntPtr GetForegroundWindow(); [DllImport(user32.dll, CharSet CharSet.Auto)] private static extern IntPtr FindWindowEx(IntPtr hwndParent, IntPtr hwndChildAfter, string lpszClass, string? lpszWindow); public static string GetWindowClassName(IntPtr hwnd) { if (hwnd IntPtr.Zero) return string.Empty; StringBuilder sb new StringBuilder(256); GetClassName(hwnd, sb, sb.Capacity); return sb.ToString(); } public static IntPtr FindShellDefViewFromPoint(POINT pt) { IntPtr hwnd WindowFromPoint(pt); IntPtr defView FindShellDefViewUpward(hwnd); if (defView ! IntPtr.Zero) return defView; // 兜底某些新版 Explorer 的视图不是从鼠标命中点直系父链可达的 // 鼠标点击通常会让资源管理器窗口获得焦点所以从前台窗口向下找 IntPtr fg GetForegroundWindow(); return FindShellDefViewDownward(fg); } private static IntPtr FindShellDefViewUpward(IntPtr start) { IntPtr cur start; while (cur ! IntPtr.Zero) { if (GetWindowClassName(cur) SHELLDLL_DefView) { return cur; } cur GetParent(cur); } return IntPtr.Zero; } private static IntPtr FindShellDefViewDownward(IntPtr root) { if (root IntPtr.Zero) return IntPtr.Zero; string cls GetWindowClassName(root); if (cls SHELLDLL_DefView) return root; IntPtr child FindWindowEx(root, IntPtr.Zero, null, null); while (child ! IntPtr.Zero) { IntPtr result FindShellDefViewDownward(child); if (result ! IntPtr.Zero) return result; child FindWindowEx(root, child, null, null); } return IntPtr.Zero; } }为什么要准备两条查找路径传统 Win10 Explorer 里鼠标命中 DirectUIHWND沿父链一路上行一定追得到 SHELLDLL_DefView。但有些新版系统、第三方文件管理器或特殊视图鼠标命中的窗口不一定与 Shell 壳保持严格的父子关系。而鼠标点击通常会激活资源管理器窗口前台窗口向下递归找 SHELLDLL_DefView 是更稳的兜底。POINT 结构在主代码里保持和 MSLLHOOKSTRUCT.POINT 同一定义别跨类再写一份避免字段错位。3.4 第三步通过 Shell COM 拿选中项路径这一步是整个方案的灵魂。这里用 dynamic 调 COM 而不是手写 IShellWindows 全套接口定义是因为 Shell 自动化对象走 IDispatch 动态派发dynamic 语法可以直接调代码量能少一半。using System; using System.Collections.Generic; using System.Diagnostics; public static class ExplorerSelection { // CLSID_Shell.Application private static readonly Guid ShellClassClsid new Guid(13709620-C279-11CE-A49E-444553540000); public static Liststring GetSelectedPaths(IntPtr shellDefViewHwnd) { Liststring result new Liststring(); try { Type? shellType Type.GetTypeFromCLSID(ShellClassClsid); if (shellType null) return result; dynamic shell Activator.CreateInstance(shellType)!; dynamic windows shell.Windows(); int windowCount windows.Count; for (int i 0; i windowCount; i) { dynamic win windows.Item(i); int hwndValue Convert.ToInt32(win.HWND); IntPtr winHwnd new IntPtr(hwndValue); bool match winHwnd shellDefViewHwnd || Win32Window.IsChild(winHwnd, shellDefViewHwnd); if (!match) continue; dynamic? doc win.Document; if (doc null) continue; dynamic items doc.SelectedItems(); int itemCount Convert.ToInt32(items.Count); for (int j 0; j itemCount; j) { dynamic item items.Item(j); object? pathObj item.Path; string path Convert.ToString(pathObj) ?? string.Empty; if (!string.IsNullOrEmpty(path)) { result.Add(path); } } break; } } catch (Exception ex) { Debug.WriteLine(ex); } return result; } }这段代码能跑通的关键是 CLSID 正确。Shell.Application 的类标识一直是 13709620-C279-11CE-A49E-444553540000不管 32 位还是 64 位环境都一样。ShellWindows 集合里只包含注册在 Shell 命名空间里的浏览器窗口也就是文件管理器不会混入记事本、浏览器页面这类普通窗口。另外注意三点枚举匹配用 IsChild 判断是因为 Explorer 主窗口和 SHELLDLL_DefView 不一定直接是父子很可能隔着多层中间窗口查询选中项必须放在 STA 线程里Shell 窗口的 Document 对象在一些特殊视图下会抛异常try/catch 必须包住。3.5 第四步主界面集成完整的 WinForms 示例窗口如下using System; using System.Collections.Generic; using System.Windows.Forms; public class MainForm : Form { private ListBox _pathList new ListBox(); private Label _status new Label(); private bool _processing; public MainForm() { Text Explorer 选中路径监控; Width 640; Height 480; _status.Text 点击任意资源管理器里文件这里会实时显示完整路径; _status.Dock DockStyle.Top; _status.Height 30; _pathList.Dock DockStyle.Fill; Controls.Add(_pathList); Controls.Add(_status); } protected override void OnLoad(EventArgs e) { base.OnLoad(e); MouseHook.LeftButtonUp OnLeftButtonUp; MouseHook.Start(); FormClosing (s, a) MouseHook.Stop(); } private void OnLeftButtonUp(int x, int y) { if (!IsHandleCreated || _processing) return; _processing true; BeginInvoke(new Action(() { try { HandleMouseClick(x, y); } finally { _processing false; } })); } private void HandleMouseClick(int x, int y) { Win32Window.POINT pt new Win32Window.POINT { x x, y y }; IntPtr defView Win32Window.FindShellDefViewFromPoint(pt); if (defView IntPtr.Zero) return; Liststring paths ExplorerSelection.GetSelectedPaths(defView); _pathList.Items.Clear(); foreach (string path in paths) { _pathList.Items.Add(path); } if (paths.Count 0) { _status.Text $命中 {paths.Count} 项最后一项{paths[paths.Count - 1]}; } else { _status.Text 点击位置没有选中项; } } }运行时直接[STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); }这里把查询操作从钩子回调 BeginInvoke 到 UI 线程是必须的低级钩子回调所在的上下文虽然也是消息循环线程但它承载的是系统钩子分发在这个上下文里做 COM 调用和控件更新轻则性能抖动重则界面卡死。BeginInvoke 之后查询动作发生在正常的 UI 线程空闲时刻COM 调用是线程安全的。3.6 流程测试与验证我把两个资源管理器窗口并排放着做验证。左边选中 A 文件夹里的文件右边选中 B 文件夹里的文件点击左边窗口时拿到的是 A 路径点击右边窗口时拿到的是 B 路径互不串场。多选时 SelectedItems 返回全部项。操作场景预期结果点选单个文件显示该文件完整路径按住 Ctrl 点选多个文件显示所有选中文件完整路径点选文件列表空白处显示空列表提示没有选中项在桌面点击图标看当前是否走了桌面兜底路径普通文件管理器窗口不受影响在地址栏、搜索框、导航栏点击查询结果为空不属于文件列表选中操作在文件管理器右键菜单弹出时点击其他地方菜单状态不影响查询钩子只关注左键抬起4. 实战中绕不开的那些坑4.1 钩子装上却没反应低级钩子装上没反应九成是下面几个原因第一钩子回调委托被 GC 回收。局部定义的匿名委托很快就会被回收一旦回收系统回调就会进入一个已经释放的函数地址轻则钩子失效。必须像我上面代码那样用一个静态只读字段把委托实例存住。第二程序没有消息循环。WH_MOUSE_LL 的回调需要消息泵控制台程序直接 Main 里 Start 然后 Sleep 是收不到事件的。用 Application.Run 或者自己写 GetMessage 循环。第三hMod 传了空指针导致部分场景不可用。低级钩子的文档说 hMod 可以给空指针但在某些系统版本和权限组合下传空指针会有诡异的不稳定表现。我统一按“尽量拿当前模块句柄”处理拿不到再传空。第四UIPI 限制。如果用户当前在管理员权限的资源管理器里操作而你的程序是普通权限那么低权限进程安装的低级钩子收不到来自高权限窗口的鼠标消息。解决方式是按需提权运行或者彻底让程序以相同权限级别启动。4.2 找到 defView 却查不到选中项找到 SHELLDLL_DefView 是第一步但找到它不代表一定有选中项。常见表现是明明看到文件高亮查询结果却是空。这时先检查目标是否落在了文件列表区域。如果用户点击的是地址栏、搜索框、导航树、状态栏这些地方不属于文件列表视图自然没有选中项。另外如果点击发生在右键菜单弹出之后焦点可能在菜单窗口Shell 视图对象返回的选中状态也可能因为焦点迁移而丢失。还有个我实测遇到的情况从快速访问、搜索页这类虚拟视图里选中文件SelectedItems 返回有值但项 Path 是空的因为底层不是纯文件系统对象。这种时候别强行解析先判断 Path 是否为空再决定要不要拼接。4.3 桌面场景要单独处理在桌面图标上点击时WindowFromPoint 最终也能找到 SHELLDLL_DefView但它的父链是 WorkerW 或 Progman不在 ShellWindows 枚举的浏览器窗口集合里。也就是说ExplorerSelection.GetSelectedPaths 拿不到桌面的选中项。桌面特殊场景通常对应几种诉求监听桌面图标点击、监听桌面空白点击、监听桌面右键菜单。如果只是需要“鼠标点在桌面某个图标上时取路径”可以退而求其次拿到桌面文件夹绝对路径再从 IAccessible 的 accSelection 里读选中项名称拼成路径。[DllImport(oleacc.dll)] static extern int AccessibleObjectFromWindow(IntPtr hwnd, uint dwId, ref Guid iid, out object? ppvObject);大致流程是往 SHELLDLL_DefView 的子窗口发 OBJID_CLIENT 获取 IAccessible然后读 accSelection。这个方案能拿到桌面文件名但库、网络位置、这台电脑这种虚拟图标会拼出无效路径需要额外过滤。如果桌面图标的路径精度要求不高还有一个最暴力的办法模拟 CtrlC。这里不展开后面第五章会对比。4.4 虚拟文件夹与外壳路径回收站、控制面板、库、搜索结果页、这台电脑这些视图FolderItem.Path 的行为很不一样。以回收站为例Path 可能返回空字符串也可能返回一个类似“::{645FF040-5081-101B-9F08-00AA002F954E}”的 CLSID 字符串。这种不是文件系统路径不能直接丢给 File.Exists 或 FileInfo。所以我在代码里做了一层过滤Convert.ToString 之后空字符串直接忽略。如果业务上确实需要处理虚拟视图建议用 item.Title 或 item.Name 兜底并且明确告诉用户“这是显示名不是文件系统路径”。4.5 性能与防抖用户连续点击多个文件时钩子事件会密集触发。如果每次点击都在 BeginInvoke 之后立刻做 ShellWindows 枚举一瞬间的窗口切换也能带来明显卡顿。我的做法是在 UI 层加 _processing 开关上一次查询没结束前下一次点击直接丢弃。对于“监听选中文件路径”这个低频需求这种防抖已经足够。另外查询逻辑不要放在钩子回调里同步执行。低级钩子回调每执行一次系统的鼠标事件分发就多等一次。之前我在回调里直接查 COM结果表现为鼠标点击后界面有明显的延迟尤其是打开多个资源管理器窗口时问题更明显。全部改成 PostMessage/BeginInvoke 后手感立刻正常了。4.6 线程模型和编译目标全套 Shell COM 调用依赖 OLE 的 STA 公寓模型。WinForms 主线程默认就是 STA所以 Application.Run 里直接调用没问题。如果你把查询逻辑放在 ThreadPool 线程或 Task 里就必须先手动 CoInitializeEx 成 STA否则 COM 对象转换会莫名其妙地失败甚至返回错误的对象。编译目标建议直接锁 x64。低层钩子本身和指针宽度无关但 Shell COM 的 Interop 在 32 位进程里访问 64 位资源管理器窗口时偶尔会出现句柄转换和 COM 套间交互的怪异问题。静态编译成 x64能少排查一类故障。5. 另外几套“能做但各有代价”的方案这套并非唯一解法。我在替换方案上花了差不多一倍的时间结论是按需选用。5.1 剪贴板模拟 CtrlC原理用户选中文件后把焦点切到对应 Explorer 窗口发送 CtrlC然后从剪贴板读文件路径列表。Clipboard.GetFileDropList();这条路优点是代码极短不需要碰任何 COM。缺点是它确实动了用户的剪贴板如果用户选中了超大量文件复制操作可能触发文件复制进度估算性能和体验都不可控。更重要的是它要求程序主动切换目标窗口焦点用户正点到一半窗口焦点被抢走视觉上非常突兀。只适合在你完全把握焦点、并且能接受剪贴板被改写的一次性脚本场景里用。5.2 UI Automation 选中项方案用 UIAutomation 的 SelectionPattern 可以拿到资源管理器选中项的名称代码写起来比 Shell COM 简单AutomationElement root AutomationElement.FromHandle(defViewHwnd); SelectionPattern pattern root.GetCurrentPattern(SelectionPattern.Pattern) as SelectionPattern;但 UIA 暴露的是文件显示名不是全路径。要得到完整路径你得自己拼拿当前 Explorer 地址栏路径或窗口标题里的目录路径再用 Path.Combine 和选中的名称拼接。这套方法遇到搜索页、虚拟文件夹、第三方文件管理器时拼接结果经常是错的。我最终没用它但如果你已经有了成熟的 UIA 基础设施可以考虑做“拿名称 目录路径”的兜底。5.3 Shell Change NotificationShChangeNotifyRegister 是 Shell 提供的事件通知机制能监听文件系统变化、Shell 命名空间变化。但注意它监听的是“文件创建/删除/重命名”不是“用户选中了哪个文件”。如果你的需求其实是“当资源管理器里的文件发生增删改时自动感知”它比鼠标钩子优雅得多不消耗全局钩子资源也不用轮询。而如果需求就是字面意义上的“鼠标选中”事件通知机制就用不上了。它不知道用户此刻是不是在点击某个文件只知道哪个路径发生了什么变化。三套方案放一起比对方案路径完整性是否影响用户代码量稳定性适用场景Shell COM 全局钩子完整支持多选无感中高后台监控、文件自动化触发剪贴板 CtrlC完整抢占焦点、改剪贴板极低中一次性小工具、无焦点冲突环境UI Automation受视图类型限制无感低中已有 UIA 基础、允许名称拼接Shell Change Notification文件路径无感低高监视文件变化不适合监听鼠标选择最后再补两条实践心得这个功能折腾完我最深的体会是监控类功能别一上来就奔着“全自动”去一定要先做日志。把每次点击的屏幕坐标、命中窗口类名、是否找到 SHELLDLL_DefView、SelectedItems 数量这四样信息打出来再决定往哪个方向修。我第一次遇到 Win11 某个预览版资源管理器时就是靠日志发现鼠标命中的窗口根本不是传统控件树结构才补上了前台窗口向下查找的兜底分支。调试技巧也值得说一句把鼠标命中窗口的类名输出到调试窗口你会看到 SysListView32、DirectUIHWND、Windows.UI.Core.CoreWindow 这些类不断出现。看到这些名字不用慌它们都是“界面层”用 Shell COM 拿选中项才是“业务层”。界面层怎么变Shell 命名空间这套底子短时间内还不会动。如果你的场景只是“文件拖进自己程序窗口时自动获取完整路径”那根本不需要全局钩子直接处理 DragEnter 和 DragDrop 事件就能拿到文件路径还不用管 Explorer 内部结构。这套鼠标监控方案是留给“用户在外面点击程序在后台安静观察”这类真实自动化场景的。我实际用下来稳定性能满足日常工具需求。