ReactiveUI e il modello MVVM nelle applicazioni WPF

Pubblicato: 2022-03-11

La programmazione reattiva è un paradigma di programmazione asincrono che si occupa dei flussi di dati e della propagazione del cambiamento. – Wikipedia

Una volta che hai letto quella frase, potresti comunque finire come ho fatto io quando l'ho letta per la prima volta: nessun posto vicino alla comprensione della sua rilevanza. Un po' più di dedizione ai concetti di base e capirai rapidamente la sua importanza. Fondamentalmente, all'inizio potresti pensare alla programmazione reattiva come: "Programmazione guidata da eventi sugli steroidi". Immagina un gestore di eventi come un flusso e considera ogni attivazione del gestore come un nuovo dato nel flusso. In poche parole, ciò che si ottiene è la programmazione reattiva.

Ci sono alcuni concetti che potresti voler capire prima di approfondire un po' di più la programmazione reattiva. Gli osservabili sono gli oggetti che ti danno accesso ai flussi di cui abbiamo parlato. Il loro scopo è darti una finestra sui dati nel flusso. Una volta aperta quella finestra, puoi guardare i dati in qualsiasi modo tu scelga utilizzando Operators su di essa e quindi decidere quando e come la tua applicazione reagisce al flusso. Infine, si definiscono gli Osservatori sul flusso risultante per definire l'azione che avverrà ogni volta che un nuovo dato viene emesso dal flusso.

In termini pratici, ciò significa semplicemente che ottieni un maggiore controllo sul modo in cui la tua applicazione reagisce a ciò che sta accadendo, che si tratti del tuo utente che fa clic su un pulsante, della tua app che riceve una risposta HTTP o del ripristino da eccezioni. Una volta che inizierai a vedere i vantaggi dell'utilizzo della Programmazione Reattiva (di cui ce ne sono molti), difficilmente potrai tornare indietro. Questo semplicemente perché la maggior parte delle cose che fa un'app è reagire in un certo modo a una determinata eventualità.

Ora, questo non significa che non ci siano aspetti negativi in ​​questo nuovo approccio. Prima di tutto, la sua curva di apprendimento può essere piuttosto ripida. Ho visto in prima persona come gli sviluppatori (junior, senior e architetti, tra gli altri) faticano a capire cosa dovrebbero scrivere prima, in quale ordine viene eseguito il loro codice o come eseguire il debug degli errori. La mia raccomandazione quando introduco per la prima volta questi concetti è di mostrare molti esempi. Quando gli sviluppatori inizieranno a vedere come le cose dovrebbero funzionare e come essere utilizzate, ne prenderanno la mano.

Ho lavorato con app desktop per oltre 10 anni (principalmente Visual Basic 6, Java Swing e Windows Forms) prima di mettere le mani su Windows Presentation Foundation (WPF) per la prima volta nel 2010. Fondamentalmente, WPF è stato creato per sostituisce Windows Forms, che è il primo framework di sviluppo desktop di .NET.

Le principali differenze tra WPF e Windows Forms sono sostanziali, ma le più importanti sono:

  • WPF utilizza nuovi paradigmi di sviluppo che sono più robusti e sono stati accuratamente testati.
  • Con WPF, puoi avere un forte disaccoppiamento del design e della codifica per l'interfaccia utente.
  • WPF consente molta personalizzazione e controllo sull'interfaccia utente.

Una volta che ho iniziato a imparare WPF e le sue capacità, l'ho adorato! Non potevo credere a quanto fosse facile implementare il modello MVVM e quanto bene funzionasse l'associazione delle proprietà. Non pensavo di trovare nulla per migliorare quel modo di lavorare finché non mi sono imbattuto nella programmazione reattiva e nel suo utilizzo con WPF:

In questo post, spero di poter mostrare un'implementazione molto semplice di un'app WPF utilizzando la programmazione reattiva con il pattern MVVM e di accedere a un'API REST.

L'applicazione sarà in grado di:

  • Tieni traccia delle auto e delle loro posizioni
  • Prendi le informazioni estratte da una fonte simulata
  • Visualizza queste informazioni per l'utente in un controllo WPF di Bing Maps

L'architettura

Creerai un client WPF che utilizza un servizio RESTful Web API Core 2.

Il lato cliente:

  • WPF
  • UI reattiva
  • Iniezione di dipendenza
  • Modello MVVM
  • Riparare
  • Controllo WPF di Bing Maps
  • Solo a scopo di test, utilizzeremo Postman

Lato server:

  • .NET C# API Web Core 2
  • Iniezione di dipendenza
  • Autenticazione JWT

Cosa ti servirà:

  • Visual Studio 2017 Community (o qualsiasi edizione che potresti avere)

Il back-end

Avvio rapido

Avvia una nuova soluzione di Visual Studio con un'applicazione Web ASP.NET Core.

wpf reactiveui: nuova applicazione web ASP.NET Core di Visual Studio

Configurala in modo che sia un'API poiché la useremo solo come back-end per la nostra app WPF.

wpf reactiveui: configura come API

Dovremmo finire con una soluzione VS con una struttura simile a questa:

wpf reactiveui: esempio di soluzione VS

Finora, abbiamo tutto ciò di cui abbiamo bisogno per avviare il nostro back-end API REST. Se eseguiamo il nostro progetto, caricherà un browser Web (quello che abbiamo impostato su Visual Studio) che punta a un sito Web ospitato su IIS Express che mostrerà una risposta a una chiamata REST con un oggetto JSON.

Ora imposteremo l'autenticazione JWT per il nostro servizio REST.

Alla fine del file startup.cs , aggiungi le seguenti righe.

 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 }; }); }

Inoltre, all'interno del metodo ConfigureServices , chiama il metodo appena creato prima che venga chiamato il metodo AddMvc .

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

Infine, regola il metodo Configure in modo che assomigli a questo:

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

Finora, abbiamo stabilito l'autenticazione JWT da utilizzare sui nostri controller, se definita. Successivamente, regoleremo il controller in modo che utilizzi l'autenticazione che abbiamo descritto.

In ValuesController aggiungeremo AuthorizeAttribute modo che assomigli a questo:

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

Ora, se proviamo a eseguire il nostro servizio, otterremo un errore 401 Non autorizzato come questo:

Errore non autorizzato sul postino

Quindi, dovremo aggiungere un metodo per autenticare i nostri utenti. Per semplicità qui, lo faremo sulla stessa classe 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); }

Ora abbiamo creato un metodo con accesso Anonimo, il che significa che tutti i client, anche quelli non autenticati, potranno chiamarlo utilizzando un messaggio POST contenente un oggetto JSON e passando una stringa per il suo nome utente e una stringa per la sua password.

Quando lo esaminiamo con Postman, otteniamo questo:

Reattivo WPF: autenticazione

Come possiamo vedere, il risultato del metodo di autenticazione è stata la stessa stringa che ora dobbiamo usare come token per ogni chiamata che vogliamo fare all'API.

Una volta inserito il token nelle intestazioni del messaggio, avviene la validazione e, se vengono passati i parametri corretti, il servizio esegue il metodo e ne restituisce il valore. Ad esempio, se ora chiamiamo il controller dei valori e passiamo il token, otteniamo lo stesso risultato di prima:

Reattivo WPF: autenticazione 2

Ora creeremo un metodo per ottenere la latitudine e la longitudine per l'auto corrente che stiamo monitorando. Ancora una volta, per semplicità, sarà solo un metodo fittizio che restituirà inizialmente una posizione casuale e inizierà a spostare l'auto di una distanza fissa ogni volta che viene chiamato il metodo.

Innanzitutto, regoliamo il metodo Get(int id) nella classe ValuesController per farlo apparire così:

 [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(); }

Quindi, aggiungiamo una nuova classe LocationHelper che gestirà le posizioni attuali e future delle auto monitorate.

 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)>(); }

Questo è tutto per il back-end.

L'avantreno:

Ora creeremo una nuova app WPF. Dopo averlo creato, Visual Studio aggiungerà un nuovo progetto con la struttura seguente alla nostra soluzione.

Struttura dell'app WPF

Controllo di Bing Maps:

Per utilizzare il controllo WPF per Bing Maps, dovremo installare l'SDK (riferito sopra) e aggiungerlo come riferimento alla nostra applicazione WPF. A seconda di dove è stata installata, la DLL potrebbe trovarsi in un percorso diverso. L'ho installato nella posizione predefinita e l'ho aggiunto come segue:

Passaggio 1: fare clic con il pulsante destro del mouse sulla sezione Riferimenti per il progetto WPF e quindi fare clic su
Passaggio 1: fai clic con il pulsante destro del mouse sulla sezione Riferimenti per il tuo progetto WPF, quindi fai clic su "Aggiungi riferimento".

Passaggio 2: vai al percorso dell'installazione di Bing Maps WPF Control.
Passaggio 2: vai al percorso dell'installazione di Bing Maps WPF Control.

Passaggio 3: fare clic su OK per aggiungerlo al progetto.
Passaggio 3: fare clic su OK per aggiungerlo al progetto.

Successivamente, aggiungeremo pacchetti nuget per reactiveui , reactiveui-wpf e refit al nostro progetto WPF, che ci consentirà di creare modelli di visualizzazione utilizzando la programmazione reattiva e di consumare la nostra API REST.

Passaggio 1: fare clic con il pulsante destro del mouse sulla sezione Riferimenti del progetto WPF e fare clic su Gestisci pacchetti NuGet.
Passaggio 1: fare clic con il pulsante destro del mouse sulla sezione Riferimenti del progetto WPF e fare clic su Gestisci pacchetti NuGet.

Passaggio 2: nella scheda Sfoglia, cerca "reactiveui", fai clic su Installa, cerca "reactiveui-wpf", fai clic su Installa e, infine, cerca "refit" e fai clic su Installa.
Passaggio 2: nella scheda Sfoglia, cerca "reactiveui", fai clic su Installa, cerca "reactiveui-wpf", fai clic su Installa e, infine, cerca "refit" e fai clic su Installa.

Creeremo ora il nostro ViewModel . Aggiungi una nuova classe chiamata MainViewModel.cs e falla assomigliare a questa:

 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 }

Per far sapere alla vista che c'è un ViewModel allegato e poterlo usare, dovremo apportare alcune modifiche al file 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); } }

Quindi, modificheremo il file MainWindow.xaml in modo che assomigli a questo:

 <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>

È importante modificare la proprietà CredentialsProvider con la propria chiave Bing Maps.

Per poter accedere alla nostra API REST, utilizzeremo refit. Tutto ciò che dobbiamo fare è creare un'interfaccia che descriva i metodi delle API che utilizzeremo. Quindi, creiamo una nuova interfaccia chiamata ITrackingService con il seguente contenuto:

 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); }

Infine, modifichiamo la classe App per includere l'iniezione di dipendenza (usando Splat, che è stato aggiunto quando abbiamo incluso il riferimento a reactiveui ), impostiamo ServerUri (che dovresti cambiare su qualsiasi porta tu ottenga quando esegui l'API REST) ​​e simuliamo il nostro effettuare il login all'inizio dell'applicazione.

 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}"; } }

Infine, quando eseguiamo la nostra applicazione, saremo in grado di vedere una simulazione in tempo reale di un'auto in movimento con le sue coordinate prelevate dall'API REST ogni 500 ms. L'utente può anche cambiare l'auto seguita con qualsiasi altro ID e verrà creato un nuovo set di dati per esso.

Spero che questo piccolo esempio abbia mostrato le basi della gestione di un'API REST con programmazione reattiva in WPF in modo accessibile.

Puoi sempre scaricare l'intero progetto sorgente da questo repository.

Ci sono alcune aree in cui continuare con questo esempio che potrebbero aiutarti a comprendere meglio:

  • Crea una finestra di accesso e consenti all'utente di accedere e disconnettersi.
  • Convalida i dati utente da un database.
  • Crea ruoli utente diversi e limita determinati metodi nell'API REST in modo che solo un utente con un determinato ruolo possa accedervi.
  • Scopri di più sulla programmazione reattiva passando attraverso tutti gli operatori e il loro comportamento con Rx Marbles. Rx Marbles è un'applicazione ordinata che ti consente di interagire con i flussi e applicare operatori ai punti dati in essi contenuti.

Conclusione

La programmazione reattiva può rivelarsi utile quando si cerca di ottenere un modo controllato di utilizzare la programmazione guidata dagli eventi senza incappare nei soliti problemi inerenti a questo paradigma. Usarlo per i nuovi sviluppi è semplice come aggiungere un paio di riferimenti a librerie open source ben supportate. Ma, soprattutto, incorporarlo nelle basi di codice esistenti può essere progressivo e non dovrebbe interrompere la compatibilità con le versioni precedenti con i componenti che non lo implementano. Questo articolo trattava della programmazione reattiva per WPF, ma ci sono port per la maggior parte dei principali linguaggi e framework che rendono la programmazione reattiva una buona avventura per qualsiasi tipo di sviluppatore.

Come esercizio, quindi, dovresti:

  • Estendi il comportamento del progetto di
    • Aggiunta di un database per utenti, auto e posizioni
    • Ottenere la posizione delle auto dal database e mostrarle all'utente. Consenti all'utente di esplorare i movimenti di un'auto in un periodo di tempo
    • Aggiunta di autorizzazioni utente. Consenti agli utenti amministratori di creare nuove auto e utenti e di concedere l'accesso in sola lettura agli utenti regolari. Aggiungi ruoli all'autenticazione JWT.
  • Esamina il codice sorgente per le estensioni reattive .NET in https://github.com/dotnet/reactive