F# 编程

借助 F# 构建 MVVM 应用程序

Chris Marinos

下载代码示例

尽管对于 Visual Studio 家族来说,F# 是一个新成员,但它已经帮助许多 .NET 开发人员认识到了函数式编程的强大之处。 F# 在简化并行和异步编程、数据处理和金融建模等复杂问题方面声誉卓著,而且越来越受欢迎。 但是,这并不意味着 F# 是一种小众语言,它也适合解决日常遇到的问题。

在本文中,您将了解如何使用 F# 来构建实用的 Silverlight 和 Windows Presentation Foundation (WPF) Model-View-ViewModel (MVVM) 应用程序。 您将看到 F# 在简化复杂算法方面的优势同样也可以用来降低您的视图模型的繁琐程度。 您还可以看到,F# 中广为人知的异步工作流也适用于 GUI 设置。 最后,我将介绍借助 F# 构建 MVVM 应用程序的两种常用方法,以及每种方法各自的优缺点。 在本文结束时,您将了解到 F# 不仅仅是可以解决专业问题的工具,您还可以使用 F# 构建易于阅读、编写和维护的最简单应用程序。

降低繁琐性

您可能会认为 F# 语言用来处理 GUI 应用程序很奇怪。 毕竟,函数式语言不强调副作用,而 Silverlight 和 WPF 等框架则满是副作用。 您可能认为视图模型没有包含足够复杂的逻辑,因此转换到函数式类型没什么好处。 您可能还认为函数式编程需要进行风格转变,因此很难与 MVVM 等设计模式一起使用。 而事实是,使用 F# 来构建视图模型及模型与使用 C# 一样简单。 视图模型还是一个理想的场所,能够体现出 F# 如何减少繁琐的样板代码。 如果您习惯使用 C# 编程,则可能会惊讶 F# 在改善代码信噪比方面的有效性。

图 1 包含一个简单电影模型的 F# 代码,其中包含 Name、Genre 和 Rating(可选)字段。 如果您熟悉 F# 中的 option 类型,可以将它们当做 C# 中可以为空的对象,但功能更强大、更富表现力。 通过 option 类型来声明电影模型有没有评级是非常自然的方法,但可能会给数据绑定带来问题。 例如,试图将某个 option 类型的值设置为 None(对于可以为空的对象,等同于 null)将引发异常。 如果 Rating 被设置为 None,您还可能想要隐藏绑定到 Rating 的控件。 遗憾的是,如果 WPF 或 Silverlight 在尝试获取 Rating 的值时遇到异常,您的可见性绑定可能无法正确执行。 以下是一个简单的示例,在这个示例中您需要在一个视图模型中添加有关评级的额外显示逻辑。

图 1 电影的简单模型

type Movie = {
  Name: string
  Genre: string
  Rating: int option
}

图 2 显示了一个带有此额外显示逻辑示例的视图模型。 如果 Rating 存在,其值将传递给视图;否则,Rating 的值将被赋予默认值 0。 这个简单的逻辑可以防止在 Rating 为 None 时引发异常。 此视图模型还公开了另一个属性来处理可见性绑定。 如果 Rating 为 Some,则此方法将返回 True;如果 Rating 为 None,则返回 False。 此视图模型中的逻辑很简单,但此示例的重点不是逻辑, 而是展示 F# 如何能简明扼要地表达视图模型的定义。 在 C# 中,视图模型常常被大量的样板代码所淹没,导致视图逻辑杂乱无章,而目的只是为了满足编译器的需要。

图 2 带有用于评级的显示逻辑的电影视图模型

type MovieViewModel(movie:Movie) =
  member this.Name = movie.Name
 
  member this.Genre = movie.Genre
 
  member this.Rating =
    match movie.Rating with
    | Some x -> x
    | None -> 0
 
  member this.HasRating = movie.Rating.IsSome

图 3 显示了用 C# 编写的同一视图模型。 繁琐代码的增加令人印象深刻。 在 C# 中,用于表示视图模型的代码行数大概是 F# 的四倍。 其中增加的内容很多是大括号,但即使是类型注释、return 语句和可访问性修饰符等有意义的内容也影响了对视图模型所封装的逻辑的理解。 F# 大大降低了复杂程度,只关注有意义的代码。 有时,大家可能会认为函数式编程过于简洁,因此难以理解。但在本例中,很明显 F# 并未因简洁性而牺牲清晰性。

图 3 C# 电影视图模型

class MovieViewModelCSharp
{
  Movie movie;
 
  public MovieViewModelCSharp(Movie movie)
  {
    this.movie = movie;
  }
 
  public string Name
  {
    get { return movie.Name; }
  }
 
  public string Genre
  {
    get { return movie.Genre; }
  }
 
  public int Rating
  {
    get
    {
      if(OptionModule.IsSome(movie.Rating))
      {
        return movie.Rating.Value;
      }
      else
      {
        return 0;
      }
    }
  }
 
  public bool HasRating
  {
    get
    {
      return OptionModule.IsSome(movie.Rating);
    }
  }
}

之前的示例显示了在编写视图模型时 F# 的一个好处,但是 F# 还能解决 MVVM 应用程序中视图模型的另一个常见问题。 假设您的域已经更改,您的模型需要更新;Genre 现在是一系列标记而不是单个字符串。 在模型代码中,有一行代码由:

Genre: string

更改为:

Genre: string list

但是,由于这个属性是通过视图模型传递给视图的,因此视图模型的返回类型也需要更改。 在 C# 中,这需要对视图模型进行手动更改;而在 F# 中,由于有了类型推断,这可以自动进行。 如果您对 F# 中的类型推断不习惯,可能会感到惊讶,但这正是您所需要的。 Genre 不需要任何显示逻辑,因此视图模型只把此字段传递给视图而不做任何修改。 换句话说,只要视图模型中属性的返回类型与模型中属性的返回类型相匹配,您就不必注意它。 这正是 F# 代码所表述的内容。 请记住,F# 仍然是静态类型化语言,因此在视图模型或模型(XAML 除外)中错误使用任何字段都会导致编译错误。

充分利用现有资产

图 1图 2 中的视图模型只支持单向绑定,因为它们只负责向模型中添加显示逻辑。 这些简单的视图模型对于演示 F# 在减少繁琐代码方面的能力很有用,但视图模型通常需要通过为可变属性实现 INotifyPropertyChanged 来支持双向绑定。 C# MVVM 应用程序通常包含视图模型基类,以便更容易实现 INotifyPropertyChanged 和其他视图模型内容。 您可能会担心您需要在 F# 中重新实现此行为,但 F# 允许您重用现有的 C# 视图模型基类,而无需重写。

图 4 显示了 F# 中 ViewModelBase 类的用法。 ViewModelBase 是 Brian Genisio (houseofbilz.com) 编写的 C# 基类,我喜欢在所有的 C# 和 F# 视图模型中使用该基类。 在图 4 中,该基类提供了 base.Get 和 base.Set 函数,用于实现 INotifyPropertyChange。 ViewModelBase 还通过使用 C# 中的动态编程功能支持基于约定的命令生成。 这两个功能均可以和 F# 视图模型无缝协作,因为 F# 可以轻松与其他 .NET 语言进行互操作。 有关如何使用 ViewModelBase 的详细信息,请查看 viewmodelsupport.codeplex.com 上的源代码。

图 4 从 C# ViewModelBase 类继承

type MainWindowViewModel() =
  inherit ViewModelBase()
 
  member this.Movies
    with get() =
      base.Get<ObservableCollection<MovieViewModel>>("Movies")
 
    and set(value) =
      base.Set("Movies", value)

实现异步

支持异步和可以取消的操作是对视图模型和模型的另一个常见要求。 使用传统的技术来满足此要求会大大增加应用程序的复杂性,但 F# 包含的强大异步编程功能则可以简化此任务。 图 5 显示了为获取电影数据而对 Web 服务执行的同步调用。 服务器的响应被解析为一系列电影模型。 此列表中的模型随后被投影到视图模型中,并添加到一个 ObservableCollection 中。 这个集合被数据绑定到视图中的一个控件,以向用户显示结果。

图 5 用于处理电影的 Web 请求示例

member this.GetMovies() =
  this.Movies <- new ObservableCollection<MovieViewModel>()
 
  let response = webClient.DownloadString(movieDataUri)
 
  let movies = parseMovies response
 
  movies
  |> Seq.map (fun m -> new MovieViewModel(m))
  |> Seq.iter this.Movies.Add

转换此代码以实现异步运行,需要使用传统的异步库对控制流进行彻底修改。 您需要针对每个异步调用,将代码分成多个单独的回调方法。 这会增加复杂性,使代码更难进行推理,还会大大增加代码的维护开销。 F# 模型则没有这些问题。 在 F# 中,您可以通过进行一些小更改,在不影响代码结构的前提下实现异步行为,如图 6 所示。

图 6 用于处理电影的异步 Web 请求

member this.GetMovies() =
  this.Movies <- new ObservableCollection<MovieViewModel>()
 
  let task = async {
    let!
response = webClient.AsyncDownloadString(movieDataUri)
 
    let movies = parseMovies response
 
    movies
    |> Seq.map (fun m -> new MovieViewModel(m))
    |> Seq.iter this.Movies.Add
  }
 
  Async.StartImmediate(task)

图 6 中的示例显示了异步运行的相同代码。 调用 Web 服务和更新结果列表的代码被封装在一个异步块中以开始更改。 此块中使用了 let! 关键字,指示 F# 以异步方式运行语句。 在本例中,let! 指示 Web 客户端以异步方式发出 Web 请求。 F# 将 AsyncDownloadString 方法作为对 WebClient 的扩展提供,以简化这个过程。 最后一项更改是对 Async.StartImmediate 的调用,用于在当前线程上启动异步块。 在 GUI 线程上运行可以避免当您尝试在后台线程上更新 GUI 时发生杂乱的异常,此外,异步行为还可以确保 GUI 不会在发生 Web 请求时挂起。

为了以异步方式运行此行为而进行的更改不需要使用太多的额外代码,但也许同样重要的是,它不需要更改构建代码的方式。 当您第一次编写代码时,您无需因为今后可能会以异步方式运行而在设计中加以考虑。 您可以在制作原型时随意编写同步代码,然后在需要时轻松将其转换为异步代码。 这种灵活性能够节省您的开发时间,还能让您更快速地对变化做出响应,让客户更满意。

如果您一直关注 C# 的最新发展动态,就不会对这种类型的异步编程感到陌生。 这是因为 C# 中的异步更新在很大程度上是基于 F# 模型的。 C# 中的异步通过社区技术预览 (bit.ly/qqygW9) 提供,而 F# 的异步功能现在已经可以立即投入生产使用。 F# 语言自发布之日起就提供了异步工作流,因此这是一个绝佳的示例,展示了为何了解 F# 能让您成为更出色的 C# 开发人员。

尽管 C# 和 F# 的模型很相似,它们也有一些不同,其中取消功能就是一个很大的不同。 图 7 中的代码向 GetMovies 函数中添加了取消操作。 同样,这需要进行很小的更改。 为了让工作流支持取消操作,您需要创建一个 CancellationTokenSource 并将其取消令牌传递给 Async.StartImmediate 函数。 图 7 在 GetMovies 函数的开头添加了一些附加设置代码,用于取消任何未完成的操作以避免多次更新可观察集合。 此外,还针对函数的每个调用发出了一个新的 CancellationTokenSource,以确保工作流的每次运行都有一个唯一的 CancellationToken。

图 7 F# 中的取消操作

let mutable cancellationSource = new CancellationTokenSource()
 
member this.GetMovies() =
  this.Movies <- new ObservableCollection<MovieViewModel>()
  cancellationSource.Cancel()
  cancellationSource <- new CancellationTokenSource()
 
  let task = async {
    let!
response = webClient.AsyncDownloadString(movieDataUri)
 
    let movies = parseMovies response
 
    movies
    |> Seq.map (fun m -> new MovieViewModel(m))
    |> Seq.iter this.Movies.Add
  }
 
  Async.StartImmediate(task, cancellationSource.Token)

在 C# 模型中,您需要手动在函数调用链中向下传递 CancellationToken,以支持取消操作。 这是一个插入式更改,可能需要您在许多函数签名中添加额外的参数。 您还需要手动轮询 CancellationToken,以查看是否存在取消请求。 F# 模型需要的工作量则要少得多。 当异步工作流遇到 let! 时,将检查它在 StartImmediate 调用中收到的 CancellationToken。 如果该令牌有效,工作流将照常执行。 如果令牌无效,则不会执行该操作,工作流的其余部分也会停止运行。 隐式处理取消操作是一个很好的功能,但您不必受此限制。 如果您需要为工作流手动轮询 CancellationToken,可以通过 Async.CancellationToken 属性进行访问:

let!
token = Async.CancellationToken

借助 F# 构建 MVVM 应用程序

现在,您已经了解 F# 用来增强视图模型和模型的一些切实可行的方法,接下来我将介绍如何将 F# 合并到您的 Silverlight 和 WPF 应用程序中。 在 C# 中,有很多不同的方法可用来构建您的 MVVM 应用程序,在 F# 中也是如此。 我将在本文中介绍其中的两种方法:全 F# 方法和多语言方法,后者针对视图使用 C#,针对视图模型和模型使用 F#。 由于某些原因,我更喜欢多语言方法。 首先,这是 F# 团队推荐的方法。 其次,C# 中针对 WPF 和 Silverlight 的工具支持比 F# 要强大得多。 最后,这个方法允许您将 F# 合并到现有的应用程序中,还提供了一种风险很低的方法在 MVVM 应用程序中试用 F#。

全 F# 方法也很好,因为该方法允许您以一种语言编写应用程序,但随之而来的也有一些限制。 我稍后将向您展示如何解决其中的一些常见问题,但我发现,除了一些小应用程序,为了解决问题而付出的精力与得到的回报并不成正比。 多语言方法的确限制您在视图代码中使用 F#,但结构良好的 MVVM 应用程序包含的视图逻辑应当非常少。 此外,如果需要,C# 在编写视图逻辑方面是一种非常好用的语言,因为视图逻辑在本质上往往有很强的副作用,而且具备强制性。

使用多语言方法

使用多语言方法很容易创建 MVVM 应用程序。 首先,使用 WPF 应用程序项目模板在 C# 中创建一个新的 WPF 项目。 此项目负责您的应用程序中所需的任何视图和代码隐藏。 接下来,在解决方案中添加一个新的 F# 库项目以保存视图模型、模型和其他任何非视图代码。 最后,请务必添加 C# WPF 项目对 F# 库项目的引用。 这就是开始使用多语言方法所需的全部设置。

F# 的设计旨在能够与任何 .NET 语言实现顺畅的交互操作,这就意味着您可以使用以往用于 C# 视图模型的任何方法将 F# 视图模型连接到 C# 视图。 我将为您介绍一个示例,演示如何通过使用代码隐藏来实现简化的目的。 首先,通过在 F# 项目中将 Module1.fs 重命名为 MainWindowViewModel.fs,创建一个简单的视图模型。 用图 8 中的代码填充该视图模型。 使用图 9 中的代码将 F# 视图模型连接到 C# 视图。 如果您不知道该视图模型是用 F# 编写的,就无法说出它与 C# 视图模型之间的差别。

图 8 虚拟 F# 视图模型

namespace Core.ViewModels
 
type MainWindowViewModel() =
  member this.Text = "hello world!"
Figure 9 Connecting the F# View Model to the C# View
protected override void OnInitialized(EventArgs e)
{
  base.OnInitialized(e);
 
  this.DataContext = new MainWindowViewModel();
}

图 9 将 F# 视图模型连接到 C# 视图

protected override void OnInitialized(EventArgs e)
{
  base.OnInitialized(e);
 
  this.DataContext = new MainWindowViewModel();
}

向 MainWindow.xaml 中添加一个文本框,并将 Binding 设置为 Text。 同样,运行的结果就像应用程序完全是用 C# 编写的。 通过运行应用程序并查看标准的“hello world!”问候语,确保绑定生效。

全 F# 方法

正如我之前提到的,因为多语言方法的易用性和灵活性,我更喜欢使用这种方法。 但是,我还是要讨论全 F# 方法,以向您展示其优缺点。 Visual Studio 2010 附带了一个模板,可用于在 F# 中创建 Silverlight 库,但未附带任何用于在 F# 中创建 WPF 或 Silverlight 应用程序的模板。 幸运的是,有一些不错的在线模板可以解决这个问题。 我建议使用 Daniel Mohl (bloggemdano.blogspot.com) 创建的模板,因为这些模板包含示例应用程序,您可以用来查看完整应用程序的结构。 我将演示如何从头构建一个 WPF F# 应用程序以供您学习,但在实际工作中我建议您使用在线模板。

创建一个名为 FSharpOnly 的新 F# 应用程序项目,以便开始全 F# 方法。 等待项目创建完成,然后打开项目属性,将输出类型更改为“Windows 应用程序”。 然后,添加对 PresentationCore、PresentationFramework、PresentationUI、System.Xaml 和 WindowsBase 的引用。 将名为 App.xaml 和 MainWindow.xaml 的文件添加到项目中,然后将每个文件的“生成操作”设置为 Resource。 请注意,默认情况下 F# 中没有项目模板可供生成 XAML 文件,但是您可以使用带有 .xaml 扩展名的常规文本文档模板。 分别用图 10图 11 中的代码填充 XAML 文件。 App.xaml 和 MainWindow.xaml 用于执行它们在标准 C# WPF 应用程序中执行的相同功能。

图 10 示例 App.xaml 文件

<Application
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="assembly=FSharpOnly"
  StartupUri="MainWindow.xaml">
</Application>
Figure 11 A Sample MainWindow.xaml File
<Window
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="assembly=FSharpOnly"
  Title="Sample F# WPF Application Written Only in F#"
  Height="100"
  Width="100" >
  <Grid>
    <TextBlock>Hello World!</TextBlock>
  </Grid>
</Window>

图 11 示例 MainWindow.xaml 文件

<Window
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="assembly=FSharpOnly"
  Title="Sample F# WPF Application Written Only in F#"
  Height="100"
  Width="100" >
  <Grid>
    <TextBlock>Hello World!</TextBlock>
  </Grid>
</Window>

接下来,将图 12 中的代码添加到 Program.fs 中。 此代码负责加载 App.xaml 文件并运行应用程序。

图 12 示例 Program.fs

open System
open System.Windows
open System.Windows.Controls
open System.Windows.Markup
 
[<STAThread>]
[<EntryPoint>]
let main(_) =
  let application = Application.LoadComponent(new Uri("App.xaml", UriKind.Relative)) :?> Application
  application.Run()

运行应用程序以获取“Hello World!”问候。 此时,您可以随意采用任何您喜欢的技术为模型编写视图模型,就像您使用多语言方法时一样。

您在使用全 F# 方法时可能遇到的问题涉及到静态资源。 App.xaml 在 C# WPF 项目中通常被定义为 ApplicationDefinition,但是在全 F# 方法中则被定义为 Resource。 这导致当您从其他 XAML 文件使用在 App.xaml 中定义的静态资源时,在运行时对资源的解析会失败。 解决方法很简单:将 App.xaml 文件的“生成操作”更改为 ApplicationDefinition,然后重新加载设计器。 这会让设计器正确识别 App.xaml 中的资源并加载您的视图。 当您生成应用程序时,不要忘了将 App.xaml 改回 Resource,否则您会收到生成错误。

在全 F# 方法中,代码隐藏的工作方式也不相同。 F# 不支持分部类,因此无法像在 C# 中一样将 XAML 文件与 .fs 代码隐藏文件相关联。 比较好的做法是尽量在 MVVM 应用程序中避免代码隐藏,但有时代码隐藏是解决问题的最好方法。 对于 F# 中缺少传统的代码隐藏这个问题,有几种方法可以解决。 最简单的方法就是在 F# 中构建您的整个视图。 尽管这个方法很简单,但它也可能很麻烦,因为您失去了 XAML 的声明性特性。 另一个办法就是在构建应用程序时挂接到您的可视元素。 图 13 显示了这种方法的一个示例。

图 13 修改 Main.fs 以挂接到 UI 元素

let initialize (mainWindow:Window) =
  let button = mainWindow.FindName("SampleButton") :?> Button
  let text = mainWindow.FindName("SampleText") :?> TextBlock
 
  button.Click
  |> Event.add (fun _ -> text.Text <- "I've been clicked!")
 
[<STAThread>]
[<EntryPoint>]
let main(_) =
  let application = Application.LoadComponent(new Uri("App.xaml", UriKind.Relative)) :?> Application
 
  // Hook into UI elements here
  application.Activated
  |> Event.add (fun _ -> initialize application.MainWindow)
 
  application.Run()

F# 中缺少分部类还使得它很难使用用户控件。 在 XAML 中很容易创建用户控件,但您无法在其他 XAML 文件中引用用户控件,这是因为程序集中没有分部类定义。 如图 14 中所示,您可以通过创建 XamlLoader 类解决此问题。

图 14 用于在 F# 中创建用户控件的 XamlLoader 类

type XamlLoader() =
  inherit UserControl()
 
  static let OnXamlPathChanged(d:DependencyObject) (e:DependencyPropertyChangedEventArgs) =
    let x = e.NewValue :?> string
    let control = d :?> XamlLoader
 
    let stream = Application.GetResourceStream(new Uri(x, UriKind.Relative)).Stream
    let children = XamlReader.Load(stream)
    control.AddChild(children)
 
  static let XamlPathProperty =
    DependencyProperty.Register("XamlPath", typeof<string>, typeof<XamlLoader>, new PropertyMetadata(new PropertyChangedCallback(OnXamlPathChanged)))
 
  member this.XamlPath
    with get() =
      this.GetValue(XamlPathProperty) :?> string
           
    and  set(x:string) =
      this.SetValue(XamlPathProperty, x)
 
  member this.AddChild child =
    base.AddChild(child)

这个类让您能够使用依赖属性设置指向 XAML 文件的路径。 如果您设置了此属性,加载程序将从文件解析 XAML,并将文件中定义的控件添加为自己的子项。 在 XAML 中,使用方法如下所示:

<local:XamlLoader XamlPath="UserControl.xaml" />

XamlLoader 解决方法使您无需转为多语言方法即可创建用户控件,但这个障碍当您在 C# 中创建视图时也不会遇到。

结束语

现在,您已经看到了 F# 的实际应用,很明显,这种语言适用于编写实用的应用程序。 您看到 F# 可以降低代码的繁琐程度,更容易阅读和维护您的视图模型和模型。 您学习了如何使用异步编程等功能,以便快速灵活地解决复杂问题。 最后,我为您概要介绍了借助 F# 构建 MVVM 应用程序的两种主要方法,您也体验了每种方法的优缺点。 现在,您就可以在 Silverlight 和 WPF 应用程序中充分发挥函数式编程的强大功能了。

下次编写 Silverlight 或 WPF 应用程序时,试着用 F# 编写吧。 考虑使用多语言方法在 F# 中编写部分现有应用程序。 很快您就可以看到需要编写和维护的代码数量大幅减少。 卷起袖子,亲身实践全 F# 方法,您一定会学到一些您不了解的有关 WPF、Silverlight 或 F# 的知识。 无论您下一步要做什么,一旦您体验到 F# 编程的乐趣,就无法再以同样的方式看待 C# 了。

Chris Marinos 是一名软件顾问,也是一名专注于 F# 的 Microsoft MVP。他在密歇根州 Ann Arbor 市的 SRT Solutions 公司工作,热爱 F# 和函数式编程。您可以在 Ann Arbor 地区或他的博客 (chrismarinos.com) 上听到他讨论这些内容以及其他有趣的主题。

衷心感谢以下技术专家对本文进行了审阅:Cameron FrederickDaniel Mohl