WPF 和多点触控






4.93/5 (59投票s)
探索 WPF 的功能、局限性和解决方法,以创建具有多点触控功能的尖端应用程序。
引言
尽管微软已开始忽视 WPF,但 WPF 在创建 Windows 应用程序方面依然保持着不可动摇的受欢迎程度。如果我们考虑到该框架本身仍有许多优点并提供大量优势,那么这是自然而然的。此外,WPF 为我们提供了丰富且至今仍无与伦比的编码模型。
然而,WPF 的一个缺点是对底层框架的依赖。这并非新鲜事,因为任何技术都有这种缺点。如果我们编写 Web 应用程序,那么我们也将依赖于浏览器供应商。如果我们编写微控制器,那么我们也依赖于制造商。
在本文中,我将尝试讨论在使用 WPF 进行多点触控时将遇到的可能性和问题。本文本身也将尝试说明各种解决方案。应该指出的是,本文所述的问题可能对您或您的项目来说并非问题,但对我个人而言,它一直是一个相当棘手的麻烦。
背景
大约一年前,我完成了我第一个以触控为重点的 WPF 应用程序,名为 Sumerics。该应用程序 UI 外观精美,运行流畅。然而,当使用多点触控平移或缩放图表时,我遇到了一个奇怪的行为。基本上,在开始时平移或缩放工作正常,但随后变得越来越延迟。我以为我正在使用的图表控件(非常出色的 oxyplot 库)存在 bug,但我找不到。从我的角度来看,代码看起来还可以,并且在某些情况下已经优化。
一段时间以来,我都可以忽略这种奇怪的行为,但在我最新的项目 Quantum Striker 中,我再次遇到了这种行为。我负责游戏引擎、音频和视频,以及完善的故事线。控制器集成是这个游戏引擎的一部分,因此触控是通过 WPF 集成的。我遇到的情况是,使用所谓的“striker”(它们基本上只是代表两个(永久)触点)会导致触控执行延迟。这种延迟在开始时几乎为零,但随着触控交互的持续时间而增加。
我一直在追踪这个恼人的 bug,并设法发现实际上是 WPF 工作不正常。我创建了一个小型演示程序,能够展示这种行为。如果我们只用一根手指使用该应用程序,我们将无法注意到任何明显的延迟。使用更多的手指使用相同的应用程序,我们将看到延迟随着触控交互的持续时间而增加。此外,手指的数量,即触控交互的数量,将影响这条曲线的斜率。
处理多点触控
WPF 提供了多种可能性来挂接到触控设备事件。所有这些可能性都是基于事件的。没有选项可以访问推送式 API。
一般来说,我们可以访问以下事件
Touch*
,例如TouchDown
或TouchUp
Stylus*
,例如StylusDown
或StylusUp
Manipulation*
,例如ManipulationStarted
或ManipulationCompleted
与某些 WPF 事件(实际上是所有路由事件)一样,我们也可以访问预览事件,即在捕获阶段(而不是冒泡阶段)触发的事件。这个捕获阶段有时也称为*隧道阶段*,因为事件只是穿过大多数元素,直到遇到一个称为源的元素。
在这里,我们有以下 Preview*
事件
PreviewTouch*
,例如PreviewTouchDown
或PreviewTouchUp
PreviewStylus*
,例如PreviewStylusDown
或PreviewStylusUp
当我们触摸屏幕时,WPF 会接收消息并生成这些事件。由于触控事件都是路由事件,因此 Preview*
事件会在可视化树的根部触发,向下穿过可视化树,直到遇到触控事件发生的源元素。
一旦到达源元素,事件就会触发并从源元素开始冒泡。这种冒泡会沿着可视化树向上到达根部。可以通过设置 e.IsHandled = true
来停止事件传播。
另一方面,如果触控事件传播一直到达根部,那么为了保证向后兼容性,该触控事件将被提升为鼠标事件。此时,将在隧道阶段触发 PreviewMouseDown
。最后,在冒泡阶段触发 MouseDown
事件。
如果我们附近没有控件或窗口,我们也可以通过使用静态类 Touch
来访问触控输入,该类位于 System.Windows.Input
命名空间中。通过引用 PresentationCore 库可以获得此类型。
尽管如此,这种方法也只允许我们挂接到一个名为 FrameReported
的事件。挂接到此事件,我们将通过 TouchFrameEventArgs
类在回调中接收数据。该类最重要的函数是 GetTouchPoints
。这基本上将使我们能够访问当前可用的触控点。最重要的是,每个触控点都将分配给一个触控设备,并且每个触控设备都会获得一个整数形式的 ID。这使得跟踪某些手指成为可能,只要它们停留在表面上或附近。
TouchPoint
类包含 Action
(获取最后一个动作,即此触控设备发生了什么)、Position
(获取此点相对于控件的位置)和 TouchDevice
(获取此触控设备的有用信息,例如 IsActive
或 Id
)等属性。这些就是我们开始使用 WPF 进行多点触控所需的一切。
问题所在
让我们考虑一个使用两根手指放大或缩小 3D 图的简单问题。WPF 的逻辑树包含数百个对象,而可视化树包含更多元素。WPF 现在将尝试遍历树并在每个点评估触控。这会产生巨大的开销。无需多言,实际上没有附加任何事件处理程序。虽然鼠标事件立即触发,但触控的处理方式不同。
似乎当 UI 繁忙时,触控事件没有被正确忽略。触控通常比鼠标更重,因为它会生成更多点,并且这些点有一个边界框来表示交互的大小。如果 UI 繁忙,WPF 就不应该尝试通过上下遍历树来处理事件,而应该缓冲点并将它们存储在中间点集合中。
这意味着 WPF 在触控方面的主要问题在于触控事件的频率,以及 UI 处理和所需的处理需求。如果 UI 不那么繁忙或频率降低,WPF 可能会很好地处理事件。
此外,还可以使用不同种类的事件。但是,似乎没有规律可循。在某些情况下,Stylus*
事件似乎表现更好,但在所有情况下都不是。也没有规律可循,不知何时使用这些事件更好,因此推荐。
显然,将触控输入外包给另一个不涉及任何事件隧道或冒泡的层可能会非常有益。此外,我们可能希望从 Windows API 获得特殊的触控处理。
这些事件的延迟显示在下一个图中。在这里,我们仅用时间戳标记了触控事件,并将其记录在调试日志中。由于每次转弯的延迟都存在一个恒定的偏移量,因此我们有一个累积和,即指数函数。时间延迟以任意单位显示,可能因系统而异。但是,这里要注意的是,问题会随着时间而加剧。它可能在未来某个点饱和,但在此之前肯定无法使用。
在下一节中,我们将看看可能的解决方案,以及它们的优缺点。最后,将介绍在最近的项目中使用过的解决方案,并进行简短的讨论和可能的改进。
可能的解决方案
存在各种可能的解决方案。有些解决方案可能比其他解决方案更适合,具体取决于问题。如果屏幕交互是多点触控,但仅限于几秒钟,Stylus*
事件可能就足够了。
如果我们想将触笔事件用于,例如,在 Canvas
元素上绘图,那么以下代码将能够完成此任务。
Point pt1;
Point pt2;
Int32 firstId;
Int32 fingers;
void StylusDown(Object sender, StylusEventArgs e)
{
fingers++;
if (canvas1 != null)
{
var id = e.StylusDevice.Id;
//Clear all lines
canvas1.Children.Clear();
//Capture the touch device (i.e., finger on the screen)
e.StylusDevice.Capture(canvas1);
// Record the ID of the first Stylus point if it hasn't been recorded.
if (firstId == -1)
firstId = id;
}
}
void StylusMove(Object sender, StylusEventArgs e)
{
if (canvas1 != null)
{
var id = e.StylusDevice.Id;
var tp = e.GetPosition(canvas1);
// This is the first Stylus point; just record its position.
if (id == firstId)
{
pt1.X = tp.X;
pt1.Y = tp.Y;
}
else if (id != firstId)
{
pt2.X = tp.X;
pt2.Y = tp.Y;
// Draw the line
canvas1.Children.Add(new Line
{
Stroke = new RadialGradientBrush(Colors.White, Colors.Black),
X1 = pt1.X,
X2 = pt2.X,
Y1 = pt1.Y,
Y2 = pt2.Y,
StrokeThickness = 2
});
}
}
}
void StylusUp(Object sender, StylusEventArgs e)
{
var device = e.StylusDevice;
if (canvas1 != null && device.Captured == canvas1)
canvas1.ReleaseStylusCapture();
if (--fingers == 0)
firstId = -1;
}
Manipulation 和通用触控事件通常只适用于单点触控。此外,它们似乎在短时间后会变得相当滞后。触笔事件的优势在于它们在 WPF 中的特殊作用。由于触笔交互是逐点且持久的,因此它们比其他可能性更轻量且延迟更低。
当然,这里最终将要介绍的解决方案会略有不同。首先,我们将假设我们需要一个持久、可靠且仍然可行的 WPF 解决方案。其次,我们还希望此解决方案支持尽可能多的手指(从硬件方面)。最后,如果需要,解决方案仍然允许我们在项目中其他交互,如键盘或鼠标事件。
解决方案是使用一个层,该层通过使用一个 Windows Forms Form
实例创建,该实例将位于关键区域之上。该层需要有几个属性。首先,它需要是透明的。其次,它仍然需要被 Windows 视为事件目标。第三,它应该与我们的绘图画布(或任何元素)大小相同。最后,无论之前采取了什么操作(最小化、移动等),它都应该始终显示在我们的绘图画布之上。这种一致性非常重要。
需要此解决方案,因为只有(对于托管 .NET 应用程序)Windows Forms 才能让我们完全访问消息循环。此外,Form
实例可以设置为透明或放置在其他窗口之上。
我们也可以尝试挂接到 WPF 的消息。这可以通过使用 HwndSource
类来实现。通过调用 PresentationSource
类的静态 FromVisual()
方法,我们可以获得该类的适当实例。
一些演示代码说明了这一点
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
HwndSource source = PresentationSource.FromVisual(this) as HwndSource;
source.AddHook(WndProc);
}
IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
// Handle message ...
return IntPtr.Zero;
}
}
然而,这不如 Windows Forms 那样直接。这有一个缺点,即 WPF 已经进行了一些预处理。这些预处理已经捕获了 WPF 已知的所有事件,因此对我们来说几乎无用。
使用我们自己的层,我们之后将拥有一个事件 API,它允许我们将上面三个事件处理程序(用于触笔方法)替换为以下两个。我们不需要像以前那样捕获触控,因此我们可以省略第三个处理程序,该处理程序负责 StylusUp
事件。
void WMTouchDown(Object sender, WMTouchEventArgs e)
{
if (canvas1 != null)
{
canvas1.Children.Clear();
var id = e.Id;
// Record the ID of the first Stylus point if it hasn't been recorded.
if (firstId == -1)
firstId = id;
}
}
void WMTouchMove(Object sender, WMTouchEventArgs e)
{
if (canvas1 != null)
{
var id = e.Id;
var tp = new Point(e.LocationX, e.LocationY);
// This is the first Stylus point; just record its position.
if (id == firstId)
{
pt1.X = tp.X;
pt1.Y = tp.Y;
}
else if (id != firstId)
{
pt2.X = tp.X;
pt2.Y = tp.Y;
canvas1.Children.Add(new Line
{
Stroke = new RadialGradientBrush(Colors.White, Colors.Black),
X1 = pt1.X,
X2 = pt2.X,
Y1 = pt1.Y,
Y2 = pt2.Y,
StrokeThickness = 2
});
}
}
}
API 看起来非常相似,这是一个非常重要的特征,我们正在构建的 facade。我们需要挂接到消息循环的触发事件,在我们自己的 Form
中。消息号为 **0x0240**,存储在 Unmanaged
类中。该类存储了与本机代码进行通信的所有重要实用程序,即知道结构、值和函数。
[PermissionSet(SecurityAction.Demand, Name = "FullTrust")]
protected override void WndProc(ref Message m)
{
var handled = false;
if (m.Msg == Unmanaged.WM_TOUCH)
handled = DecodeTouch(ref m);
base.WndProc(ref m);
if (handled)
m.Result = new IntPtr(1);
}
此触发器也处理触控事件。处理通过 HandleTouch()
方法实现。基本上,此方法只会查看包含的触控信息。如果事件处理程序可用,则会计算参数并触发事件。
Boolean HandleTouch(ref Message m)
{
//Get the input count from the WParam value
var inputCount = LoWord(m.WParam.ToInt32());
//Create an array of touchinput structs with the size of the computed input count
var inputs = new Unmanaged.TOUCHINPUT[inputCount];
//Stop if we could not create the touch info
if (!Unmanaged.GetTouchInputInfo(m.LParam, inputCount, inputs, touchInputSize))
return false;
var handled = false;
//Lets see if we can handle this by iterating over all touch points
for (int i = 0; i < inputCount; i++)
{
var input = inputs[i];
//Get the appropriate handler for this message
EventHandler<WMTouchEventArgs> handler = null;
if ((input.dwFlags & Unmanaged.TOUCHEVENTF_DOWN) != 0)
handler = WMTouchDown;
else if ((input.dwFlags & Unmanaged.TOUCHEVENTF_UP) != 0)
handler = WMTouchUp;
else if ((input.dwFlags & Unmanaged.TOUCHEVENTF_MOVE) != 0)
handler = WMTouchMove;
//Convert message parameters into touch event arguments and handle the event
if (handler != null)
{
var pt = parent.PointFromScreen(new win.Point(input.x * 0.01, input.y * 0.01));
var te = new WMTouchEventArgs
{
//All dimensions are 1/100 of a pixel; convert it to pixels.
//Also convert screen to client coordinates
ContactY = input.cyContact * 0.01,
ContactX = input.cxContact * 0.01,
Id = input.dwID,
LocationX = pt.X,
LocationY = pt.Y,
Time = input.dwTime,
Mask = input.dwMask,
Flags = input.dwFlags,
Count = inputCount
};
//Invoke the handler
handler(this, te);
//Alright obviously we handled something
handled = true;
}
}
Unmanaged.CloseTouchInputHandle(m.LParam);
return handled;
}
我们永远不会直接使用此代码,而是通过一个小型 API 进行通信。此 API 也不会公开任何 Windows Forms 类,即使用此层的项目不会对任何 Windows Forms 程序集有直接依赖。
此外,已发现触控事件处理可能无限期地阻塞调度程序。以下代码足以触发此行为
static Int32 _tickIdx = 0;
public static void Init()
{
var frameTimer = new DispatcherTimer();
frameTimer.Tick += Tick;
frameTimer.Interval = TimeSpan.FromSeconds(1.0 / 30.0);
frameTimer.Start();
}
static void Tick(Object sender, EventArgs e)
{
Debug.WriteLine("Tick called. Index = " + (++_tickIdx));
}
我们应该注意到,一旦触控传感器上有超过 7 个手指,日志记录就会暂停。因此,如果我们依赖于调度程序计时器,我们可能会遇到真正的麻烦。在这种情况下,tick 事件不再是可靠的频率生成器。
还有一点需要注意的是,WPF 无法*真正*从应用程序中移除所有触笔设备。即使调用了 DisableWPFTabletSupport
方法(移除所有触笔设备),WPF 平板电脑支持仍未从应用程序中移除(例如,在 Microsoft Surface Pro 4 上观察到)。
唯一可靠的方法是禁用人机接口中所有与触摸屏相关的设备。此外,仅禁用“HID 兼容触摸屏设备”是不够的。直到禁用“Intel(R) Precise touch devices”为止,触摸屏都保持激活状态,并且大多数 WPF 应用程序可能会失败。
使用代码
提供的示例项目包含一个库,其中包含用于画布元素的特殊用途的层。使用范围不限于画布元素——实际上,任何 FrameworkElement
都可以完成此工作。但是,库的某些部分可能过于专门化,因此如果需要,应进行修改。
源代码中的大多数演示应用程序都显示了一个用于触摸绘图的画布。每个类别的应用程序使用不同类型的事件源。请随时增加手指数量,以观察非分层演示的延迟。
绘图演示看起来如下
此外,还包含一个椭圆修改演示,它使用 Stylus*
事件。此演示也可以使用任何其他技术运行,但是,展示分配给 WPF 的工作量很重要。拥有另一种交互方式也很有趣。
此演示看起来如下图像(三个手指在屏幕上)
所有演示的代码都非常相似,仅用于演示目的。使用层库非常简单。只有一个访问点:使用 WMInputLayerFactory
类和静态 Create()
方法。
在 WPF 窗口的构造函数中,可以使用以下代码片段方便地使用它。
public MainWindow()
{
InitializeComponent();
Loaded += (s, ev) =>
{
//Keep reference to the following object if you want to de-activate it at some point
var layer = WMInputLayerFactory.Create(canvas1);
layer.WMTouchDown += layer_WMTouchDown;
layer.WMTouchMove += layer_WMTouchMove;
layer.Active = true;
};
}
通过设置布尔值 Active
属性为 true/false 来激活/停用该层。
另一种纯 WPF 的方式
好吧,利用上述信息以及隐藏在 MSDN 某个先前未知位置的一个小秘密,我们可以构建一个纯 WPF 解决方案。该解决方案的优点是无需管理任何层。此外,所有其他事件(鼠标、键盘等)都可以开箱即用。
此方法唯一的缺点是需要正确注册触控。可以创建一个新的 Window
称为 TouchWindow
,但是,问题仍然存在,即先前介绍的触控事件将不再在应用程序的任何地方工作。
那么这个神秘的方法是什么呢?首先,我们需要运行以下代码(在我们的应用程序中的任何地方,一旦我们需要非常响应式多点触控):
static void DisableWPFTabletSupport()
{
// Get a collection of the tablet devices for this window.
var devices = Tablet.TabletDevices;
if (devices.Count > 0)
{
// Get the Type of InputManager.
var inputManagerType = typeof(InputManager);
// Call the StylusLogic method on the InputManager.Current instance.
var stylusLogic = inputManagerType.InvokeMember("StylusLogic",
BindingFlags.GetProperty | BindingFlags.Instance | BindingFlags.NonPublic,
null, InputManager.Current, null);
if (stylusLogic != null)
{
// Get the type of the stylusLogic returned from the call to StylusLogic.
var stylusLogicType = stylusLogic.GetType();
// Loop until there are no more devices to remove.
while (devices.Count > 0)
{
// Remove the first tablet device in the devices collection.
stylusLogicType.InvokeMember("OnTabletRemoved",
BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.NonPublic,
null, stylusLogic, new object[] { (uint)0 });
}
}
}
}
上面的代码来自 MSDN,它基本上通过反射移除了所有触笔处理程序。这代码背后并没有太多魔法,但是,它是一个不错的代码片段,无需反射、进行命名和修饰符等方面的研究。一旦运行此代码,任何控件、窗口或任何东西都将无法再捕获触笔(或触控,总而言之)事件。那么我们如何获得触控呢?
这与我们使用分层的概念相同。我们使用之前的信息,即我们实际上可以过滤 WPF 的消息循环。这一次(通过禁用触笔支持),WPF 不会干扰我们的计划!因此,此代码将起作用。
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var source = PresentationSource.FromVisual(this) as HwndSource;
source.AddHook(WndProc);
RegisterTouchWindow(source.Handle, 0);
}
IntPtr WndProc(IntPtr hwnd, Int32 msg, IntPtr wParam, IntPtr lParam, ref Boolean handled)
{
if (msg == 0x0240) //WM_TOUCH
{
handled = HandleTouch(wParam, lParam);
return new IntPtr(1);
}
return IntPtr.Zero;
}
HandleTouch
方法实际上与之前的方法非常相似。总而言之,我们通过排除包含的触控事件来排除对额外层的管理。对于大多数需要持久多点触控交互的应用程序,此解决方案应该是最好的。
我已将源代码更新为包含此演示。**注意**一旦显示了“禁用触笔”演示,大多数其他演示(不包括分层演示)将不再工作。然后需要重新启动演示应用程序。
**注意** 以下签名可能在您的系统上不起作用
static extern Boolean RegisterTouchWindow(IntPtr hWnd, UInt64 ulFlags);
如果您遇到 PInvokeStackImbalance
错误,您可能需要将签名更改为
static extern Boolean RegisterTouchWindow(IntPtr hWnd, UInt32 ulFlags);
感谢 Matej Novotny 指出这一点!
一个说明性样本
我显然为此部分准备了一些内容,但是,我决定将其发布在一篇独立文章中。问题是关于 Quantum Striker 的文章不存在(不幸或幸运的是它已被删除)。此外,Quantum Striker 游戏还有许多有趣的内容,例如使用 NAudio
播放游戏中的音频效果,或者游戏背后的物理原理。
此外,还有一些甚至没有在生产中使用过的有趣内容,例如精灵管理器。我认为为整个游戏单独写一篇文章可能很有趣。这也可以让我写一些不仅仅是多点触控层之外的功能。
最终 Quantum Striker 也可能会作为 Windows 应用商店应用发布。使应用程序可移植也可能对某些人来说非常有趣。在我看来,原始代码在多大程度上可以轻松移植已经相当有趣了。
**更新** Quantum Striker 在 Intel AIC 的游戏类别中获得了大约前 5 名。这是一个巨大的成功。一旦最终视频完成,我希望有时间完成这篇文章。敬请关注!
关注点
Quantum Striker 现在已完成。如果您想了解更多关于这款游戏的信息,那么请观看 YouTube 上的视频,或者访问官方网站 quantumstriker.anglevisions.com。下图也指向官方 YouTube 视频
我强烈推荐以下资源。这些链接讨论了 WPF 在触控事件延迟方面的问题。看到整个主题是如何发展的非常有趣。不幸的是,似乎在不久的将来不会有任何进展。在我看来,它们是进一步阅读的绝佳起点
- MSDN:触控事件响应能力/性能/延迟
- Stack Overflow:应用程序,提高触控事件的性能
- Connect:WPF 触控事件触发延迟
- MSDN:禁用 WPF 应用程序的 RealTimeStylus
结论
到目前为止,这篇文章已经帮助了许多人——这又一个迹象表明 WPF 在微软强调并宣布为重要的领域存在不足。这很遗憾,但我对它最终会得到修复的希望不会破灭。
在我看来,以下引述是对本文的绝佳结论和总结
到目前为止,我的结论是,每个传感器/PC 组合在一种方法上都会有更好的性能。WMLayer 通常是最好的,但并非总是。在我拥有更可靠的数据点之前,我计划依赖一个测试应用程序,该应用程序在 10 个手指持续移动的 Surface 上测量性能,并根据此用户可以选择最适合其传感器/PC 设置的组合。随着时间的推移,我希望我能知道哪种方法在大多数情况下是最好的,这样就可以跳过测试应用程序。
历史
- v1.0.0 | 初始发布 | 2013年12月4日
- v1.1.0 | 更新了关于实时触笔的信息 | 2013年12月5日
- v1.1.1 | 修复了一些拼写错误 | 2013年12月8日
- v1.1.2 | 更新了关于 Quantum Striker 游戏的信息 | 2013年12月9日
- v1.2.0 | 包含有关注册窗口签名的信息 | 2015年11月11日
- v1.3.0 | 包含有关调度程序阻塞的信息 | 2016年7月29日
- v1.3.1 | 使用重置更新了触笔算法 | 2016年9月30日