Compartilhar via


Por que 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. Essa decisão introduz um obstáculo para adicionar suporte à interface do usuário a extensões, já que a maioria das estruturas de interface do usuário está em processo.

A interface do usuário remota é um conjunto de classes que permite definir controles do WPF (Windows Presentation Foundation) 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 que depende de XAML (Extensible Application Markup Language) e associação de dados, comandos (em vez de eventos) e gatilhos (em vez de interagir com a árvore lógica do code-behind).

Embora a interface do usuário remota tenha sido desenvolvida para dar suporte a extensões fora de processo, as APIs visualStudio.Extensibility que dependem da interface do usuário remota, como ToolWindow, também usam 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 associação ao contexto de dados e à execução de comando, são assíncronas.
  • Ao definir tipos de dados a serem usados em contextos de dados da Remote UI, eles devem ser decorados com os atributos DataContract e DataMember, e seu tipo deve ser serializável pela Remote UI (confira aqui para obter detalhes).
  • A interface do usuário remota não permite referenciar 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 dá suporte a código subjacente nem a manipuladores de eventos (soluções alternativas são descritas no documento 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 referenciar tipos e assemblies da extensão, mas pode referenciar 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 Criar sua primeira extensão fora de processo do Visual Studio.

Agora você deve ter uma extensão funcional com um único comando. A próxima etapa é adicionar um ToolWindow e um RemoteUserControl. RemoteUserControl é o equivalente à UI Remota de um controle de usuário WPF.

Você acaba com quatro arquivos:

  1. um .cs arquivo para o comando que abre a janela de ferramentas,
  2. um .cs arquivo para o ToolWindow que fornece o RemoteUserControl ao Visual Studio,
  3. um .cs arquivo para o RemoteUserControl que referencia a sua definição XAML,
  4. um .xaml arquivo para o RemoteUserControl.

Posteriormente, você adicionará um contexto de dados para o RemoteUserControl, que representa o ViewModel no padrão MVVM (Model-View-ViewModel).

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 para 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 MyToolWindow.cs arquivo e defina uma MyToolWindow classe que se estenda ToolWindow.

O GetContentAsync método deve retornar um IRemoteUserControl que você definirá na próxima etapa. Como o controle remoto do usuário é descartável, tome cuidado ao descartá-lo ao substituir 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, nomeada MyToolWindowContent, é simples:

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, portanto, pode defini-lo null por enquanto.

Uma classe que estende RemoteUserControl automaticamente usa o recurso XAML embutido 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>

A definição XAML do controle de usuário remoto é um XAML do WPF normal que descreve um DataTemplate. Esse XAML é enviado para o Visual Studio e usado para preencher o conteúdo da janela de ferramentas. 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 inserido

Por fim, abra o .csproj arquivo 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 mais 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 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 eles podem estar e seu nome de arquivo. Isso poderá criar problemas se a classe de controle de usuário remoto usar 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 inserido usando a LogicalName marca:

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

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 coresusados 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 altera automaticamente a cor quando o usuário alterna para o modo escuro:

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

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 própria extensão.

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

Adicionar um contexto de dados

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

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

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

Em seguida, atualize MyToolWindowContent.cs e MyToolWindowContent.xaml para usá-los:

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

O conteúdo do rótulo agora está definido por meio da vinculaçã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 MyToolWindowData instância 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 DataContract atributos e os DataMember atributos indicam quais tipos e propriedades são relevantes para a 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 seja 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 remoto só pode conter tipos serializáveis ou, para ser mais preciso, somente DataMember propriedades de um tipo serializável podem ser ligadas 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 o atributo DataContract e DataMember (e todos os membros de dados também são serializáveis)
  • objetos que implementam IAsyncCommand
  • Objetos XamlFragment e SolidColorBrush e valores de cor
  • Nullable<> valores 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 para ser notificado quando o controle é carregado pela primeira vez em um contêiner do WPF. Se em sua implementação, o estado do contexto de dados pode mudar independentemente de eventos de interface do usuário, o ControlLoadedAsync método é o lugar certo para inicializar o conteúdo do contexto de dados e começar a aplicar alterações a ele.

É possível também substituir o método Dispose para ser notificado quando o controle é destruído e não mais será utilizado.

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 bidirecionais

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

O contexto de dados pode ser observado 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 combinação de propriedades somente leitura e propriedades observáveis. O contexto de dados pode ser um grafo complexo de objetos, desde que 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 Remota de Usuário para também dar suporte a operações de intervalo, permitindo um 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 classe AsyncCommand.

IAsyncCommand difere de ICommand duas maneiras:

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

É importante observar que a interface do usuário remota não dá 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 comandos e associação de dados.

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. Tudo isso é XAML normal do WPF. Até mesmo o IAsyncCommand objeto é 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.

Compreendendo a assincronicidade na interface do usuário remota

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

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

  2. O controle criado a partir de MyToolWindowContent.xaml está associado ao proxy de contexto de dados,

  3. O usuário digita algum texto na caixa de texto, que é atribuído à propriedade Name do proxy de contexto de dados através da vinculaçã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 do AsyncCommand extensor é iniciada
    • o retorno de chamada assíncrono 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 por meio da associação de dados

Diagrama de vinculação bidirecional da janela de ferramentas 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 de contexto de dados no momento da execução de um comando.

Faça essa alteração associando os botões CommandParameter 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 Name propriedade é recuperado de forma síncrona do proxy de contexto de dados no momento do clique do 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 produzir (ter await expressões).

Comandos assíncronos consomem dados de várias propriedades

Usar um parâmetro de comando não será uma opção se o comando precisar consumir várias propriedades que são 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 de comando assíncrono, o valor de todas as propriedades do contexto de dados antes de ceder.

Abaixo, você pode ver um exemplo em que os valores das propriedades FirstName e LastName 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 os usuários também podem atualizar. Em outras palavras, evite a vinculação de dados TwoWay.

As informações aqui devem ser suficientes para criar componentes de interface do usuário remoto simples. Para obter tópicos adicionais relacionados ao trabalho com o modelo de interface do usuário remoto, consulte Outros conceitos de interface do usuário remota. Para cenários mais avançados, consulte conceitos de interface do usuário remota avançada.