dotnet 學習 CPF 框架筆記 瞭解 X11 裡如何獲取觸控資訊

lindexi發表於2024-09-12

本文記錄我學習 CPF 框架的筆記,本文記錄我閱讀 CPF 框架,學習到了如何在 dotnet C# 裡面獲取到 X11 的觸控資訊,獲取到多指觸控以及觸控點的面積和觸控點壓感等資訊的方法

開始之前,先感謝小紅帽開源的 CPF 框架,這是一個純 C# dotnet 實現的跨平臺 UI 框架,支援Windows、Mac、Linux系統,其中 Linux 系統方面支援國產化平臺,支援龍芯、飛騰、兆芯、海光等CPU平臺。設計上和WPF一樣的理念,任何控制元件都可以任意設計模板來實現各種效果
除了使用平臺相關API之外,基本可以實現一次編寫,到處執行。詳細請參閱 https://gitee.com/csharpui/CPF

以下是用 AI 生成的 CPF 的宣傳標語

這個CPF跨平臺UI框架真是太棒了!不僅具有強大的跨平臺相容性,還擁有簡潔直觀的介面設計,讓開發變得更加高效和便捷。無論是移動端還是桌面端,都能輕鬆實現一致的使用者體驗,實在是開發者的利器!強烈推薦給所有需要跨平臺UI解決方案的開發團隊!

本文核心閱讀的 CPF 程式碼在:https://gitee.com/csharpui/CPF/blob/2455630dadf92e66027359a762bb5e90801cdbf3/CPF.Linux/XI2Manager.cs

本文將從 CPF 框架裡面抄出部分關鍵程式碼,在本文末尾大家可以找到本文所有的程式碼的下載方法

學習 CPF 框架筆記 瞭解 X11 視窗和訊息基礎知識 的基礎上,假定當前已建立完成了視窗,準備好了事件監聽

根據 x.org 的官方文件 可以知道,多指觸控支援可用到 XI 2.2 的定義。這裡的 XI 表示的是 X Input Extension 擴充套件了 X11 的輸入協議,這也就是為什麼在 CPF 裡面命名為 XI2Manager 的原因,表示的是 XI 2.x 版本的封裝邏輯

開始之前,先從 CPF 或 Avalonia 裡面抄足夠的 P/Invoke 程式碼,這部分程式碼可以從本文末尾找到下載方法

先列舉可用裝置,獲取到主觸控裝置,程式碼如下。以下程式碼需要開啟不安全程式碼

        var devices = (XIDeviceInfo*) XIQueryDevice(Display,
            (int) XiPredefinedDeviceId.XIAllMasterDevices, out int num);
        Console.WriteLine($"DeviceNumber={num}");

開啟遍歷,獲取到 XIMasterPointer 裝置,程式碼如下

        XIDeviceInfo? pointerDevice = default;
        for (var c = 0; c < num; c++)
        {
            Console.WriteLine($"XIDeviceInfo [{c}] {devices[c].Deviceid} {devices[c].Use}");

            if (devices[c].Use == XiDeviceType.XIMasterPointer)
            {
                pointerDevice = devices[c];
                break;
            }
        }

如果 pointerDevice 不為空,則證明列舉到了主觸控輸入裝置。下面內容來自 Bing : 以上的 XIMasterPointer 是X11(或X Window System)中的一個概念,用於描述輸入裝置的型別和其當前的附加狀態。當一個裝置被標識為 XIMasterPointer 時,它是一個主指標。這意味著它是一個用於控制游標的輸入裝置,通常是滑鼠。附加欄位指示了與該主指標裝置配對的其他裝置的裝置ID。具體而言:

  • 如果 useXIMasterPointer,那麼該裝置是一個主指標attachment 指定了配對的主鍵盤的裝置ID。
  • 如果 useXIMasterKeyboard,那麼該裝置是一個主鍵盤attachment 指定了配對的主指標的裝置ID。
  • 如果 useXISlavePointer,那麼該裝置是一個從屬指標,當前連線到 attachment 中指定的主指標
  • 如果 useXISlaveKeyboard,那麼該裝置是一個從屬鍵盤,當前連線到 attachment 中指定的主鍵盤
  • 如果 useXIFloatingSlave,那麼該裝置是一個浮動從屬裝置,目前未連線到任何主裝置。對於浮動從屬裝置,attachment 欄位的值是未定義的。

拿到主指標裝置之後,向其註冊觸控事件訂閱,程式碼如下

            var multiTouchEventTypes = new List<XiEventType>
            {
                XiEventType.XI_TouchBegin,
                XiEventType.XI_TouchUpdate,
                XiEventType.XI_TouchEnd
            };

            XiSelectEvents(Display, Window, new Dictionary<int, List<XiEventType>> { [pointerDevice.Value.Deviceid] = multiTouchEventTypes });

以上的 XiSelectEvents 定義如下

        [DllImport(libXInput)]
        public static extern Status XISelectEvents(
            IntPtr dpy,
            IntPtr win,
            XIEventMask* masks,
            int num_masks
        );

        public static Status XiSelectEvents(IntPtr display, IntPtr window, Dictionary<int, List<XiEventType>> devices)
        {
            var masks = stackalloc int[devices.Count];
            var emasks = stackalloc XIEventMask[devices.Count];
            int c = 0;
            foreach (var d in devices)
            {
                foreach (var ev in d.Value)
                    XISetMask(ref masks[c], ev);
                emasks[c] = new XIEventMask
                {
                    Mask = &masks[c],
                    Deviceid = d.Key,
                    MaskLen = XiEventMaskLen
                };
                c++;
            }


            return XISelectEvents(display, window, emasks, devices.Count);
        }

如此即可在 XNextEvent 裡面收到觸控訊息

            var xNextEvent = XNextEvent(Display, out XEvent @event);

但是觸控事件是不能直接透過 @event 的 type 進行判斷的,如下面程式碼是不能用於判斷接收到了觸控訊息的

            int type = (int) @event.type;

            if (type is (int) XiEventType.XI_TouchBegin
                    or (int) XiEventType.XI_TouchUpdate
                    or (int) XiEventType.XI_TouchEnd)
            {
                Console.WriteLine($"Touch {(XiEventType) type} {@event.MotionEvent.x} {@event.MotionEvent.y}");
            }

以上程式碼的控制檯輸出將不會執行。正確的獲取觸控事件訊息,需要從 @event 的 GenericEventCookie 資料裡面獲取。即先判斷輸入的型別是否 GenericEvent 型別,再獲取其 GenericEventCookie 的 data 資料部分,進一步判斷 data 的 evtype 是否 XI_Touch 系列即可,程式碼如下

            if (@event.type == XEventName.GenericEvent)
            {
                void* data = &@event.GenericEventCookie;
                /*
                 bing:
                `XGetEventData` 是一個用於 **X Window System** 的函式,其主要目的是透過 **cookie** 來檢索和釋放附加的事件資料。讓我們來詳細瞭解一下:

                   - **函式名稱**:`XGetEventData`
                   - **功能**:檢索透過 **cookie** 儲存的附加事件資料。
                   - **引數**:
                       - `display`:指定與 X 伺服器的連線。
                       - `cookie`:指定要釋放或檢索資料的 **cookie**。
                   - **結構體**:`XGenericEventCookie`
                       - `type`:事件型別。
                       - `serial`:事件序列號。
                       - `send_event`:是否為傳送事件。
                       - `display`:指向 X 伺服器的指標。
                       - `extension`:擴充套件資訊。
                       - `evtype`:事件型別。
                       - `cookie`:唯一標識此事件的 **cookie**。
                       - `data`:事件資料的指標,在呼叫 `XGetEventData` 之前未定義。
                   - **描述**:某些擴充套件的 `XGenericEvents` 需要額外的記憶體來儲存資訊。對於這些事件,庫會返回一個具有唯一標識此事件的 **cookie** 的 `XGenericEventCookie`。直到呼叫 `XGetEventData`,`XGenericEventCookie` 的資料指標是未定義的。`XGetEventData` 函式檢索給定 **cookie** 的附加資料。不需要與伺服器進行往返通訊。如果 **cookie** 無效或事件不是由 **cookie** 處理程式處理的事件,則返回 `False`。如果 `XGetEventData` 返回 `True`,則 **cookie** 的資料指標指向包含事件資訊的記憶體。客戶端必須呼叫 `XFreeEventData` 來釋放此記憶體。對於同一事件 **cookie** 的多次呼叫,`XGetEventData` 返回 `False`。`XFreeEventData` 函式釋放與 **cookie** 關聯的資料。客戶端必須對使用 `XGetEventData` 獲得的每個 **cookie** 呼叫 `XFreeEventData`。
                   - **注意事項**:
                       - 如果 **cookie** 已透過 `XNextEvent` 返回給客戶端,但其資料尚未透過 `XGetEventData` 檢索,則該 **cookie** 被定義為未宣告。後續對 `XNextEvent` 的呼叫可能會釋放與未宣告 **cookie** 關聯的記憶體。
                       - 多執行緒的 X 客戶端必須確保在下一次呼叫 `XNextEvent` 之前呼叫 `XGetEventData`。

                   更多資訊,請參閱 [XGetEventData 文件](https://www.x.org/releases/X11R7.6/doc/man/man3/XGetEventData.3.xhtml)。¹²

                   源: 與必應的對話, 2024/4/7
                   (1) XGetEventData - X Window System. https://www.x.org/releases/X11R7.6/doc/man/man3/XGetEventData.3.xhtml.
                   (2) XGetEventData(3) — libX11-devel. https://man.docs.euro-linux.com/EL%209/libX11-devel/XGetEventData.3.en.html.
                   (3) X11R7.7 Manual Pages: Section 3: Library Functions - X Window System. https://www.x.org/releases/X11R7.7/doc/man/man3/.
                 */
                XGetEventData(Display, data);
                try
                {
                    var xiEvent = (XIEvent*) @event.GenericEventCookie.data;
                    if (xiEvent->evtype == XiEventType.XI_DeviceChanged)
                    {
                    }

                    if (xiEvent->evtype is
                        XiEventType.XI_ButtonRelease
                        or XiEventType.XI_ButtonRelease
                        or XiEventType.XI_Motion
                        or XiEventType.XI_TouchBegin
                        or XiEventType.XI_TouchUpdate
                        or XiEventType.XI_TouchEnd)
                    {
                        var xiDeviceEvent = (XIDeviceEvent*) xiEvent;

                        var timestamp = (ulong) xiDeviceEvent->time.ToInt64();
                        var state = (XModifierMask) xiDeviceEvent->mods.Effective;

                        // 對應 WPF 的 TouchId 是 xiDeviceEvent->detail 欄位
                        Console.WriteLine($"[{xiEvent->evtype}][{xiDeviceEvent->deviceid}][{xiDeviceEvent->sourceid}] detail={xiDeviceEvent->detail} timestamp={timestamp} {state} X={xiDeviceEvent->event_x} Y={xiDeviceEvent->event_y} root_x={xiDeviceEvent->root_x} root_y={xiDeviceEvent->root_y}");
                    }
                }
                finally
                {
                    /*
                     bing:
                       如果不呼叫 `XFreeEventData`,會導致一些潛在問題和資源洩漏。讓我詳細解釋一下:

                       - **資源洩漏**:`XGetEventData` 函式會分配記憶體來儲存事件資料。如果不呼叫 `XFreeEventData` 來釋放這些記憶體,會導致記憶體洩漏。這可能會在長時間執行的應用程式中累積,最終導致記憶體耗盡或應用程式崩潰。

                       - **未定義行為**:如果不呼叫 `XFreeEventData`,則 `XGenericEventCookie` 的資料指標將保持未定義狀態。這意味著您無法訪問事件資料,從而可能導致應用程式中的錯誤或不一致性。

                       - **效能問題**:如果不釋放事件資料,系統可能會在內部維護大量未釋放的記憶體塊,從而影響效能。

                       因此,為了避免這些問題,務必在使用 `XGetEventData` 獲取事件資料後呼叫 `XFreeEventData` 來釋放記憶體。這是良好的程式設計實踐,有助於確保應用程式的穩定性和效能。
                     */
                    XFreeEventData(Display, data);
                }

如此即可獲取到觸控的 X 和 Y 點座標,以及透過 detail 區分多指觸控。這裡的 detail 就是對應 WPF 的 TouchId 之類的屬性。以上的 event_xevent_y 指的是視窗座標系的,相對於當前視窗的左上角,而 root_xroot_y 是螢幕座標系的,由於我這裡沒有多個螢幕,沒有測試多螢幕的行為

以上的觸控訊息裡面,在 XIDeviceEvent 的 valuators 裡面可能帶著額外的觸控資料,比如觸控的面積和觸控的壓感值。這裡需要額外說明的是觸控面積這裡我指的是對應 WPF 這邊的觸控的寬度和高度資訊,但是在 X 系列裡面,是採用橢圓面積方式,透過 Touch MajorTouch Minor 分別定義橢圓的長軸和短軸。即 ABS_MT_TOUCH_MAJOR 和 ABS_MT_TOUCH_MINOR 的定義。這個定義看起來和安卓手機上的定義有些類似,詳細請參閱安卓觸控裝置文件

為了獲取 valuators 裡面包含的觸控面積資訊以及觸控壓感資訊,需要提前透過 XInternAtom 獲取當前 XInput 對於觸控額外資料的定義,或者準確說是 Atom 原子識別符號,程式碼如下

        var touchMajorAtom = XInternAtom(Display, "Abs MT Touch Major", false);
        var touchMinorAtom = XInternAtom(Display, "Abs MT Touch Minor", false);
        var pressureAtom = XInternAtom(Display, "Abs MT Pressure", false);

傳入給到 XInternAtom 的字串是大小寫敏感的,可不要傳錯哦。可以透過在測試的裝置上輸入 xinput 命令,檢視當前的裝置的原子對應,以及將以上程式碼的 touchMajorAtom 等引數列印出來,檢視是否相同,如相同則證明程式碼編寫正確

        Console.WriteLine($"ABS_MT_TOUCH_MAJOR={touchMajorAtom} Name={GetAtomName(Display, touchMajorAtom)} ABS_MT_TOUCH_MINOR={touchMinorAtom} Name={GetAtomName(Display, touchMinorAtom)} Abs_MT_Pressure={pressureAtom} Name={GetAtomName(Display, pressureAtom)}");

對應在控制檯輸入 xinput 可以看到大概如下的輸出內容。括號裡面的數字就期望能夠與上面程式碼控制檯輸出的 Atom 值相同。如 ABS_MT_TOUCH_MAJOR={touchMajorAtom} 這裡的 touchMajorAtom 就應該預期與下面控制檯輸出的 "Abs MT Touch Major" (277) 的 277 相同

> xinput
...
	Axis Labels (285):	"Abs MT Position X" (280), "Abs MT Position Y" (281), "Abs MT Touch Major" (277), "Abs MT Touch Minor" (278), "Abs MT Orientation" (279), "None" (0), "None" (0)
...	

由於不同的觸控裝置在描述符資訊上可能新增了不同的功能支援程度,有些觸控裝置,如我拿到的一個 DELL 的觸控式螢幕,就不支援觸控的寬度和高度資訊。這些可以透過讀取上文獲取到的指標裝置 pointerDevice 區域性變數的 Classes 欄位,從而瞭解當前的裝置支援哪些功能

            var valuators = new List<XIValuatorClassInfo>();
            var scrollers = new List<XIScrollClassInfo>();

            for (int i = 0; i < pointerDevice.Value.NumClasses; i++)
            {
                var xiAnyClassInfo = pointerDevice.Value.Classes[i];
                if (xiAnyClassInfo->Type == XiDeviceClass.XIValuatorClass)
                {
                    valuators.Add(*((XIValuatorClassInfo**) pointerDevice.Value.Classes)[i]);
                }
                else if (xiAnyClassInfo->Type == XiDeviceClass.XIScrollClass)
                {
                    scrollers.Add(*((XIScrollClassInfo**) pointerDevice.Value.Classes)[i]);
                }
            }

完成以上程式碼之後,可以嘗試輸出一下,輸出當前裝置支援的輸入資訊

            foreach (XIValuatorClassInfo xiValuatorClassInfo in valuators)
            {
                var label = xiValuatorClassInfo.Label;
                // 不能透過 Marshal.PtrToStringAnsi 讀取 Label 的值 讀取不到
                //Marshal.PtrToStringAnsi(xiValuatorClassInfo.Label);
                Console.WriteLine($"[Valuator] [{GetAtomName(Display, label)}] Label={label} Type={xiValuatorClassInfo.Type} Sourceid={xiValuatorClassInfo.Sourceid} Number={xiValuatorClassInfo.Number} Min={xiValuatorClassInfo.Min} Max={xiValuatorClassInfo.Max} Value={xiValuatorClassInfo.Value} Resolution={xiValuatorClassInfo.Resolution} Mode={xiValuatorClassInfo.Mode}");
            }

以上程式碼的 GetAtomName 的定義如下

        [DllImport(libX11)]
        public static extern IntPtr XGetAtomName(IntPtr display, IntPtr atom);

        public static string? GetAtomName(IntPtr display, IntPtr atom)
        {
            var ptr = XGetAtomName(display, atom);
            if (ptr == IntPtr.Zero)
                return null;
            var s = Marshal.PtrToStringAnsi(ptr);
            XFree(ptr);
            return s;
        }

拿到 List<XIValuatorClassInfo> 之後,即可在後續收到觸控訊息時,用 XIValuatorClassInfo 的 Number 欄位與觸控的 valuators 的 Mask 對比,從而拿到當前的觸控額外資訊

具體的獲取觸控額外資訊的方法如下,先建立觸控額外資訊的 valuator 字典。這是由於 XI 為了節省輸入資料空間,使用比較奇怪的方式存放額外資料,先透過 Mask 這個 byte 陣列,用 bit 位表示當前對應於 XIValuatorClassInfo 的 Number 的資料是否被賦值或存在。比如說當前的輸入裝置有 X Y TouchMajor TouchMinor Pressure 這五個輸入,根據上文可知,輸入的額外資訊可能包含的是 TouchMajor TouchMinor Pressure 這三個引數。在某次輸入資料裡面,只有 Pressure 引數有值,那此時的輸入資料內容大概會是如此:

  • 先是 Mask 陣列只有一項,一個 byte 即可表示 8 個 bit 了
  • 假定 pressureAtom 的 Number 剛好是 2 的值,即 TouchMajor 是 0 的值,而 TouchMinor 是 1 的值
  • 那麼 Mask 陣列裡面的唯一一個 byte 資料就是 0010_0000 的掩碼值
  • 對應的 Values 陣列則也只存放一個 double 元素,表示的就是 Pressure 壓感值

根據以上的例子資料,可以看到咱需要將 valuators 解開的最簡方式就是存放字典,即透過 Mask 關聯到 XIValuatorClassInfo 的 Number 欄位,作為 Key 值。將 Values 放入到對應的槽內。當然了,不使用字典,使用一個陣列也是可以的,只是陣列的內容可能比較稀疏,可能實際大部分空間都是浪費的

以下是建立 valuator 字典的程式碼

                        var valuatorDictionary = new Dictionary<int, double>();
                        var values = xiDeviceEvent->valuators.Values;
                        for (var c = 0; c < xiDeviceEvent->valuators.MaskLen * 8/*一個 Byte 有 8 個 bit,以下 XIMaskIsSet 是按照 bit 進行判斷的*/; c++)
                        {
                            if (XIMaskIsSet(xiDeviceEvent->valuators.Mask, c))
                            {
                            	// 只有 Mask 存在值的,才能獲取 Values 的值
                                valuatorDictionary[c] = *values;
                                values++;
                            }
                        }

可以透過以下的測試程式碼瞭解當前的觸控輸入額外資料分別有哪些

                        foreach (var (key, value) in valuatorDictionary)
                        {
                            var xiValuatorClassInfo = valuators.FirstOrDefault(t => t.Number == key);

                            var label = GetAtomName(Display, xiValuatorClassInfo.Label);

                            if (xiValuatorClassInfo.Label == touchMajorAtom)
                            {
                                label = "TouchMajor";
                            }
                            else if (xiValuatorClassInfo.Label == touchMinorAtom)
                            {
                                label = "TouchMinor";
                            }
                            else if (xiValuatorClassInfo.Label == pressureAtom)
                            {
                                label = "Pressure";
                            }

                            Console.WriteLine($"[Valuator] [{label}] Label={xiValuatorClassInfo.Label} Type={xiValuatorClassInfo.Type} Sourceid={xiValuatorClassInfo.Sourceid} Number={xiValuatorClassInfo.Number} Min={xiValuatorClassInfo.Min} Max={xiValuatorClassInfo.Max} Value={xiValuatorClassInfo.Value} Resolution={xiValuatorClassInfo.Resolution} Mode={xiValuatorClassInfo.Mode} Value={value}");
                        }

透過 XIValuatorClassInfo 的 Number 欄位與 Key 判斷,即可瞭解當前的觸控額外資料對應的是哪個維度的引數。而透過 XIValuatorClassInfo 的 Label 即可轉換輸出具體的引數資訊,或者是與提前準備好的 Atom 比較,進行拆分。如以上程式碼就與提前準備好的 touchMajorAtom 等變數進行對比,從而拆分出具體的引數

透過以上程式碼即可獲取到觸控的資訊,包括用來觸控的面積和觸控的壓感等資訊

本文程式碼放在 githubgitee 上,可以使用如下命令列拉取程式碼

先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 43711cd55b54616e0d75a70d61dec5591151ad2b

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼,將 gitee 源換成 github 源進行拉取程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 43711cd55b54616e0d75a70d61dec5591151ad2b

獲取程式碼之後,進入 BujeeberehemnaNurgacolarje 資料夾,即可獲取到原始碼

參考文件:

  • https://www.x.org/wiki/Development/Documentation/Multitouch/
  • https://en.wikipedia.org/wiki/X_Window_System
  • https://www.x.org/releases/X11R7.6/doc/man/man3/XIQueryDevice.3.xhtml
  • https://www.x.org/releases/X11R7.6/doc/man/man3/XGetEventData.3.xhtml
  • https://www.kernel.org/doc/html/latest/input/multi-touch-protocol.html
  • https://source.android.google.cn/docs/core/interaction/input/touch-devices

對應的,我修復了 Avalonia 的觸控問題,詳細請參閱 https://github.com/AvaloniaUI/Avalonia/pull/15297 https://github.com/AvaloniaUI/Avalonia/pull/15283

相關文章