WPF 应用程序中的 ReactiveUI 和 MVVM 模式

已发表: 2022-03-11

反应式编程是一种涉及数据流和变化传播的异步编程范式。 – 维基百科

一旦你读过那句话,你可能仍然会像我第一次读它时那样结束:离理解它的相关性还差得远。 对基本概念多一点投入,你就会很快理解它的重要性。 基本上,您最初可以将反应式编程视为:“类固醇上的事件驱动编程”。 将事件处理程序想象为流,并将处理程序的每次触发视为流中的新数据。 简而言之,你最终得到的是反应式编程。

在深入研究反应式编程之前,您可能需要了解一些概念。 Observables是让您访问我们一直在讨论的流的对象。 它们的目的是为您提供一个了解流中数据的窗口。 打开该窗口后,您可以使用运算符以您选择的任何方式查看数据,从而决定您的应用程序何时以及如何对流做出反应。 最后,您在结果流上定义观察者以定义每次流发出新数据时将发生的操作。

实际上,这只是意味着您可以更好地控制应用程序对正在发生的事情的反应方式,无论是用户单击按钮、应用程序接收 HTTP 响应还是从异常中恢复。 一旦你开始看到使用反应式编程的好处(其中有很多),你将几乎无法回头。 这仅仅是因为应用程序所做的大部分事情都是以某种方式对给定的可能性做出反应。

现在,这并不意味着这种新方法没有缺点。 首先,它的学习曲线可能非常陡峭。 我亲眼目睹了开发人员(初级、高级和架构师等)如何努力弄清楚他们应该首先编写什么,他们的代码执行的顺序,或者如何调试错误。 在第一次介绍这些概念时,我的建议是展示大量示例。 当开发人员开始了解事物应该如何工作和使用时,他们就会掌握它的窍门。

在 2010 年第一次接触 Windows Presentation Foundation (WPF) 之前,我已经使用桌面应用程序(主要是 Visual Basic 6、Java Swing 和 Windows Forms)超过 10 年。基本上,WPF 的创建是为了取代 Windows 窗体,这是 .NET 的第一个桌面开发框架。

WPF 和 Windows 窗体之间的主要区别是巨大的,但最重要的是:

  • WPF 使用新的开发范式,这些范式更加健壮并且经过了全面测试。
  • 使用 WPF,您可以对 UI 的设计和编码进行强解耦。
  • WPF 允许对您的 UI 进行大量自定义和控制。

一旦我开始学习 WPF 及其功能,我就非常喜欢它! 我无法相信 MVVM 模式的实现如此简单以及属性绑定的效果如何。 在我偶然发现反应式编程及其与 WPF 的用法之前,我认为我找不到任何可以改进这种工作方式的方法:

在这篇文章中,我希望能够展示一个非常简单的 WPF 应用程序实现,它使用反应式编程和 MVVM 模式并访问 REST API。

该应用程序将能够:

  • 跟踪汽车及其位置
  • 获取从模拟源中提取的信息
  • 在 Bing Maps WPF 控件中向用户显示此信息

架构

您将构建一个使用 RESTful Web API Core 2 服务的 WPF 客户端。

客户端:

  • WPF
  • 反应式UI
  • 依赖注入
  • MVVM 模式
  • 改装
  • 必应地图 WPF 控件
  • 仅出于测试目的,我们将使用 Postman

服务器端:

  • .NET C# Web API 核心 2
  • 依赖注入
  • 智威汤逊认证

你需要什么:

  • Visual Studio 2017 社区(或您可能拥有的任何版本)

后端

快速开始

使用 ASP.NET Core Web 应用程序启动新的 Visual Studio 解决方案。

wpf reactiveui:新的 Visual Studio ASP.NET Core Web 应用程序

将其配置为 API,因为我们只会将其用作 WPF 应用程序的后端。

wpf reactiveui:配置为 API

我们最终应该得到一个结构类似于以下的 VS 解决方案:

wpf reactiveui:VS 解决方案示例

到目前为止,我们已经拥有了启动 REST API 后端所需的一切。 如果我们运行我们的项目,它将加载一个 Web 浏览器(我们在 Visual Studio 上设置的那个),指向托管在 IIS Express 上的网站,该网站将显示对带有 JSON 对象的 REST 调用的响应。

现在,我们将为我们的 REST 服务设置 JWT 身份验证。

startup.cs文件的末尾,添加以下行。

 static readonly byte[] JwtKey = Encoding.ASCII.GetBytes(@"this is a test key"); private void LoadJwtAuthorization(IServiceCollection services) { services.AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(x => { x.Events = new JwtBearerEvents { OnTokenValidated = context => { var userId = int.Parse(context.Principal.Identity.Name); if (userId == 0) { //Handle user validation against DB context.Fail("Unauthorized"); } return Task.CompletedTask; } }; x.RequireHttpsMetadata = false; x.SaveToken = true; x.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(JwtKey), ValidateIssuer = false, ValidateAudience = false }; }); }

此外,在ConfigureServices方法中,在调用AddMvc方法之前调用我们刚刚创建的方法。

 public void ConfigureServices(IServiceCollection services) { LoadJwtAuthorization(services); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); }

最后,调整Configure方法,使其看起来像这样:

 public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); }

到目前为止,我们已经建立了要在控制器上使用的 JWT 身份验证(如果已定义)。 接下来,我们将调整控制器,使其使用我们描述的身份验证。

ValuesController ,我们将添加AuthorizeAttribute ,使其类似于:

 [Route("api/[controller]")] [ApiController] [Authorize] public class ValuesController : ControllerBase { ... }

现在,如果我们尝试运行我们的服务,我们将收到一个 401 Unauthorized 错误,如下所示:

邮递员出现未经授权的错误

所以,我们需要添加一个方法来验证我们的用户。 为了简单起见,我们将在同一个ValuesController类上执行此操作。

 [AllowAnonymous] [HttpPost("authenticate")] public IActionResult Authenticate([FromBody]JObject userInfo) { var username = userInfo["username"].ToString(); var password = userInfo["password"].ToString(); //We would validate against the DB if (username != "user" || password != "123") { return BadRequest(new { message = "Username or password is incorrect" }); } // return basic user info (without password) and token to store on the front-end return Ok(CreateUserToken(1)); } private string CreateUserToken(int userId) { var tokenHandler = new JwtSecurityTokenHandler(); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, userId.ToString()) }), Expires = DateTime.UtcNow.AddDays(7), SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Startup.JwtKey), SecurityAlgorithms.HmacSha256Signature) }; var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); }

现在我们创建了一个具有匿名访问权限的方法,这意味着所有客户端,即使是那些未经身份验证的客户端,都可以使用包含 JSON 对象的 POST 消息并传递其用户名字符串和密码字符串来调用它。

当我们用 Postman 审查它时,我们得到:

WPF 反应式:身份验证

正如我们所看到的,authenticate 方法的结果就是我们现在需要使用的字符串,作为我们想要对 API 进行的每次调用的令牌。

一旦令牌包含在消息的标头中,就会进行验证,如果传递了正确的参数,服务就会运行该方法并返回其值。 例如,如果我们现在调用 values 控制器并传递令牌,我们会得到与之前相同的结果:

WPF 反应式:身份验证 2

现在,我们将创建一个方法来获取我们正在跟踪的当前汽车的纬度和经度。 同样,为了简单起见,它只是一个虚拟方法,它首先会返回一个随机位置,并在每次调用该方法时开始将汽车移动一段固定的距离。

首先,我们调整ValuesController类中的Get(int id)方法,使其看起来像这样:

 [HttpGet("{id}")] public ActionResult<string> Get(int id) { var location = LocationHelper.GetCurrentLocation(id); dynamic jsonObject = new JObject(); jsonObject.Latitude = location.latitude; jsonObject.Longitude = location.longitude; return jsonObject.ToString(); }

然后,我们添加一个新的LocationHelper类,它将处理被跟踪汽车的当前和未来位置。

 public static class LocationHelper { private static readonly Random Randomizer = new Random(); private const double PositionDelta = 0.0001d; internal static (double latitude, double longitude) GetCurrentLocation(int id) { if (!Locations.ContainsKey(id)) { Locations.Add(id, default((double latitude, double longitude))); } //This method updates the last known location for the car and simulates its movement UpdateLocation(id); return Locations[id]; } private static void UpdateLocation(int id) { (double latitude, double longitude)loc = Locations[id]; //If the default value is found, randomly assign a starting point. if (loc.latitude == default(double) && loc.longitude == default(double)) { loc = Locations[id] = GetRandomStartingPoint(); } if (Randomizer.Next(2) > 0) { //In this scenario we simulate an updated latitude loc.latitude = loc.latitude + PositionDelta; } else { //Simulated longitude change loc.longitude = loc.longitude + PositionDelta; } Locations[id] = loc; } private static (double latitude, double longitude) GetRandomStartingPoint() { //Set inside the continental US return (Randomizer.Next(31, 49), Randomizer.Next(-121, -75)); } private static readonly Dictionary<int, (double latitude, double longitude)> Locations = new Dictionary<int, (double latitude, double longitude)>(); }

这就是后端。

前端:

我们现在将创建一个新的 WPF 应用程序。 一旦我们创建了它,Visual Studio 将向我们的解决方案添加一个具有以下结构的新项目。

WPF 应用程序结构

必应地图控制:

要将 WPF 控件用于 Bing 地图,我们需要安装 SDK(上面引用过)并将其添加为对 WPF 应用程序的引用。 根据您安装它的位置,DLL 可能位于不同的路径上。 我将它安装在默认位置并添加如下:

第 1 步:右键单击 WPF 项目的参考部分,然后单击
第 1 步:右键单击 WPF 项目的引用部分,然后单击“添加引用”。

第 2 步:浏览到 Bing Maps WPF Control 安装的路径。
第 2 步:浏览到 Bing Maps WPF Control 安装的路径。

第 3 步:单击“确定”将其添加到项目中。
第 3 步:单击“确定”将其添加到项目中。

接下来,我们将为我们的 WPF 项目添加用于reactiveuireactiveui-wpfrefit的 nuget 包,这将允许我们使用响应式编程以及使用我们的 REST API 创建视图模型。

步骤 1:右键单击 WPF 项目的 References 部分,然后单击 Manage NuGet Packages。
步骤 1:右键单击 WPF 项目的 References 部分,然后单击 Manage NuGet Packages。

第2步:在浏览选项卡上,搜索`reactiveui`,点击安装,搜索`reactiveui-wpf`,点击安装,最后,搜索`refit`并点击安装。
第2步:在浏览选项卡上,搜索`reactiveui`,点击安装,搜索`reactiveui-wpf`,点击安装,最后,搜索`refit`并点击安装。

我们现在将创建我们的ViewModel 。 添加一个名为MainViewModel.cs的新类,使其如下所示:

 public class MainViewModel : ReactiveObject { #region Private Members private readonly ITrackingService _service; private readonly ISubject<(double latitude, double longitude)> _locationUpdate; #endregion #region Methods public MainViewModel() { _service = Locator.Current.GetService<ITrackingService>(); _locationUpdate = new Subject<(double latitude, double longitude)>(); UpdateCar = ReactiveCommand.Create(() => { var parsedCorrectly = int.TryParse(NewCarToFollow, out int newCar); NewCarToFollow = null; if (!parsedCorrectly) { MessageBox.Show("There was an error reading the number of the car to follow. Please, review it.", "Car Tracking Service", MessageBoxButton.OK, MessageBoxImage.Warning); return; } FollowedCar = newCar; }, canExecute: this.WhenAnyValue(x => x.NewCarToFollow).Select(x => !string.IsNullOrWhiteSpace(x))); /*This Scheduled method is where we get the location for the car being followed every 500 ms. We call the service with the car id, our JWT Token, and transform the result to a ValueTuple (double latitude, double longitude) to pass to our Subject's OnNext method so it can be received by the view */ Scheduler.Default.SchedulePeriodic(TimeSpan.FromMilliseconds(500), () => _service.GetLocation(FollowedCar, App.GetToken()) .Select(jo => ( latitude: double.Parse(jo["Latitude"].ToString()), longitude: double.Parse(jo["Longitude"].ToString()) )).Subscribe(newLocation => _locationUpdate.OnNext(newLocation))); } #endregion #region Properties private string _newCarToFollow; public string NewCarToFollow { get => _newCarToFollow; set => this.RaiseAndSetIfChanged(ref _newCarToFollow, value); } private int _followedCar = 1; public int FollowedCar { get => _followedCar; set => this.RaiseAndSetIfChanged(ref _followedCar, value); } public IObservable<(double latitude, double longitude)> LocationUpdate => _locationUpdate; private ReactiveCommand _updateCar; public ReactiveCommand UpdateCar { get => _updateCar; set => this.RaiseAndSetIfChanged(ref _updateCar, value); } #endregion }

为了让视图知道附加了一个ViewModel并能够使用,我们需要对MainView.xaml.cs文件进行一些更改。

 public partial class MainWindow : IViewFor<MainViewModel> { public MainWindow() { InitializeComponent(); ViewModel = Locator.CurrentMutable.GetService<MainViewModel>(); /*Our ViewModel exposes an IObservable with a parameter of type ValueTuple (double latitude, double longitude) and it gets called every time the ViewModel updates the car's location from the REST API.*/ ViewModel.LocationUpdate .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(SetLocation); } private void SetLocation((double latitude, double longitude) newLocation) { //New location for the tracked vehicle. var location = new Location(newLocation.latitude, newLocation.longitude); //Remove previous pin myMap.Children.Clear(); //Center pin and keep same Zoom Level myMap.SetView(location, myMap.ZoomLevel); var pin = new Pushpin { Location = location, Background = Brushes.Green }; //Add new pin to the map myMap.Children.Add(pin); } /// <summary> /// Allows the ViewModel to be used on the XAML via a dependency property /// </summary> public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register("ViewModel", typeof(MainViewModel), typeof(MainWindow), new PropertyMetadata(default(MainViewModel))); /// <summary> /// Implementation for the IViewFor interface /// </summary> object IViewFor.ViewModel { get => ViewModel; set => ViewModel = (MainViewModel)value; } /// <summary> /// Regular property to use the ViewModel from this class /// </summary> public MainViewModel ViewModel { get => (MainViewModel)GetValue(ViewModelProperty); set => SetValue(ViewModelProperty, value); } }

然后,我们将修改MainWindow.xaml文件,使其如下所示:

 <Window x:Class="WpfApp.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wpf="clr-namespace:Microsoft.Maps.MapControl.WPF;assembly=Microsoft.Maps.MapControl.WPF" xmlns:local="clr-namespace:WpfApp" DataContext="{Binding ViewModel,RelativeSource={RelativeSource Self}}" d:DataContext="{d:DesignInstance Type=local:MainViewModel, IsDesignTimeCreatable=True}" mc:Ignorable="d" WindowStartupLocation="CenterScreen" Title="Car Tracker" Height="800" Width="1200"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <DockPanel> <StackPanel Orientation="Horizontal" Margin="10" DockPanel.Dock="Left"> <Label>Car to follow</Label> <TextBox Width="50" Text="{Binding NewCarToFollow, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Center"/> <Button Margin="15,0,0,0" Content="Update Followed Car" Command="{Binding UpdateCar}"/> </StackPanel> <TextBlock Text="{Binding FollowedCar,StringFormat=Following Car: {0}}" Margin="0,0,10,0" HorizontalAlignment="Right" VerticalAlignment="Center" DockPanel.Dock="Right"/> </DockPanel> <wpf:Map x:Name="myMap" ZoomLevel="15" Grid.Row="1" Margin="10" CredentialsProvider="ENTER-YOUR-BING-MAPS-CREDENTIAL-HERE"/> </Grid> </Window>

使用您自己的 Bing 地图键调整CredentialsProvider属性很重要。

为了能够访问我们的 REST API,我们将使用 refit。 我们需要做的就是创建一个接口来描述我们将使用的 API 方法。 因此,我们创建了一个名为 ITrackingService 的新接口,其内容如下:

 public interface ITrackingService { [Post("/api/values/authenticate")] IObservable<string> Authenticate([Body] JObject user); [Get("/api/values/{id}")] IObservable<JObject> GetLocation(int id, [Header("Authorization")] string authorization); }

最后,我们修改App类以包含依赖注入(使用 Splat,它是在我们包含对reactiveui的引用时添加的),设置ServerUri (您应该将其更改为运行 REST API 时获得的任何端口)并模拟我们的在应用程序的最开始登录。

 public partial class App : Application { protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); SetDependencyInjection(); LogIn(); } private void SetDependencyInjection() { Locator.CurrentMutable.RegisterLazySingleton(() => RestService.For<ITrackingService>(ServerUri), typeof(ITrackingService)); Locator.CurrentMutable.RegisterLazySingleton(() => new MainViewModel(), typeof(MainViewModel)); } private static string Token; private const string ServerUri = "http://localhost:54587"; private void LogIn() { try { var userInfo = new JObject { ["username"] = "user", ["password"] = "123" }; Token = Locator.Current.GetService<ITrackingService>() .Authenticate(userInfo) .Wait(); } catch { MessageBox.Show("There was an error validating the user. Is the service up?"); Shutdown(); } } internal static string GetToken() { return $"Bearer {Token}"; } }

最后,当我们运行我们的应用程序时,我们将能够看到一辆移动汽车的实时模拟,其坐标每 500 毫秒从 REST API 中获取一次。 用户还可以将被跟踪的汽车更改为任何其他 ID,并将为其创建一组新数据。

我希望这个小例子已经展示了在 WPF 中以一种可访问的方式处理带有反应式编程的 REST API 的基础知识。

您始终可以从此存储库下载整个源项目。

这个例子有一些地方可以继续,可能会帮助你进一步理解:

  • 创建一个登录窗口并允许用户登录和退出。
  • 验证来自数据库的用户数据。
  • 在 REST API 中创建不同的用户角色并限制某些方法,以便只有具有特定角色的用户才能访问它们。
  • 使用 Rx Marbles 了解更多通过所有运算符及其行为的反应式编程。 Rx Marbles 是一个简洁的应用程序,它允许您与流交互并将运算符应用于其中的数据点。

结论

当努力实现使用事件驱动编程的受控方式而不遇到这种范式固有的常见问题时,反应式编程可以证明是有益的。 将它用于新的开发就像添加几个对支持良好的开源库的引用一样简单。 但是,最重要的是,将它合并到现有代码库中可以是渐进的,并且它不应该破坏与未实现它的组件的向后兼容性。 本文讨论了 WPF 的反应式编程,但大多数主要语言和框架都有端口,这使得反应式编程对于任何类型的开发人员来说都是一次很好的冒险。

作为练习,接下来,您应该:

  • 通过以下方式扩展项目的行为
    • 为用户、汽车和位置添加数据库
    • 从数据库中获取汽车的位置并将其显示给用户。 允许用户在一段时间内探索汽车的运动
    • 添加用户权限。 让管理员用户创建新的汽车和用户,并为普通用户提供只读访问权限。 将角色添加到 JWT 身份验证。
  • 在 https://github.com/dotnet/reactive 查看 .NET 响应式扩展的源代码