Tutorial: Interfaz de usuario remota avanzada
En este tutorial, obtendrá información sobre los conceptos avanzados de la interfaz de usuario remota modificando incrementalmente una ventana de herramientas que muestra una lista de colores aleatorios:
Obtendrá información sobre:
- Cómo se pueden ejecutar varios comandos asincrónicos en paralelo y cómo deshabilitar los elementos de la interfaz de usuario cuando se ejecuta un comando.
- Cómo enlazar varios botones al mismo comando asincrónico.
- Cómo se controlan los tipos de referencia en el contexto de datos de la interfaz de usuario remota y su proxy.
- Cómo usar un comando asincrónico como controlador de eventos.
- Cómo deshabilitar un solo botón cuando se ejecuta la devolución de llamada de su comando asincrónico si hay varios botones enlazados al mismo comando.
- Cómo usar diccionarios de recursos XAML desde un control de interfaz de usuario remoto.
- Cómo usar tipos de WPF, como pinceles complejos, en el contexto de datos de la interfaz de usuario remota.
- Cómo controla la interfaz de usuario remota el subproceso.
Este tutorial se basa en el artículo de introducción a la interfaz de usuario remota y espera que tenga una extensión de extensibilidad de VisualStudio.Extensibility en funcionamiento, entre las que se incluyen:
- un archivo
.cs
para el comando que abre la ventana de herramientas, - un archivo
MyToolWindow.cs
para la claseToolWindow
, - un archivo
MyToolWindowContent.cs
para la claseRemoteUserControl
, - un archivo de recursos incrustado
MyToolWindowContent.xaml
para la definición xamlRemoteUserControl
, - un archivo
MyToolWindowData.cs
para el contexto de datos deRemoteUserControl
.
Para empezar, actualice MyToolWindowContent.xaml
para mostrar una vista de lista y un botón":
<DataTemplate xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vs="http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml"
xmlns:styles="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.15.0"
xmlns:colors="clr-namespace:Microsoft.VisualStudio.PlatformUI;assembly=Microsoft.VisualStudio.Shell.15.0">
<Grid x:Name="RootGrid">
<Grid.Resources>
<Style TargetType="ListView" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ThemedDialogListViewStyleKey}}" />
<Style TargetType="Button" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ButtonStyleKey}}" />
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource {x:Static styles:VsBrushes.WindowTextKey}}" />
</Style>
</Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListView ItemsSource="{Binding Colors}" HorizontalContentAlignment="Stretch">
<ListView.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding ColorText}" />
<Rectangle Fill="{Binding Color}" Width="50px" Grid.Column="1" />
<Button Content="Remove" Grid.Column="2" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<Button Content="Add color" Command="{Binding AddColorCommand}" Grid.Row="1" />
</Grid>
</DataTemplate>
A continuación, actualice la clase de contexto de datos MyToolWindowData.cs
:
using Microsoft.VisualStudio.Extensibility.UI;
using System.Collections.ObjectModel;
using System.Runtime.Serialization;
using System.Text;
using System.Windows.Media;
namespace MyToolWindowExtension;
[DataContract]
internal class MyToolWindowData
{
private Random random = new();
public MyToolWindowData()
{
AddColorCommand = new AsyncCommand(async (parameter, cancellationToken) =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
var color = new byte[3];
random.NextBytes(color);
Colors.Add(new MyColor(color[0], color[1], color[2]));
});
}
[DataMember]
public ObservableList<MyColor> Colors { get; } = new();
[DataMember]
public AsyncCommand AddColorCommand { get; }
[DataContract]
public class MyColor
{
public MyColor(byte r, byte g, byte b)
{
ColorText = Color = $"#{r:X2}{g:X2}{b:X2}";
}
[DataMember]
public string ColorText { get; }
[DataMember]
public string Color { get; }
}
}
En este código hay solo algunas cosas destacadas:
MyColor.Color
es un elementostring
, pero se usa comoBrush
cuando los datos están enlazados en XAML, esta es una funcionalidad proporcionada por WPF.- La devolución de llamada asincrónica
AddColorCommand
contiene un retraso de 2 segundos para simular una operación de larga duración. - Usamos ObservableList<T>, que es un ObservableCollection<T> extendido proporcionado por la interfaz de usuario remota para admitir también operaciones de rango, lo que permite un mejor rendimiento.
MyToolWindowData
yMyColor
no implemente INotifyPropertyChanged porque, en este momento, todas las propiedades son de solo lectura.
Control de comandos asincrónicos de ejecución prolongada
Una de las diferencias más importantes entre la interfaz de usuario remota y WPF normal es que todas las operaciones que implican la comunicación entre la interfaz de usuario y la extensión son asincrónicas.
Los comandos asincrónicos, como AddColorCommand
hacer esto explícito, proporcionan una devolución de llamada asincrónica.
Puede ver el efecto de esto si hace clic en el botón Agregar color varias veces en un breve tiempo: dado que cada ejecución de comandos tarda 2 segundos, se producen varias ejecuciones en paralelo y varios colores aparecerán en la lista juntos cuando se supere el retraso de 2 segundos. Esto puede dar la impresión al usuario de que el botón Agregar color no funciona.
Para solucionar esto, deshabilite el botón mientras se ejecuta el comando asincrónico. La manera más sencilla de hacerlo es simplemente establecer CanExecute
para que el comando sea false:
AddColorCommand = new AsyncCommand(async (parameter, ancellationToken) =>
{
AddColorCommand!.CanExecute = false;
try
{
await Task.Delay(TimeSpan.FromSeconds(2));
var color = new byte[3];
random.NextBytes(color);
Colors.Add(new MyColor(color[0], color[1], color[2]));
}
finally
{
AddColorCommand.CanExecute = true;
}
});
Esta solución todavía tiene sincronización imperfecta desde que, cuando el usuario hace clic en el botón, la devolución de llamada del comando se ejecuta de forma asincrónica en la extensión, la devolución de llamada se establece CanExecute
en false
, que a continuación se propaga de forma asincrónica al contexto de datos proxy en el proceso de Visual Studio, lo que provoca que el botón se deshabilite. El usuario podría hacer clic en el botón dos veces en sucesión rápida antes de deshabilitar el botón.
Una mejor solución es usar la propiedad RunningCommandsCount
de los comandos asincrónicos:
<Button Content="Add color" Command="{Binding AddColorCommand}" IsEnabled="{Binding AddColorCommand.RunningCommandsCount.IsZero}" Grid.Row="1" />
RunningCommandsCount
es un contador del número de ejecuciones asincrónicas simultáneas del comando actualmente en curso. Este contador se incrementa en el subproceso de la interfaz de usuario en cuanto se hace clic en el botón, lo que permite deshabilitar de forma sincrónica el botón enlazando IsEnabled
a RunningCommandsCount.IsZero
.
Dado que todos los comandos de la interfaz de usuario remota se ejecutan de forma asincrónica, el procedimiento recomendado es usar RunningCommandsCount.IsZero
siempre para deshabilitar los controles cuando corresponda, incluso si se espera que el comando se complete rápidamente.
Comandos asincrónicos y plantillas de datos
En esta sección, implementará el botón Quitar, que permite al usuario eliminar una entrada de la lista. Podemos crear un comando asincrónico para cada objeto MyColor
o podemos tener un único comando asincrónico en MyToolWindowData
y usar un parámetro para identificar qué color se debe quitar. Esta última opción es un diseño más limpio, por lo que vamos a implementarlo.
- Actualice el XAML del botón en la plantilla de datos:
<Button Content="Remove" Grid.Column="2"
Command="{Binding DataContext.RemoveColorCommand,
RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListView}}}"
CommandParameter="{Binding}"
IsEnabled="{Binding DataContext.RemoveColorCommand.RunningCommandsCount.IsZero,
RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListView}}}" />
- Añada el correspondiente
AsyncCommand
aMyToolWindowData
:
[DataMember]
public AsyncCommand RemoveColorCommand { get; }
- Establezca la devolución de llamada asincrónica del comando en el constructor de
MyToolWindowData
:
RemoveColorCommand = new AsyncCommand(async (parameter, ancellationToken) =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
Colors.Remove((MyColor)parameter!);
});
Este código usa un Task.Delay
para simular una ejecución de comandos asincrónica de larga duración.
Tipos de referencia en el contexto de datos
En el código anterior, se recibe un objeto MyColor
como parámetro de un comando asincrónico y se usa como parámetro de una llamada List<T>.Remove
, que emplea la igualdad de referencia (ya que MyColor
es un tipo de referencia que no invalida Equals
) para identificar el elemento que se va a quitar. Esto es posible porque, incluso si el parámetro se recibe de la interfaz de usuario, se recibe la instancia exacta de MyColor
que forma parte actualmente del contexto de datos, no una copia.
Los procesos de
- proxy del contexto de datos de un control de usuario remoto;
- enviar actualizaciones
INotifyPropertyChanged
de la extensión a Visual Studio o viceversa; - enviar actualizaciones de colección observables de la extensión a Visual Studio, o viceversa;
- enviar parámetros de comando asincrónicos
todos respetan la identidad de los objetos de tipo de referencia. Excepto para las cadenas, los objetos de tipo de referencia nunca se duplican cuando se transfieren de vuelta a la extensión.
En la imagen, puede ver cómo todos los objetos de tipo de referencia en el contexto de datos (los comandos, la colección, cada uno MyColor
e incluso el contexto de datos completo) se asignan a un identificador único por parte de la infraestructura de interfaz de usuario remota. Cuando el usuario hace clic en el botón Quitar del objeto de color de proxy n.º 5, el identificador único (n.º 5), no el valor del objeto, se devuelve a la extensión. La infraestructura de interfaz de usuario remota se encarga de recuperar el objeto MyColor
correspondiente y pasarlo como parámetro a la devolución de llamada del comando asincrónico.
RunningCommandsCount con varios enlaces y control de eventos
Si prueba la extensión en este momento, observe que cuando se hace clic en uno de los botones Quitar, todos los botones Quitar están deshabilitados:
Este puede ser el comportamiento deseado. Pero supongamos que solo desea deshabilitar el botón actual y permitir que el usuario ponga en cola varios colores para su eliminación: no podemos usar la propiedad RunningCommandsCount
del comando asincrónico porque tenemos un único comando compartido entre todos los botones.
Podemos lograr nuestro objetivo adjuntando una propiedad RunningCommandsCount
a cada botón para que tengamos un contador independiente para cada color. Estas características se proporcionan mediante el espacio de nombres http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml
, que permite consumir tipos de interfaz de usuario remota desde XAML:
Cambiamos el botón Quitar a lo siguiente:
<Button Content="Remove" Grid.Column="2"
IsEnabled="{Binding Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero, RelativeSource={RelativeSource Self}}">
<vs:ExtensibilityUICommands.EventHandlers>
<vs:EventHandlerCollection>
<vs:EventHandler Event="Click"
Command="{Binding DataContext.RemoveColorCommand, ElementName=RootGrid}"
CommandParameter="{Binding}"
CounterTarget="{Binding RelativeSource={RelativeSource Self}}" />
</vs:EventHandlerCollection>
</vs:ExtensibilityUICommands.EventHandlers>
</Button>
La propiedad adjunta vs:ExtensibilityUICommands.EventHandlers
permite asignar comandos asincrónicos a cualquier evento (por ejemplo, MouseRightButtonUp
) y puede ser útil en escenarios más avanzados.
vs:EventHandler
también puede tener un objeto CounterTarget
: un UIElement
que se debe adjuntar a una propiedad vs:ExtensibilityUICommands.RunningCommandsCount
, contando las ejecuciones activas relacionadas con ese evento específico. Asegúrese de usar paréntesis (por ejemplo Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero
) al enlazar a una propiedad adjunta.
En este caso, usamos vs:EventHandler
para asociar a cada botón su propio contador independiente de ejecuciones de comandos activas. Al enlazar IsEnabled
a la propiedad adjunta, solo se deshabilita ese botón específico cuando se quita el color correspondiente:
Diccionarios de recursos XAML de usuario
A partir de Visual Studio 17.10, la interfaz de usuario remota admite diccionarios de recursos XAML. Esto permite que varios controles de interfaz de usuario remoto compartan estilos, plantillas y otros recursos. También permite definir diferentes recursos (por ejemplo, cadenas) para distintos idiomas.
De forma similar a un XAML de control remoto de la interfaz de usuario, los archivos de recursos deben configurarse como recursos incrustados:
<ItemGroup>
<EmbeddedResource Include="MyResources.xaml" />
<Page Remove="MyResources.xaml" />
</ItemGroup>
La interfaz de usuario remota hace referencia a diccionarios de recursos de una manera diferente a WPF: no se agregan a los diccionarios combinados del control (los diccionarios combinados no se admiten en absoluto en la interfaz de usuario remota), pero se hace referencia por nombre en el archivo .cs del control:
internal class MyToolWindowContent : RemoteUserControl
{
public MyToolWindowContent()
: base(dataContext: new MyToolWindowData())
{
this.ResourceDictionaries.AddEmbeddedResource(
"MyToolWindowExtension.MyResources.xaml");
}
...
AddEmbeddedResource
toma el nombre completo del recurso incrustado que, de forma predeterminada, se compone del espacio de nombres raíz para el proyecto, cualquier ruta de acceso de subcarpeta en la que pueda estar y el nombre de archivo. Es posible invalidar este nombre estableciendo un LogicalName
para el EmbeddedResource
en el archivo del proyecto.
El propio archivo de recursos es un diccionario de recursos de WPF normal:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="removeButtonText">Remove</system:String>
<system:String x:Key="addButtonText">Add color</system:String>
</ResourceDictionary>
Puede hacer referencia a un recurso desde el diccionario de recursos en el control de interfaz de usuario remoto mediante DynamicResource
:
<Button Content="{DynamicResource removeButtonText}" ...
Localizar diccionarios de recursos XAML
Los diccionarios de recursos de la interfaz de usuario remota se pueden localizar de la misma manera que los recursos insertados: crea otros archivos XAML con el mismo nombre y un sufijo de idioma, por ejemplo MyResources.it.xaml
, para los recursos italianos:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="removeButtonText">Rimuovi</system:String>
<system:String x:Key="addButtonText">Aggiungi colore</system:String>
</ResourceDictionary>
Puede usar caracteres comodín en el archivo del proyecto para incluir todos los diccionarios XAML localizados como recursos incrustados:
<ItemGroup>
<EmbeddedResource Include="MyResources.*xaml" />
<Page Remove="MyResources.*xaml" />
</ItemGroup>
Use tipos de WPF en el contexto de datos
Hasta ahora, el contexto de datos de nuestro control de usuario remoto se ha compuesto de primitivos (números, cadenas, etc.), colecciones observables y nuestras propias clases marcadas con DataContract
. A veces resulta útil incluir tipos de WPF simples en el contexto de datos, como pinceles complejos.
Dado que es posible que una extensión de extensibilidad de VisualStudio.Extensibility ni siquiera se ejecute en el proceso de Visual Studio, no puede compartir objetos WPF directamente con su interfaz de usuario. Es posible que la extensión ni siquiera tenga acceso a tipos WPF, ya que puede tener como destino netstandard2.0
o net6.0
(no la variante -windows
).
La interfaz de usuario remota proporciona el tipo XamlFragment
, que permite incluir una definición XAML de un objeto WPF en el contexto de datos de un control de usuario remoto:
[DataContract]
public class MyColor
{
public MyColor(byte r, byte g, byte b)
{
ColorText = $"#{r:X2}{g:X2}{b:X2}";
Color = new(@$"<LinearGradientBrush xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation""
StartPoint=""0,0"" EndPoint=""1,1"">
<GradientStop Color=""Black"" Offset=""0.0"" />
<GradientStop Color=""{ColorText}"" Offset=""0.7"" />
</LinearGradientBrush>");
}
[DataMember]
public string ColorText { get; }
[DataMember]
public XamlFragment Color { get; }
}
Con el código anterior, el valor de la propiedad Color
se convierte en un objeto LinearGradientBrush
en el proxy de contexto de datos:
Interfaz de usuario remota y subprocesos
Las devoluciones de llamada de comandos asincrónicas (y las devoluciones de llamada INotifyPropertyChanged
para los valores actualizados por la interfaz de usuario a través de la puja de datos) se generan en subprocesos de grupo de subprocesos aleatorios. Las devoluciones de llamada se generan una a la vez y no se superponen hasta que el código produce el control (mediante una expresión await
).
Este comportamiento se puede cambiar pasando NonConcurrentSynchronizationContext al constructor RemoteUserControl
. En ese caso, puede usar el contexto de sincronización proporcionado para todos los comandos asincrónicos y devoluciones de llamada INotifyPropertyChanged
relacionados con ese control.