0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WinForms ホットキー実装|RegisterHotKey/UnregisterHotKey の要点【外伝G17】

0
Last updated at Posted at 2026-01-20

連載Index(読む順・公開済リンクはここが最新): S00_門前の誓い_総合Index

ホットキーは「開発機では動くが、配布後に落ち方が増える」種類の機能になりやすい。
キーの占有は端末ごと・常駐ツールごとに変わり、終了経路の増加で解除漏れも混ざる。
WinFormsのメッセージループへ安全に載せ、失敗時の縮退(無効化・キー変更)まで含めて組む。
そのための最短テンプレと、Win32エラーの読み方(GetLastWin32Error)を整理する。


1. このページで手に入るもの

  • RegisterHotKey/UnregisterHotKey(Win32 API)をWinFormsのライフサイクルへ載せる最短テンプレ(コピペ可)
  • 競合(登録失敗)と解除漏れ(次回起動崩れ)を同時に潰す境目(Handle生成・破棄、例外経路、終了経路)
  • GetLastWin32Error の意味・前提・落とし穴・ログに残す粒度(使いどころが迷わない)
  • WndProcを軽く保ち、UI詰まりを作らない受け方(通知→実処理の分離)
  • 失敗パターン別の直し方(悪い例 → 直す → ポイント)と、テスト観点の整理

2. 先に逆引き(症状 → 原因 → 対策)

症状(見えていること) 主な原因(混入点) まず打つ手(最短) 次の一手(運用まで)
登録できない(起動直後から効かない) 他アプリ/常駐ツールが同じキーを占有、セッション条件差 TryRegisterで失敗検知し、無効化・キー変更へ落とす 設定画面に「競合時の復旧」を用意し、Win32エラーをログへ残す
次回起動で効かない 解除漏れ(例外/多経路終了/Dispose未実施) finallyDispose で解除を必ず通す Handle再作成も含めて「登録済み状態」を管理する
押しても反応しない WM_HOTKEY未処理、base.WndProc を呼んでいない WM_HOTKEYだけ拾い、他は base.WndProc 維持 受信ログを残し、メッセージループ前提を崩さない
押すとUIが固まる WndProc内で重い処理、.Result 等で同期ブロック WndProcは通知のみ、処理は BeginInvoke 等へ async/await の戻り先(UI復帰)を前提に設計する
多重起動で奪い合う 複数インスタンスが同じキーを取り合う 多重起動を抑止、既存インスタンスへ誘導 多重起動の仕様を決め、テスト観点へ組み込む

3. 最短テンプレ(コピペ): WinFormsへ「Win32ホットキー」を安全に載せる型

Win32 API の RegisterHotKey/UnregisterHotKey を「Handleの生存期間」に結び、失敗時は縮退へ落とす。

このテンプレが狙うことは次の3点。

  • 登録・解除の境目を決める: HandleCreated/HandleDestroyedDispose に寄せ、解除漏れを減らす
  • 登録失敗を仕様として扱う: 競合・権限差が混ざる前提で、失敗検知→縮退へ落とす
  • 受け口を軽くする: WndProcは通知だけに留め、実処理は別メソッドへ移す

3.1 Win32 API 定義(P/Invoke)

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Windows.Forms;

internal static class HotKeyNative
{
    // Win32: RegisterHotKey が押下通知として投げるメッセージ
    // https://learn.microsoft.com/windows/win32/inputdev/wm-hotkey
    public const int WM_HOTKEY = 0x0312;

    // 競合の典型: ERROR_HOTKEY_ALREADY_REGISTERED (1409)
    // 既に同じホットキーが登録されている、の意味になる(別プロセス/同一プロセスを含む)
    public const int ERROR_HOTKEY_ALREADY_REGISTERED = 1409;

    [Flags]
    public enum Modifiers : uint
    {
        None = 0x0000,
        Alt = 0x0001,
        Control = 0x0002,
        Shift = 0x0004,
        Win = 0x0008,
        NoRepeat = 0x4000, // 必要ならキーリピートを抑止
    }

    // Win32: user32.dll
    // https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-registerhotkey
    [DllImport("user32.dll", SetLastError = true)]
    public static extern bool RegisterHotKey(IntPtr hWnd, int id, Modifiers fsModifiers, uint vk);

    // Win32: user32.dll
    // https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-unregisterhotkey
    [DllImport("user32.dll", SetLastError = true)]
    public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
}

/// <summary>
/// Win32ホットキー登録を「登録→解除」まで1つの塊にするヘルパ。
/// 貼る場所: Formのフィールドとして保持する。
/// 境目: Handleが生きている間だけ登録する(Handle再作成に追従しやすい)。
/// 戻り値: TryRegister は成功/失敗を bool で返し、失敗理由は win32Error に出す。
/// 例外方針: 競合は運用で起きる前提のため例外化しない。縮退(無効化・キー変更)へ落とす。
/// </summary>
internal sealed class HotKeyRegistration : IDisposable
{
    private readonly IntPtr _handle;
    private readonly int _id;

    // 解除は「登録に成功した場合のみ」行う。状態を持たないと解除の多重呼び出しが増えやすい。
    private bool _registered;

    public HotKeyRegistration(IntPtr handle, int id)
    {
        _handle = handle;
        _id = id;
    }

    public bool TryRegister(HotKeyNative.Modifiers modifiers, Keys key, out int win32Error)
    {
        // SetLastError=true の P/Invoke なので、失敗時は直後に GetLastWin32Error を読むのが前提になる。
        _registered = HotKeyNative.RegisterHotKey(_handle, _id, modifiers, (uint)key);

        // 成功時は 0 として扱う(ログが見やすい)
        win32Error = _registered ? 0 : Marshal.GetLastWin32Error();
        return _registered;
    }

    public void Dispose()
    {
        if (!_registered) return;

        // 解除失敗も運用で起きるため、戻り値は拾ってログに残せる形へする。
        // ここでは「解除を試みた」事実を優先し、例外は投げない方針に寄せる。
        var ok = HotKeyNative.UnregisterHotKey(_handle, _id);
        if (!ok)
        {
            // UnregisterHotKey も SetLastError=true。失敗理由を残す余地を確保する。
            var err = Marshal.GetLastWin32Error();
            // _logger.LogWarning($"HotKey unregister failed. id={_id} win32={err}");
        }

        _registered = false;
    }
}

3.2 Form側(ライフサイクル・受信・縮退)

using System;
using System.Windows.Forms;

public partial class MainForm : Form
{
    // HotKeyRegistration は「登録→解除」までをまとめるため、Formの生存期間に合わせて保持する。
    private HotKeyRegistration? _hotKey;

    // WM_HOTKEY の識別子。複数登録する場合はIDを増やし、WParamで区別する。
    private const int HotKeyId = 1;

    public MainForm()
    {
        InitializeComponent();

        // Handleの生成・破棄に寄せると、Handle再作成にも追従しやすい。
        this.HandleCreated += (sender, e) => RegisterHotKeyOrFallback();
        this.HandleDestroyed += (sender, e) => UnregisterHotKeySafe();
    }

    private void RegisterHotKeyOrFallback()
    {
        // 再登録前に解除を通し、二重登録の揺れを避ける。
        UnregisterHotKeySafe();

        _hotKey = new HotKeyRegistration(this.Handle, HotKeyId);

        // 例: Ctrl + Space(必要なら NoRepeat を混ぜる)
        var ok = _hotKey.TryRegister(
            modifiers: HotKeyNative.Modifiers.Control | HotKeyNative.Modifiers.NoRepeat,
            key: Keys.Space,
            win32Error: out var err);

        if (ok)
        {
            // _logger.LogInformation("HotKey registered. id=1 key=Ctrl+Space");
            return;
        }

        // 失敗は「何も起きない」にしない。縮退へ落とす。
        // err の代表例:
        // - 1409: 既に登録済み(競合)の可能性が濃い
        // - 5: アクセス拒否(環境条件/権限条件が絡む場合がある)
        // _logger.LogWarning($"HotKey register failed. id=1 win32={err}");

        // 縮退案(要件で選ぶ):
        // - ホットキー機能を無効化し、代替操作(メニュー/ボタン)へ寄せる
        // - 設定画面でキー変更を促す
        // - 既定キーを複数候補で試す(衝突率の低い組合せへ)
        _hotKey.Dispose();
        _hotKey = null;
    }

    private void UnregisterHotKeySafe()
    {
        try
        {
            _hotKey?.Dispose();
        }
        finally
        {
            _hotKey = null;
        }
    }

    protected override void WndProc(ref Message m)
    {
        // WM_HOTKEY のみ拾い、他メッセージは base.WndProc に流す。
        if (m.Msg == HotKeyNative.WM_HOTKEY && m.WParam.ToInt32() == HotKeyId)
        {
            // WndProcは通知だけに留める。重い処理はUIキューへ積む。
            this.BeginInvoke((Action)(() => OnHotKeyPressed()));
            return;
        }

        base.WndProc(ref m);
    }

    private void OnHotKeyPressed()
    {
        // 実処理を置く場所。
        // - ここでUI操作や非同期呼び出しを行う
        // - 長い処理はさらに別スレッドへ逃がし、戻りはUIへ戻す設計にする
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            // Form破棄経路でも解除を通す(終了経路が増えても漏れにくい)
            UnregisterHotKeySafe();
        }
        base.Dispose(disposing);
    }
}

4. 解説: 定義 → 評価規則 → 状態の変化 → 落とし穴

Win32 API を「成功前提で呼ぶ」と壊れ方が増える。失敗検知と境目を先に揃える。

4.1 用語と前提(Win32 APIである点を明示)

  • RegisterHotKey / UnregisterHotKey
    Windowsの user32.dll が提供するWin32 API。C#からはP/Invoke(DllImport)で呼ぶ。
    返り値は bool で、false の場合は失敗。失敗理由は Win32 の「最後のエラー(Last Error)」として別に保持される。
  • WM_HOTKEY
    RegisterHotKey で登録したキーが押されたときに、登録先ウィンドウへ投げられるメッセージ。
  • Last Error(最後のエラー)
    Win32 APIの多くは「返り値で成功/失敗」を返し、「失敗理由は別スロット(スレッド単位)へ格納する」。
    その別スロットを読むAPIが GetLastError。C#では Marshal.GetLastWin32Error() がそれに相当する。

4.2 評価規則(境目: どこで呼ぶか)

  • 登録はHandleが生きている間に行う
    WinFormsではHandleが再作成される可能性があるため、HandleCreated を登録の境目にする。
  • 解除は複数経路から必ず通す
    HandleDestroyedDispose を解除の境目にし、例外・多経路終了でも漏れにくい形にする。
  • WndProcは通知だけ
    WM_HOTKEY受信後に実処理を呼び出すだけに留めると、UI詰まりが減る。

4.3 状態の変化(登録済みを状態として扱う)

  • 登録に成功したかどうかを状態として持ち、解除は「成功した場合のみ」行う
  • 再登録(設定変更・再初期化・Handle再作成)では「解除→登録」の順へ揃える

4.4 落とし穴(壊れ方が増える混入点)

  • 競合は避け切れない(常駐ツール、IME、配列系ユーティリティ、OS機能、別プロセス、同一プロセスの二重登録)
  • 解除漏れは「その場で動く」ため、発火が遅れて追いづらい
  • WndProcに重い処理や同期待ち(.Result 等)が混ざると、入力系の詰まりが拡大する

5. GetLastWin32Error を深掘り(何者か・前提・落とし穴・使い方)

Marshal.GetLastWin32Error() は「直前のWin32 API失敗理由」を読む手段で、読むタイミングと条件が命。

5.1 何者か(Win32の Last Error を読む)

Win32 API には、次のような設計の関数が多い。

  • 返り値: 成功/失敗(BOOL など)
  • 失敗理由: スレッド単位の Last Error に格納(GetLastErrorで取得)

C# ではそれを Marshal.GetLastWin32Error() で読める。
ポイントは、「失敗した直後に読む」 こと。Last Error は後続のWin32呼び出しで上書きされうる。

5.2 使える条件(SetLastError=true が前提)

P/Invoke(DllImport)側で SetLastError = true を付けると、CLRが「Last Error を取得できる形で呼び出す」経路になる。
付けない場合、Marshal.GetLastWin32Error() を読んでも意味のある値にならないことがある。

このページのテンプレは SetLastError = true を付けているため、RegisterHotKey/UnregisterHotKey 失敗直後に読む前提が成立する。

5.3 落とし穴(読み違いが起きる典型)

  • 成功時に読んでしまう
    成功時のLast Errorは未定義になりやすい。成功時は 0 扱いへ寄せ、失敗時だけ読むのが安全。
  • 読むまでに別のWin32 APIが混ざる
    例: 失敗後にログ出力や別API呼び出しが入ると、Last Error が上書きされる可能性がある。
    失敗直後に var err = Marshal.GetLastWin32Error(); を確保してから周辺処理へ移る。
  • 別スレッドで読んでしまう
    Last Error はスレッド単位のため、失敗したスレッドと別スレッドで読むと意味が変わる。
    WndProc受信後に別スレッドへ逃がす場合でも、「失敗理由の取得」は失敗直後・同一スレッドで行う。

5.4 実務での使い方(ログ粒度と復旧へ繋ぐ)

  • ログに残す最小要素
    id(ホットキー識別子)、修飾キー、仮想キー、win32(エラー番号)
    例: HotKey register failed. id=1 key=Ctrl+Space win32=1409
  • 代表例: 1409(競合)を縮退へ繋ぐ
    1409 は「既に登録済み」。別アプリ占有の可能性が濃い。
    その場合は「機能を無効化」「キー変更」へ落とすのが最短の復旧になる。
  • 例外化しない理由
    競合は運用で起きる前提が強く、例外で落とすと復旧動線が詰まる。
    bool + エラー取得で「縮退へ落とす」を仕様として扱う方が安定する。

6. 判例: 混入点が多い順に潰す(持ち帰れる観点を増やす)

失敗を「起きない前提」にせず、混入点を設計に畳み込むと詰まりが減る。

6.1 解除漏れ(終了経路の増加で壊れ方が増える)

解除は Dispose に集約し、「呼び忘れ」を構造で減らす。

悪い例(解除が通らない経路が増えやすい):

public void StartHotKey()
{
    // 登録だけ行い、例外・終了経路・再初期化での解除が増殖しやすい
    HotKeyNative.RegisterHotKey(this.Handle, 1, HotKeyNative.Modifiers.Control, (uint)Keys.Space);
}

直す(登録済み状態を持ち、解除を必ず通す):

private HotKeyRegistration? _hotKey;

public void StartHotKey()
{
    // 再初期化・設定変更経路で二重登録にならないよう、先に解除を通す
    _hotKey?.Dispose();

    _hotKey = new HotKeyRegistration(this.Handle, 1);

    var ok = _hotKey.TryRegister(
        modifiers: HotKeyNative.Modifiers.Control,
        key: Keys.Space,
        win32Error: out var err);

    if (ok) return;

    // 失敗理由は直後に確保し、縮退へ繋ぐ(競合が多い領域)
    // _logger.LogWarning($"HotKey register failed. win32={err}");

    _hotKey.Dispose();
    _hotKey = null;
}

ポイント:

  • 終了経路が増えても解除漏れが増えないよう、解除は1箇所へ寄せる
  • 再初期化(設定変更など)のたびに「解除→登録」の順へ揃える
  • 失敗理由は直後に確保し、縮退へ繋ぐ材料にする

6.2 Handle再作成(登録したつもりが混ざる)

登録は HandleCreated を境目にし、再作成へ追従させる。

悪い例(Load/コンストラクタで1回だけ登録し、再作成で崩れる):

public MainForm()
{
    InitializeComponent();
    HotKeyNative.RegisterHotKey(this.Handle, 1, HotKeyNative.Modifiers.Control, (uint)Keys.Space);
}

直す(Handleの境目で揃える):

public MainForm()
{
    InitializeComponent();

    // Handle生成のたびに登録し、破棄のたびに解除する
    this.HandleCreated += (sender, e) => RegisterHotKeyOrFallback();
    this.HandleDestroyed += (sender, e) => UnregisterHotKeySafe();
}

ポイント:

  • 「登録したが効かない」はHandle再作成が混入点になりやすい
  • Handleの生存期間に登録・解除を結び付けると、壊れ方が減る
  • 再登録前に解除を通すと、二重登録の揺れも減る

6.3 競合(登録失敗)を縮退へ繋げないと運用が詰まる

競合は起きる前提で「失敗検知→縮退」を先に用意する。

悪い例(失敗を握りつぶし、何も起きない状態になる):

var ok = HotKeyNative.RegisterHotKey(this.Handle, 1, HotKeyNative.Modifiers.Control, (uint)Keys.Space);
// ok が false でも放置

直す(失敗直後にWin32エラーを確保し、縮退へ落とす):

var ok = HotKeyNative.RegisterHotKey(this.Handle, 1, HotKeyNative.Modifiers.Control, (uint)Keys.Space);
if (!ok)
{
    // 失敗直後・同一スレッドで読む(ここがズレると値が上書きされうる)
    var err = Marshal.GetLastWin32Error();

    // 1409 は競合の可能性が濃い。縮退へ落とす判断材料になる。
    // _logger.LogWarning($"HotKey register failed. win32={err}");

    // 縮退:
    // - 無効化して代替操作へ
    // - 設定画面でキー変更
}

ポイント:

  • 失敗理由の取得タイミングが命(直後に確保)
  • 競合(1409)はよく出るため、縮退の仕様を先に持つ
  • 「何も起きない」を避けると、現場での詰まりが減る

6.4 WndProc重化(入力系の詰まりが拡大する)

WndProcは通知だけ。実処理は別メソッドへ逃がす。

悪い例(受信口で同期待ち・重処理が混ざる):

protected override void WndProc(ref Message m)
{
    if (m.Msg == HotKeyNative.WM_HOTKEY)
    {
        var result = SomeAsync().Result; // UIスレッドで同期待ちになりやすい
        DoHeavy(result);
        return;
    }
    base.WndProc(ref m);
}

直す(通知→UIキューへ積む):

protected override void WndProc(ref Message m)
{
    if (m.Msg == HotKeyNative.WM_HOTKEY)
    {
        // 受信口は軽く保つ。実処理は別へ。
        this.BeginInvoke((Action)(() => OnHotKeyPressed()));
        return;
    }
    base.WndProc(ref m);
}

private async void OnHotKeyPressed()
{
    // 実処理側で非同期を扱う(戻り先がUIになる前提を意識する)
    var result = await SomeAsync();
    DoHeavy(result);
}

ポイント:

  • 受信口に重い処理が入ると、入力系の詰まりが連鎖する
  • .Result は同期待ちを作りやすい
  • 通知と実処理を分離すると、設計意図が説明しやすくなる

6.5 多重起動(自分自身が競合の混入点になる)

多重起動の仕様が未確定だと、ホットキーの奪い合いが起きる。

悪い例(何も決めずに2起動でき、片方だけ効く):

// 2インスタンスが同じホットキーを取り合う前提のまま

直す(多重起動の仕様を決め、挙動を揃える):

// 例: Mutexで抑止し、既存インスタンスへ誘導する等
// 実装詳細は要件次第。重要なのは「仕様として決める」こと。

ポイント:

  • 競合の原因が「別アプリ」だけでなく「自分の多重起動」になる
  • 仕様(抑止/既存へ誘導/並行許可)を先に決めると説明が通る
  • テスト観点に「多重起動」を入れると崩れ方の再現が進む

7. チェックリスト(確認観点)

「境目」「縮退」「Last Error」「受信口軽量化」が揃うと品質が上がる。

観点 チェック内容 具体の確認ポイント
Win32前提 Win32 APIである点が明記されている user32.dll / P/Invoke / SetLastError=true が揃う
境目 登録・解除の境目が揃っている HandleCreated で登録、HandleDestroyed/Dispose で解除
状態 登録済みを状態として扱っている 成功時のみ解除、再登録は「解除→登録」
Last Error GetLastWin32Error の読み方が成立している 失敗直後・同一スレッドで確保、成功時は読まない
縮退 登録失敗時に復旧できる 無効化/キー変更/代替操作などへ落とせる
受信口 WndProcが軽い WM_HOTKEYのみ拾い、他は base.WndProc 維持
入力詰まり 同期待ちが混ざらない .Result 等が受信口に入らない

8. セルフチェック(5問)

全て「はい」になると、配布後の詰まりが減る。

  1. RegisterHotKey/UnregisterHotKey が Win32 API(user32.dll)である点を前提に設計できているか
  2. 登録・解除が Handle の生存期間に結び付いているか(Handle再作成でも追従するか)
  3. 登録失敗時に GetLastWin32Error を失敗直後に確保し、縮退へ繋げられるか
  4. 解除が Dispose/finally 経由で必ず通り、解除漏れが増えにくいか
  5. WndProc が通知だけで、実処理や同期待ちが混ざっていないか
回答の目安
  • 1: user32.dll / DllImport / SetLastError=true が揃っているなら「はい」
  • 2: HandleCreated/HandleDestroyed と Dispose で管理しているなら「はい」
  • 3: 失敗直後に var err = Marshal.GetLastWin32Error(); を確保し、縮退へ繋げられるなら「はい」
  • 4: 解除が1箇所へ寄っており、例外や終了経路増加に引きずられないなら「はい」
  • 5: WndProcは通知のみで、実処理は別メソッドなら「はい」

9. 関連トピック


連載Index(読む順・公開済リンクはここが最新): S00_門前の誓い_総合Index

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?