ReactiveUI y el patrón MVVM en aplicaciones WPF
Publicado: 2022-03-11La programación reactiva es un paradigma de programación asincrónica que se ocupa de los flujos de datos y la propagación del cambio. –Wikipedia
Una vez que haya leído esa oración, es posible que termine de la misma manera que cuando la leí por primera vez: nada más cerca de comprender su relevancia. Un poco más de dedicación a los conceptos básicos y comprenderás rápidamente su importancia. Básicamente, podrías pensar en la Programación reactiva al principio como: "Programación basada en eventos con esteroides". Imagine un controlador de eventos como una secuencia y piense en cada disparo del controlador como un nuevo dato en la secuencia. En pocas palabras, lo que obtienes al final es la programación reactiva.
Hay algunos conceptos que quizás desee comprender antes de profundizar un poco más en la programación reactiva. Los observables son los objetos que le dan acceso a los flujos de los que hemos estado hablando. Su propósito es darle una ventana a los datos en la secuencia. Una vez que se ha abierto esa ventana, puede ver los datos de la forma que elija utilizando Operadores en ella y, por lo tanto, decidir cuándo y cómo reacciona su aplicación a la transmisión. Por último, defina los Observadores en la secuencia resultante para definir la acción que sucederá cada vez que la secuencia emita un nuevo dato.
En términos prácticos, eso solo significa que tiene más control sobre la forma en que su aplicación reacciona a lo que sucede, ya sea que el usuario haga clic en un botón, que su aplicación reciba una respuesta HTTP o que se recupere de las excepciones. Una vez que empieces a ver los beneficios de usar la Programación Reactiva (de la que hay muchas), difícilmente podrás volver atrás. Eso es simplemente porque la mayoría de las cosas que hace una aplicación es reaccionar de cierta manera ante una eventualidad dada.
Ahora, esto no significa que no haya desventajas en este nuevo enfoque. En primer lugar, su curva de aprendizaje puede ser bastante empinada. He visto de primera mano cómo los desarrolladores (junior, senior y arquitectos, entre otros) se esfuerzan por descubrir qué se supone que deben escribir primero, en qué orden se ejecuta su código o cómo depurar errores. Mi recomendación al presentar estos conceptos por primera vez es mostrar muchos ejemplos. Cuando los desarrolladores comiencen a ver cómo se supone que deben funcionar y usarse las cosas, se acostumbrarán.
Trabajé con aplicaciones de escritorio durante más de 10 años (principalmente Visual Basic 6, Java Swing y Windows Forms) antes de probar Windows Presentation Foundation (WPF) por primera vez en 2010. Básicamente, WPF se creó para reemplaza Windows Forms, que es el primer marco de desarrollo de escritorio de .NET.
Las principales diferencias entre WPF y Windows Forms son sustanciales, pero las más importantes son:
- WPF utiliza nuevos paradigmas de desarrollo que son más sólidos y se han probado exhaustivamente.
- Con WPF, puede tener un fuerte desacoplamiento del diseño y la codificación para la interfaz de usuario.
- WPF permite mucha personalización y control sobre su interfaz de usuario.
Una vez que comencé a aprender WPF y sus capacidades, ¡me encantó! No podía creer lo fácil que fue implementar el patrón MVVM y lo bien que funcionó el enlace de propiedades. No pensé que encontraría nada para mejorar esa forma de trabajar hasta que me topé con la Programación reactiva y su uso con WPF:
En esta publicación, espero poder mostrar una implementación muy simple de una aplicación WPF usando Programación Reactiva con el patrón MVVM y acceder a una API REST.
La aplicación podrá:
- Seguimiento de coches y sus ubicaciones.
- Tomar información extraída de una fuente simulada
- Mostrar esta información al usuario en un control WPF de Bing Maps
La arquitectura
Construirá un cliente WPF que consume un servicio RESTful Web API Core 2.
El lado del cliente:
- WPF
- interfaz de usuario reactiva
- Inyección de dependencia
- patrón MVVM
- Repararse
- Control WPF de mapas de Bing
- Solo con fines de prueba, usaremos Postman
El lado del servidor:
- .NET C# Web API Núcleo 2
- Inyección de dependencia
- autenticación JWT
Lo que necesitarás:
- Comunidad de Visual Studio 2017 (o cualquier edición que pueda tener)
el back-end
Inicio rápido
Inicie una nueva solución de Visual Studio con una aplicación web ASP.NET Core.
Configúrelo para que sea una API, ya que solo lo usaremos como back-end para nuestra aplicación WPF.
Deberíamos terminar con una solución VS con una estructura similar a esta:
Hasta ahora, tenemos todo lo que necesitamos para iniciar nuestro back-end de REST API. Si ejecutamos nuestro proyecto, cargará un navegador web (el que tenemos configurado en Visual Studio) apuntando a un sitio web alojado en IIS Express que mostrará una respuesta a una llamada REST con un objeto JSON.
Ahora, configuraremos la autenticación JWT para nuestro servicio REST.
Al final del archivo startup.cs
, agregue las siguientes líneas.
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 }; }); }
Además, dentro del método ConfigureServices
, llame al método que acabamos de crear antes de que se llame al método AddMvc
.
public void ConfigureServices(IServiceCollection services) { LoadJwtAuthorization(services); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); }
Por último, ajuste el método Configure
para que se vea así:
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseMvc(); }
Hasta ahora, hemos establecido la autenticación JWT para que se use en nuestros controladores si está definida. A continuación, ajustaremos el controlador para que use la autenticación que describimos.
En ValuesController
, agregaremos AuthorizeAttribute
para que se asemeje a esto:
[Route("api/[controller]")] [ApiController] [Authorize] public class ValuesController : ControllerBase { ... }
Ahora, si intentamos ejecutar nuestro servicio, obtendremos un error 401 no autorizado como este:
Entonces, necesitaremos agregar un método para autenticar a nuestros usuarios. En aras de la simplicidad aquí, lo haremos en la misma clase 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); }
Ahora hemos creado un método con acceso anónimo, lo que significa que todos los clientes, incluso aquellos que no están autenticados, podrán llamarlo usando un mensaje POST que contiene un objeto JSON y pasando una cadena para su nombre de usuario y una cadena para su contraseña.
Cuando lo revisamos con Postman, obtenemos esto:
Como podemos ver, el resultado del método de autenticación fue la misma cadena que ahora necesitamos usar como token para cada llamada que queremos hacer a la API.
Una vez que el token se incluye en los encabezados del mensaje, se realiza la validación y, si se pasan los parámetros correctos, el servicio ejecuta el método y devuelve su valor. Por ejemplo, si ahora llamamos al controlador de valores y pasamos el token, obtenemos el mismo resultado que antes:
Ahora, crearemos un método para obtener la latitud y la longitud del automóvil actual que estamos rastreando. Nuevamente, para simplificar, solo será un método ficticio que devolverá al principio una ubicación aleatoria y comenzará a mover el automóvil una distancia fija cada vez que se llame al método.
Primero, ajustamos el método Get(int id)
en la clase ValuesController
para que se vea así:
[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(); }
Luego, agregamos una nueva clase LocationHelper
que manejará las ubicaciones actuales y futuras de los autos que se rastrean.
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)>(); }
Eso es todo por la parte de atrás.
La parte delantera:
Ahora crearemos una nueva aplicación WPF. Una vez que lo hayamos creado, Visual Studio agregará un nuevo proyecto con la siguiente estructura a nuestra solución.
Control de mapas de Bing:
Para usar el control WPF para Bing Maps, necesitaremos instalar el SDK (mencionado anteriormente) y agregarlo como referencia a nuestra aplicación WPF. Dependiendo de dónde lo haya instalado, la DLL podría estar en una ruta diferente. Lo instalé en la ubicación predeterminada y lo agregué de la siguiente manera:

A continuación, agregaremos paquetes nuget para reactiveui
, reactiveui-wpf
y los refit
a nuestro proyecto WPF, lo que nos permitirá crear modelos de vista usando Programación reactiva y consumir nuestra API REST.
Ahora crearemos nuestro ViewModel
. Agrega una nueva clase llamada MainViewModel.cs
y haz que se vea así:
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 }
Para que la vista sepa que hay un ViewModel
adjunto y poder usarlo, debemos realizar algunos cambios en el archivo 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); } }
Luego, modificaremos el archivo MainWindow.xaml
para que se vea así:
<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 importante ajustar la propiedad CredentialsProvider
con su propia clave de Bing Maps.
Para poder acceder a nuestra API REST, usaremos refit. Todo lo que tenemos que hacer es crear una interfaz que describa los métodos de las API que usaremos. Entonces, creamos una nueva interfaz llamada ITrackingService con el siguiente contenido:
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); }
Finalmente, modificamos la clase de la App
para incluir la inyección de dependencia (usando Splat, que se agregó cuando incluimos la referencia a reactiveui
), configuramos el ServerUri
(que debe cambiar a cualquier puerto que obtenga cuando ejecuta la API REST) y simulamos nuestro Inicie sesión al comienzo de la aplicación.
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}"; } }
Finalmente, cuando ejecutemos nuestra aplicación, podremos ver una simulación en tiempo real de un automóvil en movimiento con sus coordenadas tomadas de la API REST cada 500 ms. El usuario también puede cambiar el automóvil que se está siguiendo a cualquier otra identificación, y se creará un nuevo conjunto de datos para él.
Espero que este pequeño ejemplo haya mostrado los conceptos básicos del manejo de una API REST con Programación Reactiva en WPF de una manera accesible.
Siempre puede descargar el proyecto fuente completo desde este repositorio.
Hay algunas áreas para continuar con este ejemplo que podrían ayudarlo a comprender mejor:
- Cree una ventana de inicio de sesión y permita que el usuario inicie y cierre sesión.
- Validar los datos del usuario desde una base de datos.
- Cree diferentes roles de usuario y restrinja ciertos métodos en la API REST para que solo un usuario con un rol determinado pueda acceder a ellos.
- Comprenda más sobre la Programación Reactiva pasando por todos los operadores y su comportamiento con Rx Marbles. Rx Marbles es una aplicación ordenada que le permite interactuar con flujos y aplicar operadores a los puntos de datos en ellos.
Conclusión
La programación reactiva puede resultar beneficiosa cuando se trata de lograr una forma controlada de usar la programación dirigida por eventos sin encontrarse con los problemas habituales inherentes a este paradigma. Usarlo para nuevos desarrollos es tan simple como agregar un par de referencias a bibliotecas de código abierto bien compatibles. Pero, lo que es más importante, incorporarlo en las bases de código existentes puede ser progresivo y no debería romper la compatibilidad con componentes que no lo implementan. Este artículo trató sobre la Programación reactiva para WPF, pero hay puertos para la mayoría de los principales lenguajes y marcos, lo que hace que la Programación reactiva sea una buena aventura para cualquier tipo de desarrollador.
Como ejercicio, a continuación, debe:
- Extender el comportamiento del proyecto por
- Adición de una base de datos para usuarios, automóviles y ubicaciones
- Obtener la ubicación de los autos de la base de datos y mostrárselos al usuario. Permitir al usuario explorar los movimientos de un automóvil durante un período de tiempo
- Adición de permisos de usuario. Permita que los usuarios administradores creen autos y usuarios nuevos y otorgue acceso de solo lectura a los usuarios regulares. Agregue roles a la autenticación JWT.
- Revise el código fuente de las extensiones reactivas de .NET en https://github.com/dotnet/reactive