dotnet 讀 WPF 原始碼筆記 從 WM_POINTER 訊息到 Touch 事件

lindexi發表於2024-09-09

本文記錄我讀 WPF 原始碼的筆記,本文將介紹在 WPF 底層是如何從 Win32 的訊息迴圈裡獲取到的 WM_POINTER 訊息處理轉換作為 Touch 事件的引數

由於 WPF 觸控部分會兼顧開啟 Pointer 訊息和不開啟 Pointer 訊息,在 WPF 框架裡面的邏輯會有部分是相容邏輯,為了方便大家理解,本文分為兩個部分。第一個部分是脫離 WPF 框架,聊聊一個 Win32 程式如何從 Win32 的訊息迴圈獲取到的 WM_POINTER 訊息處理轉換為輸入座標點,以及在觸控下獲取觸控資訊。第二部分是 WPF 框架是如何安排上這些處理邏輯,如何和 WPF 框架的進行對接

第一部分脫離了 WPF 框架,也就沒有了相容不開啟 Pointer 訊息的負擔,我將使用簡單的描述點出關鍵部分

處理 Pointer 訊息

在 Win32 應用程式中,大概有三個方式來進行對 Pointer 訊息進行處理。我將從簡單到複雜和大家講述這三個方式

方式1:

接收到 WM_POINTER 訊息之後,將 wparam 轉換為 pointerId 引數,呼叫 GetPointerTouchInfo 方法即可獲取到 POINTER_INFO 資訊

獲取 POINTER_INFOptPixelLocationRaw 欄位,即可拿到基於螢幕座標系的畫素點

只需將其轉換為視窗座標系和處理 DPI 即可使用

此方法的最大缺點在於 ptPixelLocationRaw 欄位拿到的是丟失精度的點,畫素為單位。如果在精度稍微高的觸控式螢幕下,將會有明顯的鋸齒效果

優點在於其獲取特別簡單

方式2:

依然是接收到 WM_POINTER 訊息之後,將 wparam 轉換為 pointerId 引數,呼叫 GetPointerTouchInfo 方法即可獲取到 POINTER_INFO 資訊

只是從獲取 POINTER_INFOptPixelLocationRaw 欄位換成 ptHimetricLocationRaw 欄位

使用 ptHimetricLocationRaw 欄位的優勢在於可以獲取不丟失精度的資訊,但需要額外呼叫 GetPointerDeviceRects 函式獲取 displayRectpointerDeviceRect 資訊用於轉換座標點,轉換邏輯如以下程式碼所示

            PInvoke.GetPointerDeviceRects(pointerInfo.sourceDevice, &pointerDeviceRect, &displayRect);

            // 如果想要獲取比較高精度的觸控點,可以使用 ptHimetricLocationRaw 欄位
            // 由於 ptHimetricLocationRaw 採用的是 pointerDeviceRect 座標系,需要轉換到螢幕座標系
            // 轉換方法就是先將 ptHimetricLocationRaw 的 X 座標,壓縮到 [0-1] 範圍內,然後乘以 displayRect 的寬度,再加上 displayRect 的 left 值,即得到了螢幕座標系的 X 座標。壓縮到 [0-1] 範圍內的方法就是除以 pointerDeviceRect 的寬度
            // 為什麼需要加上 displayRect.left 的值?考慮多屏的情況,螢幕可能是副屏
            // Y 座標同理
            var point2D = new Point2D(
                pointerInfo.ptHimetricLocationRaw.X / (double) pointerDeviceRect.Width * displayRect.Width +
                displayRect.left,
                pointerInfo.ptHimetricLocationRaw.Y / (double) pointerDeviceRect.Height * displayRect.Height +
                displayRect.top);

            // 獲取到的螢幕座標系的點,需要轉換到 WPF 座標系
            // 轉換過程的兩個重點:
            // 1. 底層 ClientToScreen 只支援整數型別,直接轉換會丟失精度。即使是 WPF 封裝的 PointFromScreen 或 PointToScreen 方法也會丟失精度
            // 2. 需要進行 DPI 換算,必須要求 DPI 感知

            // 先測量視窗與螢幕的偏移量,這裡直接取 0 0 點即可,因為這裡獲取到的是虛擬螢幕座標系,不需要考慮多屏的情況
            var screenTranslate = new Point(0, 0);
            PInvoke.ClientToScreen(new HWND(hwnd), ref screenTranslate);
            // 獲取當前的 DPI 值
            var dpi = VisualTreeHelper.GetDpi(this);
            // 先做平移,再做 DPI 換算
            point2D = new Point2D(point2D.X - screenTranslate.X, point2D.Y - screenTranslate.Y);
            point2D = new Point2D(point2D.X / dpi.DpiScaleX, point2D.Y / dpi.DpiScaleY);

以上方式2的程式碼放在 githubgitee 上,可以使用如下命令列拉取程式碼。我整個程式碼倉庫比較龐大,使用以下命令列可以進行部分拉取,拉取速度比較快

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

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 322313ee55d0eeaae7148b24ca279e1df087871e

以上使用的是國內的 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼,將 gitee 源換成 github 源進行拉取程式碼。如果依然拉取不到程式碼,可以發郵件向我要程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 322313ee55d0eeaae7148b24ca279e1df087871e

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

方式2的優點在於可以獲取到更高的精度。缺點是相對來說比較複雜,需要多了點點處理

方式3:

此方式會更加複雜,但功能能夠更加全面,適合用在要求更高控制的應用裡面

先呼叫 GetPointerDeviceProperties 方法,獲取 HID 描述符上報的對應裝置屬性,此時可以獲取到的是具備完全的 HID 描述符屬性的方法,可以包括 Windows 的 Pen 協議 裡面列舉的各個屬性,如寬度高度旋轉角等資訊

收到 WM_POINTER 訊息時,呼叫 GetRawPointerDeviceData 獲取最原始的觸控資訊,再對原始觸控資訊進行解析處理

原始觸控資訊的解析處理需要先應用獲取每個觸控點的資料包長度,再拆資料包。原始觸控資訊拿到的是一個二進位制陣列,這個二進位制陣列裡面可能包含多個觸控點的資訊,需要根據資料包長度拆分為多個觸控點資訊

解析處理就是除了前面兩個分別是屬於 X 和 Y 之外,後面的資料就根據 GetPointerDeviceProperties 方法獲取到的觸控描述資訊進行套入

此方式的複雜程度比較高,且拿到的是原始的觸控資訊,需要做比較多的處理。即使解析到 X 和 Y 座標點之後,還需要執行座標的轉換,將其轉換為螢幕座標系

這裡拿到的 X 和 Y 座標點是裝置座標系,這裡的裝置座標系不是 GetPointerDeviceRects 函式獲取 的 pointerDeviceRect 裝置範圍座標系,而是對應 GetPointerDeviceProperties 方法獲取到的描述符的邏輯最大值和最小值的座標範圍

其正確計算方法為從 GetPointerDeviceProperties 方法獲取到的 X 和 Y 描述資訊,分別取 POINTER_DEVICE_PROPERTYlogicalMax 作為最大值範圍。分別將 X 和 Y 除以 logicalMax 縮放到 [0,1] 範圍內,再乘以螢幕尺寸即可轉換為螢幕座標系

這裡的 螢幕尺寸 是透過 GetPointerDeviceRects 函式獲取 的 displayRect 尺寸

轉換為螢幕座標系之後,就需要再次處理 DPI 和轉換為視窗座標系的才能使用

可以看到方式3相對來說還是比較複雜的,但其優點是可以獲取到更多的裝置描述資訊,獲取到輸入點的更多資訊,如可以計算出觸控寬度對應的物理觸控尺寸面積等資訊

對於 WPF 框架來說,自然是選最複雜且功能全強的方法了

在 WPF 框架的對接

瞭解了一個 Win32 應用與 WM_POINTER 訊息的對接方式,咱來看看 WPF 具體是如何做的。瞭解了對接方式之後,閱讀 WPF 原始碼的方式可以是透過必須呼叫的方法的引用,找到整個 WPF 的脈絡

在開始之前必須說明的是,本文的大部分程式碼都是有刪減的程式碼,只保留和本文相關的部分。現在 WPF 是完全開源的,基於最友好的 MIT 協議,可以自己拉下來程式碼進行二次修改釋出,想看完全的程式碼和除錯整個過程可以自己從開源地址拉取整個倉庫下來,開源地址是: https://github.com/dotnet/wpf

本文以下部分僅為開啟 WM_POINTER 訊息時的 WPF 框架對接的邏輯,如對不開啟 WM_POINTER 支援時的邏輯感興趣,還請參閱 WPF 觸控到事件

在 WPF 裡面,觸控初始化的故事開始是在 PointerTabletDeviceCollection.cs 裡面,呼叫 GetPointerDevices 方法進行初始化獲取裝置數量,之後的每個裝置都呼叫 GetPointerDeviceProperties 方法,獲取 HID 描述符上報的對應裝置屬性,有刪減的程式碼如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Maintains a collection of pointer device information for currently installed pointer devices
    /// </summary>
    internal class PointerTabletDeviceCollection : TabletDeviceCollection
    {
        internal void Refresh()
        {
            ... // 忽略其他程式碼
                    UnsafeNativeMethods.POINTER_DEVICE_INFO[] deviceInfos
                         = new UnsafeNativeMethods.POINTER_DEVICE_INFO[deviceCount];

                    IsValid = UnsafeNativeMethods.GetPointerDevices(ref deviceCount, deviceInfos);
            ... // 忽略其他程式碼
        }
    }
}

獲取到裝置之後,將其轉換放入到 WPF 定義的 PointerTabletDevice 裡面,大概的程式碼如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Maintains a collection of pointer device information for currently installed pointer devices
    /// </summary>
    internal class PointerTabletDeviceCollection : TabletDeviceCollection
    {
        internal void Refresh()
        {
            ... // 忽略其他程式碼
                    UnsafeNativeMethods.POINTER_DEVICE_INFO[] deviceInfos
                         = new UnsafeNativeMethods.POINTER_DEVICE_INFO[deviceCount];

                    IsValid = UnsafeNativeMethods.GetPointerDevices(ref deviceCount, deviceInfos);

                    if (IsValid)
                    {
                        foreach (var deviceInfo in deviceInfos)
                        {
                            // Old PenIMC code gets this id via a straight cast from COM pointer address
                            // into an int32.  This does a very similar thing semantically using the pointer
                            // to the tablet from the WM_POINTER stack.  While it may have similar issues
                            // (chopping the upper bits, duplicate ids) we don't use this id internally
                            // and have never received complaints about this in the WISP stack.
                            int id = MS.Win32.NativeMethods.IntPtrToInt32(deviceInfo.device);

                            PointerTabletDeviceInfo ptdi = new PointerTabletDeviceInfo(id, deviceInfo);

                            // Don't add a device that fails initialization.  This means we will try a refresh
                            // next time around if we receive stylus input and the device is not available.
                            // <see cref="HwndPointerInputProvider.UpdateCurrentTabletAndStylus">
                            if (ptdi.TryInitialize())
                            {
                                PointerTabletDevice tablet = new PointerTabletDevice(ptdi);

                                _tabletDeviceMap[tablet.Device] = tablet;
                                TabletDevices.Add(tablet.TabletDevice);
                            }
                        }
                    }
            ... // 忽略其他程式碼
        }

        /// <summary>
        /// Holds a mapping of TabletDevices from their WM_POINTER device id
        /// </summary>
        private Dictionary<IntPtr, PointerTabletDevice> _tabletDeviceMap = new Dictionary<IntPtr, PointerTabletDevice>();
    }
}

namespace System.Windows.Input
{
    /// <summary>
    ///     Collection of the tablet devices that are available on the machine.
    /// </summary>
    public class TabletDeviceCollection : ICollection, IEnumerable
    {
        internal List<TabletDevice> TabletDevices { get; set; } = new List<TabletDevice>();
    }
}

在 PointerTabletDeviceInfo 的 TryInitialize 方法,即 if (ptdi.TryInitialize()) 這行程式碼裡面,將會呼叫 GetPointerDeviceProperties 獲取裝置屬性資訊,其程式碼邏輯如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// WM_POINTER specific information about a TabletDevice
    /// </summary>
    internal class PointerTabletDeviceInfo : TabletDeviceInfo
    {
        internal PointerTabletDeviceInfo(int id, UnsafeNativeMethods.POINTER_DEVICE_INFO deviceInfo)
        {
            _deviceInfo = deviceInfo;

            Id = id;
            Name = _deviceInfo.productString;
            PlugAndPlayId = _deviceInfo.productString;
        }

        internal bool TryInitialize()
        {
            ... // 忽略其他程式碼

            var success = TryInitializeSupportedStylusPointProperties();

            ... // 忽略其他程式碼

            return success;
        }

        private bool TryInitializeSupportedStylusPointProperties()
        {
            bool success = false;

            ... // 忽略其他程式碼

            // Retrieve all properties from the WM_POINTER stack
            success = UnsafeNativeMethods.GetPointerDeviceProperties(Device, ref propCount, null);

            if (success)
            {
                success = UnsafeNativeMethods.GetPointerDeviceProperties(Device, ref propCount, SupportedPointerProperties);

                if (success)
                {
                    ... // 執行更具體的初始化邏輯
                }
            }

            ... // 忽略其他程式碼
        }

        /// <summary>
        /// The specific id for this TabletDevice
        /// </summary>
        internal IntPtr Device { get { return _deviceInfo.device; } }

        /// <summary>
        /// Store the WM_POINTER device information directly
        /// </summary>
        private UnsafeNativeMethods.POINTER_DEVICE_INFO _deviceInfo;
    }
}

為什麼這裡會呼叫 GetPointerDeviceProperties 兩次?第一次只是拿數量,第二次才是真正的拿值

回顧以上程式碼,可以看到 PointerTabletDeviceInfo 物件是在 PointerTabletDeviceCollection 的 Refresh 方法裡面建立的,如以下程式碼所示

    internal class PointerTabletDeviceCollection : TabletDeviceCollection
    {
        internal void Refresh()
        {
            ... // 忽略其他程式碼
                    UnsafeNativeMethods.POINTER_DEVICE_INFO[] deviceInfos
                         = new UnsafeNativeMethods.POINTER_DEVICE_INFO[deviceCount];

                    IsValid = UnsafeNativeMethods.GetPointerDevices(ref deviceCount, deviceInfos);
                        foreach (var deviceInfo in deviceInfos)
                        {
                            // Old PenIMC code gets this id via a straight cast from COM pointer address
                            // into an int32.  This does a very similar thing semantically using the pointer
                            // to the tablet from the WM_POINTER stack.  While it may have similar issues
                            // (chopping the upper bits, duplicate ids) we don't use this id internally
                            // and have never received complaints about this in the WISP stack.
                            int id = MS.Win32.NativeMethods.IntPtrToInt32(deviceInfo.device);

                            PointerTabletDeviceInfo ptdi = new PointerTabletDeviceInfo(id, deviceInfo);

                            if (ptdi.TryInitialize())
                            {
                                
                            }
                        }
            ... // 忽略其他程式碼
        }
    }

從 GetPointerDevices 獲取到的 POINTER_DEVICE_INFO 資訊會存放在 PointerTabletDeviceInfo_deviceInfo 欄位裡面,如下面程式碼所示

    internal class PointerTabletDeviceInfo : TabletDeviceInfo
    {
        internal PointerTabletDeviceInfo(int id, UnsafeNativeMethods.POINTER_DEVICE_INFO deviceInfo)
        {
            _deviceInfo = deviceInfo;

            Id = id;
        }

        /// <summary>
        /// The specific id for this TabletDevice
        /// </summary>
        internal IntPtr Device { get { return _deviceInfo.device; } }

        /// <summary>
        /// Store the WM_POINTER device information directly
        /// </summary>
        private UnsafeNativeMethods.POINTER_DEVICE_INFO _deviceInfo;
    }

呼叫 GetPointerDeviceProperties 時,就會將 POINTER_DEVICE_INFOdevice 欄位作為引數傳入,從而獲取到 POINTER_DEVICE_PROPERTY 結構體列表資訊

獲取到的 POINTER_DEVICE_PROPERTY 結構體資訊和 HID 描述符上報的資訊非常對應。結構體的定義程式碼大概如下

        /// <summary>
        /// A struct representing the information for a particular pointer property.
        /// These correspond to the raw data from WM_POINTER.
        /// </summary>
        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
        internal struct POINTER_DEVICE_PROPERTY
        {
            internal Int32 logicalMin;
            internal Int32 logicalMax;
            internal Int32 physicalMin;
            internal Int32 physicalMax;
            internal UInt32 unit;
            internal UInt32 unitExponent;
            internal UInt16 usagePageId;
            internal UInt16 usageId;
        }

根據 HID 基礎知識可以知道,透過 usagePageIdusageId 即可瞭解到此裝置屬性的具體含義。更多請參閱 HID 標準文件: http://www.usb.org/developers/hidpage/Hut1_12v2.pdf

在 WPF 使用到的 Pointer 的 usagePageId 的只有以下列舉所列舉的值

        /// <summary>
        ///
        /// WM_POINTER stack must parse out HID spec usage pages
        /// <see cref="http://www.usb.org/developers/hidpage/Hut1_12v2.pdf"/> 
        /// </summary>
        internal enum HidUsagePage
        {
            Undefined = 0x00,
            Generic = 0x01,
            Simulation = 0x02,
            Vr = 0x03,
            Sport = 0x04,
            Game = 0x05,
            Keyboard = 0x07,
            Led = 0x08,
            Button = 0x09,
            Ordinal = 0x0a,
            Telephony = 0x0b,
            Consumer = 0x0c,
            Digitizer = 0x0d,
            Unicode = 0x10,
            Alphanumeric = 0x14,
            BarcodeScanner = 0x8C,
            WeighingDevice = 0x8D,
            MagneticStripeReader = 0x8E,
            CameraControl = 0x90,
            MicrosoftBluetoothHandsfree = 0xfff3,
        }

在 WPF 使用到的 Pointer 的 usageId 的只有以下列舉所列舉的值

       /// <summary>
       ///
       /// 
       /// WISP pre-parsed these, WM_POINTER stack must do it itself
       /// 
       /// See Stylus\biblio.txt - 1
       /// <see cref="http://www.usb.org/developers/hidpage/Hut1_12v2.pdf"/> 
       /// </summary>
       internal enum HidUsage
       {
           TipPressure = 0x30,
           X = 0x30,
           BarrelPressure = 0x31,
           Y = 0x31,
           Z = 0x32,
           XTilt = 0x3D,
           YTilt = 0x3E,
           Azimuth = 0x3F,
           Altitude = 0x40,
           Twist = 0x41,
           TipSwitch = 0x42,
           SecondaryTipSwitch = 0x43,
           BarrelSwitch = 0x44,
           TouchConfidence = 0x47,
           Width = 0x48,
           Height = 0x49,
           TransducerSerialNumber = 0x5B,
       }

在 WPF 的古老版本里面,約定了使用 GUID 去獲取 StylusPointDescription 裡面的額外資料資訊。為了與此行為相容,在 WPF 裡面就定義了 HidUsagePage 和 HidUsage 與 GUID 的對應關係,實現程式碼如下

namespace System.Windows.Input
{
    /// <summary>
    /// StylusPointPropertyIds
    /// </summary>
    /// <ExternalAPI/>
    internal static class StylusPointPropertyIds
    {
        /// <summary>
        /// The x-coordinate in the tablet coordinate space.
        /// </summary>
        /// <ExternalAPI/>
        public static readonly Guid X = new Guid(0x598A6A8F, 0x52C0, 0x4BA0, 0x93, 0xAF, 0xAF, 0x35, 0x74, 0x11, 0xA5, 0x61);
        /// <summary>
        /// The y-coordinate in the tablet coordinate space.
        /// </summary>
        /// <ExternalAPI/>
        public static readonly Guid Y = new Guid(0xB53F9F75, 0x04E0, 0x4498, 0xA7, 0xEE, 0xC3, 0x0D, 0xBB, 0x5A, 0x90, 0x11);

        public static readonly Guid Z = ...

        ...

        /// <summary>
        ///
        /// WM_POINTER stack usage preparation based on associations maintained from the legacy WISP based stack
        /// </summary>
        private static Dictionary<HidUsagePage, Dictionary<HidUsage, Guid>> _hidToGuidMap = new Dictionary<HidUsagePage, Dictionary<HidUsage, Guid>>()
        {
            { HidUsagePage.Generic,
                new Dictionary<HidUsage, Guid>()
                {
                    { HidUsage.X, X },
                    { HidUsage.Y, Y },
                    { HidUsage.Z, Z },
                }
            },
            { HidUsagePage.Digitizer,
                new Dictionary<HidUsage, Guid>()
                {
                    { HidUsage.Width, Width },
                    { HidUsage.Height, Height },
                    { HidUsage.TouchConfidence, SystemTouch },
                    { HidUsage.TipPressure, NormalPressure },
                    { HidUsage.BarrelPressure, ButtonPressure },
                    { HidUsage.XTilt, XTiltOrientation },
                    { HidUsage.YTilt, YTiltOrientation },
                    { HidUsage.Azimuth, AzimuthOrientation },
                    { HidUsage.Altitude, AltitudeOrientation },
                    { HidUsage.Twist, TwistOrientation },
                    { HidUsage.TipSwitch, TipButton },
                    { HidUsage.SecondaryTipSwitch, SecondaryTipButton },
                    { HidUsage.BarrelSwitch, BarrelButton },
                    { HidUsage.TransducerSerialNumber, SerialNumber },
                }
            },
        };

        /// <summary>
        /// Retrieves the GUID of the stylus property associated with the usage page and usage ids
        /// within the HID specification.
        /// </summary>
        /// <param name="page">The usage page id of the HID specification</param>
        /// <param name="usage">The usage id of the HID specification</param>
        /// <returns>
        /// If known, the GUID associated with the usagePageId and usageId.
        /// If not known, GUID.Empty
        /// </returns>
        internal static Guid GetKnownGuid(HidUsagePage page, HidUsage usage)
        {
            Guid result = Guid.Empty;

            Dictionary<HidUsage, Guid> pageMap = null;

            if (_hidToGuidMap.TryGetValue(page, out pageMap))
            {
                pageMap.TryGetValue(usage, out result);
            }

            return result;
        }
    }
}

透過以上的 _hidToGuidMap 的定義關聯關係,呼叫 GetKnownGuid 方法,即可將 POINTER_DEVICE_PROPERTY 描述資訊關聯到 WPF 框架層的定義

具體的對應邏輯如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Contains a WM_POINTER specific functions to parse out stylus property info
    /// </summary>
    internal class PointerStylusPointPropertyInfoHelper
    {
        /// <summary>
        /// Creates WPF property infos from WM_POINTER device properties.  This appropriately maps and converts HID spec
        /// properties found in WM_POINTER to their WPF equivalents.  This is based on code from the WISP implementation
        /// that feeds the legacy WISP based stack.
        /// </summary>
        /// <param name="prop">The pointer property to convert</param>
        /// <returns>The equivalent WPF property info</returns>
        internal static StylusPointPropertyInfo CreatePropertyInfo(UnsafeNativeMethods.POINTER_DEVICE_PROPERTY prop)
        {
            StylusPointPropertyInfo result = null;

            // Get the mapped GUID for the HID usages
            Guid propGuid =
                StylusPointPropertyIds.GetKnownGuid(
                    (StylusPointPropertyIds.HidUsagePage)prop.usagePageId,
                    (StylusPointPropertyIds.HidUsage)prop.usageId);

            if (propGuid != Guid.Empty)
            {
                StylusPointProperty stylusProp = new StylusPointProperty(propGuid, StylusPointPropertyIds.IsKnownButton(propGuid));

                // Set Units
                StylusPointPropertyUnit? unit = StylusPointPropertyUnitHelper.FromPointerUnit(prop.unit);

                // If the parsed unit is invalid, set the default
                if (!unit.HasValue)
                {
                    unit = StylusPointPropertyInfoDefaults.GetStylusPointPropertyInfoDefault(stylusProp).Unit;
                }

                // Set to default resolution
                float resolution = StylusPointPropertyInfoDefaults.GetStylusPointPropertyInfoDefault(stylusProp).Resolution;

                short mappedExponent = 0;

                if (_hidExponentMap.TryGetValue((byte)(prop.unitExponent & HidExponentMask), out mappedExponent))
                {
                    float exponent = (float)Math.Pow(10, mappedExponent);

                    // Guard against divide by zero or negative resolution
                    if (prop.physicalMax - prop.physicalMin > 0)
                    {
                        // Calculated resolution is a scaling factor from logical units into the physical space
                        // at the given exponentiation.
                        resolution =
                            (prop.logicalMax - prop.logicalMin) / ((prop.physicalMax - prop.physicalMin) * exponent);
                    }
                }

                result = new StylusPointPropertyInfo(
                      stylusProp,
                      prop.logicalMin,
                      prop.logicalMax,
                      unit.Value,
                      resolution);
            }

            return result;
        }
    }
}

以上的一個小細節點在於對 unit 單位的處理,即 StylusPointPropertyUnit? unit = StylusPointPropertyUnitHelper.FromPointerUnit(prop.unit); 這行程式碼的實現定義,具體實現如下

    internal static class StylusPointPropertyUnitHelper
    {
        /// <summary>
        /// Convert WM_POINTER units to WPF units
        /// </summary>
        /// <param name="pointerUnit"></param>
        /// <returns></returns>
        internal static StylusPointPropertyUnit? FromPointerUnit(uint pointerUnit)
        {
            StylusPointPropertyUnit unit = StylusPointPropertyUnit.None;

            _pointerUnitMap.TryGetValue(pointerUnit & UNIT_MASK, out unit);

            return (IsDefined(unit)) ? unit : (StylusPointPropertyUnit?)null;
        }

        /// <summary>
        /// Mapping for WM_POINTER based unit, taken from legacy WISP code
        /// </summary>
        private static Dictionary<uint, StylusPointPropertyUnit> _pointerUnitMap = new Dictionary<uint, StylusPointPropertyUnit>()
        {
            { 1, StylusPointPropertyUnit.Centimeters },
            { 2, StylusPointPropertyUnit.Radians },
            { 3, StylusPointPropertyUnit.Inches },
            { 4, StylusPointPropertyUnit.Degrees },
        };

        /// <summary>
        /// Mask to extract units from raw WM_POINTER data
        /// <see cref="http://www.usb.org/developers/hidpage/Hut1_12v2.pdf"/> 
        /// </summary>
        private const uint UNIT_MASK = 0x000F;
    }

這裡的單位的作用是什麼呢?用於和 POINTER_DEVICE_PROPERTY 的物理值做關聯對應關係,比如觸控面積 Width 和 Height 的物理尺寸就是透過大概如下演算法計算出來的

                short mappedExponent = 0;

                if (_hidExponentMap.TryGetValue((byte)(prop.unitExponent & HidExponentMask), out mappedExponent))
                {
                    float exponent = (float)Math.Pow(10, mappedExponent);

                    // Guard against divide by zero or negative resolution
                    if (prop.physicalMax - prop.physicalMin > 0)
                    {
                        // Calculated resolution is a scaling factor from logical units into the physical space
                        // at the given exponentiation.
                        resolution =
                            (prop.logicalMax - prop.logicalMin) / ((prop.physicalMax - prop.physicalMin) * exponent);
                    }
                }

        /// <summary>
        /// Contains the mappings from WM_POINTER exponents to our local supported values.
        /// This mapping is taken from WISP code, see Stylus\Biblio.txt - 4,
        /// as an array of HidExponents.
        /// </summary>
        private static Dictionary<byte, short> _hidExponentMap = new Dictionary<byte, short>()
        {
            { 5, 5 },
            { 6, 6 },
            { 7, 7 },
            { 8, -8 },
            { 9, -7 },
            { 0xA, -6 },
            { 0xB, -5 },
            { 0xC, -4 },
            { 0xD, -3 },
            { 0xE, -2 },
            { 0xF, -1 },
        };

透過 resolution 與具體後續收到的觸控點的值進行計算,帶上 StylusPointPropertyUnit 單位,這就是觸控裝置上報的物理尺寸了

以上 logicalMaxlogicalMin 在行業內常被稱為邏輯值,以上的 physicalMaxphysicalMin 常被稱為物理值

經過以上的處理之後,即可將 GetPointerDeviceProperties 拿到的裝置屬性列表給轉換為 WPF 框架對應的定義屬性內容

以上過程有一個細節,那就是 GetPointerDeviceProperties 拿到的裝置屬性列表的順序是非常關鍵的,裝置屬性列表的順序和在後續 WM_POINTER 訊息拿到的裸資料的順序是直接對應的

大家可以看到,在開啟 Pointer 訊息時,觸控模組初始化獲取觸控資訊是完全透過 Win32 的 WM_POINTER 模組提供的相關方法完成的。這裡需要和不開 WM_POINTER 訊息的從 COM 獲取觸控裝置資訊區分,和 dotnet 讀 WPF 原始碼筆記 插入觸控裝置的初始化獲取裝置資訊 提供的方法是不相同的

完成上述初始化邏輯之後,接下來看看訊息迴圈收到 WM_POINTER 訊息的處理

收到 WM_POINTER 訊息時,呼叫 GetRawPointerDeviceData 獲取最原始的觸控資訊,再對原始觸控資訊進行解析處理

在 WPF 裡面,大家都知道,底層的訊息迴圈處理的在 HwndSource.cs 裡面定義,輸入處理部分如下

namespace System.Windows.Interop
{
    /// <summary>
    ///     The HwndSource class presents content within a Win32 HWND.
    /// </summary>
    public class HwndSource : PresentationSource, IDisposable, IWin32Window, IKeyboardInputSink
    {
        private IntPtr InputFilterMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            ... // 忽略其他程式碼
            // NOTE (alexz): invoke _stylus.FilterMessage before _mouse.FilterMessage
            // to give _stylus a chance to eat mouse message generated by stylus
            if (!_isDisposed && _stylus != null && !handled)
            {
                result = _stylus.Value.FilterMessage(hwnd, message, wParam, lParam, ref handled);
            }
            ... // 忽略其他程式碼
        }

        private SecurityCriticalDataClass<IStylusInputProvider>        _stylus;
    }
}

以上程式碼的 _stylus 就是根據不同的配置引數決定是否使用 Pointer 訊息處理的 HwndPointerInputProvider 型別,程式碼如下

namespace System.Windows.Interop
{
    /// <summary>
    ///     The HwndSource class presents content within a Win32 HWND.
    /// </summary>
    public class HwndSource : PresentationSource, IDisposable, IWin32Window, IKeyboardInputSink
    {
        private void Initialize(HwndSourceParameters parameters)
        {
            ... // 忽略其他程式碼
            if (StylusLogic.IsStylusAndTouchSupportEnabled)
            {
                // Choose between Wisp and Pointer stacks
                if (StylusLogic.IsPointerStackEnabled)
                {
                    _stylus = new SecurityCriticalDataClass<IStylusInputProvider>(new HwndPointerInputProvider(this));
                }
                else
                {
                    _stylus = new SecurityCriticalDataClass<IStylusInputProvider>(new HwndStylusInputProvider(this));
                }
            }
            ... // 忽略其他程式碼
        }
    }
}

在本文這裡初始化的是 HwndPointerInputProvider 型別,將會進入到 HwndPointerInputProvider 的 FilterMessage 方法處理輸入資料

namespace System.Windows.Interop
{
    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Processes the message loop for the HwndSource, filtering WM_POINTER messages where needed
        /// </summary>
        /// <param name="hwnd">The hwnd the message is for</param>
        /// <param name="msg">The message</param>
        /// <param name="wParam"></param>
        /// <param name="lParam"></param>
        /// <param name="handled">If this has been successfully processed</param>
        /// <returns></returns>
        IntPtr IStylusInputProvider.FilterMessage(IntPtr hwnd, WindowMessage msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            handled = false;

            // Do not process any messages if the stack was disabled via reflection hack
            if (PointerLogic.IsEnabled)
            {
                switch (msg)
                {
                    case WindowMessage.WM_ENABLE:
                        {
                            IsWindowEnabled = MS.Win32.NativeMethods.IntPtrToInt32(wParam) == 1;
                        }
                        break;
                    case WindowMessage.WM_POINTERENTER:
                        {
                            // Enter can be processed as an InRange.  
                            // The MSDN documentation is not correct for InRange (according to feisu)
                            // As such, using enter is the correct way to generate this.  This is also what DirectInk uses.
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.InRange, Environment.TickCount);
                        }
                        break;
                    case WindowMessage.WM_POINTERUPDATE:
                        {
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Move, Environment.TickCount);
                        }
                        break;
                    case WindowMessage.WM_POINTERDOWN:
                        {
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Down, Environment.TickCount);
                        }
                        break;
                    case WindowMessage.WM_POINTERUP:
                        {
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Up, Environment.TickCount);
                        }
                        break;
                    case WindowMessage.WM_POINTERLEAVE:
                        {
                            // Leave can be processed as an OutOfRange.  
                            // The MSDN documentation is not correct for OutOfRange (according to feisu)
                            // As such, using leave is the correct way to generate this.  This is also what DirectInk uses.
                            handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.OutOfRange, Environment.TickCount);
                        }
                        break;
                }
            }

            return IntPtr.Zero;
        }

        ... // 忽略其他程式碼
    }
}

對於收到 Pointer 的按下移動抬起訊息,都會進入到 ProcessMessage 方法

進入之前呼叫的 GetPointerId(wParam) 程式碼的 GetPointerId 方法實現如下

        /// <summary>
        /// Extracts the pointer id
        /// </summary>
        /// <param name="wParam">The parameter containing the id</param>
        /// <returns>The pointer id</returns>
        private uint GetPointerId(IntPtr wParam)
        {
            return (uint)MS.Win32.NativeMethods.SignedLOWORD(wParam);
        }

    internal partial class NativeMethods
    {
        public static int SignedLOWORD(IntPtr intPtr)
        {
            return SignedLOWORD(IntPtrToInt32(intPtr));
        }

        public static int IntPtrToInt32(IntPtr intPtr)
        {
            return unchecked((int)intPtr.ToInt64());
        }

        public static int SignedLOWORD(int n)
        {
            int i = (int)(short)(n & 0xFFFF);

            return i;
        }
    }

當然了,以上程式碼簡單寫就和下面程式碼差不多

            var pointerId = (uint) (ToInt32(wparam) & 0xFFFF);

在 WM_POINTER 的設計上,將會源源不斷透過訊息迴圈傳送指標訊息,傳送的指標訊息裡面不直接包含具體的資料資訊,而是隻將 PointerId 當成 wparam 傳送。咱從訊息迴圈裡面拿到的只有 PointerId 的值,轉換方法如上述程式碼所示

為什麼是這樣設計的呢?考慮到現在大部分觸控式螢幕的精度都不低,至少比許多很便宜滑鼠的高,這就可能導致應用程式完全無法頂得住每次觸控資料過來都透過訊息迴圈懟進來。在 WM_POINTER 的設計上,只是將 PointerId 透過訊息迴圈傳送過來,具體的訊息體資料需要使用 GetPointerInfo 方法來獲取。這麼設計有什麼優勢?這麼設計是用來解決應用卡頓的時候,被堆積訊息的問題。假定現在有三個觸控訊息進來,第一個觸控訊息進來就傳送了 Win32 訊息給到應用,然而應用等待到系統收集到了三個觸控點訊息時,才呼叫 GetPointerInfo 方法。那此時系統觸控模組就可以很開森的知道了應用處於卡頓狀態,即第二個和第三個觸控訊息到來時,判斷第一個訊息還沒被應用消費,就不再傳送 Win32 訊息給到應用。當應用呼叫 GetPointerInfo 方法時,就直接返回第三個點給到應用,跳過中間第一個和第二個觸控點。同時,使用歷史點的概念,將第一個點和第二個點和第三個點給到應用,如果此時應用感興趣的話

利用如上所述機制,即可實現到當觸控裝置產生的觸控訊息過快時,不會讓應用的訊息迴圈過度忙碌,而是可以讓應用有機會一次性拿到過去一段時間內的多個觸控點資訊。如此可以提升整體系統的效能,減少應用程式忙碌於處理過往的觸控訊息

舉一個虛擬的例子,讓大家更好的理解這套機制的思想。假定咱在製作一個應用,應用有一個功能,就是有一個矩形元素,這個元素可以響應觸控拖動,可以用觸控拖動矩形元素。這個應用編寫的有些離譜,每次拖動的做法就是設定新的座標點為當前觸控點,但是這個過程需要 15 毫秒,因為中間新增了一些有趣且保密(其實我還沒編出來)的演算法。當應用跑在一個觸控裝置上,這個觸控裝置在觸控拖動的過程中,每 10 毫秒將產生一次觸控點資訊報告給到系統。假定當前的系統的觸控模組是如實的每次收到裝置傳送過來的觸控點,都透過 Win32 訊息傳送給到應用,那將會讓應用的消費速度慢於訊息的生產速度,這就意味著大家可以明顯看到拖動矩形元素時具備很大的延遲感。如拖著拖著才發現矩形元素還在後面慢慢挪動,整體的體驗比較糟糕。那如果採用現在的這套玩法呢?應用程式從 Win32 訊息收到的是 PointerId 資訊,再透過 GetPointerInfo 方法獲取觸控點資訊,此時獲取到的觸控點就是最後一個觸控點,對於咱這個應用來說剛剛好,直接就是響應設定矩形元素座標為最後一個觸控點的對應座標。如此即可看到矩形元素飛快跳著走,且由於剛好矩形元素拖動過程為 15 毫秒,小於 16 毫秒,意味著大部分情況下大家看到的是矩形元素平滑的移動,即飛快跳著走在人類看來是一個連續移動的過程

期望透過以上的例子可以讓大家瞭解到微軟的“良苦”用心

這裡需要額外說明的是 PointerId 和 TouchDevice 等的 Id 是不一樣的,在下文將會給出詳細的描述

在 WPF 這邊,如上面程式碼所示,收到觸控點資訊之後,將會進入到 ProcessMessage 方法,只是這個過程中我感覺有一點小鍋的是,時間戳拿的是當前系統時間戳 Environment.TickCount 的值,而不是取 Pointer 訊息裡面的時間戳內容

繼續看一下 ProcessMessage 方法的定義和實現

namespace System.Windows.Interop
{
    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Processes the latest WM_POINTER message and forwards it to the WPF input stack.
        /// </summary>
        /// <param name="pointerId">The id of the pointer message</param>
        /// <param name="action">The stylus action being done</param>
        /// <param name="timestamp">The time (in ticks) the message arrived</param>
        /// <returns>True if successfully processed (handled), false otherwise</returns>
        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
            ... // 忽略其他程式碼
        }
    }

    ... // 忽略其他程式碼
}

在 ProcessMessage 裡面將建立 PointerData 物件,這個 PointerData 型別是一個輔助類,在建構函式里面將呼叫 GetPointerInfo 方法獲取指標點資訊

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
            bool handled = false;

            // Acquire all pointer data needed
            PointerData data = new PointerData(pointerId);

            ... // 忽略其他程式碼
        }

以下是 PointerData 建構函式的簡單定義的有刪減的程式碼

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Provides a wrapping class that aggregates Pointer data from a pointer event/message
    /// </summary>
    internal class PointerData
    {
        /// <summary>
        /// Queries all needed data from a particular pointer message and stores
        /// it locally.
        /// </summary>
        /// <param name="pointerId">The id of the pointer message</param>
        internal PointerData(uint pointerId)
        {
            if (IsValid = GetPointerInfo(pointerId, ref _info))
            {
                _history = new POINTER_INFO[_info.historyCount];

                // Fill the pointer history
                // If we fail just return a blank history
                if (!GetPointerInfoHistory(pointerId, ref _info.historyCount, _history))
                {
                    _history = Array.Empty<POINTER_INFO>();
                }

                ... // 忽略其他程式碼
            }
        }

        /// <summary>
        /// Standard pointer information
        /// </summary>
        private POINTER_INFO _info;

        /// <summary>
        /// The full history available for the current pointer (used for coalesced input)
        /// </summary>
        private POINTER_INFO[] _history;

        /// <summary>
        /// If true, we have correctly queried pointer data, false otherwise.
        /// </summary>
        internal bool IsValid { get; private set; } = false;
    }

透過上述程式碼可以看到,開始是呼叫 GetPointerInfo 方法獲取指標點資訊。在 WPF 的基礎事件裡面也是支援歷史點的,意圖和 Pointer 的設計意圖差不多,都是為了解決業務端的消費資料速度問題。於是在 WPF 底層也就立刻呼叫 GetPointerInfoHistory 獲取歷史點資訊

對於 Pointer 訊息來說,對觸控和觸筆有著不同的資料提供分支,分別是 GetPointerTouchInfo 方法和 GetPointerPenInfo 方法

在 PointerData 建構函式里面,也透過判斷 POINTER_INFOpointerType 欄位決定呼叫不同的方法,程式碼如下

            if (IsValid = GetPointerInfo(pointerId, ref _info))
            {
                switch (_info.pointerType)
                {
                    case POINTER_INPUT_TYPE.PT_TOUCH:
                        {
                            // If we have a touch device, pull the touch specific information down
                            IsValid &= GetPointerTouchInfo(pointerId, ref _touchInfo);
                        }
                        break;
                    case POINTER_INPUT_TYPE.PT_PEN:
                        {
                            // Otherwise we have a pen device, so pull down pen specific information
                            IsValid &= GetPointerPenInfo(pointerId, ref _penInfo);
                        }
                        break;
                    default:
                        {
                            // Only process touch or pen messages, do not process mouse or touchpad
                            IsValid = false;
                        }
                        break;
                }
            }

對於 WPF 的 HwndPointerInputProvider 模組來說,只處理 PT_TOUCH 和 PT_PEN 訊息,即觸控和觸筆訊息。對於 Mouse 滑鼠和 Touchpad 觸控板來說都不走 Pointer 處理,依然是走原來的 Win32 訊息。為什麼這麼設計呢?因為 WPF 裡面沒有 Pointer 路由事件,在 WPF 裡面分開了 Touch 和 Stylus 和 Mouse 事件。就不需要全部都在 Pointer 模組處理了,依然在原來的訊息迴圈裡面處理,既減少 Pointer 模組的工作量,也能減少後續從 Pointer 分發到 Touch 和 Stylus 和 Mouse 事件的工作量。原先的模組看起來也跑得很穩,那就一起都不改了

完成 PointerData 的建構函式之後,繼續到 HwndPointerInputProvider 的 ProcessMessage 函式里面,在此函式里面判斷是 PT_TOUCH 和 PT_PEN 訊息,則進行處理

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
            bool handled = false;

            // Acquire all pointer data needed
            PointerData data = new PointerData(pointerId);

            // Only process touch or pen messages, do not process mouse or touchpad
            if (data.IsValid
                && (data.Info.pointerType == UnsafeNativeMethods.POINTER_INPUT_TYPE.PT_TOUCH
                || data.Info.pointerType == UnsafeNativeMethods.POINTER_INPUT_TYPE.PT_PEN))
            {
                ... // 忽略其他程式碼
            }

            return handled;
        }

對於觸控和觸筆的處理上,先是執行觸控裝置關聯。觸控裝置關聯一個在上層業務的表現就是讓當前的指標訊息關聯上 TouchDevice 的 Id 或 StylusDevice 的 Id 值

關聯的方法是透過 GetPointerCursorId 方法先獲取 CursorId 的值,再配合對應的輸入的 Pointer 的輸入裝置 POINTER_INFOsourceDevice 欄位,即可與初始化過程中建立的裝置相關聯,實現程式碼如下

            if (data.IsValid
                && (data.Info.pointerType == UnsafeNativeMethods.POINTER_INPUT_TYPE.PT_TOUCH
                || data.Info.pointerType == UnsafeNativeMethods.POINTER_INPUT_TYPE.PT_PEN))
            {
                uint cursorId = 0;

                if (UnsafeNativeMethods.GetPointerCursorId(pointerId, ref cursorId))
                {
                    IntPtr deviceId = data.Info.sourceDevice;

                    // If we cannot acquire the latest tablet and stylus then wait for the
                    // next message.
                    if (!UpdateCurrentTabletAndStylus(deviceId, cursorId))
                    {
                        return false;
                    }

                     ... // 忽略其他程式碼
                }

                ... // 忽略其他程式碼
            }

在 WPF 初始化工作裡面將輸入的 Pointer 的輸入裝置 POINTER_INFOsourceDevice 當成 deviceId 的概念,即 TabletDevice 的 Id 值。而 cursorId 則是對應 StylusDevice 的 Id 值,其更新程式碼的核心非常簡單,如下面程式碼

        /// <summary>
        /// Attempts to update the current stylus and tablet devices for the latest WM_POINTER message.
        /// Will attempt retries if the tablet collection is invalid or does not contain the proper ids.
        /// </summary>
        /// <param name="deviceId">The id of the TabletDevice</param>
        /// <param name="cursorId">The id of the StylusDevice</param>
        /// <returns>True if successfully updated, false otherwise.</returns>
        private bool UpdateCurrentTabletAndStylus(IntPtr deviceId, uint cursorId)
        {
            _currentTabletDevice = tablets?.GetByDeviceId(deviceId);

            _currentStylusDevice = _currentTabletDevice?.GetStylusByCursorId(cursorId);
            
            ... // 忽略其他程式碼

                if (_currentTabletDevice == null || _currentStylusDevice == null)
                {
                    return false;
                }
            

            return true;
        }

對應的 GetByDeviceId 方法的程式碼如下

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// Maintains a collection of pointer device information for currently installed pointer devices
    /// </summary>
    internal class PointerTabletDeviceCollection : TabletDeviceCollection
    {
        /// <summary>
        /// Holds a mapping of TabletDevices from their WM_POINTER device id
        /// </summary>
        private Dictionary<IntPtr, PointerTabletDevice> _tabletDeviceMap = new Dictionary<IntPtr, PointerTabletDevice>();

         ... // 忽略其他程式碼

        /// <summary>
        /// Retrieve the TabletDevice associated with the device id
        /// </summary>
        /// <param name="deviceId">The device id</param>
        /// <returns>The TabletDevice associated with the device id</returns>
        internal PointerTabletDevice GetByDeviceId(IntPtr deviceId)
        {
            PointerTabletDevice tablet = null;

            _tabletDeviceMap.TryGetValue(deviceId, out tablet);

            return tablet;
        }
    }
}

對應的 GetStylusByCursorId 的程式碼如下

namespace System.Windows.Input.StylusPointer
{  
    /// <summary>
    /// A WM_POINTER based implementation of the TabletDeviceBase class.
    /// </summary>
    internal class PointerTabletDevice : TabletDeviceBase
    {
        /// <summary>
        /// A mapping from StylusDevice id to the actual StylusDevice for quick lookup.
        /// </summary>
        private Dictionary<uint, PointerStylusDevice> _stylusDeviceMap = new Dictionary<uint, PointerStylusDevice>();

        /// <summary>
        /// Retrieves the StylusDevice associated with the cursor id.
        /// </summary>
        /// <param name="cursorId">The id of the StylusDevice to retrieve</param>
        /// <returns>The StylusDevice associated with the id</returns>
        internal PointerStylusDevice GetStylusByCursorId(uint cursorId)
        {
            PointerStylusDevice stylus = null;
            _stylusDeviceMap.TryGetValue(cursorId, out stylus);
            return stylus;
        }
    }
}

透過以上方式即可透過 PointerId 獲取的 cursorId 進而獲取到對應 WPF 裡面的裝置物件,進而拿到 WPF 裡面的裝置 Id 號。透過上文的描述也可以看到 PointerId 和 TouchDevice 等的 Id 是不一樣的,但是之間有關聯關係

呼叫了 UpdateCurrentTabletAndStylus 的一個副作用就是同步更新了 _currentTabletDevice_currentStylusDevice 欄位的值,後續邏輯即可直接使用這兩個欄位而不是傳引數

完成關聯邏輯之後,即進入 GenerateRawStylusData 方法,這個方法是 WPF 獲取 Pointer 具體的訊息的核心方法,方法簽名如下

namespace System.Windows.Interop
{
    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Creates raw stylus data from the raw WM_POINTER properties
        /// </summary>
        /// <param name="pointerData">The current pointer info</param>
        /// <param name="tabletDevice">The current TabletDevice</param>
        /// <returns>An array of raw pointer data</returns>
        private int[] GenerateRawStylusData(PointerData pointerData, PointerTabletDevice tabletDevice)
        {
            ... // 忽略其他程式碼
        }

        ... // 忽略其他程式碼
    }
}

此 GenerateRawStylusData 被呼叫是這麼寫的

namespace System.Windows.Interop
{
    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Processes the latest WM_POINTER message and forwards it to the WPF input stack.
        /// </summary>
        /// <param name="pointerId">The id of the pointer message</param>
        /// <param name="action">The stylus action being done</param>
        /// <param name="timestamp">The time (in ticks) the message arrived</param>
        /// <returns>True if successfully processed (handled), false otherwise</returns>
        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
            PointerData data = new PointerData(pointerId);

            ... // 忽略其他程式碼
                uint cursorId = 0;
                if (UnsafeNativeMethods.GetPointerCursorId(pointerId, ref cursorId))
                {
                    ... // 忽略其他程式碼
                    GenerateRawStylusData(data, _currentTabletDevice);
                    ... // 忽略其他程式碼
                }

        }
        ... // 忽略其他程式碼
    }
}

在 GenerateRawStylusData 方法裡面,先透過 PointerTabletDevice 取出支援的 Pointer 的裝置屬性列表的長度,用於和輸入點的資訊進行匹配。回憶一下,這部分獲取邏輯是在上文介紹到對 GetPointerDeviceProperties 函式的呼叫提到的,且也說明了此函式拿到的裝置屬性列表的順序是非常關鍵的,裝置屬性列表的順序和在後續 WM_POINTER 訊息拿到的裸資料的順序是直接對應的

    /// <summary>
    /// Implements an input provider per hwnd for WM_POINTER messages
    /// </summary>
    internal sealed class HwndPointerInputProvider : DispatcherObject, IStylusInputProvider
    {
        /// <summary>
        /// Creates raw stylus data from the raw WM_POINTER properties
        /// </summary>
        /// <param name="pointerData">The current pointer info</param>
        /// <param name="tabletDevice">The current TabletDevice</param>
        /// <returns>An array of raw pointer data</returns>
        private int[] GenerateRawStylusData(PointerData pointerData, PointerTabletDevice tabletDevice)
        {
            // Since we are copying raw pointer data, we want to use every property supported by this pointer.
            // We may never access some of the unknown (unsupported by WPF) properties, but they should be there
            // for consumption by the developer.
            int pointerPropertyCount = tabletDevice.DeviceInfo.SupportedPointerProperties.Length;

            // The data is as wide as the pointer properties and is per history point
            int[] rawPointerData = new int[pointerPropertyCount * pointerData.Info.historyCount];

            ... // 忽略其他程式碼
        }

        ... // 忽略其他程式碼
    }

由每個 Pointer 的屬性長度配合總共的歷史點數量,即可獲取到這裡面使用到的 rawPointerData 陣列的長度。這部分程式碼相信大家很好就理解了

接著就是核心部分,呼叫 GetRawPointerDeviceData 獲取最原始的觸控資訊,再對原始觸控資訊進行解析處理

            int pointerPropertyCount = tabletDevice.DeviceInfo.SupportedPointerProperties.Length;

            // The data is as wide as the pointer properties and is per history point
            int[] rawPointerData = new int[pointerPropertyCount * pointerData.Info.historyCount];

            // Get the raw data formatted to our supported properties
            if (UnsafeNativeMethods.GetRawPointerDeviceData(
                pointerData.Info.pointerId,
                pointerData.Info.historyCount,
                (uint)pointerPropertyCount,
                tabletDevice.DeviceInfo.SupportedPointerProperties,
                rawPointerData))
            {
                ... // 忽略其他程式碼
            }

在 Pointer 的設計裡面,歷史點 historyCount 是包含當前點的,且當前點就是最後一個點。這就是為什麼這裡只需要傳入歷史點數量即可,換句話說就是歷史點最少包含一個點,那就是當前點

由於 Pointer 獲取到的點都是相對於螢幕座標的,這裡需要先偏移一下修改為視窗座標系,程式碼如下

                // Get the X and Y offsets to translate device coords to the origin of the hwnd
                int originOffsetX, originOffsetY;
                GetOriginOffsetsLogical(out originOffsetX, out originOffsetY);

        private void GetOriginOffsetsLogical(out int originOffsetX, out int originOffsetY)
        {
            Point originScreenCoord = _source.Value.RootVisual.PointToScreen(new Point(0, 0));

            // Use the inverse of our logical tablet to screen matrix to generate tablet coords
            MatrixTransform screenToTablet = new MatrixTransform(_currentTabletDevice.TabletToScreen);
            screenToTablet = (MatrixTransform)screenToTablet.Inverse;

            Point originTabletCoord = originScreenCoord * screenToTablet.Matrix;

            originOffsetX = (int)Math.Round(originTabletCoord.X);
            originOffsetY = (int)Math.Round(originTabletCoord.Y);
        }

        /// <summary>
        /// The HwndSource for WM_POINTER messages
        /// </summary>
        private SecurityCriticalDataClass<HwndSource> _source;

這裡的 GetOriginOffsetsLogical 的實現邏輯就是去視窗的 0,0 點,看這個點會在螢幕的哪裡,從而知道其偏移量。至於新增的 MatrixTransform 矩陣的 TabletToScreen 則在後文的具體轉換邏輯會講到,這裡先跳過

獲取到相對於視窗的座標偏移量之後,即可將其疊加給到每個點上,用於將這些點轉換為視窗座標系。但是在此之前還需要將獲取到的 rawPointerData 進行加工。這一個步驟僅僅只是在 WPF 有需求,僅僅只是為了相容 WISP 獲取到的裸資料的方式。其相差點在於透過 Pointer 獲取到的 rawPointerData 的二進位制資料格式裡面,沒有帶上按鈕的支援情況的資訊,在 WPF 這邊需要重新建立一個陣列對 rawPointerData 重新排列,確保每個點的資料都加上按鈕的資訊資料

這部分處理僅只是為了相容考慮,讓後續的 StylusPointCollection 開森而已,咱就跳著看就好了

                int numButtons = tabletDevice.DeviceInfo.SupportedPointerProperties.Length - tabletDevice.DeviceInfo.SupportedButtonPropertyIndex;

                int rawDataPointSize = (numButtons > 0) ? pointerPropertyCount - numButtons + 1 : pointerPropertyCount;

                // Instead of a single entry for each button we use one entry for all buttons so reflect that in the raw data size
                data = new int[rawDataPointSize * pointerData.Info.historyCount];

                for (int i = 0, j = rawPointerData.Length - pointerPropertyCount; i < data.Length; i += rawDataPointSize, j -= pointerPropertyCount)
                {
                    Array.Copy(rawPointerData, j, data, i, rawDataPointSize);

                    // Apply offsets from the origin to raw pointer data here
                    data[i + StylusPointDescription.RequiredXIndex] -= originOffsetX;
                    data[i + StylusPointDescription.RequiredYIndex] -= originOffsetY;

                    ... // 忽略其他程式碼
                }

             ... // 忽略其他程式碼
            return data;

重新複製的過程,還將點的座標更換成視窗座標系,即以上的 data[i + StylusPointDescription.RequiredXIndex] -= originOffsetX;data[i + StylusPointDescription.RequiredYIndex] -= originOffsetY; 兩個程式碼

完成獲取之後,就將獲取到的裸資料給返回了,這就是 GenerateRawStylusData 的內容

在 ProcessMessage 方法裡面獲取到 GenerateRawStylusData 返回的原始指標資訊,即可將其給到 RawStylusInputReport 作為引數,程式碼如下

                    // Generate a raw input to send to the input manager to start the event chain in PointerLogic
                    Int32[] rawData = GenerateRawStylusData(data, _currentTabletDevice);
                    RawStylusInputReport rsir =
                        new RawStylusInputReport(
                            InputMode.Foreground,
                            timestamp,
                            _source.Value,
                            action,
                            () => { return _currentTabletDevice.StylusPointDescription; },
                            _currentTabletDevice.Id,
                            _currentStylusDevice.Id,
                            rawData)
                        {
                            StylusDevice = _currentStylusDevice.StylusDevice,
                        };

將建立的 RawStylusInputReport 更新到當前的裝置,作為裝置的最新的指標資訊

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {

            PointerData data = new PointerData(pointerId);

             ... // 忽略其他程式碼

                    _currentStylusDevice.Update(this, _source.Value, data, rsir);
             ... // 忽略其他程式碼
        }

        private SecurityCriticalDataClass<HwndSource> _source;

且還加入到 InputManager 的 ProcessInput 裡面,進入 WPF 的框架內的訊息排程

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {

            PointerData data = new PointerData(pointerId);

             ... // 忽略其他程式碼

                    _currentStylusDevice.Update(this, _source.Value, data, rsir);
                    // Now send the input report
                    InputManager.UnsecureCurrent.ProcessInput(irea);
             ... // 忽略其他程式碼
        }

在進入 InputManager 的 ProcessInput 排程訊息之前,先看看 _currentStylusDevice.Update 裡面的對原始指標資訊的解析實現邏輯

_currentStylusDevice.Update 裡面的對原始指標資訊的解析實現完全是靠 StylusPointCollection 和 StylusPoint 的建構函式實現的

namespace System.Windows.Input.StylusPointer
{
    /// <summary>
    /// A WM_POINTER specific implementation of the StylusDeviceBase.
    /// 
    /// Supports direct access to WM_POINTER structures and basing behavior off of the WM_POINTER data.
    /// </summary>
    internal class PointerStylusDevice : StylusDeviceBase
    {
        /// <summary>
        /// Updates the internal StylusDevice state based on the WM_POINTER input and the formed raw data.
        /// </summary>
        /// <param name="provider">The hwnd associated WM_POINTER provider</param>
        /// <param name="inputSource">The PresentationSource where this message originated</param>
        /// <param name="pointerData">The aggregated pointer data retrieved from the WM_POINTER stack</param>
        /// <param name="rsir">The raw stylus input generated from the pointer data</param>
        internal void Update(HwndPointerInputProvider provider, PresentationSource inputSource,
            PointerData pointerData, RawStylusInputReport rsir)
        {
             ... // 忽略其他程式碼

            // First get the initial stylus points.  Raw data from pointer input comes in screen coordinates, keep that here since that is what we expect.
            _currentStylusPoints = new StylusPointCollection(rsir.StylusPointDescription, rsir.GetRawPacketData(), GetTabletToElementTransform(null), Matrix.Identity);

             ... // 忽略其他程式碼
        }
    }
}

這裡的 rsir.GetRawPacketData() 是返回上文提到的 GenerateRawStylusData 方法給出的裸資料的複製,程式碼如下

    internal class RawStylusInputReport : InputReport
    {
        /// <summary>
        ///     Read-only access to the raw data that was reported.
        /// </summary>
        internal int[] GetRawPacketData()
        {
            if (_data == null)
                return null;
            return (int[])_data.Clone();
        }

        /// <summary>
        /// The raw data for this input report
        /// </summary>
        int[] _data;

        ... // 忽略其他程式碼
    }

這裡的 GetTabletToElementTransform 包含了一個核心轉換,方法程式碼如下

    internal class PointerStylusDevice : StylusDeviceBase
    {
        /// <summary>
        ///     Returns the transform for converting from tablet to element
        ///     relative coordinates.
        /// </summary>
        internal GeneralTransform GetTabletToElementTransform(IInputElement relativeTo)
        {
            GeneralTransformGroup group = new GeneralTransformGroup();
            Matrix toDevice = _inputSource.Value.CompositionTarget.TransformToDevice;
            toDevice.Invert();
            group.Children.Add(new MatrixTransform(PointerTabletDevice.TabletToScreen * toDevice));
            group.Children.Add(StylusDevice.GetElementTransform(relativeTo));
            return group;
        }

        ... // 忽略其他程式碼
    }

這裡面方法存在重點內容,那就是 PointerTabletDevice 的 TabletToScreen 屬性的計算方法。這個矩陣的計算需要用到開始初始化過程的 GetPointerDeviceRects 函式獲取 的 displayRect 尺寸,以及 GetPointerDeviceProperties 獲取的 X 和 Y 屬性描述資訊,屬性的定義程式碼如下

        internal Matrix TabletToScreen
        {
            get
            {
                return new Matrix(_tabletInfo.SizeInfo.ScreenSize.Width / _tabletInfo.SizeInfo.TabletSize.Width, 0,
                                   0, _tabletInfo.SizeInfo.ScreenSize.Height / _tabletInfo.SizeInfo.TabletSize.Height,
                                   0, 0);
            }
        }

可以看到這是一個用於縮放的 Matrix 物件,正是 GetPointerDeviceRects 獲取的螢幕尺寸以及 GetPointerDeviceProperties 獲取的 X 和 Y 屬性描述資訊構成的 TabletSize 的比值

回顧一下 _tabletInfo 的 SizeInfo 的建立程式碼,可以看到 TabletSize 完全是由描述符的尺寸決定,程式碼如下

            // 以下程式碼在 PointerTabletDeviceInfo.cs 檔案中
            // private bool TryInitializeSupportedStylusPointProperties()
            SupportedPointerProperties = new UnsafeNativeMethods.POINTER_DEVICE_PROPERTY[propCount];

            success = UnsafeNativeMethods.GetPointerDeviceProperties(Device, ref propCount, SupportedPointerProperties);

            ... // 忽略其他程式碼

            // private bool TryInitializeDeviceRects()
            var deviceRect = new UnsafeNativeMethods.RECT();
            var displayRect = new UnsafeNativeMethods.RECT();

            success = UnsafeNativeMethods.GetPointerDeviceRects(_deviceInfo.device, ref deviceRect, ref displayRect);

            if (success)
            {
                // We use the max X and Y properties here as this is more readily useful for raw data
                // which is where all conversions come from.
                SizeInfo = new TabletDeviceSizeInfo
                (
                    new Size(SupportedPointerProperties[StylusPointDescription.RequiredXIndex].logicalMax,
                    SupportedPointerProperties[StylusPointDescription.RequiredYIndex].logicalMax),

                    new Size(displayRect.right - displayRect.left, displayRect.bottom - displayRect.top)
                );
            }

    internal struct TabletDeviceSizeInfo
    {
        public Size TabletSize;
        public Size ScreenSize;

        internal TabletDeviceSizeInfo(Size tabletSize, Size screenSize)
        {
            TabletSize = tabletSize;
            ScreenSize = screenSize;
        }
    }

如此即可使用 TabletToScreen 屬性將收到的基於 Tablet 座標系的裸指標訊息的座標轉換為螢幕座標,再配合 TransformToDevice 取反即可轉換到 WPF 座標系

在以上程式碼裡面,由於傳入 GetTabletToElementTransform 的 relativeTo 引數是 null 的值,將導致 StylusDevice.GetElementTransform(relativeTo) 返回一個單位矩陣,這就意味著在 GetTabletToElementTransform 方法裡面的 group.Children.Add(StylusDevice.GetElementTransform(relativeTo)); 是多餘的,也許後續 WPF 版本這裡會被我最佳化掉

回顧一下 StylusPointCollection 的建構函式引數,有用的引數只有前三個,分別是 rsir.StylusPointDescription 傳入描述符資訊,以及 rsir.GetRawPacketData() 返回裸指標資料,以及 GetTabletToElementTransform(null) 方法返回轉換為 WPF 座標系的矩陣

_currentStylusPoints = new StylusPointCollection(rsir.StylusPointDescription, rsir.GetRawPacketData(), GetTabletToElementTransform(null), Matrix.Identity);

那 StylusPointCollection 的最後一個引數,即上述程式碼傳入的 Matrix.Identity 有什麼用途?其實在 StylusPointCollection 的設計裡面,第三個引數和第四個引數是二選一的,且第三個引數的優先順序大於第四個引數。即在 StylusPointCollection 底層會判斷第三個引數是否有值,如果沒有值才會使用第四個引數

在 StylusPointCollection 建構函式里面將會對裸 Pointer 資料進行處理,現在 GetRawPacketData 拿到的裸 Pointer 資料的 int 陣列裡面的資料排列內容大概如下

| X 座標 | Y 座標 | 壓感(可選)| StylusPointDescription 裡面的屬性列表一一對應 |
| X 座標 | Y 座標 | 壓感(可選)| StylusPointDescription 裡面的屬性列表一一對應 |
| X 座標 | Y 座標 | 壓感(可選)| StylusPointDescription 裡面的屬性列表一一對應 |

存放的是一個或多個點資訊,每個點的資訊都是相同的二進位制長度,分包非常簡單

進入到 StylusPointCollection 的建構函式,看看其程式碼簽名定義

namespace System.Windows.Input
{
    public class StylusPointCollection : Collection<StylusPoint>
    {
        internal StylusPointCollection(StylusPointDescription stylusPointDescription, int[] rawPacketData, GeneralTransform tabletToView, Matrix tabletToViewMatrix)
        {
            ... // 忽略其他程式碼
        }
    }
}

在建構函式里面,先呼叫 StylusPointDescription 的 GetInputArrayLengthPerPoint 方法,獲取每個點的二進位制長度,程式碼如下

    public class StylusPointCollection : Collection<StylusPoint>
    {
        internal StylusPointCollection(StylusPointDescription stylusPointDescription, int[] rawPacketData, GeneralTransform tabletToView, Matrix tabletToViewMatrix)
        {
            ... // 忽略其他程式碼
            int lengthPerPoint = stylusPointDescription.GetInputArrayLengthPerPoint();

            ... // 忽略其他程式碼
        }
    }

獲取到了一個點的二進位制長度,自然就能算出傳入的 rawPacketData 引數包含多少個點的資訊

        internal StylusPointCollection(StylusPointDescription stylusPointDescription, int[] rawPacketData, GeneralTransform tabletToView, Matrix tabletToViewMatrix)
        {
            ... // 忽略其他程式碼
            int lengthPerPoint = stylusPointDescription.GetInputArrayLengthPerPoint();
            int logicalPointCount = rawPacketData.Length / lengthPerPoint;
            Debug.Assert(0 == rawPacketData.Length % lengthPerPoint, "Invalid assumption about packet length, there shouldn't be any remainder");
            ... // 忽略其他程式碼
        }

以上程式碼的 Debug.Assert 就是要確保傳入的 rawPacketData 是可以被 lengthPerPoint 即每個點的二進位制長度所整除

完成準備工作之後,接下來就可以將 rawPacketData 解出點了,如下面程式碼所示

            int lengthPerPoint = stylusPointDescription.GetInputArrayLengthPerPoint();
            int logicalPointCount = rawPacketData.Length / lengthPerPoint;

            for (int count = 0, i = 0; count < logicalPointCount; count++, i += lengthPerPoint)
            {
                //first, determine the x, y values by xf-ing them
                Point p = new Point(rawPacketData[i], rawPacketData[i + 1]);

                ... // 忽略其他程式碼

                int startIndex = 2;

                ... // 忽略其他程式碼

                int[] data = null;
                int dataLength = lengthPerPoint - startIndex;
                if (dataLength > 0)
                {
                    //copy the rest of the data
                    var rawArrayStartIndex = i + startIndex;
                    data = rawPacketData.AsSpan(rawArrayStartIndex, dataLength).ToArray();
                }

                StylusPoint newPoint = new StylusPoint(p.X, p.Y, StylusPoint.DefaultPressure, _stylusPointDescription, data, false, false);

                ... // 忽略其他程式碼

                ((List<StylusPoint>)this.Items).Add(newPoint);
            }

以上程式碼忽略的部分包含了一些細節,如對 Point 的座標轉換,使用 Point p = new Point(rawPacketData[i], rawPacketData[i + 1]); 拿到的點的座標是屬於 Tablet 座標,需要使用傳入的引數轉換為 WPF 座標,如下面程式碼所示

        internal StylusPointCollection(StylusPointDescription stylusPointDescription, int[] rawPacketData, GeneralTransform tabletToView, Matrix tabletToViewMatrix)
        {
                ... // 忽略其他程式碼

                Point p = new Point(rawPacketData[i], rawPacketData[i + 1]);
                if (tabletToView != null)
                {
                    tabletToView.TryTransform(p, out p);
                }
                else
                {
                    p = tabletToViewMatrix.Transform(p);
                }

                ... // 忽略其他程式碼
        }

透過以上的程式碼就可以看到 StylusPointCollection 建構函式使用了第三個或第四個引數作為變換,如果第三個引數存在則優先使用第三個引數

其他處理的邏輯就是對壓感的額外處理,壓感作為 StylusPoint 的一個明確引數,需要額外判斷處理

                int startIndex = 2; // X 和 Y 佔用了兩個元素
                bool containsTruePressure = stylusPointDescription.ContainsTruePressure;
                if (containsTruePressure)
                {
                    // 如果有壓感的話,壓感也需要多佔一個元素
                    //don't copy pressure in the int[] for extra data
                    startIndex++;
                }

                StylusPoint newPoint = new StylusPoint(p.X, p.Y, StylusPoint.DefaultPressure, _stylusPointDescription, data, false, false);
                if (containsTruePressure)
                {
                    // 壓感必定是第三個元素,有壓感則更新壓感
                    //use the algorithm to set pressure in StylusPoint
                    int pressure = rawPacketData[i + 2];
                    newPoint.SetPropertyValue(StylusPointProperties.NormalPressure, pressure);
                }

如此即可解包 | X 座標 | Y 座標 | 壓感(可選)| StylusPointDescription 裡面的屬性列表一一對應 | 裡面前三個元素,其中壓感是可選的。後續的 StylusPointDescription 裡面的屬性列表一一對應 部分需要重新建立 data 陣列傳入到各個 StylusPoint 裡面,程式碼如下

                int[] data = null;
                int dataLength = lengthPerPoint - startIndex;
                if (dataLength > 0)
                {
                    //copy the rest of the data
                    var rawArrayStartIndex = i + startIndex;
                    data = rawPacketData.AsSpan(rawArrayStartIndex, dataLength).ToArray();
                }

後續對 StylusPoint 獲取屬性時,即可透過描述資訊獲取,描述資訊獲取到值的方式就是取以上程式碼傳入的 data 二進位制陣列的對應下標的元素,比如觸控點的寬度或高度資訊

完成轉換為 StylusPointCollection 之後,即可使用 InputManager.UnsecureCurrent.ProcessInput 方法將裸輸入資訊排程到 WPF 輸入管理器

        private bool ProcessMessage(uint pointerId, RawStylusActions action, int timestamp)
        {
             ... // 忽略其他程式碼
                    InputReportEventArgs irea = new InputReportEventArgs(_currentStylusDevice.StylusDevice, rsir)
                    {
                        RoutedEvent = InputManager.PreviewInputReportEvent,
                    };

                    // Now send the input report
                    InputManager.UnsecureCurrent.ProcessInput(irea);
             ... // 忽略其他程式碼
        }

進入到 ProcessInput 裡面將會走標準的路由事件機制,透過路由機制觸發 Touch 或 Stylus 事件,接下來的邏輯看一下呼叫堆疊即可,和其他的輸入事件邏輯差不多

>   Lindexi.dll!Lindexi.MainWindow.MainWindow_TouchDown(object sender, System.Windows.Input.TouchEventArgs e)
    PresentationCore.dll!System.Windows.RoutedEventArgs.InvokeHandler(System.Delegate handler, object target)
    PresentationCore.dll!System.Windows.EventRoute.InvokeHandlersImpl(object source, System.Windows.RoutedEventArgs args, bool reRaised) 
    PresentationCore.dll!System.Windows.UIElement.RaiseEventImpl(System.Windows.DependencyObject sender, System.Windows.RoutedEventArgs args)
    PresentationCore.dll!System.Windows.UIElement.RaiseTrustedEvent(System.Windows.RoutedEventArgs args) 
    PresentationCore.dll!System.Windows.Input.InputManager.ProcessStagingArea()
    PresentationCore.dll!System.Windows.Input.TouchDevice.RaiseTouchDown() 
    PresentationCore.dll!System.Windows.Input.TouchDevice.ReportDown() 
    PresentationCore.dll!System.Windows.Input.StylusTouchDeviceBase.OnDown() 
    PresentationCore.dll!System.Windows.Input.StylusPointer.PointerLogic.PromoteMainDownToTouch(System.Windows.Input.StylusPointer.PointerStylusDevice stylusDevice, System.Windows.Input.StagingAreaInputItem stagingItem)
    PresentationCore.dll!System.Windows.Input.InputManager.RaiseProcessInputEventHandlers(System.Tuple<System.Windows.Input.ProcessInputEventHandler, System.Delegate[]> postProcessInput, System.Windows.Input.ProcessInputEventArgs processInputEventArgs) 
    PresentationCore.dll!System.Windows.Input.InputManager.ProcessStagingArea()
    PresentationCore.dll!System.Windows.Interop.HwndPointerInputProvider.ProcessMessage(uint pointerId, System.Windows.Input.RawStylusActions action, int timestamp) 
    PresentationCore.dll!System.Windows.Interop.HwndPointerInputProvider.System.Windows.Interop.IStylusInputProvider.FilterMessage(nint hwnd, MS.Internal.Interop.WindowMessage msg, nint wParam, nint lParam, ref bool handled) 
    PresentationCore.dll!System.Windows.Interop.HwndSource.InputFilterMessage(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled)

由於我跑的是 Release 版本的 WPF 導致了有一些函式被內聯,如從 HwndPointerInputProvider.ProcessMessageInputManager.ProcessStagingArea 中間就少了 InputManager.ProcessInput 函式,完全的無函式內聯的堆疊應該如下

    PresentationCore.dll!System.Windows.Input.InputManager.ProcessStagingArea()
    PresentationCore.dll!System.Windows.Input.InputManager.ProcessInput()
    PresentationCore.dll!System.Windows.Interop.HwndPointerInputProvider.ProcessMessage(uint pointerId, System.Windows.Input.RawStylusActions action, int timestamp)

如下面程式碼是 ProcessInput 函式的程式碼

    public sealed class InputManager : DispatcherObject
    {
        public bool ProcessInput(InputEventArgs input)
        {
            ... // 忽略其他程式碼
            PushMarker();
            PushInput(input, null);
            RequestContinueProcessingStagingArea();

            bool handled = ProcessStagingArea();
            return handled;
        }
    }

進入到 ProcessStagingArea 方法會執行具體的排程邏輯,用上述觸控按下的堆疊作為例子,將會進入到 PointerLogic 的 PostProcessInput 方法裡面,由 PostProcessInput 方法呼叫到 PromoteMainToOther 再到 PromoteMainToTouch 最後到 PromoteMainDownToTouch 方法。只不過中間的幾個方法被內聯了,直接從堆疊上看就是從 RaiseProcessInputEventHandlers 到 PromoteMainDownToTouch 方法,堆疊如下

PresentationCore.dll!System.Windows.Input.StylusPointer.PointerLogic.PromoteMainDownToTouch(...)
PresentationCore.dll!System.Windows.Input.InputManager.RaiseProcessInputEventHandlers(...)

核心觸發按下的程式碼就在 PromoteMainDownToTouch 裡,其程式碼大概如下

        private void PromoteMainDownToTouch(PointerStylusDevice stylusDevice, StagingAreaInputItem stagingItem)
        {
            PointerTouchDevice touchDevice = stylusDevice.TouchDevice;

            ... // 忽略其他程式碼

            touchDevice.OnActivate();
            touchDevice.OnDown();
        }

從上文可以知道,在 HwndPointerInputProvider 的 ProcessMessage 裡面呼叫了 _currentStylusDevice.Update 方法時,就將輸入的資料存放到 PointerStylusDevice 裡面

後續的邏輯就和 WPF 模擬觸控裝置 提到的使用方法差不多,只是資料提供源是從 PointerStylusDevice 提供。如果大家對進入到 InputManager 的後續邏輯感興趣,可參考 WPF 透過 InputManager 模擬排程觸控事件 提供的方法自己跑一下

更多觸控請看 WPF 觸控相關

相關文章