WPF 从裸 Win 32 的 WM_Pointer 消息获取触摸点绘制字迹

打印 上一主题 下一主题

主题 562|帖子 562|积分 1686

本文将告诉大家怎样在 WPF 里面,接收裸 Win 32 的 WM_Pointer 消息,从消息里面获取触摸点信息,利用触摸点信息绘制简单的字迹
开始之前必须说明的是利用本文的方法不会带来什么上风,既不能带来字迹书写上的加速,也不能带来字迹效果的平滑,且代码复杂。本文唯一的作用只是让大家了解一下基础机制
需要再次说明的是,在 WPF 里面,开启了 WM_Pointer 消息之后,通过 Touch 或 Stylus 变乱收到的信息也是从 WM_Pointer 消息里面过来的。大家可以尝试在 Touch 变乱监听函数添加断点,通过堆栈可以看到是从 Windows 消息循环来的
可以从调用堆栈看到如下函数,此函数就是核心的 WPF 框架里面从 WM_Pointer 消息获取触摸信息的代码
  1. >        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)
复制代码
这个 FilterMessage 函数的大概代码如下
  1.         nint IStylusInputProvider.FilterMessage(nint hwnd, WindowMessage msg, nint wParam, nint lParam, ref bool handled)
  2.         {
  3.                 handled = false;
  4.                 if (PointerLogic.IsEnabled)
  5.                 {
  6.                         switch (msg)
  7.                         {
  8.                         case WindowMessage.WM_ENABLE:
  9.                                 IsWindowEnabled = MS.Win32.NativeMethods.IntPtrToInt32(wParam) == 1;
  10.                                 break;
  11.                         case WindowMessage.WM_POINTERENTER:
  12.                                 handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.InRange, Environment.TickCount);
  13.                                 break;
  14.                         case WindowMessage.WM_POINTERUPDATE:
  15.                                 handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Move, Environment.TickCount);
  16.                                 break;
  17.                         case WindowMessage.WM_POINTERDOWN:
  18.                                 handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Down, Environment.TickCount);
  19.                                 break;
  20.                         case WindowMessage.WM_POINTERUP:
  21.                                 handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Up, Environment.TickCount);
  22.                                 break;
  23.                         case WindowMessage.WM_POINTERLEAVE:
  24.                                 handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.OutOfRange, Environment.TickCount);
  25.                                 break;
  26.                         }
  27.                 }
  28.                 return IntPtr.Zero;
  29.         }
复制代码
由此可以了解到,利用本文自己从 Win32 消息获取的触摸信息,和从 WPF 提供的 Touch 或 Stylus 变乱里面获取的触摸信息的泉源是相同的
这时候大概有人会说,在 WPF 里面经过了一些封装,可能性能不如自己写的。我只想说,不要过于自信了哦。且别忘了消息是从 UI 线程里面获取的,无论你用不用 WPF 的变乱,在 WPF 底层的解析消息获取触摸数据引发变乱的代码都会跑,也就是无论你用不用,需要 WPF 干的活一点都没少。只有一个 UI 线程的情况下,如果用自己解析的,那还会多一点点处置惩罚逻辑,完全不如直接利用 WPF 的。再加上 WPF 的解析部分没有多少代码,如果有做性能分析的话,可以看到乃至做路由变乱时的命中测试,判断命中到哪个控件和引发变乱等逻辑的耗时远比解析来的多。且解析消息的数据耗时接近无法被直接丈量出来,即丈量所需时间大于解析的性能
科普就到这里,如果对 WPF 触摸相干感爱好,请看 WPF 触摸相干
为了能够在消息里面收到 POINTER 消息,我根据 WPF dotnet core 怎样开启 Pointer 消息的支持 博客提供的方法,在 App 构造函数里面添加如下代码开启 Pointer 消息的支持。本文内容里面只给出关键代码片段,如需要全部的项目文件,可到本文末尾找到本文所有代码的下载方法
  1.     public App()
  2.     {
  3.         AppContext.SetSwitch("Switch.System.Windows.Input.Stylus.EnablePointerSupport", true);
  4.     }
复制代码
接下来按照 WPF 怎样确定应用程序开启了 Pointer 触摸消息的支持 博客提供的方法添加消息监听处置惩罚逻辑,如以下代码
  1.     public MainWindow()
  2.     {
  3.         InitializeComponent();
  4.         SourceInitialized += OnSourceInitialized;
  5.     }
  6.     private void OnSourceInitialized(object? sender, EventArgs e)
  7.     {
  8.         var windowInteropHelper = new WindowInteropHelper(this);
  9.         var hwnd = windowInteropHelper.Handle;
  10.         HwndSource source = HwndSource.FromHwnd(hwnd)!;
  11.         source.AddHook(Hook);
  12.     }
  13.     private unsafe IntPtr Hook(IntPtr hwnd, int msg, IntPtr wparam, IntPtr lparam, ref bool handled)
  14.     {
  15.         ... // 忽略其他代码
  16.         return IntPtr.Zero;
  17.     }
复制代码
再定义上一些消息常量,然后跑起来代码确定 Pointer 消息开启成功
  1.     private unsafe IntPtr Hook(IntPtr hwnd, int msg, IntPtr wparam, IntPtr lparam, ref bool handled)
  2.     {
  3.         const int WM_POINTERDOWN = 0x0246;
  4.         const int WM_POINTERUPDATE = 0x0245;
  5.         const int WM_POINTERUP = 0x0247;
  6.         if (msg is WM_POINTERDOWN or WM_POINTERUPDATE or WM_POINTERUP)
  7.         {
  8.              // 在这里打断点,如果能进断点则证明 Pointer 消息开启成功
  9.         }
  10.         ... // 忽略其他代码
  11.         return IntPtr.Zero;
  12.     }
复制代码
以下逻辑需要调用一些 Win32 的 API 函数,为了方便利用,根据 dotnet 利用 CsWin32 库简化 Win32 函数调用逻辑 博客提供的方法,利用 CsWin32 库简化 Win32 函数调用逻辑,可以减少大量的 PInvoke 定义
可以避免定义错 PInvoke 函数导致的诡异失败
编辑 csproj 项目文件,更换为如下代码用于快速安装 CsWin32 库
  1. <Project Sdk="Microsoft.NET.Sdk">
  2.   <PropertyGroup>
  3.     <OutputType>Exe</OutputType>
  4.     <TargetFramework>net9.0-windows</TargetFramework>
  5.     <Nullable>enable</Nullable>
  6.     <ImplicitUsings>enable</ImplicitUsings>
  7.     <UseWPF>true</UseWPF>
  8.   </PropertyGroup>
  9.   <ItemGroup>
  10.     <PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" Version="0.3.106" />
  11.   </ItemGroup>
  12. </Project>
复制代码
大家可以看到以上的项目文件代码的 OutputType 被我设置为 exe 类型,如此启动项目将会有默认的控制台,方便我在控制台输出内容
按照 dotnet 利用 CsWin32 库简化 Win32 函数调用逻辑 博客提供的方法添加 NativeMethods.txt 文件,在此文件里面添加一些代码需要用到的 Win32 函数
  1. GetPointerTouchInfo
  2. ScreenToClient
  3. RegisterTouchWindow
  4. WM_TOUCH
  5. GetTouchInputInfo
  6. GetPointerDeviceRects
  7. ClientToScreen
复制代码
在 NativeMethods.txt 文件添加的是所需的 Win32 函数名,添加之后将会由 CsWin32 库利用源代码生成器方式生成对应的 PInvoke 代码和参数所需的类型,如结构体和枚举
根据 WPF 的源代码,先将消息过来的 wparam 转换为 pointerId 参数,代码如下
  1.             var pointerId = (uint) (ToInt32(wparam) & 0xFFFF);
  2.             PInvoke.GetPointerTouchInfo(pointerId, out var info);
复制代码
这里需要额外说明的是这个 pointerId 参数不等于装备 Id 号,即如 WPF 的 TouchDevice.Id 等,这是不相同的,需要利用 GetPointerCursorId 进行关联才能拿到和 WPF 一样的值。但是利用 pointerId 参数去区分不同的触摸点还是可以的
如此即可拿到核心的 POINTER_INFO 结构体对象
  1.             POINTER_INFO pointerInfo = info.pointerInfo;
复制代码
简单处置惩罚的话,拿到的 pointerInfo 的 ptPixelLocation 字段就是当前触摸的坐标点了,采用的是像素坐标,利用屏幕坐标系
  1.             var point = pointerInfo.ptPixelLocation;
复制代码
从屏幕坐标系转换为 WPF 坐标系,代码如下
  1.             PInvoke.ScreenToClient(new HWND(hwnd), ref point);
复制代码
不考虑 DPI 的情况下,这样就可以利用了
按照 WPF 最简逻辑实现多指顺滑的字迹书写 博客提供的方法进行字迹对接即可绘制出字迹
这就是最简单的从 Win32 消息接收 Pointer 消息绘制字迹的方法
然而以上的方法也存在不少的问题,比如忽略了 DPI 问题,以及精度问题。在大尺寸触摸屏上,直接利用 ptPixelLocation 字段将会画出锯齿的字迹。如下图,黑色的线是直接利用 ptPixelLocation 字段收到的触摸点连接的折线

上图赤色的曲线是利用 WPF 记一个特别简单的点集滤波平滑方法 博客提供的方法进行平滑的字迹线
在大屏触摸装备上,从硬件层面就有一层平滑算法了,但是受限于硬件的计算资源,只有简单的平滑。在 Windows 的 WISPTIS 模块里面,也会对触摸做一定的平滑算法,如丢弃某些过于离谱的触摸点。关于 Windows 上的 WISPTIS 模块的平滑算法属于我和体系软件,即软硬件工程师,进行互助测试出来的,他输入的点和我利用 BusHound 抓到得点和 WPF 层报告的点做对比,可以看到硬件层发送过来的点和 BusHound 抓到的相同,而和 WPF 层报告的点大部分情况下相同,只有某些点被丢弃。被丢弃的点是我这边筹划的杂点。但是如果报告的触摸点,有瞬间飞到 0,0 点的情况,那这个 0,0 点则不会被丢弃
在 WPF 层上,从消息到 Touch 变乱这里,是不会对点的坐标进行处置惩罚,不会执行平滑算法,最多只有做控件坐标转换。在 WPF 的 Ink 模块里面才会对输入的点做更进一步的平滑处置惩罚
我对比了从 Pointer 消息的 ptPixelLocation 字段收到的触摸点对接的 WPF 最简逻辑实现多指顺滑的字迹书写 博客提供的方法,和原始博客提供的程序,可以看到还是原来的字迹更加顺滑
其核心原因在于 Pointer 消息的 ptPixelLocation 字段拿到的是丢失精度的点,像素为单位。如果在精度稍微高的触摸屏下,将会有明显的锯齿效果
如果想要获取比较高精度的触摸点,可以利用 ptHimetricLocationRaw 字段。这里需要对后缀 Raw 作出更多的说明,在微软官方文档里面说了不带 Raw 的是预测的值,即 ptPixelLocation 是预测的像素坐标点,而 ptPixelLocationRaw 是不带预测的像素坐标点。对于咱如果是利用在字迹上,其实更应该利用的是 ptPixelLocationRaw 是不带预测的像素坐标点。否则预测效果可能会导致毛刺
利用 ptHimetricLocationRaw 字段会稍微复杂,由于 ptHimetricLocationRaw 采用的是 pointerDeviceRect 坐标系,需要转换到屏幕坐标系
转换方法就是先将 ptHimetricLocationRaw 的 X 坐标,压缩到 [0-1] 范围内,然后乘以 displayRect 的宽度,再加上 displayRect 的 left 值,即得到了屏幕坐标系的 X 坐标。压缩到 [0-1] 范围内的方法就是除以 pointerDeviceRect 的宽度。同理可以计算 Y 坐标
以上的 displayRect 和 pointerDeviceRect 需要利用 GetPointerDeviceRects 函数获取
  1.             global::Windows.Win32.Foundation.RECT pointerDeviceRect = default;
  2.             global::Windows.Win32.Foundation.RECT displayRect = default;
  3.             PInvoke.GetPointerDeviceRects(pointerInfo.sourceDevice, &pointerDeviceRect, &displayRect);
复制代码
以上代码用到了不安全代码,记得给 Hook 函数标志上 unsafe 作为不安全代码
根据上文提供的算法,编写如下代码将 ptHimetricLocationRaw 转换为 WPF 坐标系的点
  1.             // 如果想要获取比较高精度的触摸点,可以使用 ptHimetricLocationRaw 字段
  2.             // 由于 ptHimetricLocationRaw 采用的是 pointerDeviceRect 坐标系,需要转换到屏幕坐标系
  3.             // 转换方法就是先将 ptHimetricLocationRaw 的 X 坐标,压缩到 [0-1] 范围内,然后乘以 displayRect 的宽度,再加上 displayRect 的 left 值,即得到了屏幕坐标系的 X 坐标。压缩到 [0-1] 范围内的方法就是除以 pointerDeviceRect 的宽度
  4.             // 为什么需要加上 displayRect.left 的值?考虑多屏的情况,屏幕可能是副屏
  5.             // Y 坐标同理
  6.            var point2D = new Point2D(
  7.                 pointerInfo.ptHimetricLocationRaw.X / (double) pointerDeviceRect.Width * displayRect.Width +
  8.                 displayRect.left,
  9.                 pointerInfo.ptHimetricLocationRaw.Y / (double) pointerDeviceRect.Height * displayRect.Height +
  10.                 displayRect.top);
复制代码
以上代码的 Point2D 类型的定义如下
  1. readonly record struct Point2D(double X, double Y);
复制代码
以上代码获取的是屏幕坐标系的点,需要转换到 WPF 坐标系
转换过程的两个重点:
1.底层 ClientToScreen 只支持整数类型,直接转换会丢失精度。纵然是 WPF 封装的 PointFromScreen 或 PointToScreen 方法也会丢失精度
2.需要进行 DPI 换算,必须要求 DPI 感知
先丈量窗口与屏幕的偏移量,这里直接取 0 0 点即可,因为这里获取到的是假造屏幕坐标系,不需要考虑多屏的情况
  1.             var screenTranslate = new Point(0, 0);
  2.             PInvoke.ClientToScreen(new HWND(hwnd), ref screenTranslate);
复制代码
获取当前的 DPI 值
  1.             var dpi = VisualTreeHelper.GetDpi(this);
复制代码
先做平移,再做 DPI 换算
  1.             point2D = new Point2D(point2D.X - screenTranslate.X, point2D.Y - screenTranslate.Y);
  2.             point2D = new Point2D(point2D.X / dpi.DpiScaleX, point2D.Y / dpi.DpiScaleY);
复制代码
此时拿到的 point2D 就是 WPF 坐标系的点了,但是拿这个点对接字迹,如以下代码
  1.             if (msg == WM_POINTERUPDATE)
  2.             {
  3.                 var strokeVisual = GetStrokeVisual(pointerId);
  4.                 strokeVisual.Add(new StylusPoint(point2D.X, point2D.Y));
  5.                 strokeVisual.Redraw();
  6.             }
  7.             else if (msg == WM_POINTERUP)
  8.             {
  9.                 StrokeVisualList.Remove(pointerId);
  10.             }
复制代码
运行代码即可看到可以在较高精度触摸屏上绘制出比较顺滑的字迹
本文代码放在 githubgitee 上,可以利用如下命令行拉取代码。我整个代码仓库比较巨大,利用以下命令行可以进行部分拉取,拉取速度比较快
先创建一个空文件夹,接着利用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码
  1. git init
  2. git remote add origin https://gitee.com/lindexi/lindexi_gd.git
  3. git pull origin 322313ee55d0eeaae7148b24ca279e1df087871e
复制代码
以上利用的是国内的 gitee 的源,如果 gitee 不能访问,请更换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码。如果依然拉取不到代码,可以发邮件向我要代码
  1. git remote remove origin
  2. git remote add origin https://github.com/lindexi/lindexi_gd.git
  3. git pull origin 322313ee55d0eeaae7148b24ca279e1df087871e
复制代码
获取代码之后,进入 WPFDemo/DefilireceHowemdalaqu 文件夹,即可获取到源代码
更多 WPF 触摸相干技术博客,请参阅 博客导航

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

郭卫东

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表