ReactiveUI und das MVVM-Muster in WPF-Anwendungen
Veröffentlicht: 2022-03-11Reaktive Programmierung ist ein asynchrones Programmierparadigma, das sich mit Datenströmen und der Ausbreitung von Änderungen befasst. – Wikipedia
Wenn Sie diesen Satz einmal gelesen haben, enden Sie vielleicht immer noch so, wie ich es getan habe, als ich ihn zum ersten Mal gelesen habe: Nirgendwo näher daran, seine Relevanz zu verstehen. Ein wenig mehr Hingabe an die grundlegenden Konzepte und Sie werden schnell verstehen, wie wichtig sie sind. Im Grunde könnte man sich Reactive Programming zunächst vorstellen als: „Ereignisgesteuertes Programmieren auf Steroiden“. Stellen Sie sich einen Event-Handler als Stream vor und stellen Sie sich jedes Auslösen des Handlers als ein neues Datum im Stream vor. Kurz gesagt, was Sie am Ende haben, ist reaktive Programmierung.
Es gibt einige Konzepte, die Sie vielleicht verstehen möchten, bevor Sie sich ein wenig mehr mit der reaktiven Programmierung befassen. Observables sind die Objekte, die Ihnen Zugriff auf die Streams geben, über die wir gesprochen haben. Ihr Zweck ist es, Ihnen einen Einblick in die Daten im Stream zu geben. Sobald dieses Fenster geöffnet wurde, können Sie die Daten auf beliebige Weise betrachten , indem Sie Operatoren darauf verwenden, und so entscheiden, wann und wie Ihre Anwendung auf den Stream reagiert . Zuletzt definieren Sie die Beobachter für den resultierenden Stream, um die Aktion zu definieren, die jedes Mal ausgeführt wird, wenn ein neues Datum vom Stream ausgegeben wird.
In der Praxis bedeutet das nur, dass Sie mehr Kontrolle darüber erhalten, wie Ihre Anwendung auf das Geschehen reagiert, egal ob Ihr Benutzer auf eine Schaltfläche klickt, Ihre App eine HTTP-Antwort erhält oder sich von Ausnahmen erholt. Sobald Sie anfangen, die Vorteile der reaktiven Programmierung (von denen es viele gibt) zu sehen, werden Sie kaum mehr zurückkommen können. Das liegt einfach daran, dass die meisten Dinge, die eine App tut, auf eine bestimmte Art und Weise auf eine bestimmte Eventualität reagieren.
Das heißt aber nicht, dass dieser neue Ansatz keine Nachteile hat. Zunächst einmal kann die Lernkurve ziemlich steil sein. Ich habe aus erster Hand gesehen, wie Entwickler (unter anderem Junioren, Senioren und Architekten) damit kämpfen, herauszufinden, was sie zuerst schreiben sollen, in welcher Reihenfolge ihr Code ausgeführt wird oder wie sie Fehler beheben können. Meine Empfehlung bei der ersten Einführung dieser Konzepte ist, viele Beispiele zu zeigen. Wenn Entwickler anfangen zu sehen, wie Dinge funktionieren und verwendet werden sollen, werden sie den Dreh raus haben.
Ich habe über 10 Jahre mit Desktop-Apps gearbeitet (hauptsächlich Visual Basic 6, Java Swing und Windows Forms), bevor ich 2010 zum ersten Mal die Windows Presentation Foundation (WPF) in die Hände bekam. Im Grunde wurde WPF dazu entwickelt ersetzen Windows Forms, das erste Desktop-Entwicklungs-Framework von .NET.
Die Hauptunterschiede zwischen WPF und Windows Forms sind erheblich, aber die wichtigsten sind:
- WPF verwendet neue Entwicklungsparadigmen, die robuster sind und gründlich getestet wurden.
- Mit WPF können Sie eine starke Entkopplung von Design und Codierung für die Benutzeroberfläche erreichen.
- WPF ermöglicht zahlreiche Anpassungen und Kontrolle über Ihre Benutzeroberfläche.
Als ich anfing, WPF und seine Fähigkeiten zu lernen, war ich absolut begeistert! Ich konnte nicht glauben, wie einfach das MVVM-Muster zu implementieren war und wie gut die Eigenschaftsbindung funktionierte. Ich dachte nicht, dass ich etwas finden würde, um diese Arbeitsweise zu verbessern, bis ich auf Reactive Programming und seine Verwendung mit WPF stieß:
In diesem Beitrag hoffe ich, eine sehr einfache Implementierung einer WPF-App mithilfe von Reactive Programming mit dem MVVM-Pattern zeigen und auf eine REST-API zugreifen zu können.
Die Anwendung kann:
- Verfolgen Sie Autos und ihre Standorte
- Nehmen Sie Informationen aus einer simulierten Quelle
- Zeigen Sie dem Benutzer diese Informationen in einem Bing Maps WPF-Steuerelement an
Die Architektur
Sie erstellen einen WPF-Client, der einen RESTful-Web-API-Core-2-Dienst nutzt.
Die Kundenseite:
- WPF
- ReaktiveUI
- Abhängigkeitsspritze
- MVVM-Muster
- Nachrüsten
- Bing Maps WPF-Steuerelement
- Nur zu Testzwecken verwenden wir Postman
Die Serverseite:
- .NET C#-Web-API Core 2
- Abhängigkeitsspritze
- JWT-Authentifizierung
Was du brauchen wirst:
- Visual Studio 2017 Community (oder eine beliebige Edition, die Sie haben)
Das Backend
Schnellstart
Starten Sie eine neue Visual Studio-Lösung mit einer ASP.NET Core-Webanwendung.
Konfigurieren Sie es als API, da wir es nur als Back-End für unsere WPF-App verwenden werden.
Wir sollten am Ende eine VS-Lösung mit einer ähnlichen Struktur wie dieser haben:
Bisher haben wir alles, was wir brauchen, um unser REST-API-Backend zu starten. Wenn wir unser Projekt ausführen, lädt es einen Webbrowser (den wir auf Visual Studio eingestellt haben), der auf eine Website verweist, die auf IIS Express gehostet wird und eine Antwort auf einen REST-Aufruf mit einem JSON-Objekt anzeigt.
Jetzt richten wir die JWT-Authentifizierung für unseren REST-Dienst ein.
Fügen Sie am Ende der Datei startup.cs
die folgenden Zeilen hinzu.
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 }; }); }
Rufen Sie außerdem innerhalb der ConfigureServices
-Methode die soeben erstellte Methode auf, bevor die AddMvc
Methode aufgerufen wird.
public void ConfigureServices(IServiceCollection services) { LoadJwtAuthorization(services); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); }
Passen Sie zuletzt die Configure
Methode so an, dass sie so aussieht:
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); }
Bisher haben wir die JWT-Authentifizierung eingerichtet, die auf unseren Controllern verwendet werden soll, wenn sie definiert ist. Als Nächstes passen wir den Controller so an, dass er die von uns beschriebene Authentifizierung verwendet.
Auf dem ValuesController
fügen wir das AuthorizeAttribute
hinzu, sodass es wie folgt aussieht:
[Route("api/[controller]")] [ApiController] [Authorize] public class ValuesController : ControllerBase { ... }
Wenn wir nun versuchen, unseren Dienst auszuführen, erhalten wir einen 401 Unauthorized-Fehler wie diesen:
Wir müssen also eine Methode hinzufügen, um unsere Benutzer zu authentifizieren. Der Einfachheit halber verwenden wir hier dieselbe ValuesController
-Klasse.
[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); }
Jetzt haben wir eine Methode mit anonymem Zugriff erstellt, was bedeutet, dass alle Clients, auch diejenigen, die nicht authentifiziert sind, sie mithilfe einer POST-Nachricht aufrufen können, die ein JSON-Objekt enthält und eine Zeichenfolge für ihren Benutzernamen und eine Zeichenfolge für ihr Kennwort übergibt.
Wenn wir es mit Postman überprüfen, erhalten wir Folgendes:
Wie wir sehen können, war das Ergebnis der Methode „authenticate“ genau der String, den wir jetzt als Token für jeden Aufruf der API verwenden müssen.
Sobald das Token in den Headern der Nachricht enthalten ist, findet die Validierung statt, und wenn die richtigen Parameter übergeben werden, führt der Dienst die Methode aus und gibt ihren Wert zurück. Rufen wir nun beispielsweise den Werte-Controller auf und übergeben den Token, erhalten wir das gleiche Ergebnis wie zuvor:
Jetzt erstellen wir eine Methode, um den Breiten- und Längengrad für das aktuelle Auto, das wir verfolgen, abzurufen. Auch hier handelt es sich der Einfachheit halber nur um eine Dummy-Methode, die zunächst eine zufällige Position zurückgibt und das Auto bei jedem Aufruf der Methode um eine festgelegte Strecke bewegt.
Zuerst passen wir die Get(int id)
-Methode in der ValuesController
-Klasse so an, dass sie so aussieht:
[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(); }
Dann fügen wir eine neue LocationHelper
-Klasse hinzu, die die aktuellen und zukünftigen Standorte der verfolgten Autos handhabt.
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)>(); }
Das war's für das Backend.
Das vordere Ende:
Wir erstellen jetzt eine neue WPF-App. Sobald wir es erstellt haben, fügt Visual Studio unserer Lösung ein neues Projekt mit der folgenden Struktur hinzu.
Bing Maps-Steuerung:
Um das WPF-Steuerelement für Bing Maps zu verwenden, müssen wir das SDK (siehe oben) installieren und es als Referenz zu unserer WPF-Anwendung hinzufügen. Je nachdem, wo Sie es installiert haben, befindet sich die DLL möglicherweise in einem anderen Pfad. Ich habe es am Standardspeicherort installiert und wie folgt hinzugefügt:

Als Nächstes fügen wir unserem WPF-Projekt Nuget-Pakete fürreaktiveui, reactiveui
reactiveui-wpf
und refit
hinzu, mit denen wir Ansichtsmodelle mit reaktiver Programmierung erstellen und unsere REST-API nutzen können.
Wir erstellen jetzt unser ViewModel
. Fügen Sie eine neue Klasse namens MainViewModel.cs
und lassen Sie sie so aussehen:
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 }
Damit die Ansicht weiß, dass ein ViewModel
an sie angehängt ist und verwendet werden kann, müssen wir einige Änderungen an der Datei MainView.xaml.cs
“ vornehmen.
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); } }
Dann ändern wir die Datei MainWindow.xaml
so, dass sie so aussieht:
<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>
Es ist wichtig, die CredentialsProvider
Eigenschaft mit Ihrem eigenen Bing Maps-Schlüssel anzupassen.
Um auf unsere REST-API zugreifen zu können, verwenden wir refit. Wir müssen lediglich eine Schnittstelle erstellen, die die API-Methoden beschreibt, die wir verwenden werden. Also erstellen wir eine neue Schnittstelle namens ITrackingService mit folgendem Inhalt:
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); }
Schließlich ändern wir die App
-Klasse so, dass sie die Abhängigkeitsinjektion enthält (unter Verwendung von Splat, das hinzugefügt wurde, als wir den Verweis reactiveui
einschlossen), legen Sie die ServerUri
(die Sie auf den Port ändern sollten, den Sie beim Ausführen der REST-API erhalten) und simulieren Sie unsere Melden Sie sich ganz am Anfang der Anwendung an.
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}"; } }
Wenn wir schließlich unsere Anwendung ausführen, können wir eine Echtzeitsimulation eines fahrenden Autos sehen, dessen Koordinaten alle 500 ms von der REST-API übernommen werden. Der Benutzer kann auch das verfolgte Auto auf eine andere ID ändern, und es wird ein neuer Datensatz dafür erstellt.
Ich hoffe, dieses kleine Beispiel hat die Grundlagen des Umgangs mit einer REST-API mit reaktiver Programmierung in WPF auf zugängliche Weise gezeigt.
Sie können jederzeit das gesamte Quellprojekt aus diesem Repository herunterladen.
Es gibt einige Bereiche, in denen Sie mit diesem Beispiel fortfahren können, die Ihnen helfen könnten, Ihr Verständnis zu verbessern:
- Erstellen Sie ein Anmeldefenster und erlauben Sie dem Benutzer, sich an- und abzumelden.
- Validieren Sie die Benutzerdaten aus einer Datenbank.
- Erstellen Sie verschiedene Benutzerrollen und beschränken Sie bestimmte Methoden in der REST-API, sodass nur ein Benutzer mit einer bestimmten Rolle darauf zugreifen kann.
- Verstehen Sie mehr über die reaktive Programmierung, indem Sie alle Operatoren und ihr Verhalten mit Rx Marbles durchgehen. Rx Marbles ist eine nette Anwendung, mit der Sie mit Streams interagieren und Operatoren auf die darin enthaltenen Datenpunkte anwenden können.
Fazit
Die reaktive Programmierung kann sich als vorteilhaft erweisen, wenn Sie eine kontrollierte Art der Verwendung der ereignisgesteuerten Programmierung anstreben, ohne auf die üblichen Probleme zu stoßen, die diesem Paradigma innewohnen. Die Verwendung für neue Entwicklungen ist so einfach wie das Hinzufügen einiger Verweise auf gut unterstützte Open-Source-Bibliotheken. Aber am wichtigsten ist, dass die Integration in vorhandene Codebasen progressiv sein kann und die Abwärtskompatibilität mit Komponenten, die es nicht implementieren, nicht beeinträchtigt werden sollte. Dieser Artikel befasste sich mit der reaktiven Programmierung für WPF, aber es gibt Portierungen zu den meisten wichtigen Sprachen und Frameworks, was die reaktive Programmierung zu einem guten Abenteuer für jede Art von Entwickler macht.
Als Nächstes sollten Sie Folgendes tun:
- Erweitern Sie das Verhalten des Projekts um
- Hinzufügen einer Datenbank für Benutzer, Autos und Standorte
- Den Standort für die Autos aus der Datenbank abrufen und dem Benutzer anzeigen. Ermöglichen Sie dem Benutzer, die Bewegungen eines Autos über einen bestimmten Zeitraum zu untersuchen
- Benutzerberechtigungen hinzufügen. Lassen Sie Admin-Benutzer neue Autos und Benutzer erstellen und geben Sie regulären Benutzern Lesezugriff. Rollen zur JWT-Authentifizierung hinzufügen.
- Überprüfen Sie den Quellcode für reaktive .NET-Erweiterungen unter https://github.com/dotnet/reactive