Compartilhar via


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, dependendo do XAML e da associaçã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 associaçã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 atributos DataContract e DataMember seu tipo deve ser serializável pela interface do usuário remota (consulte aqui para obter detalhes).
  • 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 (mas potencialmente complexo e aninhado) objeto de contexto de dados.
  • 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 da interface do usuário remota avançada).
  • 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 ToolWindow e um RemoteUserControl. O RemoteUserControl é o equivalente da interface do usuário remota de um controle de usuário WPF.

Você terminará com quatro arquivos:

  1. um arquivo .cs para o comando que abre a janela de ferramentas,
  2. um arquivo .cs para o ToolWindow que fornece o RemoteUserControl para o Visual Studio,
  3. um arquivo .cs para o RemoteUserControl que faz referência à própria definição em XAML,
  4. um arquivo .xaml 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 de ferramentas usando ShowToolWindowAsync:

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

Você também pode considerar alterar CommandConfiguration e string-resources.json por uma mensagem de exibição e um 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 arquivo MyToolWindow.cs e defina uma classe MyToolWindow estendendo ToolWindow.

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

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 pode defini-lo como null agora.

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

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>

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

Definindo o XAML como um recurso inserido

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

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

Conforme descrito anteriormente, o arquivo XAML 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 inserido. Por exemplo, se o nome completo da classe de controle de usuário remoto for MyToolWindowExtension.MyToolWindowContent, o nome do recurso inserido deverá ser MyToolWindowExtension.MyToolWindowContent.xaml. Por padrão, os recursos inseridos recebem um nome que é composto pelo namespace raiz do projeto, qualquer caminho de subpasta em que ele possa estar e o nome do 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ê poderá forçar um nome para o recurso incorporado usando a tag LogicalName:

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

Testando a extensão

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

Captura de tela mostrando o menu e a janela de ferramentas.

Adicionar suporte a temas

é uma boa ideia escrever a interface do usuário tendo em mente que o Visual Studio pode ser temático, o que resulta na utilização de cores diferentes.

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:

Captura de tela mostrando a janela de ferramentas temática.

Aqui, o atributo xmlns 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 PackageReference ao Microsoft.VisualStudio.Shell.15.0 para a extensão do projeto. 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 esse 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 atualize MyToolWindowContent.cs e MyToolWindowContent.xaml para usá-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 associação de dados:

Captura de tela mostrando a janela de ferramentas com associação de dados.

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

O contexto de dados do controle de usuário remoto é passado como um parâmetro de construtor da classe RemoteUserControl: a propriedade RemoteUserControl.DataContext é 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, tornaremos MyToolWindowData mutável e observável.

Tipos serializáveis e contexto de dados da interface do usuário remota

Um contexto de dados de interface do usuário remota só pode conter tipos serializáveis ou, para ser mais preciso, somente propriedades DataMember de um tipo serializável podem ser associadas a dados.

Somente os seguintes tipos são serializáveis pela interface do usuário remota:

  • dados primitivos (a maioria dos tipos numéricos do .NET, enums, bool, string, DateTime)
  • tipos definidos pelo extensor que são marcados com os atributos DataContract e DataMember (e todos os seus membros de dados também são serializáveis)
  • objetos implementando IAsyncCommand
  • objetos XamlFragment e SolidColorBrush e valores de Cor
  • valores Nullable<> para um tipo serializável
  • coleções de tipos serializáveis, incluindo coleções observáveis.

Ciclo de vida de um controle de usuário remoto

Você pode substituir o método ControlLoadedAsync a ser notificado quando o 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 ControlLoadedAsync é o lugar certo para inicializar o conteúdo do contexto de dados e começar a aplicar alterações a ele.

Você também pode substituir o método Dispose para ser notificado quando o controle for destruído e deixar de ser 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 associaçã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 feito observável mediante a implementação de INotifyPropertyChanged. Como alternativa, a interface do usuário remota fornece uma classe abstrata conveniente, NotifyPropertyChangedObject, que 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 DataContract e DataMember e implementem INotifyPropertyChanged conforme necessário. Também é possível ter coleções observáveis ou uma ObservableList<T>, que é uma ObservableCollection<T> estendida fornecida pela interface do usuário remota para também oferecer suporte a operações de intervalo, possibilitando assim um melhor desempenho.

Também precisamos adicionar um comando ao contexto de dados. Na interface do usuário remota, os comandos implementamIAsyncCommand, mas muitas vezes é mais fácil criar uma instância da classe AsyncCommand.

IAsyncCommand difere de ICommand de duas formas:

  • O método Execute é substituído por ExecuteAsync porque tudo na interface do usuário remota é assíncrono!
  • O método CanExecute(object) é substituído por uma propriedade CanExecute. A classe AsyncCommand 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 associaçã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 construtor MyToolWindowContent:

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

Atualize MyToolWindowContent.xaml para usar as novas propriedades no contexto de dados. Isso tudo é XAML WPF normal. Até mesmo o objeto IAsyncCommand é acessado por meio de um proxy chamado ICommand no processo do Visual Studio para que ele possa ser associado 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>

Diagrama da janela de ferramentas com associação bidirecional e um comando.

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 de MyToolWindowContent.xaml tem associação de dados com proxy de contexto de dados.

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

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

    • o HelloCommand no proxy de contexto de dados é executado
    • a execução assíncrona do código AsyncCommand do extensor é iniciada
    • o retorno de chamada assíncrona para HelloCommand atualiza o valor da propriedade observável Text
    • o novo valor de Text é propagado para o proxy de contexto de dados
    • O bloco de texto na janela de ferramentas é atualizado para o novo valor de Text via associação de dados

Diagrama da janela de ferramentas com associação bidirecional e comunicação de comandos.

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 design 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 o CommandParameter do botão a Name:

<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 valor da propriedade Name é 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 retorno da chamada HelloCommand for alterado no futuro para yeld (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 quando o comando precisa 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 assíncrono, o valor de todas as propriedades do contexto de dados antes do yelding.

Abaixo podemos ver um exemplo onde os valores das propriedades FirstName e LastName são recuperados antes do yelding 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 associação de dados bidirecional.

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