使用 Reactive Extensions (Rx) 的响应式面孔






4.94/5 (30投票s)
Reactive Extensions (Rx) 的简单 WPF 用法。
目录
引言
"Rx" 代表 **Reactive Extensions**,它是 Microsoft DevLabs 举办的项目倡议之一。DevLabs 是微软团队开发中的早期技术的研究场所。这些原型项目发布后会经过开发社区的评估,并根据其成功与否,可能有一天会成为 .NET Framework 的一部分,或成为一种新工具等。
自第一个版本的 .NET Framework 以来,甚至在此之前很久,开发人员一直在处理各种类型的事件:UI 事件(如按键和鼠标移动)、时间事件(如计时器滴答)、异步事件(如 Web 服务响应异步调用)等等。当 DevLabs 团队在这些众多事件类型之间预见到“共性”时,Reactive Extensions 便应运而生。他们努力为我们提供更智能地处理不同事件的工具。本文展示了一些你可以使用 Reactive Extensions 的实用技术,希望它们对你未来的项目有所帮助。
系统要求
要使用本文提供的 WPF ReactiveFace,如果你已经安装了 Visual Studio 2010,那么运行该应用程序就足够了。如果你没有,可以从 Microsoft 直接下载以下这款 100% 免费的开发工具。
此外,你还必须通过单击 DevLabs 页面上同名按钮来下载并安装 **Rx for .NET Framework 4.0**。
Reactive Face 项目
Reactive Face 是一个小型 WPF 项目,它利用了 Reactive Extensions。屏幕上这个有点丑陋、令人毛骨悚然、不完整的头部只是用来说明 Rx 的一些特性的借口。
当你运行该项目时,首先会注意到眼皮在眨动(即使没有眼球!)。正如我之前所说,这个小程序是为了说明 Rx 的特性,所以让我们从眨眼开始。
眨眼本身是通过动画完成的,该动画移动“皮肤”,即覆盖面部眼洞背面的矩形。动画启动后,矩形会快速地上下移动,模拟眨眼。
动画存储在 storyboard 中,而 storyboard 又存储在窗口的 XAML 中。
<Window.Resources>
...
<Storyboard x:Key="sbBlinkLeftEye">
<DoubleAnimation x:Name="daBlinkLeftEye"
Storyboard.TargetName="recEyelidLeft"
Storyboard.TargetProperty="Height"
From="18" To="48"
Duration="0:0:0.100" AutoReverse="True">
</DoubleAnimation>
</Storyboard>
<Storyboard x:Key="sbBlinkRightEye">
<DoubleAnimation x:Name="daBlinkRightEye"
Storyboard.TargetName="recEyelidRight"
Storyboard.TargetProperty="Height"
From="18" To="48"
Duration="0:0:0.100" AutoReverse="True">
</DoubleAnimation>
</Storyboard>
</Window.Resources>
一个简单的计时器
下面的代码将启动 storyboard,以便每 2000 毫秒(2 秒)发生一次眨眼。如果你对动画略知一二,我敢肯定你现在会问:“为什么不在 XAML 本身设置一个 `RepeatBehavior` 为 `Forever`?” 确实,你说得对,但我必须说这只是为了说明你可以如何用 Rx 代码做到这一点。
//Find and store storyboard resources
var sbBlinkLeftEye = (Storyboard)FindResource("sbBlinkLeftEye");
var sbBlinkRightEye = (Storyboard)FindResource("sbBlinkRightEye");
//Set a new observable sequence which produces
//a value each 2000 milliseconds
var blinkTimer = Observable.ObserveOnDispatcher(
Observable.Interval(TimeSpan.FromMilliseconds(2000))
);
//Subscribe to the timer sequence, in order
//to begin both blinking eye storyboards
blinkTimer.Subscribe(e =>
{
sbBlinkLeftEye.Begin();
sbBlinkRightEye.Begin();
}
);
上面代码片段的前几行很简单:它们从 XAML 中查找并实例化 storyboard 变量。接下来是 `Observable.ObserveOnDispatcher` 方法,我稍后会解释。然后是重要部分:`Observable.Interval(TimeSpan.FromMilliseconds(2000))`。这段代码返回一个可观察序列,该序列在每个周期后(在本例中,每 2 秒)产生一个值。如果你想说:“这是个计时器!”,你说得没错。它确实是一个计时器,我们将其用作计时器。请注意,这已经是 Rx 框架提供的一项新功能。因此,虽然你可以使用 `DispatcherTimer` 或其他内置的 .NET 计时器,但现在有了新的 `Observable.Interval` 方法来执行相同的任务。但使用可观察序列的优势在于,正如你稍后将看到的,你可以使用 LINQ 来操作序列的生成方式。
上面代码示例的最后几行指示应用程序在每次可观察序列生成一个值时启动眨眼 storyboard。也就是说,每 2 秒,我们丑陋的面部就会眨一下眼睛。还记得上面的 `Observable.ObserveOnDispatcher` 行吗?该方法用于避免我们在访问 storyboard 对象时(这些对象是在与计时器不同的线程中创建的)出现错误的线程异常。
收集数据
与 `MainWindow.xaml.cs` 一起,你会看到一个私有类 `ElementAndPoint`,你可能会想它为什么在那里。它只是一个 POCO(Plain Old CLR Object),用于在我们移动鼠标和按下/释放鼠标按钮时存储有关控件和点的信息。在下一节中,你会更清楚地看到这一点。
/// <summary />
/// We use this private class just to gather data about the control and the point
/// affected by mouse events
/// </summary />
private class ElementAndPoint
{
public ElementAndPoint(FrameworkElement element, Point point)
{
this.Element = element;
this.Point = point;
}
public FrameworkElement Element { get; set; }
public Point Point { get; set; }
}
来自事件的序列
现在我们来面对一个新的 Rx 方法:`Observable.FromEvent`。此方法返回一个可观察序列,该序列包含底层 .NET 事件的值。也就是说,我们告诉应用程序从 `MouseMove` 和 `MouseUp` 事件创建可观察序列,而值和序列是通过 `GetPosition` 函数返回的点。
//Create 2 observable sequences from mouse events
//targeting the MainWindow
var mouseMove = Observable.FromEvent<mouseeventargs />(this, "MouseMove").Select(
e => new ElementAndPoint(null, e.EventArgs.GetPosition(mainCanvas)));
var mouseUp = Observable.FromEvent<mousebuttoneventargs />(this, "MouseUp").Select(
e => new ElementAndPoint(null, e.EventArgs.GetPosition(mainCanvas)));
让我们仔细看看这些行。
- `Observable.FromEvent<MouseEventArgs>(this, "MouseMove")` 部分指示应用程序从 `MouseMove` 事件创建 `MouseEventArgs` 的可观察序列,并将当前窗口 (`this`) 作为目标元素。仅此指令就会返回一个 `MouseEventArgs` 值序列,但在此示例中,我们通过使用 `Select` 方法为序列中的每个值返回一个新的 `ElementAndPoint` 对象来修改序列值类型。基本上,我们说元素是 `null`(也就是说,我们不关心元素),而 `Point` 是鼠标相对于 `mainCanvas` 元素的鼠标位置(当鼠标移动时)。
- `Observable.FromEvent<MouseButtonEventArgs>(this, "MouseUp")` 使用相同的逻辑,但在此示例中,我们必须小心并定义源类型为 `MouseButtonEventArgs`,这是 `MouseUp` 事件返回的类型。
接下来的两行也为两个不同的事件定义了一个可观察序列:`MouseEnter` 和 `MouseLeave`。每当鼠标进入网格面部区域(由 `grdFace` 元素分隔)时,第一个序列会产生一个值。当你离开该区域时,第二个序列会产生一个值。同样,我将在稍后解释如何使用这些序列。
//Create 2 observable sequences from mouse events
//targeting the face grid
var mouseEnterFace = Observable.FromEvent<mouseeventargs />(grdFace, "MouseEnter").Select(
e => new ElementAndPoint(null, e.EventArgs.GetPosition(mainCanvas)));
var mouseLeaveFace = Observable.FromEvent<mouseeventargs />(grdFace, "MouseLeave").Select(
e => new ElementAndPoint(null, e.EventArgs.GetPosition(mainCanvas)));
使用更复杂的查询
接下来是创建构成面部部件(眼睛、眉毛、鼻子、嘴巴)的用户控件列表的行。
//We store a list of user controls (representing portions of the face)
//so that we can create new observable events and
//subscribe to them independently
var controlList = new List<usercontrol />();
controlList.Add(ucLeftEyeBrow);
controlList.Add(ucLeftEye);
controlList.Add(ucRightEyeBrow);
controlList.Add(ucRightEye);
controlList.Add(ucNose);
controlList.Add(ucMouth);
获得列表后,我们可以轻松地遍历其元素,从针对这些面部部件的事件创建可观察序列。
foreach (var uc in controlList)
{
//Initialize each user control with
//predefined Canvas attached properties.
Canvas.SetZIndex(uc, 1);
Canvas.SetLeft(uc, 0);
Canvas.SetTop(uc, 0);
. . .
现在我们正在遍历用户控件列表,我们基于 `MouseDown` 和 `MouseUp` UI 事件创建可观察序列。还请注意,我们正在使用 `Select` 方法返回 `ElementAndPoint` 对象序列,将 `(FrameworkElement)e.Sender` 的值作为元素。换句话说,序列中的每个值现在包含:
- 鼠标按钮被按下或释放的 `Point`
- 发生鼠标按下/释放事件的 `Element`
//Create 2 observable sequence from mouse events
//targetting the current user control
var mouseDownControl = Observable.FromEvent<mousebuttoneventargs />(uc, "MouseDown").Select(
e => new ElementAndPoint((FrameworkElement)e.Sender, e.EventArgs.GetPosition(mainCanvas)));
var mouseUpControl = Observable.FromEvent<mousebuttoneventargs />(uc, "MouseUp").Select(
e => new ElementAndPoint((FrameworkElement)e.Sender, e.EventArgs.GetPosition(mainCanvas)));
语法一开始可能看起来有点奇怪,但我相信如果你通过这个进行少量练习,你会习惯的。
我们应用程序中的另一个重要部分是拖放功能。每个面部部件都可以进行拖放,这基本上由两段代码完成:第一段是 LINQ 查询,它创建一个在面部部件被拖动时填充的可观察序列。第二段代码订阅该可观察序列并相应地移动面部部件。
//Create a observable sequence that starts producing values
//when the mouse button is down over a user control, and stops producing values
//once the mouse button is up over that control,
//while gathering information about mouse movements in the process.
var osDragging = from mDown in mouseDownControl
from mMove in mouseMove.StartWith(mDown).TakeUntil(mouseUp)
.Let(mm => mm.Zip(mm.Skip(1), (prev, cur) =>
new
{
element = mDown.Element,
point = cur.Point,
deltaX = cur.Point.X - prev.Point.X,
deltaY = cur.Point.Y - prev.Point.Y
}
))
select mMove;
//Subscribe to the dragging sequence, using the information to
//move the user control around the canvas.
osDragging.Subscribe(e =>
{
Canvas.SetLeft(e.element, Canvas.GetLeft(e.element) + e.deltaX);
Canvas.SetTop(e.element, Canvas.GetTop(e.element) + e.deltaY);
}
);
上面的代码片段可以用通俗的英语翻译为:“在用户按下某个元素的鼠标按钮后,并且在用户尚未释放按钮的情况下,每当用户在当前窗口上移动鼠标时,就返回一个包含被拖动元素、鼠标指针当前位置的点以及自上次鼠标移动以来表示坐标移动的增量的序列。并且对于返回的每个值,根据计算出的 X、Y 增量移动受影响元素的 X、Y 坐标。” 很容易,不是吗?
现在让我们更仔细地关注我们刚才所做的。
- 上面 LINQ 查询的核心是 `mouseMove` 可观察序列(我们在前面已经声明过)。
- `StartWith` 和 `TakeUntil` 方法告诉我们的应用程序何时可观察序列必须开始/停止生成值。
- `mm.Zip(mm.Skip(1), (prev, cur)` 部分是一个指令,它将两个序列值合并为一个序列值:这非常方便,因为它使我们能够使用上一个序列值和当前序列值并将它们组合起来计算增量。
- 以 *new { element...* 开头的匿名类型修改了返回类型,以便我们可以获得有关拖动操作的更多信息。
- `Subscribe` 方法描述了一个每次拖动面部部件时执行的操作。在本例中,该元素的 `Left` 和 `Top` 属性被设置,以便该元素可以四处移动。
按需订阅
进入下一部分:假设我们想让选定的部件浮动到屏幕上其他元素之上:在这种情况下,我们可以将 `ZIndex` 设置为一个较高的值,比如 100。然后,我们所要做的就是向 `mouseDownControl` 可观察序列订阅另一个操作,并使用以下方式修改元素的属性:
...
var mouseDownControl = Observable.FromEvent<mousebuttoneventargs />(uc, "MouseDown").Select(
e => new ElementAndPoint((FrameworkElement)e.Sender,
e.EventArgs.GetPosition(mainCanvas)));
...
//Once the mouse button is up, the ZIndex is set to 100, that is,
//we want to make the user control to move on top of any other controls
//on the screen.
mouseDownControl.Subscribe(e =>
{
Canvas.SetZIndex(e.Element, 100);
}
);
使用相同技术,我们可以在用户释放元素后将其恢复到其正确的 `ZIndex` 值。在我们的示例中,这允许眼球保持在眼皮后面。我们通过订阅 `mouseUpControl` 序列来做到这一点。
...
var mouseUpControl = Observable.FromEvent<mousebuttoneventargs />(uc, "MouseUp").Select(
e => new ElementAndPoint((FrameworkElement)e.Sender,
e.EventArgs.GetPosition(mainCanvas)));
...
//Once the mouse button is down, the ZIndex is set to the proper value (1),
//unless for the eye controls, which are set to -1 in order to put them
//behind the face.
mouseUpControl.Subscribe(e =>
{
switch (e.Element.Name)
{
case "ucLeftEye":
case "ucRightEye":
Canvas.SetZIndex(e.Element, -1);
break;
default:
Canvas.SetZIndex(e.Element, 1);
break;
}
}
);
}
完成界面
最后,我们订阅 `mouseMove` 可观察序列。请注意,这里有很多事情正在发生:眉毛在移动,眼睛在看着鼠标光标,牙齿在上下移动。我们美丽而丑陋的面孔已经完成,并且在关注我们的鼠标移动。
当然,我们可以使用单独的操作,甚至单独的函数。只要以最适合你的方式使用它。
var leftPupilCenter = new Point(60, 110);
var rightPupilCenter = new Point(130, 110);
//Subscribe to the mousemove event on the MainWindow. This is used
//to move eyes and eyebrows.
mouseMove.Subscribe(e =>
{
double leftDeltaX = e.Point.X - leftPupilCenter.X;
double leftDeltaY = e.Point.Y - leftPupilCenter.Y;
var leftH = Math.Sqrt(Math.Pow(leftDeltaY, 2.0) + Math.Pow(leftDeltaX, 2.0));
var leftSin = leftDeltaY / leftH;
var leftCos = leftDeltaX / leftH;
double rightDeltaX = e.Point.X - rightPupilCenter.X;
double rightDeltaY = e.Point.Y - rightPupilCenter.Y;
var rightH = Math.Sqrt(Math.Pow(rightDeltaY, 2.0) + Math.Pow(rightDeltaX, 2.0));
var rightSin = rightDeltaY / rightH;
var rightCos = rightDeltaX / rightH;
if (!double.IsNaN(leftCos) &&
!double.IsNaN(leftSin))
{
ucLeftEye.grdLeftPupil.Margin =
new Thickness(leftCos * 16.0, leftSin * 16.0, 0, 0);
}
if (!double.IsNaN(rightCos) &&
!double.IsNaN(rightSin))
{
ucRightEye.grdRightPupil.Margin =
new Thickness(rightCos * 16.0, rightSin * 16.0, 0, 0);
}
var distFromFaceCenter = Math.Sqrt(Math.Pow(e.Point.X - 90.0, 2.0) +
Math.Pow(e.Point.Y - 169.0, 2.0));
ucLeftEyeBrow.rotateLeftEyebrow.Angle = -10 + 10 * (distFromFaceCenter / 90.0);
ucRightEyeBrow.rotateRightEyebrow.Angle = 10 - 10 * (distFromFaceCenter / 90.0);
ucMouth.pnlTeeth.Margin =
new Thickness(0, 10 * (distFromFaceCenter / 90.0) % 15, 0, 0);
}
);
最终考虑
正如我之前所说,这只是 Rx 功能的一瞥。 **Reactive Extensions** 当然还有很多可以做的事情,但如果本文能以任何方式对您有所帮助,我将很高兴。有关 Rx 的更多方法,请阅读 Code Project 上其他很棒的 Rx 文章。
历史
- 2011-02-27:初始版本。