Compartir a través de


¿Por qué usar la interfaz de usuario remota?

Uno de los principales objetivos del modelo de extensibilidad de VisualStudio.Extensibility es permitir que las extensiones se ejecuten fuera del proceso de Visual Studio. Esto presenta un obstáculo para agregar compatibilidad con la interfaz de usuario a extensiones, ya que la mayoría de los marcos de interfaz de usuario están en proceso.

La interfaz de usuario remota es un conjunto de clases que permiten definir controles WPF en una extensión fuera de proceso y mostrarlos como parte de la interfaz de usuario de Visual Studio.

La interfaz de usuario remota se inclina en gran medida hacia el patrón de diseño Model-View-ViewModel que se basa en XAML y enlace de datos, comandos (en lugar de eventos) y desencadenadores (en lugar de interactuar con el árbol lógico de código subyacente).

Aunque se desarrolló la interfaz de usuario remota para admitir extensiones fuera de proceso, las API de extensibilidad de VisualStudio.Extensibility que se basan en la interfaz de usuario remota, como ToolWindow, también usarán la interfaz de usuario remota para extensiones en proceso.

Las principales diferencias entre la interfaz de usuario remota y el desarrollo normal de WPF son:

  • La mayoría de las operaciones de interfaz de usuario remota, incluido el enlace al contexto de datos y la ejecución de comandos, son asincrónicas.
  • Al definir los tipos de datos que se van a usar en contextos de datos de interfaz de usuario remota, deben estar decorados con los atributos DataContract y DataMember y su tipo debe ser serializable por interfaz de usuario remota (consulte aquí para obtener más información).
  • La interfaz de usuario remota no permite hacer referencia a sus propios controles personalizados.
  • Un control de usuario remoto está totalmente definido en un único archivo XAML que hace referencia a un único objeto de contexto de datos (pero potencialmente complejo y anidado).
  • La interfaz de usuario remota no admite código subyacente o controladores de eventos (las soluciones alternativas se describen en el documento de conceptos avanzados de la interfaz de usuario remota).
  • Se crea una instancia de un control de usuario remoto en el proceso de Visual Studio, no en el proceso que hospeda la extensión: el XAML no puede hacer referencia a tipos y ensamblados desde la extensión, pero puede hacer referencia a tipos y ensamblados desde el proceso de Visual Studio.

Creación de una extensión de Hola mundo de interfaz de usuario remota

Empiece por crear la extensión de interfaz de usuario remota más básica. Siga las instrucciones de Creación de la primera extensión de Visual Studio fuera de proceso.

Ahora debería tener una extensión de trabajo con un solo comando, el siguiente paso es agregar ToolWindow y RemoteUserControl. RemoteUserControl es el equivalente de interfaz de usuario remota de un control de usuario de WPF.

Terminará con cuatro archivos:

  1. un archivo .cs para el comando que abre la ventana de herramientas,
  2. un archivo .cs para el objeto ToolWindow que proporciona RemoteUserControl a Visual Studio,
  3. un archivo .cs para el RemoteUserControl que hace referencia a su definición XAML,
  4. un archivo .xaml para RemoteUserControl.

Más adelante, agregará un contexto de datos para RemoteUserControl, que representa ViewModel en el patrón MVVM.

Actualizar el comando

Actualice el código del comando para mostrar la ventana de herramientas mediante ShowToolWindowAsync:

public override Task ExecuteCommandAsync(IClientContext context, CancellationToken cancellationToken)
{
    return Extensibility.Shell().ShowToolWindowAsync<MyToolWindow>(activate: true, cancellationToken);
}

También puede considerar la posibilidad de cambiar CommandConfiguration y string-resources.json para un mensaje de visualización y ubicación más adecuado:

public override CommandConfiguration CommandConfiguration => new("%MyToolWindowCommand.DisplayName%")
{
    Placements = new[] { CommandPlacement.KnownPlacements.ViewOtherWindowsMenu },
};
{
  "MyToolWindowCommand.DisplayName": "My Tool Window"
}

Creación de la ventana de herramientas

Cree un nuevo archivo MyToolWindow.cs y defina una clase MyToolWindow que extienda ToolWindow.

Se supone que el método GetContentAsync devuelve un elemento IRemoteUserControl que definirá en el paso siguiente. Puesto que el control remoto de usuario es descartable, se encarga de eliminarlo invalidando el método Dispose(bool).

namespace MyToolWindowExtension;

using Microsoft.VisualStudio.Extensibility;
using Microsoft.VisualStudio.Extensibility.ToolWindows;
using Microsoft.VisualStudio.RpcContracts.RemoteUI;

[VisualStudioContribution]
internal class MyToolWindow : ToolWindow
{
    private readonly MyToolWindowContent content = new();

    public MyToolWindow(VisualStudioExtensibility extensibility)
        : base(extensibility)
    {
        Title = "My Tool Window";
    }

    public override ToolWindowConfiguration ToolWindowConfiguration => new()
    {
        Placement = ToolWindowPlacement.DocumentWell,
    };

    public override async Task<IRemoteUserControl> GetContentAsync(CancellationToken cancellationToken)
        => content;

    public override Task InitializeAsync(CancellationToken cancellationToken)
        => Task.CompletedTask;

    protected override void Dispose(bool disposing)
    {
        if (disposing)
            content.Dispose();

        base.Dispose(disposing);
    }
}

Creación del control de usuario remoto

Realice esta acción en tres archivos:

Clase de control de usuario remoto

La clase de control de usuario remoto, denominada MyToolWindowContent, es sencilla:

namespace MyToolWindowExtension;

using Microsoft.VisualStudio.Extensibility.UI;

internal class MyToolWindowContent : RemoteUserControl
{
    public MyToolWindowContent()
        : base(dataContext: null)
    {
    }
}

Aún no necesita un contexto de datos, por lo que puede establecerlo en null por ahora.

Una clase que se extiende RemoteUserControl automáticamente usa el recurso incrustado XAML con el mismo nombre. Si desea cambiar este comportamiento, invalide el método GetXamlAsync.

Definición de XAML

A continuación, cree un archivo llamado MyToolWindowContent.xaml:

<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">
    <Label>Hello World</Label>
</DataTemplate>

La definición XAML del control de usuario remoto es el XAML de WPF normal que describe un DataTemplate. Este XAML se envía a Visual Studio y se usa para rellenar el contenido de la ventana de herramientas. Usamos un espacio de nombres especial (atributo xmlns) para XAML de interfaz de usuario remota: http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml.

Establecimiento del XAML como un recurso incrustado

Por último, abra el archivo .csproj y asegúrese de que el archivo XAML se trata como un recurso incrustado:

<ItemGroup>
  <EmbeddedResource Include="MyToolWindowContent.xaml" />
  <Page Remove="MyToolWindowContent.xaml" />
</ItemGroup>

Como se ha descrito anteriormente, el archivo XAML debe tener el mismo nombre que la clase de control de usuario remoto. Para ser precisos, el nombre completo de la extensión de clase RemoteUserControl debe coincidir con el nombre del recurso incrustado. Por ejemplo, si el nombre completo de la clase de control de usuario remoto es MyToolWindowExtension.MyToolWindowContent, el nombre del recurso incrustado debe ser MyToolWindowExtension.MyToolWindowContent.xaml. De forma predeterminada, a los recursos incrustados se les asigna un nombre compuesto por el espacio de nombres raíz del proyecto, cualquier ruta de acceso de subcarpeta en la que estén y su nombre de archivo. Esto puede crear problemas si la clase de control de usuario remoto usa un espacio de nombres diferente del espacio de nombres raíz del proyecto o si el archivo xaml no está en la carpeta raíz del proyecto. Si es necesario, puede forzar un nombre para el recurso incrustado mediante la etiqueta LogicalName:

<ItemGroup>
  <EmbeddedResource Include="MyToolWindowContent.xaml" LogicalName="MyToolWindowExtension.MyToolWindowContent.xaml" />
  <Page Remove="MyToolWindowContent.xaml" />
</ItemGroup>

Prueba de la extensión

Ahora debería poder presionar F5 para depurar la extensión.

Captura de pantalla que muestra la ventana de herramientas y menús.

Añadir compatibilidad para temas

es una buena idea escribir la interfaz de usuario teniendo en cuenta que Visual Studio puede aplicar temas, lo que da lugar a que se usen colores diferentes.

Actualice el XAML para usar los estilos y colores usados en Visual Studio:

<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>
        <Grid.Resources>
            <Style TargetType="Label" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ThemedDialogLabelStyleKey}}" />
        </Grid.Resources>
        <Label>Hello World</Label>
    </Grid>
</DataTemplate>

La etiqueta ahora usa el mismo tema que el resto de la interfaz de usuario de Visual Studio y cambia automáticamente el color cuando el usuario cambia al modo oscuro:

Captura de pantalla que muestra la ventana de herramientas temáticas.

Aquí, el atributo xmlns hace referencia al ensamblado Microsoft.VisualStudio.Shell.15.0, que no es una de las dependencias de extensión. Esto es correcto porque el proceso de Visual Studio usa este XAML, que tiene una dependencia en Shell.15, no por la propia extensión.

Para obtener una mejor experiencia de edición de XAML, puede agregar temporalmente un elemento PackageReference al proyecto de extensión Microsoft.VisualStudio.Shell.15.0. No olvide quitarlo más adelante, ya que una extensión de extensibilidad VisualStudio.Extensibility no debe hacer referencia a este paquete.

Adición de un contexto de datos

Agregue una clase de contexto de datos para el control de usuario remoto:

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

[DataContract]
internal class MyToolWindowData
{
    [DataMember]
    public string? LabelText { get; init; }
}

y actualizar MyToolWindowContent.cs y MyToolWindowContent.xaml para usarlo:

internal class MyToolWindowContent : RemoteUserControl
{
    public MyToolWindowContent()
        : base(dataContext: new MyToolWindowData { LabelText = "Hello Binding!"})
    {
    }
<Label Content="{Binding LabelText}" />

El contenido de la etiqueta ahora se establece mediante el enlace de datos:

Captura de pantalla que muestra la ventana de herramientas con enlace de datos.

El tipo de contexto de datos aquí está marcado con los atributos DataContract y DataMember. Esto se debe a que la instancia MyToolWindowData existe en el proceso de host de extensión mientras el control WPF creado a partir de MyToolWindowContent.xaml existe en el proceso de Visual Studio. Para que el enlace de datos funcione, la infraestructura de interfaz de usuario remota genera un proxy del objeto MyToolWindowData en el proceso de Visual Studio. Los atributos DataContract y DataMember indican qué tipos y propiedades son relevantes para el enlace de datos y se deben replicar en el proxy.

El contexto de datos del control de usuario remoto se pasa como parámetro de constructor de la clase RemoteUserControl: la propiedad RemoteUserControl.DataContext es de solo lectura. Esto no implica que todo el contexto de datos sea inmutable, pero no se puede reemplazar el objeto de contexto de datos raíz de un control de usuario remoto. En la sección siguiente, crearemos MyToolWindowData mutables y observables.

Tipos serializables y contexto de datos de la interfaz de usuario remota

Un contexto de datos de interfaz de usuario remota solo puede contener tipos serializables o, para ser más precisos, solo las propiedades DataMember de un tipo serializable pueden ser de entrada de datos.

Solo la interfaz de usuario remota puede serializar los siguientes tipos:

  • datos primitivos (la mayoría de los tipos numéricos de .NET, enumeraciones, bool, string, DateTime)
  • Tipos definidos por extensores marcados con atributos DataContract y DataMember (y todos sus miembros de datos también son serializables)
  • objetos que implementan IAsyncCommand
  • Objetos XamlFragment y SolidColorBrush y valores de Color
  • valores Nullable<> de un tipo serializable
  • colecciones de tipos serializables, incluidas colecciones observables.

Ciclo de vida de un control de usuario remoto

Puede invalidar el método ControlLoadedAsync para recibir una notificación cuando el control se carga por primera vez en un contenedor de WPF. Si en la implementación, el estado del contexto de datos puede cambiar independientemente de los eventos de la interfaz de usuario, el método ControlLoadedAsync es el lugar adecuado para inicializar el contenido del contexto de datos y empezar a aplicar cambios a él.

También puede invalidar el método Dispose que se va a notificar cuando se destruye el control y ya no se usará.

internal class MyToolWindowContent : RemoteUserControl
{
    public MyToolWindowContent()
        : base(dataContext: new MyToolWindowData())
    {
    }

    public override async Task ControlLoadedAsync(CancellationToken cancellationToken)
    {
        await base.ControlLoadedAsync(cancellationToken);
        // Your code here
    }

    protected override void Dispose(bool disposing)
    {
        // Your code here
        base.Dispose(disposing);
    }
}

Comandos, observabilidad y enlace de datos bidireccionales

A continuación, vamos a hacer que el contexto de datos sea observable y agregue un botón al cuadro de herramientas.

El contexto de datos se puede hacer observable mediante la implementación de INotifyPropertyChanged. Como alternativa, la interfaz de usuario remota proporciona una clase abstracta conveniente, NotifyPropertyChangedObject, que podemos ampliar para reducir el código reutilizable.

Normalmente, un contexto de datos tiene una combinación de propiedades de solo lectura y propiedades observables. El contexto de datos puede ser un gráfico complejo de objetos siempre que estén marcados con los atributos DataContract y DataMember e implemente INotifyPropertyChanged según sea necesario. También es posible tener colecciones observables, o un 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.

También es necesario agregar un comando al contexto de datos. En la interfaz de usuario remota, los comandos implementan IAsyncCommand, pero a menudo es más fácil crear una instancia de la clase AsyncCommand.

IAsyncCommand difiere de ICommand de dos maneras:

  • El método Execute se reemplaza por ExecuteAsync porque todo en la interfaz de usuario remota es asincrónica.
  • El método CanExecute(object) se reemplaza por una propiedad CanExecute. La clase AsyncCommand se encarga de hacer CanExecute observable.

Es importante tener en cuenta que la interfaz de usuario remota no admite controladores de eventos, por lo que todas las notificaciones de la interfaz de usuario a la extensión deben implementarse mediante el enlace de datos y los comandos.

Este es el código resultante para MyToolWindowData:

[DataContract]
internal class MyToolWindowData : NotifyPropertyChangedObject
{
    public MyToolWindowData()
    {
        HelloCommand = new((parameter, cancellationToken) =>
        {
            Text = $"Hello {Name}!";
            return Task.CompletedTask;
        });
    }

    private string _name = string.Empty;
    [DataMember]
    public string Name
    {
        get => _name;
        set => SetProperty(ref this._name, value);
    }

    private string _text = string.Empty;
    [DataMember]
    public string Text
    {
        get => _text;
        set => SetProperty(ref this._text, value);
    }

    [DataMember]
    public AsyncCommand HelloCommand { get; }
}

Corrija el constructor MyToolWindowContent:

public MyToolWindowContent()
    : base(dataContext: new MyToolWindowData())
{
}

Actualice MyToolWindowContent.xaml para usar las nuevas propiedades en el contexto de datos. Esto es todo XAML de WPF normal. Incluso se accede al objeto IAsyncCommand a través de un proxy denominado ICommand en el proceso de Visual Studio para que pueda enlazarse a datos como de costumbre.

<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>
        <Grid.Resources>
            <Style TargetType="Label" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ThemedDialogLabelStyleKey}}" />
            <Style TargetType="TextBox" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.TextBoxStyleKey}}" />
            <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.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="Auto" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <Label Content="Name:" />
        <TextBox Text="{Binding Name}" Grid.Column="1" />
        <Button Content="Say Hello" Command="{Binding HelloCommand}" Grid.Column="2" />
        <TextBlock Text="{Binding Text}" Grid.ColumnSpan="2" Grid.Row="1" />
    </Grid>
</DataTemplate>

Diagrama de la ventana de herramientas con enlace bidireccional y un comando.

Descripción de la asincronía en la interfaz de usuario remota

Toda la comunicación de interfaz de usuario remota para esta ventana de herramientas sigue estos pasos:

  1. Se accede al contexto de datos a través de un proxy dentro del proceso de Visual Studio con su contenido original,

  2. El control creado a partir de MyToolWindowContent.xaml es datos enlazados al proxy de contexto de datos,

  3. El usuario escribe algún texto en el cuadro de texto, que se asigna a la propiedad Name del proxy de contexto de datos a través del enlace de datos. El nuevo valor de Name se propaga al objeto MyToolWindowData.

  4. El usuario hace clic en el botón que provoca una cascada de efectos:

    • el HelloCommand en el proxy de contexto de datos se ejecuta
    • se inicia la ejecución asincrónica del código del extensor AsyncCommand.
    • la devolución de llamada asincrónica para HelloCommand actualiza el valor de la propiedad observable Text
    • El nuevo valor de Text se propaga al proxy de contexto de datos
    • el bloque de texto de la ventana de herramientas se actualiza al nuevo valor de Text a través del enlace de datos.

Diagrama de la ventana de herramientas enlace bidireccional y comunicación de comandos.

Uso de parámetros de comando para evitar condiciones de carrera

Todas las operaciones que implican la comunicación entre Visual Studio y la extensión (flechas azules en el diagrama) son asincrónicas. Es importante tener en cuenta este aspecto en el diseño general de la extensión.

Por este motivo, si la coherencia es importante, es mejor usar parámetros de comando, en lugar de enlace bidireccional, para recuperar el estado del contexto de datos en el momento de la ejecución de un comando.

Para realizar este cambio, enlace el botón CommandParameter a Name:

<Button Content="Say Hello" Command="{Binding HelloCommand}" CommandParameter="{Binding Name}" Grid.Column="2" />

A continuación, modifique la devolución de llamada del comando para usar el parámetro:

HelloCommand = new AsyncCommand((parameter, cancellationToken) =>
{
    Text = $"Hello {(string)parameter!}!";
    return Task.CompletedTask;
});

Con este enfoque, el valor de la propiedad Name se recupera sincrónicamente del proxy de contexto de datos en el momento del clic del botón y se envía a la extensión. Esto evita cualquier condición de carrera, especialmente si la devolución de llamada HelloCommand se cambia en el futuro para producir (tienen expresiones await).

Los comandos asincrónicos consumen datos de varias propiedades

El uso de un parámetro de comando no es una opción si el comando necesita consumir varias propiedades que el usuario puede establecer. Por ejemplo, si la interfaz de usuario tenía dos cuadros de texto: "Nombre" y "Apellido".

La solución en este caso es recuperar, en la devolución de llamada del comando asincrónico, el valor de todas las propiedades del contexto de datos antes de producir.

A continuación puede ver un ejemplo en el que se recuperan los valores de propiedad FirstName y LastName antes de producir para asegurarse de que se usa el valor en el momento de la invocación del comando:

HelloCommand = new(async (parameter, cancellationToken) =>
{
    string firstName = FirstName;
    string lastName = LastName;
    await Task.Delay(TimeSpan.FromSeconds(1));
    Text = $"Hello {firstName} {lastName}!";
});

También es importante evitar que la extensión actualice de forma asincrónica el valor de las propiedades que también puede actualizar el usuario. En otras palabras, evite el enlace de datos de TwoWay.

La información aquí debe ser suficiente para crear componentes sencillos de la interfaz de usuario remota. Para ver escenarios más avanzados, consulte Conceptos avanzados de la interfaz de usuario remota.