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:

Screenshot showing random colors tool window.

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 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 .cs archivo para el comando que abre la ventana de herramientas,
  2. un MyToolWindow.cs archivo para la ToolWindow clase ,
  3. un MyToolWindowContent.cs archivo para la RemoteUserControl clase ,
  4. un MyToolWindowContent.xaml archivo de recursos incrustado para la RemoteUserControl definición xaml,
  5. un MyToolWindowData.cs archivo 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 MyToolWindowData.csde contexto de datos :

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 string elemento , pero se usa como Brush cuando los datos enlazados en XAML, esta es una funcionalidad proporcionada por WPF.
  • La AddColorCommand devolución de llamada asincrónica contiene un retraso de 2 segundos para simular una operación de larga duración.
  • Usamos ObservableList<T>, que es un T observableCollection<> 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.

Diagram of overlapped async command execution.

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 CanExecutefalseen , 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 RunningCommandsCount propiedad 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 MyColor objeto 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. Agregue el objeto 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 para Task.Delay 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 MyColor objeto como parámetro de un comando asincrónico y se usa como parámetro de una List<T>.Remove llamada, que emplea la igualdad de referencia (ya MyColor que 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 INotifyPropertyChanged actualizaciones 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.

Diagram of Remote UI data binding reference types.

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 (#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 correspondiente MyColor 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:

Diagram of async Command with multiple bindings.

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 del RunningCommandsCount comando asincrónico porque tenemos un único comando compartido entre todos los botones.

Podemos lograr nuestro objetivo adjuntando una RunningCommandsCount propiedad a cada botón para que tengamos un contador independiente para cada color. Estas características se proporcionan mediante el http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml espacio de nombres , 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 vs:ExtensibilityUICommands.EventHandlers propiedad adjunta 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 CounterTargetobjeto : al UIElement que se debe adjuntar una vs:ExtensibilityUICommands.RunningCommandsCount propiedad, 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:

Diagram of async Command with targeted RunningCommandsCount.

Usar 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 -windows variante).

La interfaz de usuario remota proporciona el XamlFragment tipo , 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 Color propiedad se convierte en un LinearGradientBrush objeto en el proxy de contexto de datos: Screenshot showing WPF types in data context

Interfaz de usuario remota y subprocesos

Las devoluciones de llamada de comandos asincrónicas (y INotifyPropertyChanged las devoluciones de llamada 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 await expresión).

Este comportamiento se puede cambiar pasando nonConcurrentSynchronizationContext al RemoteUserControl constructor. En ese caso, puede usar el contexto de sincronización proporcionado para todos los comandos asincrónicos y INotifyPropertyChanged devoluciones de llamada relacionados con ese control.