1. 程式人生 > 其它 >.NET/C# 使用 SetWindowsHookEx 監聽滑鼠或鍵盤訊息以及此方法的坑

.NET/C# 使用 SetWindowsHookEx 監聽滑鼠或鍵盤訊息以及此方法的坑

一般來說,大家在需要監聽全域性訊息的時候會考慮SetWindowsHookEx這個 API。或者需要處理一些非自己編寫的視窗的訊息迴圈的時候,也會考慮使用它。

如果要知道如何使用這個 API,你可以在網上搜到大量這樣的文章/部落格/教程/文件,然而大多不會提及使用此 API 時遇到的一些坑。閱讀本文,你當然也可以知道應該如何使用這個 API,但同時也能瞭解如何正確使用以避免一些奇怪的問題。


本文內容

基本使用

如果你在閱讀本文的時候遇到了一些問題,可考慮去 GitHub 上克隆我的原始碼,跑一跑試試。在這裡:walterlv/Walterlv.Demo.SetWindowsHookEx

簡單一點,先貼出一部分可以工作起來的程式碼,你直接可以放到你的專案當中執行測試:

public partial class MainWindow : Window
{
    private readonly HookProc _mouseHook;
    private IntPtr _hMouseHook;

    public MainWindow()
    {
        InitializeComponent();
        _mouseHook = OnMouseHook;
        Loaded += OnLoaded;
    }

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        var hModule = GetModuleHandle(null);
        // 你可能會在網上搜索到下面註釋掉的這種程式碼,但實際上已經過時了。
        //   下面程式碼在 .NET Core 3.x 以上可正常工作,在 .NET Framework 4.0 以下可正常工作。
        //   如果不滿足此條件,你也可能可以正常工作,詳情請閱讀本文後續內容。
        // var hModule = Marshal.GetHINSTANCE(Assembly.GetExecutingAssembly().GetModules()[0]);

        _hMouseHook = SetWindowsHookEx(
            HookType.WH_MOUSE_LL,
            _mouseHook,
            hModule,
            0);
        if (_hMouseHook == IntPtr.Zero)
        {
            int errorCode = Marshal.GetLastWin32Error();
            throw new Win32Exception(errorCode);
        }
    }

    private IntPtr OnMouseHook(int nCode, IntPtr wParam, IntPtr lParam)
    {
        // 在這裡,你可以處理全域性滑鼠訊息。
        return CallNextHookEx(new IntPtr(0), nCode, wParam, lParam);
    }
}

本文討論使用 .NET/C# 來完成SetWindowsHookEx的呼叫,所以自然少不了 P/Invoke(平臺呼叫)。因此你必須將以下程式碼也新增到你的程式碼倉庫中:

[DllImport("kernel32.dll")]
public static extern IntPtr GetModuleHandle(string lpModuleName);

[DllImport("kernel32", SetLastError = true)]
static extern IntPtr LoadLibrary(string lpFileName);

[DllImport("user32.dll")]
static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);

[DllImport("user32.dll", SetLastError = true)]
static extern IntPtr SetWindowsHookEx(HookType hookType, HookProc lpfn, IntPtr hMod, uint dwThreadId);

[DllImport("user32.dll")]
static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);

private delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam);

public enum HookType : int
{
    WH_JOURNALRECORD = 0,
    WH_JOURNALPLAYBACK = 1,
    WH_KEYBOARD = 2,
    WH_GETMESSAGE = 3,
    WH_CALLWNDPROC = 4,
    WH_CBT = 5,
    WH_SYSMSGFILTER = 6,
    WH_MOUSE = 7,
    WH_HARDWARE = 8,
    WH_DEBUG = 9,
    WH_SHELL = 10,
    WH_FOREGROUNDIDLE = 11,
    WH_CALLWNDPROCRET = 12,
    WH_KEYBOARD_LL = 13,
    WH_MOUSE_LL = 14
}

SetWindowsHookEx

SetWindowsHookEx的簽名如下:

HHOOK SetWindowsHookExA(
  int       idHook,
  HOOKPROC  lpfn,
  HINSTANCE hmod,
  DWORD     dwThreadId
);
  • 當方法執行成功時,返回值是鉤子處理函式的控制代碼,用於在鉤子的訊息處理中呼叫CallNextHookEx方法。當方法執行失敗時,這裡返回0
  • idHood 引數表示需要處理的訊息型別(我們前面定義成了列舉型別HookType
  • lpfn 是自己定義的鉤子的訊息處理方法(對應我們前面定義的委託)
  • hmod 是模組的控制代碼,在本機程式碼中,對應 dll 的控制代碼(可在 dll 的入口函式中獲取);而我們是託管程式碼
  • dwThreadId 是執行緒 Id,傳入 0 則為全域性所有執行緒,否則傳入特定的執行緒 Id

需要注意的坑

模組控制代碼傳什麼?

本文一開始被註釋掉的程式碼中,我使用Marshal直接從託管程式集中獲取了模組控制代碼。

這裡需要說明,託管程式集不能注入到其他程序,因此也不可以掛接鉤子。但有例外,WH_KEYBOARD_LL或者WH_MOUSE_LL這兩個是不需要注入 dll 的,因此可以掛接鉤子。

對於WH_KEYBOARD_LLWH_MOUSE_LLSetWindowsHookEx方法裡面根本沒有使用這個模組做什麼真正的事情,它只是驗證一下一個模組而已。只要存在於你的程序中。

所以,傳入其他的模組都是可以的:

var hModule = LoadLibrary("user32.dll");

傳入口模組也是可以的:

var hModule = Marshal.GetHINSTANCE(Assembly.GetEntryAssembly().GetModules()[0]);
var hModule = GetModuleHandle(null);

這也是一開始我在 P/Invoke 的方法裡面預留了LoadLibraryGetModuleHandle方法的原因。

通過除錯也能發現這兩個的入口模組是相同的:

至於為什麼可以用 user32.dll。嗯,反正我們建立視窗監聽訊息都已經大量呼叫 user32.dll 的 API 了,這 dll 肯定已經加入到我們的程序中了,所以我們把這個傳入到引數中是可以通過驗證的。

錯誤 126:找不到指定的模組。

The specified module could not be found.

如果你只是拿程式碼做做 demo 可能一切順利,但放到實際專案裡面就掛得一塌糊塗:

這也是我在一開始的 P/Invoke 裡面加上了SetLassError的重要原因,因為這 API 容易掛。

檢查的錯誤碼是 126(0x0000007E)。

然而我的 dll 是存在的呀!

讓我們再來看我一開始預留的註釋:

// 下面程式碼在 .NET Core 3.x 以上可正常工作,在 .NET Framework 4.0 以下可正常工作。
// 如果不滿足此條件,你也可能可以正常工作,詳情請閱讀本文後續內容。
var hModule = Marshal.GetHINSTANCE(Assembly.GetExecutingAssembly().GetModules()[0]);

是的,你遇到這樣的異常,多半意味著你落入 .NET Framework 4.x 版本的執行時了。

.NET Framework 4.0 相比於之前的 CLR 發生了很大的更改,不再假裝 JIT 程式碼存在一非託管模組中,因此Marshal.GetHINSTANCE將不再起作用。

對於低階鉤子來說,SetWindowsHookEx需要一個有效的模組控制代碼進行檢查,但實際上此 API 執行時根本沒有使用這個模組。所以更推薦使用前一小節中提供的LoadLibrary函式來獲取模組控制代碼,而不是獲取當前託管模組的控制代碼。

解決方法,兩/三個:

  1. 方法一:使用LoadLibrary("user32.dll")獲取模組控制代碼代替Marshal.GetHINSTANCE
  2. 方法二:將獲取控制代碼的模組改為入口程式集(exe),即Assembly.GetEntryAssembly()
  3. 方法三:升級成純 .NET Core 程式

錯誤 1428:沒有模組控制代碼無法設定非本機的掛接。

Cannot set nonlocal hook without a module handle.

對於前面說的 126 錯誤,你可能從Assembly.GetExecutingAssembly改成Assembly.GetEntryAssembly()之後會出現此異常。

解決方法:

  • 使用LoadLibrary("user32.dll")獲取模組控制代碼代替Marshal.GetHINSTANCE

錯誤 1429:此掛接程式只可整體設定。

This hook procedure can only be set globally.

估計找到這裡的方式可能是搜尋,因為這段中文讀起來真的是太晦澀了。不過我把英文貼到上一行了,相信你差不多就知道是怎麼回事了。

因為你給SetWindowsHookEx方法中傳入的HookType引數指定了低階型別(Low Level,HookType列舉後面帶了 LL 字尾的),這時只能全域性設定鉤子。意味著你的第四個引數必須傳入0

如何只處理特定視窗的訊息?

訊息迴圈屬於“執行緒”,而不是屬於某個視窗或者程序。在CreateWindowEx建立視窗時傳入的訊息處理函式會僅處理特定視窗的訊息,然而當通過鉤子的方式來處理訊息的話,無法精確定位到某個特定的視窗,只能針對訊息迴圈所在的執行緒。因此,要處理特定視窗的訊息,只能先拿到此視窗所在的執行緒。

前面的 P/Invoke 中我也預留了獲取視窗所線上程的方法。因此,可以直接使用以下呼叫來獲取hWnd控制代碼視窗所在的執行緒。

var threadId = GetWindowThreadProcessId(hWnd, out _);

本來在SetWindowsHookEx最後一個引數傳入 0 表示全域性鉤子的,那麼現在傳入threadId即僅監聽此執行緒的訊息。

另外,如果只是打算處理單個視窗的訊息,而不是這個執行緒裡的所有訊息,那麼建議使用子類化的方式來實現。詳情可閱讀我的另一篇部落格:

為什麼會導致其他程序閃退?

你可能會發現,明明按照本文所述的方法掛接了鉤子,但一執行起來後,其他程式(被掛接的程式)出現了閃退現象。

接下來說明:

HookType的所有種類中,只有WH_MOUSE_LLWH_KEYBOARD_LL是不需要注入到目標程序的,其他都必須將 dll 注入到目標程序才可以完成掛接。然而 .NET 程式集無法被注入到其他程序;隨便用一個其他 dll 時,裡面沒有被掛接的函式地址,在注入後就會導致目標程序崩潰。

所以:

  1. 如果需要掛接的程序就在本程序內(最後引數指定的執行緒是本程序內的執行緒),那麼所有種類都可以掛接;
  2. 如果需要全域性掛接,或者要掛接別的程序,那麼 .NET 程式只能使用WH_MOUSE_LLWH_KEYBOARD_LL兩種掛接型別;

如果就是要掛接其他型別的鉤子怎麼辦?辦法總還是有的:

  1. 可以考慮做非託管 dll,專門用來掛接;
  2. 可以考慮使用SetWinEventHook,這個是不用注入到目標程序的;
  3. 可以考慮使用System.Windows.Automation抓取一部分有限的資訊。

其他問題

如果你在各種折騰之後還是有問題,可考慮去 GitHub 上克隆我的原始碼,跑一跑試試。在這裡:walterlv/Walterlv.Demo.SetWindowsHookEx。或者通過本文後面附帶的聯絡方式與我聯絡。


參考資料

本文會經常更新,請閱讀原文:https://blog.walterlv.com/post/add-global-windows-hook-in-dotnet.html,以避免陳舊錯誤知識的誤導,同時有更好的閱讀體驗。