Compartir a través de


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:

Captura de pantalla que muestra la ventana de herramientas 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:

  1. un archivo .cs para el comando que abre la ventana de herramientas,
  2. un archivo MyToolWindow.cs para la clase ToolWindow,
  3. un archivo MyToolWindowContent.cs para la clase RemoteUserControl,
  4. un archivo de recursos incrustado MyToolWindowContent.xaml para la definición xaml RemoteUserControl,
  5. un archivo MyToolWindowData.cs para el contexto de datos de RemoteUserControl.

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 elemento string, pero se usa como Brush 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 y MyColor 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.

Diagrama de ejecución de comandos asincrónicos superpuestos.

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.

  1. 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}}}" />
  1. Añada el correspondiente AsyncCommand a MyToolWindowData:
[DataMember]
public AsyncCommand RemoveColorCommand { get; }
  1. 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.

Diagrama de tipos de referencia de enlace de datos de interfaz de usuario remota.

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:

Diagrama del comando asincrónico con varios enlaces.

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:

Diagrama del comando asincrónico con RunningCommandsCount de destino.

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: Captura de pantalla que muestra los tipos WPF en el 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.