【C#】グローバルフックで連打を検知したい

# はじめに
自作アプリを作るにあたって，特定のコマンドを入力することで画面を表示するような機能が欲しいと思った．はじめは，Ctrlキー＋何かのキーを考えていたが，押しやすそうなものは既に割り当てられていたため，「Ctrlキー2回連打」を検知することにした．

結果，あえなく撃沈した．

# 【コード】Ctrlキー2回連打の検知
以下は，Ctrl2回連打すると，「検知しました」というダイアログボックスが表示されるサンプルコードである．
```C#:Form1.cs
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows.Forms;

namespace sample
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            //最大化・最小化ボタンを無効にする
            //本題とは関係ナシ
            this.MinimizeBox = false;
            this.MaximizeBox = false;

            //フォームを閉じる直前に，OnFormClosingを呼び出す
            this.FormClosing += OnFormClosing;
        }

        KeyboardHook keyboardHook = new KeyboardHook();

        protected override void OnLoad(EventArgs e)
        {
            keyboardHook.KeyDownEvent += KeyboardHook_KeyDownEvent;
            keyboardHook.Hook();
        }

        private struct command
        {
            public string key;
            public string time;

            public command(string key, string time)
            {
                this.key = key;
                this.time = time;
            }
        }

        command[] pressedKeys = new command[2]
        {
            new command(null,null),
            new command(null,null)
        };

        private void KeyboardHook_KeyDownEvent(object sender, KeyEventArg e)
        {
            KeysConverter kc = new KeysConverter();

            DateTime dt = DateTime.Now;

            for(int i=0; i<2; i++)
            {
                if (pressedKeys[i].key == null)
                {
                    pressedKeys[i].key = kc.ConvertToString(e.KeyCode);
                    pressedKeys[i].time = dt.ToString("yyyy/MM/dd HH:mm:ss");
                    break;
                }
            }

            for(int i=0; i<2; i++)
            {
                if (pressedKeys[i].key == null) return;
            }

            DateTime lastKeyDownTime = DateTime.Parse(pressedKeys[0].time);
            DateTime nowKeyDownTime = DateTime.Parse(pressedKeys[1].time);

            TimeSpan interval = nowKeyDownTime - lastKeyDownTime;

            if(lastKeyDownTime.Year == nowKeyDownTime.Year && lastKeyDownTime.Month == nowKeyDownTime.Month && lastKeyDownTime.Date == nowKeyDownTime.Date
                && interval.Hours == 0 && interval.Minutes == 0 && interval.Seconds == 0 && interval.Milliseconds <= 7)
            {
                string lastKey = pressedKeys[0].key;
                string nowKey = pressedKeys[1].key;

                if(lastKey.Equals("LControlKey") && nowKey.Equals("LControlKey"))
                {
                    keyboardHook.UnHook();
                    MessageBox.Show("コマンドを検知しました．");
                    keyboardHook.Hook();
                }
            }

            for(int i=0; i<2; i++)
            {
                pressedKeys[i].key = null;
                pressedKeys[i].time = null;
            }
        }

        private void OnFormClosing(object sender, FormClosingEventArgs e)
        {
            keyboardHook.UnHook();
        }


    }

    class KeyboardHook
    {
        protected const int WH_KEYBOARD_LL = 0x000D;
        protected const int WM_KEYDOWN = 0x0100;
        protected const int WM_KEYUP = 0x0101;
        protected const int WM_SYSKEYDOWN = 0x0104;
        protected const int WM_SYSKEYUP = 0x0105;

        [StructLayout(LayoutKind.Sequential)]
        public class KBDLLHOOKSTRUCT
        {
            public uint vkCode;
            public uint scanCode;
            public KBDLLHOOKSTRUCTFlags flags;
            public uint time;
            public UIntPtr dwExtraInfo;
        }

        [Flags]
        public enum KBDLLHOOKSTRUCTFlags : uint
        {
            KEYEVENTF_EXTENDEDKEY = 0x0001,
            KEYEVENTF_KEYUP = 0x0002,
            KEYEVENTF_SCANCODE = 0x0008,
            KEYEVENTF_UNICODE = 0x0004,
        }

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern IntPtr SetWindowsHookEx(int idHook, KeyboardProc lpfn, IntPtr hMod, uint dwThreadId);

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern bool UnhookWindowsHookEx(IntPtr hhk);

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        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);

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

        private KeyboardProc proc;
        private IntPtr hookId = IntPtr.Zero;

        public void Hook()
        {
            if (hookId == IntPtr.Zero)
            {
                proc = HookProcedure;
                using (var curProcess = Process.GetCurrentProcess())
                {
                    using (ProcessModule curModule = curProcess.MainModule)
                    {
                        hookId = SetWindowsHookEx(WH_KEYBOARD_LL, proc, GetModuleHandle(curModule.ModuleName), 0);
                    }
                }
            }
        }

        public void UnHook()
        {
            UnhookWindowsHookEx(hookId);
            hookId = IntPtr.Zero;
        }

        public IntPtr HookProcedure(int nCode, IntPtr wParam, IntPtr lParam)
        {
            if (nCode >= 0 && (wParam == (IntPtr)WM_KEYDOWN || wParam == (IntPtr)WM_SYSKEYDOWN))
            {
                var kb = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
                var vkCode = (int)kb.vkCode;
                OnKeyDownEvent(vkCode);
            }
            else if (nCode >= 0 && (wParam == (IntPtr)WM_KEYUP || wParam == (IntPtr)WM_SYSKEYUP))
            {
                var kb = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
                var vkCode = (int)kb.vkCode;
                OnKeyUpEvent(vkCode);
            }
            return CallNextHookEx(hookId, nCode, wParam, lParam);
        }

        public delegate void KeyEventHandler(object sender, KeyEventArg e);
        public event KeyEventHandler KeyDownEvent;
        public event KeyEventHandler KeyUpEvent;

        protected void OnKeyDownEvent(int keyCode)
        {
            KeyDownEvent?.Invoke(this, new KeyEventArg(keyCode));
        }
        protected void OnKeyUpEvent(int keyCode)
        {
            KeyUpEvent?.Invoke(this, new KeyEventArg(keyCode));
        }

    }

    public class KeyEventArg : EventArgs
    {
        public int KeyCode { get; }

        public KeyEventArg(int keyCode)
        {
            KeyCode = keyCode;
        }
    }
}

```

# 説明
何かキーが押されたら，command構造体型配列pressedKeysの空いている方に，押されたキーと時刻を文字列として格納．pressedKeysがいっぱいになったら(直近2回のキー入力が格納されたら)，最後のキー入力と1つ前の入力との時刻の差分を求める．年月日が一緒でありかつ時刻の差分が0.7秒以内であったら連打とみなすようになっている．連打でありかつ2入力がともに左Ctrlキーであったら，「コマンドを検知しました．」というダイアログボックスが表示される．

ダイアログボックス表示前後で，UnHookとHookを行っているのは，表示中の連打を無視するためである．これがないと，連打しまくるもしくは長押しすると，ダイアログボックスがわらわらと現れてしまう．

# うまくいかないとき

### コマンド入力が奇数回目で終わるとき

配列長2の配列が埋まったタイミングでコマンドを確かめているため，2回目のCtrlキー入力が奇数回目で終わるとき，例えば，「Z＋Ctrl+Ctrl」と押したときは，
1. 「Z＋Ctrl」は違う
1. 配列がまっさらになる
1. 「Ctrl＋...?」

となり，コマンドが検知されない．

# 改善案

- Ctrlキー以外の入力は完全に無視して，Ctrlキーが1度入力されたタイミングで配列への格納を始めれば，どのタイミングでもCtrlキー次の入力を格納でき，コマンドの検知が可能になる．

- そもそももっと良い方法があるかもしれない

# そもそもグローバルフックの利用が良くないかも
グローバルフックは，システムに大きな負担をかけるため，特別な理由がない限り使うのは避けるべきらしい．
『グローバル フックは、デバッグ目的でのみ使用する必要があります。それ以外の場合は、それらを避ける必要があります。 グローバル フックはシステムのパフォーマンスを低下させ、同じ種類のグローバル フックを実装する他のアプリケーションとの競合を引き起こします。』

https://learn.microsoft.com/ja-jp/windows/win32/winmsg/about-hooks

実際，グローバルフックを使っているときのPCは普段よりも若干もたついている気がする．

# 参考サイト

https://lets-csharp.com/keyboard-hook/

キーフックの実装部分に関しては，このサイトのコードを拝借いたしました．初めてのアプリ制作で，C#は初めて...キーフックって何？とあたふたしていたため，とても助かりました．鳩でもわかるC#さんありがとうございます．