Compartir a través de


Por qué 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. Esta decisión introduce 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 (Windows Presentation Foundation) 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 basado en XAML (Lenguaje extensible de marcado de aplicaciones) y enlace de datos, comandos (en lugar de eventos) y desencadenadores (en lugar de interactuar con el árbol lógico desde código subyacente).

Aunque la interfaz de usuario remota se desarrolló para admitir extensiones fuera del proceso, las API de extensibilidad de VisualStudio.Extensibility que se basan en la interfaz de usuario remota, como ToolWindow, también usan 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 DataContract atributos 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 Hello World 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 funcional con un solo comando. El siguiente paso consiste en agregar un ToolWindow y un RemoteUserControl. RemoteUserControl es el equivalente de UI remota a un control de usuario de WPF.

Termina con cuatro archivos:

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

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

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.

El GetContentAsync método debe devolver un IRemoteUserControl que definirá en el paso siguiente. Puesto que el control remoto de usuario es desechable, asegúrese de desecharlo sobrescribiendo 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 GetXamlAsync método .

Definición de XAML

A continuación, cree un archivo denominado 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 (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>

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

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 con temas

Es una buena idea escribir la interfaz de usuario teniendo en cuenta que Visual Studio puede tener temas, lo que resulta en el uso de 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 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 posteriormente, ya que una extensión de VisualStudio.Extensibility fuera del proceso no debe hacer referencia a este paquete.

Agregar 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; }
}

A continuación, actualice MyToolWindowContent.cs y MyToolWindowContent.xaml para usarlos:

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 MyToolWindowData instancia existe en el proceso de host de extensión mientras que 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 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, haremos que MyToolWindowData sea mutable y observable.

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 de un tipo serializable pueden vincularse a 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, objetos SolidColorBrush y valores de Color
  • Nullable<> valores de un tipo serializable
  • colecciones de tipos serializables, incluidas colecciones 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 sobrescribir el Dispose método para recibir una notificación cuando el control se destruya y ya no se vaya a 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 bidireccional

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 grafo complejo de objetos siempre que estén marcados con los atributos DataContract y DataMember e implementen INotifyPropertyChanged según sea necesario. También es posible tener colecciones observables, o una ObservableList<T>, que es una ObservableCollection<T> extendida proporcionada 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. Es todo XAML normal de WPF. 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>

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 desde MyToolWindowContent.xaml está vinculado al proxy de contexto de datos,

  3. El usuario escribe texto en un cuadro de texto, que se asigna a la Name propiedad del proxy de contexto de datos a través de la vinculación 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 el proxy de contexto de datos, se ejecuta HelloCommand
    • se inicia la ejecución asincrónica del código del AsyncCommand extensor.
    • 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 del contexto de datos
    • el bloque de texto de la ventana de herramientas se actualiza al nuevo valor a través de la vinculación de datos de Text.

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

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 función de retorno 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 competencia, especialmente si la HelloCommand devolución de llamada es cambiado en el futuro para generar (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 el callback del comando asíncrono, el valor de todas las propiedades del contexto de datos antes de ceder el control.

A continuación puede ver un ejemplo en el que se recuperan los valores de las propiedades FirstName y LastName antes de ceder, para asegurar que se utilice 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 los usuarios también pueden actualizar. En otras palabras, evite el enlace de datos TwoWay.

La información aquí debe ser suficiente para crear componentes sencillos de la interfaz de usuario remota. Para ver temas adicionales relacionados con el trabajo con el modelo de interfaz de usuario remota, consulte Otros conceptos de la interfaz de usuario remota. Para ver escenarios más avanzados, consulte Conceptos avanzados de la interfaz de usuario remota.