Por que usar a interface do usuário remota

Um dos principais objetivos do modelo VisualStudio.Extensibility é permitir que as extensões sejam executadas fora do processo do Visual Studio. Isso introduz um obstáculo para adicionar suporte à interface do usuário às extensões, já que a maioria das estruturas da interface do usuário está em processo.

A interface do usuário remota é um conjunto de classes que permite definir controles WPF em uma extensão fora do processo e mostrá-los como parte da interface do usuário do Visual Studio.

A interface do usuário remota se inclina fortemente para o padrão de design Model-View-ViewModel, contando com XAML e vinculação de dados, comandos (em vez de eventos) e gatilhos (em vez de interagir com a árvore lógica a partir do code-behind).

Embora a interface do usuário remota tenha sido desenvolvida para oferecer suporte a extensões fora do processo, as APIs do VisualStudio.Extensibility que dependem da interface do usuário remota, como ToolWindow, também usarão a interface do usuário remota para extensões em processo.

As principais diferenças entre a interface do usuário remota e o desenvolvimento normal do WPF são:

  • A maioria das operações de interface do usuário remota, incluindo a vinculação ao contexto de dados e a execução de comandos, são assíncronas.
  • Ao definir tipos de dados a serem usados em contextos de dados de interface do usuário remota, eles devem ser decorados com os DataContract atributos e DataMember .
  • A interface do usuário remota não permite fazer referência aos seus próprios controles personalizados.
  • Um controle de usuário remoto é totalmente definido em um único arquivo XAML que faz referência a um único objeto de contexto de dados (mas potencialmente complexo e aninhado).
  • A interface do usuário remota não oferece suporte a code-behind ou manipuladores de eventos (as soluções alternativas são descritas no documento de conceitos avançados de interface do usuário remota).
  • Um controle de usuário remoto é instanciado no processo do Visual Studio, não no processo que hospeda a extensão: o XAML não pode fazer referência a tipos e assemblies da extensão, mas pode fazer referência a tipos e assemblies do processo do Visual Studio.

Criar uma extensão Hello World da interface do usuário remota

Comece criando a extensão de interface do usuário remota mais básica. Siga as instruções em Criando sua primeira extensão do Visual Studio fora de processo.

Agora você deve ter uma extensão de trabalho com um único comando, a próxima etapa é adicionar um e um ToolWindowRemoteUserControl. O RemoteUserControl é o equivalente da interface do usuário remota de um controle de usuário WPF.

Você vai acabar com quatro arquivos:

  1. um .cs arquivo para o comando que abre a janela da ferramenta,
  2. um .cs arquivo para o ToolWindow que fornece o para Visual RemoteUserControl Studio,
  3. um .cs arquivo para o RemoteUserControl que faz referência à sua definição XAML,
  4. um .xaml arquivo para o RemoteUserControl.

Posteriormente, você adiciona um contexto de dados para o RemoteUserControl, que representa o ViewModel no padrão MVVM.

Atualizar o comando

Atualize o código do comando para mostrar a janela da ferramenta usando ShowToolWindowAsync:

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

Você também pode considerar a alteração CommandConfiguration e para uma mensagem de exibição e string-resources.json posicionamento mais apropriados:

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

Criar a janela de ferramentas

Crie um novo MyToolWindow.cs arquivo e defina uma MyToolWindow classe estendendo ToolWindow.

O GetContentAsync método deve retornar um IRemoteUserControl que você definirá na próxima etapa. Como o controle de usuário remoto é descartável, cuide de descartá-lo substituindo o 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);
    }
}

Criar o controle de usuário remoto

Execute esta ação em três arquivos:

Classe de controle de usuário remoto

A classe de controle de usuário remoto, chamada MyToolWindowContent, é direta:

namespace MyToolWindowExtension;

using Microsoft.VisualStudio.Extensibility.UI;

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

Você ainda não precisa de um contexto de dados, então você pode defini-lo para null agora.

Uma classe que se estende RemoteUserControl usa automaticamente o recurso incorporado XAML com o mesmo nome. Se você quiser alterar esse comportamento, substitua o GetXamlAsync método.

Definição de XAML

Em seguida, crie um arquivo chamado 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>

Conforme descrito anteriormente, esse arquivo deve ter o mesmo nome que a classe de controle de usuário remoto. Para ser preciso, o nome completo da classe que estende RemoteUserControl deve corresponder ao nome do recurso incorporado. Por exemplo, se o nome completo da classe de controle de usuário remoto for MyToolWindowExtension.MyToolWindowContent, o nome do recurso incorporado deverá ser MyToolWindowExtension.MyToolWindowContent.xaml. Por padrão, os recursos incorporados recebem um nome que é composto pelo namespace raiz do projeto, qualquer caminho de subpasta em que possam estar e seu nome de arquivo. Isso pode criar problemas se sua classe de controle de usuário remoto estiver usando um namespace diferente do namespace raiz do projeto ou se o arquivo xaml não estiver na pasta raiz do projeto. Se necessário, você pode forçar um nome para o recurso incorporado usando a LogicalName marca:

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

A definição XAML do controle de usuário remoto é WPF XAML normal descrevendo um DataTemplatearquivo . Esse XAML é enviado ao Visual Studio e usado para preencher o conteúdo da janela da ferramenta. Usamos um namespace especial (xmlns atributo ) para XAML da interface do usuário remota: http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml.

Definindo o XAML como um recurso incorporado

Finalmente, abra o arquivo e verifique se o .csproj arquivo XAML é tratado como um recurso incorporado:

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

Você também pode alterar a estrutura de destino para sua extensão de net6.0 para net6.0-windows obter melhor preenchimento automático no arquivo XAML.

Testando a extensão

Agora você deve ser capaz de pressionar F5 para depurar a extensão.

Screenshot showing menu and tool window.

Adicionar suporte para temas

é uma boa ideia escrever a interface do usuário tendo em mente que o Visual Studio pode ser temático, resultando em cores diferentes sendo usadas.

Atualize o XAML para usar os estilos e cores usados no 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>

O rótulo agora usa o mesmo tema que o restante da interface do usuário do Visual Studio e muda automaticamente de cor quando o usuário alterna para o modo escuro:

Screenshot showing themed tool window.

Aqui, o xmlns atributo faz referência ao assembly Microsoft.VisualStudio.Shell.15.0 , que não é uma das dependências de extensão. Isso é bom porque esse XAML é usado pelo processo do Visual Studio, que tem uma dependência do Shell.15, não pela extensão em si.

Para obter uma melhor experiência de edição XAML, você pode adicionar temporariamente um ao projeto de PackageReferenceMicrosoft.VisualStudio.Shell.15.0 extensão. Não se esqueça de removê-lo mais tarde, pois uma extensão VisualStudio.Extensibility fora de processo não deve fazer referência a este pacote!

Adicionar um contexto de dados

Adicione uma classe de contexto de dados para o controle de usuário remoto:

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

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

e atualizá-lo MyToolWindowContent.cs e MyToolWindowContent.xaml utilizá-lo:

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

O conteúdo do rótulo agora é definido por meio de vinculação de dados:

Screenshot showing tool window with data binding.

O tipo de contexto de dados aqui é marcado com DataContract e DataMember atributos. Isso ocorre porque a instância existe no processo de host de extensão enquanto o controle WPF criado a MyToolWindowData partir existe no processo do MyToolWindowContent.xaml Visual Studio. Para fazer a vinculação de dados funcionar, a infraestrutura de interface do usuário remota gera um proxy do objeto no processo do MyToolWindowData Visual Studio. Os DataContract atributos e indicam quais tipos e propriedades são relevantes para vinculação de dados e DataMember devem ser replicados no proxy.

O contexto de dados do controle de usuário remoto é passado como um parâmetro de construtor da RemoteUserControl classe: a RemoteUserControl.DataContext propriedade é somente leitura. Isso não implica que todo o contexto de dados é imutável, mas o objeto de contexto de dados raiz de um controle de usuário remoto não pode ser substituído. Na próxima seção, faremos MyToolWindowData mutável e observável.

Ciclo de vida de um controle de usuário remoto

Você pode substituir o método a ser notificado quando o ControlLoadedAsync controle é carregado pela primeira vez em um contêiner WPF. Se em sua implementação, o estado do contexto de dados pode mudar independentemente dos eventos da interface do usuário, o método é o lugar certo para inicializar o ControlLoadedAsync conteúdo do contexto de dados e começar a aplicar alterações a ele.

Você também pode substituir o método para ser notificado quando o Dispose controle for destruído e não será mais usado.

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, observabilidade e vinculação de dados bidirecional

Em seguida, vamos tornar o contexto de dados observável e adicionar um botão à caixa de ferramentas.

O contexto de dados pode ser observável implementando INotifyPropertyChanged. Como alternativa, a interface do usuário remota fornece uma classe abstrata conveniente, , NotifyPropertyChangedObjectque podemos estender para reduzir o código clichê.

Um contexto de dados geralmente tem uma mistura de propriedades somente leitura e propriedades observáveis. O contexto de dados pode ser um gráfico complexo de objetos, desde que eles sejam marcados com os atributos e DataMember implementem DataContractINotifyPropertyChanged conforme necessário. Também é possível ter coleções observáveis, ou um ObservableList T, que é um ObservableCollection<T>> estendido fornecido pela interface do usuário remota para também suportar operações de intervalo, permitindo melhor desempenho.<

Também precisamos adicionar um comando ao contexto de dados. Na interface do usuário remota, os comandos são implementados IAsyncCommand , mas geralmente é mais fácil criar uma instância da AsyncCommand classe.

IAsyncCommand difere de ICommand duas maneiras:

  • O Execute método é substituído por ExecuteAsync porque tudo na interface do usuário remota é assíncrono!
  • O CanExecute(object) método é substituído por uma CanExecute propriedade. A AsyncCommand classe se encarrega de tornar CanExecute observável.

É importante observar que a interface do usuário remota não oferece suporte a manipuladores de eventos, portanto, todas as notificações da interface do usuário para a extensão devem ser implementadas por meio de vinculação de dados e comandos.

Este é o 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 o MyToolWindowContent construtor:

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

Atualize MyToolWindowContent.xaml para usar as novas propriedades no contexto de dados. Isso tudo é WPF XAML normal. Até mesmo o IAsyncCommand objeto é acessado por meio de um proxy chamado ICommand no processo do Visual Studio para que ele possa ser vinculado a dados como de costume.

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

Noções básicas sobre assincronicidade na interface do usuário remota

Toda a comunicação da interface do usuário remota para esta janela de ferramenta segue estas etapas:

  1. O contexto de dados é acessado por meio de um proxy dentro do processo do Visual Studio com seu conteúdo original,

  2. O controle criado a partir de dados vinculados ao proxy de contexto de MyToolWindowContent.xaml dados,

  3. O usuário digita algum texto na caixa de texto, que é atribuída à Name propriedade do proxy de contexto de dados por meio de vinculação de dados. O novo valor de Name é propagado para o MyToolWindowData objeto.

  4. O usuário clica no botão causando uma cascata de efeitos:

    • o HelloCommand proxy de contexto de dados é executado
    • A execução assíncrona do código do extensor AsyncCommand é iniciada
    • O retorno de chamada assíncrono para HelloCommand atualiza o valor da propriedade observável Text
    • o novo valor de é propagado para o proxy de contexto de Text dados
    • O bloco de texto na janela Ferramenta é atualizado para o novo valor de Através da vinculação de Text dados

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

Usando parâmetros de comando para evitar condições de corrida

Todas as operações que envolvem a comunicação entre o Visual Studio e a extensão (setas azuis no diagrama) são assíncronas. É importante considerar esse aspecto no projeto geral da extensão.

Por esse motivo, se a consistência for importante, é melhor usar parâmetros de comando, em vez de associação bidirecional, para recuperar o estado do contexto de dados no momento da execução de um comando.

Faça essa alteração vinculando os botões a CommandParameterName:

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

Em seguida, modifique o retorno de chamada do comando para usar o parâmetro:

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

Com essa abordagem, o Name valor da propriedade é recuperado de forma síncrona do proxy de contexto de dados no momento do clique no botão e enviado para a extensão. Isso evita quaisquer condições de corrida, especialmente se o HelloCommand retorno de chamada for alterado no futuro para render (ter await expressões).

Os comandos assíncronos consomem dados de várias propriedades

Usar um parâmetro de comando não é uma opção se o comando precisar consumir várias propriedades configuráveis pelo usuário. Por exemplo, se a interface do usuário tiver duas caixas de texto: "Nome" e "Sobrenome".

A solução, nesse caso, é recuperar, no retorno de chamada do comando async, o valor de todas as propriedades do contexto de dados antes de produzir.

Abaixo você pode ver um exemplo onde os FirstName valores e LastName propriedade são recuperados antes de ceder para garantir que o valor no momento da invocação do comando seja usado:

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

Também é importante evitar que a extensão atualize de forma assíncrona o valor das propriedades que também podem ser atualizadas pelo usuário. Em outras palavras, evite a vinculação de dados bidirecional .

As informações aqui devem ser suficientes para criar componentes simples de interface do usuário remota. Para cenários mais avançados, consulte Conceitos avançados de interface do usuário remota.