上一篇中,老周簡單膚淺地介紹了 XInput API 的使用,並模擬了滑鼠移動,左、右鍵單擊和滾輪。本篇,咱們用 .NET 代碼來完成相同的效果。 說起來也是倒霉,博文寫了一半,電腦忽然斷電了。不知道什麼原因,可能是 UPS 電源出故障。重新開機進來一看,博文沒有自動保存到草稿箱。我記得以前是有自動 ...
上一篇中,老周簡單膚淺地介紹了 XInput API 的使用,並模擬了滑鼠移動,左、右鍵單擊和滾輪。本篇,咱們用 .NET 代碼來完成相同的效果。
說起來也是倒霉,博文寫了一半,電腦忽然斷電了。不知道什麼原因,可能是 UPS 電源出故障。重新開機進來一看,博文沒有自動保存到草稿箱。我記得以前是有自動保存這功能的。很無奈,只好重寫了。
在 dll 導入的時,容易出問題的是 INPUT 結構體,因為這貨有 union 成員。不知各位還記不記得。
typedef struct tagINPUT { DWORD type; union { MOUSEINPUT mi; KEYBDINPUT ki; HARDWAREINPUT hi; } DUMMYUNIONNAME; } INPUT, *PINPUT, FAR* LPINPUT;
導入代碼網上一搜一大把,然而,那些代碼都是恐龍時代的,在 32 位平臺上是沒問題的,但在 64 位平臺上會無法正常用的。伙伴們可能會說,如果不自定義各種屬性,運行時不是自動處理的嗎?對的,如果應用在欄位成員上的各種特性(如 [StructLayout(LayoutKind.Sequential)])是會自動對齊位元組的。
而 INPUT 結構體特別啊,在 type 後面的三個欄位是共用記憶體的,所以,必須明確設置位元組偏移。這個結構體在 32 位系統中是 4 位元組對齊的,大小為 28;而在 64 位系統上是 8 位元組對齊的,大小是 40 位元組。type 欄位占 4 位元組,這個不變,但如果 8 位元組對齊,那麼,type 後面還要額外填充 4 個位元組,即 mi、ki 等成員的偏移是從第 9 個位元組開始的,索引是 8。如果你抄網上的代碼,offset = 4,在 64 位系統上運行,是無效的。
解決這個核心問題,dll 導入就很順利了。
public enum InputType : uint { INPUT_MOUSE = 0, INPUT_KEYBOARD = 1, INPUT_HARDWARE = 2 } [Flags] public enum MouseEventFlags : uint { MOUSEEVENTF_MOVE = 0x0001, MOUSEEVENTF_LEFTDOWN = 0x0002, MOUSEEVENTF_LEFTUP = 0x0004, MOUSEEVENTF_RIGHTDOWN = 0x0008, MOUSEEVENTF_RIGHTUP = 0x0010, MOUSEEVENTF_ABSOLUTE = 0x8000 } [Flags] public enum KeyboardEventFlags : uint { KEYEVENTF_KEYDOWN = 0x0000, KEYEVENTF_EXTENDEDKEY = 0x0001, KEYEVENTF_KEYUP = 0x0002, KEYEVENTF_UNICODE = 0x0004, KEYEVENTF_SCANCODE = 0x0008 }
這些在頭文件中本來是巨集定義的,我全定義為枚舉,用起來方便幾個檔次。
[StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { public int dx; public int dy; public uint MouseData; public MouseEventFlags Flags; public uint Time; public nuint ExtraInfo; } [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT { public ushort Vk; public ushort Scan; public KeyboardEventFlags Flags; public uint Time; public nuint ExtraInfo; }
以上兩個結構體無需特殊處理,就按常規就行。但下麵的 INPUT 結構體就要註意了。
public enum InputType : uint { INPUT_MOUSE = 0, INPUT_KEYBOARD = 1, INPUT_HARDWARE = 2 } [StructLayout(LayoutKind.Explicit)] public struct INPUT { [FieldOffset(0)] public InputType Type; [FieldOffset(8)] public MOUSEINPUT mi; [FieldOffset(8)] public KEYBDINPUT ki; }
StructLayoutAttribute 特性類在應用時,目標結構體的成員排列要設置為 Explicit。即由咱們手動指定各個成員的偏移位元組。記住,在 64 位系統中,偏移量是 8(鑒於現在很多人都用 64 位了,所以我這裡就不設置條件編譯了,如果你要相容,可以設定條件編譯,32 位的偏移量是 4,64位的是 8)。
上面那一大堆東西弄好,SendInput 函數就可以導入了。
[DllImport("user32.dll")] public static extern uint SendInput( uint Inputs, [MarshalAs(UnmanagedType.LPArray)] INPUT[] inputs, int size);
然後是 XInput 的函數,這個就按常規方式導入即可(熟悉的配方,熟悉的味道)。
[Flags] public enum GamePadButtons : ushort { XINPUT_GAMEPAD_DPAD_UP = 0x0001, XINPUT_GAMEPAD_DPAD_DOWN = 0x0002, XINPUT_GAMEPAD_DPAD_LEFT = 0x0004, XINPUT_GAMEPAD_DPAD_RIGHT = 0x0008, XINPUT_GAMEPAD_START = 0x0010, XINPUT_GAMEPAD_BACK = 0x0020, XINPUT_GAMEPAD_LEFT_THUMB = 0x0040, XINPUT_GAMEPAD_RIGHT_THUMB = 0x0080, XINPUT_GAMEPAD_LEFT_SHOULDER = 0x0100, XINPUT_GAMEPAD_RIGHT_SHOULDER = 0x0200, XINPUT_GAMEPAD_A = 0x1000, XINPUT_GAMEPAD_B = 0x2000, XINPUT_GAMEPAD_X = 0x4000, XINPUT_GAMEPAD_Y = 0x8000 } [StructLayout(LayoutKind.Sequential)] public struct XINPUT_GAMEPAD { public GamePadButtons Buttons; public byte LeftTrigger; public byte RightTrigger; public short ThumbLX; public short ThumbLY; public short ThumbRX; public short ThumbRY; } [StructLayout(LayoutKind.Sequential)] public struct XINPUT_STATE { public uint PacketNumber; public XINPUT_GAMEPAD GamePad; }
導入 XInputGetState 函數。
[DllImport("Xinput1_4.dll")] public static extern uint XInputGetState( uint UserIndex, ref XINPUT_STATE State);
兩個 API 咱們封裝到一個類中。
static class WinApi { [DllImport("user32.dll")] public static extern uint SendInput( uint Inputs, [MarshalAs(UnmanagedType.LPArray)] INPUT[] inputs, int size); [DllImport("Xinput1_4.dll")] public static extern uint XInputGetState( uint UserIndex, ref XINPUT_STATE State); }
好了,API 已經導入,可以玩了。這一次老周只做了:
1、左邊的搖桿負責控制滑鼠移動;
2、A 鍵表示左鍵單擊,B 鍵表示右鍵單擊。
下麵是示例代碼:
internal class Program { // 記錄序號,如果序號改變,才表示有新的數據 static uint SerialID = default; static void Main(string[] args) { while (true) { Thread.Sleep(80); // 讀取數據 XINPUT_STATE state = default; if (WinApi.XInputGetState(0, ref state) != 0) { // 返回值不為0,表示不成功,跳過 continue; } // 比較一下序號,看是不是新的數據 if (SerialID == state.PacketNumber) { continue; // 數據是舊的,不處理 } // 保存新的序號 SerialID = state.PacketNumber; // 要發送的輸入消息列表 List<INPUT> inputList = new(); // 計算滑鼠移動量 int dx = state.GamePad.ThumbLX / 1000; int dy = -state.GamePad.ThumbLY / 1000; INPUT mouseMove = new(); mouseMove.Type = InputType.INPUT_MOUSE; // 消息類型是滑鼠 // 設置滑鼠事件標誌 mouseMove.mi.Flags = MouseEventFlags.MOUSEEVENTF_MOVE; // 設置移動量 mouseMove.mi.dx = dx; mouseMove.mi.dy = dy; inputList.Add(mouseMove); // 判斷按鍵 if ((state.GamePad.Buttons & GamePadButtons.XINPUT_GAMEPAD_A) == GamePadButtons.XINPUT_GAMEPAD_A) { // 左鍵按下消息 INPUT lbpress = new INPUT(); lbpress.Type = InputType.INPUT_MOUSE; lbpress.mi.Flags = MouseEventFlags.MOUSEEVENTF_LEFTDOWN; inputList.Add(lbpress); // 左鍵釋放 INPUT lbrelease = new INPUT(); lbrelease.Type = InputType.INPUT_MOUSE; lbrelease.mi.Flags = MouseEventFlags.MOUSEEVENTF_LEFTUP; inputList.Add(lbrelease); } if ((state.GamePad.Buttons & GamePadButtons.XINPUT_GAMEPAD_B) == GamePadButtons.XINPUT_GAMEPAD_B) { // 右鍵按下 INPUT rbpress = new(); rbpress.Type = InputType.INPUT_MOUSE; rbpress.mi.Flags = MouseEventFlags.MOUSEEVENTF_RIGHTDOWN; inputList.Add(rbpress); // 右鍵釋放 INPUT rbrelease = new INPUT(); rbrelease.Type = InputType.INPUT_MOUSE; rbrelease.mi.Flags = MouseEventFlags.MOUSEEVENTF_RIGHTUP; inputList.Add(rbrelease); } // 發送消息 WinApi.SendInput((uint)inputList.Count, inputList.ToArray(), Marshal.SizeOf<INPUT>()); } } }
原理和上一篇中所述一樣,先讀取手柄數據,然後發送滑鼠輸入消息。
===================================================================================
微軟其實有提供了新的 XInput API,即給 UWP 應用程式使用的,而實際上。.NET 應用項目是可以使用 UWP API 的。畢竟,Win 10/11 是內置了運行庫的。
接下來,咱們就用 UWP 方案,這個不需要 Dll 導入,用起來方便多了。
1、像平常一樣,創建 .NET 項目。WPF、WinForms 或 UWP App 都無所謂,但不建議控制台,有可能讀不到數據。API 文檔中說要求是可以 Focus 的視窗才能接收輸入;
2、打開系統 CMD 視窗,或任意終端都行。執行 systeminfo
這裡能看到 build 版本號,比如老周的是 Win 11,只要記住前兩位數字就行了,即 10.0.22000.0。
3、回到開發環境,打開項目文件,找到這一行。
<TargetFramework>net8.0</TargetFramework>
預設是 net-<ver>,表明這個控制台應用是跨平臺的,我們把它改為 Windows 特供的。
<TargetFramework>net8.0-Windows10.0.22000.0</TargetFramework>
保存,關閉文件。此時,你的項目可以用 UWP API 了。
註意:要模擬滑鼠動作也是要導入 Win API 的,和前文一樣,只是讀手柄的API不同罷了。
下麵的例子,老周就用一個 System.Threading.Timer 來每 100 ms 讀取一次數據,並顯示在視窗上。視窗的結構如下:
主要用到的是 Windows.Gaming.Input 命名空間下的 Gamepad 類,這個類的構造函數不是公共的,不能直接實例化,而是訪問它的靜態屬性 Gamepads。這是一個集合,如果連接了多個手柄,裡面會有多個元素。
我在視窗的 Load 事件處理中,開一個 Task 來獲取。
_ = Task.Run(async () => { while (gamePad == null) { gamePad = Gamepad.Gamepads.FirstOrDefault(); await Task.Delay(1000); } });
這裡假設只連接了一個手柄,所以總是獲取集合中的第一個元素。為什麼要這樣獲取呢?因為當應用程式初始化時,訪問 Gamepads 集合不一定能獲取到手柄(有時候會有一兩秒的延時),所以咱們要這樣來獲取。
本示例中,老周用來讀數據的 Timer 是後臺線程的。儘量不要用 System.Windows.Forms 下的 Timer,因為那個定時器用的是 UI 線程。在 UI 線程上讀數據要把獲取數據的一段代碼放在 lock 裡面,否則讀到的全是 0,或者讀到錯的值。同理,WPF 也不用 DispatcherTimer,那個定時器也是在 UI 線程上運行的。
用非 UI 線程的定時器,在讀取數據時可以不進行 lock。下麵是定時器使用過程:
1、在視窗類中定義 Timer 為私有欄位。
private Gamepad? gamePad; private System.Threading.Timer timer;
gamepad 也是私有欄位,待會兒用於引用 Gamepad 實例。
2、在視窗類的構造函數中,new 一個 Timer 實例,用 Change 方法禁用定時器。
public MyWindow() { InitializeComponent(); Load += OnLoad; FormClosing += OnClosing; timer = new System.Threading.Timer(OnTick); timer.Change(Timeout.Infinite, Timeout.Infinite); }
傳給 Timer 構造函數的是一個回調委托,這裡我綁定的是 OnTick 方法。委托類型接收一個 object 類型的參數,是用戶自定義的狀態數據,不使用的話可以忽略。這個 Timer 沒有 Start、Stop 等方法,用 Change 方法設置超時為永不超時,這樣就等於禁用定時器了。
實現 OnTick 方法,迴圈讀取手柄數據,顯示在視窗上。
private void OnTick(object? state) { if (gamePad == null) return; // 讀數 GamepadReading data = gamePad.GetCurrentReading(); BeginInvoke(() => { // 左搖桿 txtLeftX.Text = data.LeftThumbstickX.ToString("N4"); txtLeftY.Text = data.LeftThumbstickY.ToString("N4"); // 右搖桿 txtRightX.Text = data.RightThumbstickX.ToString("N4"); txtRightY.Text = data.RightThumbstickY.ToString("N4"); // 左右扳機鍵 txtLeftTrigger.Text = data.LeftTrigger.ToString("N2"); txtRightTrigger.Text = data.RightTrigger.ToString("N2"); // 檢查按鍵 ckbX.Checked = (data.Buttons & GamepadButtons.X) == GamepadButtons.X; ckbY.Checked = (data.Buttons & GamepadButtons.Y) == GamepadButtons.Y; ckbStart.Checked = (data.Buttons & GamepadButtons.Menu) == GamepadButtons.Menu; }); }
調用 GetCurrentReading 方法就可以獲取實時讀數了。返回的是 GamepadReading 結構體。註意它和 XInput API 的讀數範圍是不同的。
這個 UWP API 的讀範圍是 -1 到 1,如果搖桿在中間位置(預設位置),那麼讀數是 0。讀出來的值是 -1 到 1 的小數(含-1 和 1)。
GamepadButtons 枚舉定義的是手柄的按鍵,這個和 XInput API 差不多。
public enum GamepadButtons : uint { // 未按下任何鍵 None = 0u, // 菜單鍵,老周的手柄上是 Start 鍵 Menu = 1u, // 這個不知道是什麼 View = 2u, // A、B、X、Y 按鍵 A = 4u, B = 8u, X = 0x10u, Y = 0x20u, // 手柄上的四個方向鍵 DPadUp = 0x40u, DPadDown = 0x80u, DPadLeft = 0x100u, DPadRight = 0x200u, // 這兩個是兩個肩膀按鍵 LeftShoulder = 0x400u, RightShoulder = 0x800u, // 下麵兩個指的是搖桿上的按鍵,搖桿除了可以搖,還可以按下去。 // 其實搖桿中間是一個輕觸按鈕 LeftThumbstick = 0x1000u, RightThumbstick = 0x2000u, // 其他按鍵 }
一起來看看效果。
最後,共用點猛料給大伙伴。AOSP Android 14 原生系統,樹莓派 4 / 5 鏡像,都是最新版的。
鏈接:https://pan.baidu.com/s/1q9xnLh4n7pNBl62djxDNnQ?pwd=1981
提取碼:1981
下載後解壓出來,直接寫入記憶體卡就行,就跟安裝官方系統一樣。
把卡插到 Pi 上,第一次運行要用 HDMI 口連顯示器,如果顯示器不能觸控,順便連上鍵盤滑鼠。如果你有 DSI 接的觸控顯示屏,需要到 設置 - 系統 - Raspberry Pi 設置中打開 7 寸觸控屏選項。不一定要官方的屏幕(很貴),某寶上隨便弄的只要是 DSI 排線連接的,多數屏幕是可以用的。DSI 排線要在樹莓派關機斷電後再連接,不要熱插拔。接了觸控屏就不要再接 HDMI 口了。
由於是原生系統,時間伺服器是不能用的,要自動更新網路時間,需要用 adb 改為國內的 NTP 伺服器,方法可以百度,很多教程。
經老周測試,不管是4代還是5代,聲音、觸控、WiFi、藍牙、HDMI 音/視頻、GPIO 等功能都可正常使用。但是,自己連接到 i2c 上的 MPU6050(重力加速和陀螺儀)不能用。這個是在設置 - 系統 - Raspberry pi 設置中的感測選項中開啟的,反正老周買的模塊無法正常使用。
另外,把 GPIO 21 接低電平,可以觸發電源按鈕功能,就像手機上的電源鍵,可以長按關機/重啟、喚醒鎖屏等,有鍵盤的可以按 F5。