16.3.2 绘制矩形
以函数方式解决问题是出奇的困难,用户交互在 Windows 窗体控件上绘制图形对象。假设我们想要绘制一个矩形,用户首先要在矩形的一个角上按下鼠标按钮,将光标移到对角处,然后释放按钮。在移动光标的过程中,按钮一直是按下的,应用程序绘制矩形的当前形状,释放该按钮时,它最后变成位图,或储以矢量形状列表的形式保存。
典型的命令式实现,是使用一个可变标志指定当前是否正在绘图,一个可变变量来保存用户按下鼠标按钮的最后一个位置。然后,处理 MouseDown 和 MouseMove 事件,当一个事件触发时,适时地修改状态。我们可以在 MouseMove 事件的处理程序中,检查是否完成绘图,因为它也携带有关于鼠标按钮的状态。或者,我们可以使用 MouseUp 按钮,但开始时的第一个版本将更容易。如果我们想起应用程序的控制流,可以看到,它是相当简单的。流程图显示在图 16.5 中。
图 16.5 当应用程序在等待,我们可以按下按钮开始绘制。在这个状态下,可以通过移动鼠标,选择继续绘制,也可以释放按钮,完成这个任务,并改变应用程序的状态,回到等待。
我们几乎可以使用异步工作流,把这种状态机转换成 F# 程序,但是,首先,我们需要一个用来绘制的窗体,和一个工具函数,以帮助完成绘制矩形的基本任务。
实现程序基础
我们以后会改进这个应用程序,先从一个空窗体开始,它可以绘制矩形。清单 16.10 显示创建窗体所需的代码,和一个函数 drawRectangle,使用指定的颜色和两个矩形的任何角点,在窗体上绘制一个矩形。
Listing 16.10 Creating a user interface and drawing utility (F#)
open System
open System.Drawing
open System.Windows.Forms
let form = new Form(ClientSize=Size(800, 600))
let drawRectangle(clr, (x1, y1), (x2, y2)) =
use gr = form.CreateGraphics()
use br = new SolidBrush(clr)
let left, top = min x1 x2, min y1 y2
let width, height = abs(x1 - x2), abs(y1 - y2)
gr.Clear(Color.White)
gr.FillRectangle(br, Rectangle(left, top, width, height))
清单 16.10 非常简单。函数 drawRectangle 的所有参数是作为一个元组,所以,它可以按照调用 .NET 方法的方式使用。另外,它的第二和第三个参数是嵌套的元组,表示矩形角的 X 和 Y 坐标。它使得代码的其余部分更容易。
实现状态机
既然我们已经有了应用程序的所有基本要素,就可以实现用户交互了。我们按照图 16.5 所描述的状态机,有两个状态(等待和绘图),它们之间有各种转换。异步工作流可以直接翻译这个,一个函数表示一个状态。转换可以编码为函数调用,或通过从函数中返回值。
对于我们的示例,这意味着,会有两个函数调用 drawingLoop 和 waitingLoop。第一个函数也要记住某些状态,我们用该函数的参数来表示。清单 16.11 显示了这两个函数。
Listing 16.11 Workflow for drawing rectangles (F#)
let rec drawingLoop(clr, from) = async {
let! move = Async.AwaitObservable(form.MouseMove)
if (move.Button &&& MouseButtons.Left) = MouseButtons.Left then
drawRectangle(clr, from, (move.X, move.Y))
return! drawingLoop(clr, from)
else
return (move.X, move.Y) }
let waitingLoop() = async {
while true do
let! down = Async.AwaitObservable(form.MouseDown)
let downPos = (down.X, down.Y)
if (down.Button &&& MouseButtons.Left) = MouseButtons.Left then
let! upPos = drawingLoop(Color.IndianRed, downPos)
do printfn "Drawn rectangle (%A, %A)" downPos upPos }
编码状态机最直接的方法,是使用递归调用,在这两个函数之间使用 return! 关键字。清单 16.11 对此做了的改变,以增加可读性。WaitingLoop 函数包含无限 while 循环,以等待直到用户单击左键,然后,将控制转移到 drawingLoop 函数。当 drawingLoop 完成后,它将返回矩形最后的位置,并把控制转移回 waitingLoop。然后,我们可以打印关于绘制的矩形、等待另一个 MouseDown 事件的信息。
用户在绘制矩形时运行的函数,使用递归调用,一直在循环,因为它需要保持某些状态。它首先等待 MouseMove 事件,当按钮释放时,也会触发。然后,测试该按钮当前是否被按下,如果真是这样,会刷新这个窗体视图。这种转变对应于绘图状态中的循环的弧。释放按钮时, 返回最后的位置作为结果,转移回等待状态。
这几乎就是运行这个应用程序所需要的一切,剩下的是开始异步工作流,处理绘制矩形,并运行应用程序。我们将使用 Async.StartImmediately 基元,在图形用户界面线程上启动工作流:
[<STAThread>]
do
Async.StartImmediately(waitingLoop())
Application.Run(form)
在这个简单的应用程序中,我们只需要一个异步工作流,处理所有与该应用程序的交互,但多个工作流也可以轻松地组合。如果我们想要允许使用鼠标右键绘制多边形,现有代码不需要做任何更改便可以实现。我们会创建另一个绘制多边形的工作流程,使用 Async.StartImmediately 单独启动它。这种写用户界面代码的方式,是一种模块化的方式,把复杂的交互拆分成到单独的进程。
在图形用户界面线程上运行工作流
我们刚才实现的应用程序包含一个运行的进程,但必须认识到,我们正在使用的一个进程,在某种意义上,并不对应线程。虽然我们有多个等待图形用户界面事件的进程,但这个应用程序将仍然是单线程的。
在前面的示例中,当我们运行 Async.StartImmediately 方法时,它将启动运行的工作流,直至工作流到达等待异步操作完成的点(例如,等待一个事件),才会完成。Async.Sleep 和 Async.AwaitObservable,迄今我们已经使用过的,在完成都返回到调用者线程,所以,这个工作流会继续以独占方式运行在图形用户界面线程上。
虽然我们添加了等待用户界面事件的多个进程,但这个技术并未引入任何并行度。运行图形用户界面线程上的所有代码,如果一个事件引起了多个进程中的状态转换,那么,这些工作流中的位会顺序执行。使用异步工作流来写用户界面,可以使写单线程的图形用户界面处理的方法更简单。
稍后,我们会看到一种技术,能够把处理图形用户界面的这个窗体,和其他进程集成起来,可以以并行方式运行。但是,像这样处理用户界面交互的代码,应该是简单的,不会执行任何复杂的计算,因此不需要并行度。甚至当我们需要执行一些耗时计算,用于图形用户界面时,把所有的工作移到后台线程中,仍是个好主意。
到目前为止,我们所写的代码并不是一个绘图应用程序,因为它并不存储已经绘制的矩形。一旦用户释放按钮,矩形就完成了,它会打印信息到控制台,并忘记该矩形。我们可以把矩形的列表作为 waitingLoop 函数的参数,保存起来(如果我们把它写成递归函数),但这会导致其他问题。对于绘图的循环来说,这个列表可能是私有的,因此,从应用程序的其他部分不能访问它。我们需要别的方法来处理,用于整个应用程序的全局状态。