个性化阅读
专注于IT技术分析

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

本文概述

响应式编程是一种异步编程范例, 涉及数据流和更改的传播。 –维基百科

阅读完该句子后, 你可能仍会获得与初读时相同的结果:距离理解它的相关性再远了。稍微投入一些基本概念, 你就会很快了解其重要性。基本上, 你可以首先将响应式编程视为:”类固醇的事件驱动编程”。将事件处理程序想象为一个流, 并将处理程序的每次触发都视为流中的新数据。简而言之, 最终得到的是响应式编程。

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

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

现在, 这并不意味着这种新方法没有不利之处。首先, 它的学习曲线可能非常陡峭。我已经亲眼目睹了开发人员(初级, 中级和建筑师等)如何努力弄清楚他们应该首先编写什么, 以什么顺序执行代码或如何调试错误。首次介绍这些概念时, 我的建议是显示许多示例。当开发人员开始了解事物应该如何工作和被使用时, 他们将一窍不通。

在使用台式机应用程序(主要是Visual Basic 6, Java Swing和Windows Forms)之前, 我已经工作了10多年, 直到2010年我才首次接触Windows Presentation Foundation(WPF)时。取代Windows Forms, 后者是.NET的第一个桌面开发框架。

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

  • WPF使用了更强大的新开发范例, 并且已经过全面测试。
  • 使用WPF, 你可以将UI的设计和编码完全去耦。
  • WPF允许对UI进行大量自定义和控制。

一旦我开始学习WPF及其功能, 我绝对会喜欢它!我不敢相信MVVM模式的实施有多么容易, 以及属性绑定的工作情况如何。在我偶然发现Reactive Programming及其在WPF中的使用之前, 我认为我找不到任何可以改善这种工作方式的方法:

在本文中, 我希望能够展示使用Reactive Programming和MVVM模式的WPF应用程序的非常简单的实现, 并能够访问REST API。

该应用程序将能够:

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

架构

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

客户端:

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

服务器端:

  • .NET C#Web API核心2
  • 依赖注入
  • JWT认证

你需要什么:

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

后端

快速开始

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

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

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

wpf reactui:配置为API

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

wpf reactui:VS解决方案示例

到目前为止, 我们已经拥有启动REST API后端所需的一切。如果我们运行项目, 它将加载一个网络浏览器(在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未经授权的错误, 如下所示:

邮递员的未经授权的错误

因此, 我们需要添加一种方法来验证用户身份。为了简单起见, 我们将在同一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 App结构

必应地图控件:

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

步骤1:右键单击WPF项目的"引用"部分,然后单击"添加引用"。

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

步骤2:浏览至Bing Maps WPF控件安装路径。

步骤2:浏览至Bing Maps WPF控件安装路径。

步骤3:单击OK(确定)将其添加到项目中。

步骤3:单击OK(确定)将其添加到项目中。

接下来, 我们将为反应堆, reactiveui-wpf和refit添加nuget包到我们的WPF项目中, 这将使我们能够使用反应式编程来创建视图模型并使用我们的REST API。

步骤1:右键单击WPF项目的"引用"部分,然后单击"管理NuGet包"。

步骤1:右键单击WPF项目的”引用”部分, 然后单击”管理NuGet包”。

步骤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 Maps键调整CredentialsProvider属性很重要。

为了能够访问我们的REST API, 我们将使用改装。我们需要做的就是创建一个接口, 该接口描述我们将要使用的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, 当我们包含对reactui的引用时添加了Splat), 设置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中使用Reactive Programming处理REST API的基础。

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

此示例在某些方面可以继续进行, 可能有助于你进一步理解:

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

总结

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

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

  • 通过以下方式扩展项目的行为
    • 为用户, 汽车和位置添加数据库
    • 从数据库中获取汽车的位置, 并将其显示给用户。允许用户在一段时间内探索汽车的运动
    • 添加用户权限。让管理员用户创建新的汽车和用户, 并向普通用户提供只读访问权限。将角色添加到JWT身份验证。
  • 在https://github.com/dotnet/reactive中查看.NET反应性扩展的源代码
赞(0)
未经允许不得转载:srcmini » WPF应用程序中的ReactiveUI和MVVM模式

评论 抢沙发

评论前必须登录!