¿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 tipos de datos que se van a usar en contextos de datos de interfaz de usuario remota, deben estar decorados con los DataContract atributos y DataMember .
  • 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 y ToolWindow .RemoteUserControl RemoteUserControl es el equivalente de interfaz de usuario remota de un control de usuario de WPF.

Terminará con cuatro archivos:

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

Más adelante, agregará un contexto de datos para , RemoteUserControlque 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 MyToolWindow.cs archivo y defina una MyToolWindow clase que extienda ToolWindow.

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

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 null en 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 GetXamlAsync método .

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>

Como se ha descrito anteriormente, este archivo debe tener el mismo nombre que la clase de control de usuario remoto. Para ser precisos, el nombre completo de la extensión RemoteUserControl de clase 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 LogicalName etiqueta :

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

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 (xmlns atributo) 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 .csproj archivo y asegúrese de que el archivo XAML se trata como un recurso incrustado:

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

También puedes cambiar el marco de destino de la extensión de net6.0 a net6.0-windows para obtener una mejor autocompletar en el archivo XAML.

Prueba de la extensión

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

Screenshot showing menu and tool window.

Adición de compatibilidad con temas

es una buena idea escribir la interfaz de usuario teniendo en cuenta que Visual Studio se 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:

Screenshot showing themed tool window.

Aquí, el xmlns atributo 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, puedes agregar temporalmente un PackageReference elemento al Microsoft.VisualStudio.Shell.15.0 proyecto de extensión. 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 actualizarlo MyToolWindowContent.cs y MyToolWindowContent.xaml 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:

Screenshot showing tool window with data binding.

El tipo de contexto de datos aquí está marcado con DataContract los atributos y DataMember . Esto se debe a que la MyToolWindowData instancia existe en el proceso de host de extensión mientras el control WPF creado a partir 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 MyToolWindowData objeto en el proceso de Visual Studio. Los DataContract atributos 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 RemoteUserControl clase: la RemoteUserControl.DataContext propiedad 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.

Ciclo de vida de un control de usuario remoto

Puede invalidar el ControlLoadedAsync método 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 ControlLoadedAsync método es el lugar adecuado para inicializar el contenido del contexto de datos y empezar a aplicar cambios a él.

También puede invalidar el Dispose método 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 DataContract atributos y DataMember e implemente INotifyPropertyChanged según sea necesario. También es posible tener colecciones observables, o un 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.

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 AsyncCommand clase .

IAsyncCommand difiere de ICommand dos maneras:

  • El Execute método se reemplaza por ExecuteAsync porque todo en la interfaz de usuario remota es asincrónico.
  • El CanExecute(object) método se reemplaza por una CanExecute propiedad . La AsyncCommand clase 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 MyToolWindowContent constructor:

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 IAsyncCommand objeto 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>

Diagram of tool window with two-way binding and a command.

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 Name propiedad del proxy de contexto de datos a través del enlace de datos. El nuevo valor de Name se propaga al MyToolWindowData objeto .

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

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

Diagram of tool window two-way binding and commands communication.

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 Name propiedad 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 HelloCommand devolución de llamada se cambia en el futuro para producir (tienen await expresiones).

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.