ReactiveUI și modelul MVVM în aplicațiile WPF
Publicat: 2022-03-11Programarea reactivă este o paradigmă de programare asincronă preocupată de fluxurile de date și de propagarea schimbării. – Wikipedia
Odată ce ai citit acea propoziție, s-ar putea să ajungi tot așa cum am făcut-o când am citit-o prima dată: Nicăieri mai aproape de a înțelege relevanța ei. Mai multă dedicare la conceptele de bază și veți înțelege rapid importanța acesteia. Practic, te-ai putea gândi la programarea reactivă la început ca: „Programare bazată pe evenimente pe steroizi”. Imaginează-ți un handler de evenimente ca pe un flux și gândește-te la fiecare declanșare a handler-ului ca la o nouă dată în flux. Pe scurt, ceea ce ajungeți este programarea reactivă.
Există câteva concepte pe care ați dori să le înțelegeți înainte de a vă aprofunda puțin mai mult în programarea reactivă. Observabilele sunt obiectele care vă oferă acces la fluxurile despre care am tot vorbit. Scopul lor este să vă ofere o fereastră către datele din flux. Odată ce acea fereastră a fost deschisă, puteți privi datele în orice mod pe care îl alegeți utilizând Operatori pe ea și, astfel, puteți decide când și cum reacționează aplicația dvs. la flux. În cele din urmă, definiți observatorii pe fluxul rezultat pentru a defini acțiunea care se va întâmpla de fiecare dată când un nou datum este emis de flux.
În termeni practici, asta înseamnă doar că obții mai mult control asupra modului în care aplicația ta reacționează la ceea ce se întâmplă, fie că este vorba de utilizatorul care dă clic pe un buton, aplicația ta care primește un răspuns HTTP sau recuperarea de la excepții. Odată ce începeți să vedeți beneficiile utilizării programării reactive (dintre care sunt multe), cu greu veți putea să vă întoarceți. Acest lucru se datorează faptului că cele mai multe dintre lucrurile pe care le face o aplicație reacționează într-un anumit mod la o eventualitate dată.
Acum, asta nu înseamnă că această nouă abordare nu are dezavantaje. În primul rând, curba sa de învățare poate fi destul de abruptă. Am văzut direct cum dezvoltatorii (juniori, seniori și arhitecți, printre alții) se luptă să-și dea seama ce ar trebui să scrie mai întâi, în ce ordine este executat codul lor sau cum să depaneze erorile. Recomandarea mea când am introdus pentru prima dată aceste concepte este să arăți o mulțime de exemple. Când dezvoltatorii încep să vadă cum ar trebui să funcționeze și să fie folosite lucrurile, vor înțelege.
Lucram cu aplicații desktop de peste 10 ani (mai ales Visual Basic 6, Java Swing și Windows Forms) înainte de a pune mâna pe Windows Presentation Foundation (WPF) pentru prima dată în 2010. Practic, WPF a fost creat pentru înlocuiește Windows Forms, care este primul cadru de dezvoltare desktop al .NET.
Diferențele majore dintre WPF și Windows Forms sunt substanțiale, dar cele mai importante sunt:
- WPF folosește noi paradigme de dezvoltare care sunt mai robuste și au fost testate temeinic.
- Cu WPF, puteți avea o decuplare puternică a designului și codării pentru interfața de utilizare.
- WPF permite multă personalizare și control asupra interfeței dvs. de utilizare.
Odată ce am început să învăț WPF și capacitățile sale, mi-a plăcut absolut! Nu-mi venea să cred cât de ușor a fost modelul MVVM de implementat și cât de bine a funcționat legarea proprietăților. Nu am crezut că voi găsi ceva care să îmbunătățească acest mod de lucru până când nu am dat peste programarea reactivă și utilizarea acesteia cu WPF:
În această postare, sper să pot arăta o implementare foarte simplă a unei aplicații WPF folosind programarea reactivă cu modelul MVVM și să accesez un API REST.
Aplicația va putea:
- Urmăriți mașinile și locațiile acestora
- Luați informații extrase dintr-o sursă simulată
- Afișați aceste informații utilizatorului într-un control Bing Maps WPF
Arhitectura
Veți construi un client WPF care consumă un serviciu RESTful Web API Core 2.
Partea clientului:
- WPF
- ReactiveUI
- Injecție de dependență
- Model MVVM
- Remontați
- Bing Maps WPF Control
- Numai în scopuri de testare, vom folosi Postman
Partea serverului:
- .NET C# Web API Core 2
- Injecție de dependență
- Autentificare JWT
Ce vei avea nevoie:
- Comunitatea Visual Studio 2017 (sau orice ediție pe care o aveți)
Back-end-ul
Pornire rapidă
Porniți o nouă soluție Visual Studio cu o aplicație web ASP.NET Core.
Configurați-l să fie un API, deoarece îl vom folosi doar ca back-end pentru aplicația noastră WPF.
Ar trebui să ajungem cu o soluție VS cu o structură similară cu aceasta:
Până acum, avem tot ce ne trebuie pentru a porni back-end-ul nostru REST API. Dacă rulăm proiectul nostru, acesta va încărca un browser web (cel pe care l-am setat pe Visual Studio) care indică către un site web găzduit pe IIS Express care va afișa un răspuns la un apel REST cu un obiect JSON.
Acum, vom configura autentificarea JWT pentru serviciul nostru REST.
La sfârșitul fișierului startup.cs
, adăugați următoarele rânduri.
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 }; }); }
De asemenea, în cadrul metodei ConfigureServices
, apelați metoda pe care tocmai am creat-o înainte ca metoda AddMvc
să fie apelată.
public void ConfigureServices(IServiceCollection services) { LoadJwtAuthorization(services); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); }
În cele din urmă, ajustați metoda Configure
astfel încât să arate astfel:
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); }
Până acum, am stabilit autentificarea JWT pentru a fi utilizată pe controlerele noastre dacă este definită. Apoi, vom ajusta controlerul astfel încât să folosească autentificarea pe care am descris-o.
Pe ValuesController
, vom adăuga AuthorizeAttribute
, astfel încât să semene cu acesta:
[Route("api/[controller]")] [ApiController] [Authorize] public class ValuesController : ControllerBase { ... }
Acum, dacă încercăm să rulăm serviciul nostru, vom primi o eroare 401 neautorizată ca aceasta:
Deci, va trebui să adăugăm o metodă pentru a ne autentifica utilizatorii. De dragul simplității aici, o vom face pe aceeași clasă 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); }
Acum am creat o metodă cu acces anonim, ceea ce înseamnă că toți clienții, chiar și cei neautentificați, vor putea să o apeleze folosind un mesaj POST care conține un obiect JSON și trecând un șir pentru numele de utilizator și un șir pentru parola.
Când îl revizuim cu Postman, obținem asta:
După cum putem vedea, rezultatul metodei de autentificare a fost chiar șirul pe care trebuie să îl folosim acum ca simbol pentru fiecare apel pe care vrem să-l facem către API.
Odată ce jetonul este inclus în anteturile mesajului, are loc validarea și, dacă sunt trecuți parametrii corecti, serviciul rulează metoda și returnează valoarea acesteia. De exemplu, dacă apelăm acum controlerul de valori și transmitem jetonul, obținem același rezultat ca înainte:
Acum, vom crea o metodă pentru a obține latitudinea și longitudinea mașinii curente pe care o urmărim. Din nou, pentru simplitate, va fi doar o metodă inactivă care va reveni la început într-o locație aleatorie și va începe să miște mașina pe o anumită distanță de fiecare dată când metoda este apelată.
Mai întâi, ajustăm metoda Get(int id)
din clasa ValuesController
pentru a o face astfel:
[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(); }
Apoi, adăugăm o nouă clasă LocationHelper
care se va ocupa de locațiile actuale și viitoare ale mașinilor urmărite.
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)>(); }
Asta e pentru partea din spate.
Partea din față:
Acum vom crea o nouă aplicație WPF. Odată ce l-am creat, Visual Studio va adăuga un nou proiect cu următoarea structură la soluția noastră.
Control Hărți Bing:
Pentru a folosi controlul WPF pentru Bing Maps, va trebui să instalăm SDK-ul (la care se face referire mai sus) și să îl adăugăm ca referință la aplicația noastră WPF. În funcție de locul în care l-ați instalat, DLL-ul poate fi pe o cale diferită. L-am instalat în locația implicită și l-am adăugat după cum urmează:

În continuare, vom adăuga pachete nuget pentru reactiveui
, reactiveui-wpf
și vom refit
în proiectul nostru WPF, ceea ce ne va permite să creăm modele de vizualizare utilizând programarea reactivă, precum și consumând API-ul nostru REST.
Acum vom crea ViewModel
-ul nostru. Adăugați o nouă clasă numită MainViewModel.cs
și faceți-o să arate astfel:
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 }
Pentru a anunța vizualizarea că este atașat un ViewModel
și pentru a putea folosi, va trebui să facem câteva modificări fișierului 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); } }
Apoi, vom modifica fișierul MainWindow.xaml
pentru a-l face să arate astfel:
<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>
Este important să ajustați proprietatea CredentialsProvider
cu propria cheie Bing Maps.
Pentru a putea accesa API-ul nostru REST, vom folosi refit. Tot ce trebuie să facem este să creăm o interfață care să descrie metodele API-uri pe care le vom folosi. Deci, creăm o nouă interfață numită ITTrackingService cu următorul conținut:
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); }
În cele din urmă, modificăm clasa App
pentru a include injecția de dependență (folosind Splat, care a fost adăugat când am inclus referința la reactiveui
), setăm ServerUri
(pe care ar trebui să îl schimbați la orice port pe care îl obțineți când rulați API-ul REST) și simulăm autentificați-vă chiar la începutul aplicației.
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}"; } }
În cele din urmă, când rulăm aplicația noastră, vom putea vedea o simulare în timp real a unei mașini în mișcare, coordonatele acesteia fiind preluate din API-ul REST la fiecare 500 ms. De asemenea, utilizatorul poate schimba mașina urmărită cu orice alt ID și va fi creat un nou set de date pentru aceasta.
Sper că acest mic exemplu a arătat elementele de bază ale manipulării unui API REST cu programare reactivă în WPF într-un mod accesibil.
Puteți descărca oricând întregul proiect sursă din acest depozit.
Există câteva zone de continuat cu acest exemplu care vă pot ajuta să vă înțelegeți mai bine:
- Creați o fereastră de conectare și permiteți utilizatorului să se conecteze și să se deconecteze.
- Validați datele utilizatorului dintr-o bază de date.
- Creați diferite roluri de utilizator și restricționați anumite metode în API-ul REST, astfel încât doar un utilizator cu un anumit rol să le poată accesa.
- Înțelegeți mai multe despre programarea reactivă care trece prin toți operatorii și comportamentul lor cu Rx Marbles. Rx Marbles este o aplicație îngrijită care vă permite să interacționați cu fluxurile și să aplicați operatori la punctele de date din acestea.
Concluzie
Programarea reactivă se poate dovedi benefică atunci când se străduiește să obțină un mod controlat de utilizare a programării bazate pe evenimente, fără a întâlni problemele obișnuite inerente acestei paradigme. Folosirea lui pentru noile dezvoltări este la fel de simplă ca și adăugarea de câteva referințe la biblioteci open source bine acceptate. Dar, cel mai important, încorporarea acestuia în bazele de cod existente poate fi progresivă și nu ar trebui să rupă compatibilitatea cu componentele care nu o implementează. Acest articol a tratat programarea reactivă pentru WPF, dar există porturi pentru majoritatea limbajelor și cadrelor majore, ceea ce face din programarea reactivă o aventură bună pentru orice tip de dezvoltator.
Ca exercițiu, în continuare, ar trebui să:
- Extindeți comportamentul proiectului prin
- Adăugarea unei baze de date pentru utilizatori, mașini și locații
- Obținerea locației mașinilor din baza de date și afișarea acestora utilizatorului. Permite utilizatorului să exploreze mișcările unei mașini într-o perioadă de timp
- Adăugarea permisiunilor utilizatorului. Permiteți utilizatorilor administratori să creeze mașini și utilizatori noi și să acorde acces numai pentru citire utilizatorilor obișnuiți. Adăugați roluri la autentificarea JWT.
- Examinați codul sursă pentru extensiile reactive .NET în https://github.com/dotnet/reactive