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 響應式擴展的源代碼