当我们在实际办公场景中,经常会出现打开一个文档后由于处理其他事情而最小化了文档窗口,或者当前窗口被其他窗口遮挡后一段时间,从而忘记了已经打开过原文档。此时重新打开这个文档,那么如何做到不创建新窗口而直接激活原来的文档窗口,这就是当前探讨的问题。

一、 取得窗口句柄

  激活窗口的关键在于如何找到这个窗口,在Win系统中有提供如下可用的窗口函数。

  • EnumDesktopWindows
  • GetWindowText

  首先我们通过 EnumDesktopWindows 枚举所有的桌面顶级窗口,该函数接受一个 WNDENUMPROC 函数指针,将枚举过程将委托给函数指针执行。该函数原型如下:

delegate bool WNDENUMPROC(IntPtr hWnd, IntPtr lParam);

  该原型中 hWnd 为枚举到窗口句柄, lParam 为承接 EnumDeskWindows 所传入的自定义数据。

  要激活一个已经打开文档窗口,我们可以在窗口枚举过程中使用文档名称匹配窗口名称来
定位实际的文档窗口。EnumDesktopWindows 函数的原型如下:

bool EnumDesktopWindows([In()] IntPtr hDesktop, WNDENUMPROC lpfn, [In] IntPtr lParam);

  该枚举函数不直接返回枚举结果,基于 lParam 的指针参数类型,我们需要实现一个结构来承载数据的输入和输出:

IntPtr[] args = new[] {
    Marshal.StringToHGlobalAnsi(name),
    Marshal.AllocHGlobal(IntPtr.Size), 
};

  我们以 name 作为窗口匹配名称并分配到非托管内存,紧接着在非托管内存再分配一个可容纳指针的内存块存储返回数据,我们将两块内存的指针构建一个新的指针数组并将其传递到 EnumDesktopWinddows 中来启动枚举操作。

GCHandle handle = GCHandle.Alloc(args, GCHandleType.Pinned);
IntPtr argsPtr = handle.AddrOfPinnedObject(); 
EnumDesktopWindows(IntPtr.Zero, _enumProc, argsPtr);

  为防止 GC 移动对象扰乱 PInvoke 的交互操作,需要将此指针数组固定在内存中。枚举工作正式启动,下面调用栈来到 WNDENUMPROC 的枚举回调中:

IntPtr namePtr = Marshal.ReadIntPtr(lParam);
IntPtr writePtr = Marshal.ReadIntPtr(IntPtr.Add(lParam, IntPtr.Size)); //数组的第二个元素
string findName = Marshal.PtrToStringAnsi(namePtr);

  在枚举过程中通过传入数据的 lParam 分别在 GC 固定对象的内存处,取得我们需要的窗口匹配名称字符串指针以及匹配完成的输出内存指针。

string wndTitle = new string(new char[MAX_PATH]);
GetWindowText(hWnd, wndTitle, MAX_PATH - 1);
if (wndTitle.Contains(findName))
    Marshal.WriteIntPtr(writePtr, hWnd);

  基于 GetWindowText 取得当前枚举窗口的窗口名称,并与 wndTitle 对比即可找到对应的窗口。将找到的窗口指针写回到GC固定内存的第二个位置,就完成了数据回传,通过直接使用指针数组的第二元素即可访问该回传窗口指针。

二、 激活窗口到前台

  激活一个窗口到前台,可能遇到的情况有这么几种。

  • 窗口处于Normal,但处于非激活的后台窗口
  • 窗口已被Minimize,此时激活窗口必须先还原状态
  • 窗口已被Hide,此时激活窗口必须先对窗口完成Show

  实现以上这些操作则需要用到如下接口函数:

  • GetWindowThreadProcessId
  • GetCurrentThreadId
  • AttachThreadInput
  • IsIconic
  • ShowWindow
  • SetActiveWindow
  • SetWindowPos
  • SetForegroundWindow
  • SetFocus

  要完成以上的这些操作,有一个前提是完成这些操作的线程必须是创建窗口的线程。显然该文档窗口由打开文档的程序自己线程创建,无法达到这一要求。基于此的原因是Windows系统设计中,每一个 Thread 有自己的 MessageQueue。这也是无论基于MFC、WinForms、WPF设计的窗体程序,这些窗体都有自己的一个 WndProc 消息循环函数。要在一个线程中操作另一个线程创建的窗口,此时就需要借助 AttachThreadInput 来连接两个线程的消息队列。

uint procId = Win32.GetWindowThreadProcessId(hWnd, IntPtr.Zero);
uint thrId = Win32.GetCurrentThreadId();
AttachThreadInput(procId, thrId, true);

  我们首先借助 GetWindowThreadProcessId 通过窗口句柄 hWnd 来取得窗口的线程,然后使用 GetCurrentThreadId 取得当前线程,使用两个线程ID完成最后连接。

if (IsIconic(hWnd)
    ShowWindow(hWnd, SW_RESTORE);
else
    ShowWindow(hWnd, SW_SHOW));

  完成连接后,使用 IsIconic 检测窗口的显示状态是否已最小化,如果是的话则对其还原显示状态,否则强制完成 Show,用作处理窗口被 Hide 的情况。

SetActiveWindow(hWnd);
SetWindowPos(hWnd, HWND_TOPMOS, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
SetWindowPos(hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
SetForegroundWindow(hWnd);
SetFocus(hWnd);
AttachThreadInput(procId, thrId, false);

  使用 SetActiveWindow 将其窗口激活,再通过 SetWindowPos 交错使用参数 HWND_TOPMOSHWND_NOTOPMOST 将窗口置顶后还原,来将其窗口带到其他窗口的上层显示。

  借助 SetForegroundWindow 设置窗口为前台窗口,并使用 SetFocus 给予窗口键盘焦点。最后分离两个线程完成整个操作。