Compartilhar via


Tutorial: Interface do usuário remota avançada

Neste tutorial, você aprenderá sobre conceitos de uma interface do usuário remota avançada modificando incrementalmente uma janela de ferramenta que mostra uma lista de cores aleatórias:

Captura de tela mostrando a janela da ferramenta de cores aleatórias.

Você saberá mais sobre:

  • Como várias execuções de comandos assíncronos podem ser executadas em paralelo e como desabilitar elementos da interface do usuário quando um comando está em execução.
  • Como associar vários botões ao mesmo comando assíncrono.
  • Como os tipos de referência são manipulados no contexto de dados da interface do usuário remota e seu proxy.
  • Como usar um comando assíncrono como um manipulador de eventos.
  • Como desabilitar um único botão quando o retorno de chamada do comando assíncrono estiver em execução e vários botões estiverem associados ao mesmo comando.
  • Como usar dicionários de recursos XAML de um controle de interface do usuário remota.
  • Como usar tipos WPF, como pincéis complexos, no contexto de dados da interface do usuário remota.
  • Como a interface do usuário remota lida com o threading.

Este tutorial é baseado no artigo introdutório da Interface do usuário remota e espera que você tenha uma extensão VisualStudio.Extensibility em funcionamento, incluindo:

  1. um arquivo .cs para o comando que abre a janela de ferramentas,
  2. um arquivo MyToolWindow.cs para a classe ToolWindow,
  3. um arquivo MyToolWindowContent.cs para a classe RemoteUserControl,
  4. um arquivo de recurso inserido MyToolWindowContent.xaml para a definição xaml de RemoteUserControl,
  5. um arquivo MyToolWindowData.cs para o contexto de dados do RemoteUserControl.

Para iniciar, atualize MyToolWindowContent.xaml para mostrar um modo de exibição de lista e um botão":

<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 x:Name="RootGrid">
        <Grid.Resources>
            <Style TargetType="ListView" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ThemedDialogListViewStyleKey}}" />
            <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.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <ListView ItemsSource="{Binding Colors}" HorizontalContentAlignment="Stretch">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="*" />
                            <ColumnDefinition Width="Auto" />
                            <ColumnDefinition Width="Auto" />
                        </Grid.ColumnDefinitions>
                        <TextBlock Text="{Binding ColorText}" />
                        <Rectangle Fill="{Binding Color}" Width="50px" Grid.Column="1" />
                        <Button Content="Remove" Grid.Column="2" />
                    </Grid>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
        <Button Content="Add color" Command="{Binding AddColorCommand}" Grid.Row="1" />
    </Grid>
</DataTemplate>

Em seguida, atualize a classe de contexto de dados MyToolWindowData.cs:

using Microsoft.VisualStudio.Extensibility.UI;
using System.Collections.ObjectModel;
using System.Runtime.Serialization;
using System.Text;
using System.Windows.Media;

namespace MyToolWindowExtension;

[DataContract]
internal class MyToolWindowData
{
    private Random random = new();

    public MyToolWindowData()
    {
        AddColorCommand = new AsyncCommand(async (parameter, cancellationToken) =>
        {
            await Task.Delay(TimeSpan.FromSeconds(2));

            var color = new byte[3];
            random.NextBytes(color);
            Colors.Add(new MyColor(color[0], color[1], color[2]));
        });
    }

    [DataMember]
    public ObservableList<MyColor> Colors { get; } = new();

    [DataMember]
    public AsyncCommand AddColorCommand { get; }

    [DataContract]
    public class MyColor
    {
        public MyColor(byte r, byte g, byte b)
        {
            ColorText = Color = $"#{r:X2}{g:X2}{b:X2}";
        }

        [DataMember]
        public string ColorText { get; }

        [DataMember]
        public string Color { get; }
    }
}

Há apenas alguns aspectos em destaque neste código:

  • MyColor.Color é um string, mas é usado como um Brush quando associado a dados em XAML, esse é um recurso fornecido pelo WPF.
  • O retorno de chamada assíncrona AddColorCommand contém um atraso de 2 segundos para simular uma operação de execução prolongada.
  • Usamos 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.
  • MyToolWindowData e MyColor não implementam INotifyPropertyChanged porque, no momento, todas as propriedades são somente leitura.

Manipular comandos assíncronos de execução prolongada

Uma das diferenças mais importantes entre a interface do usuário remota e o WPF normal é que todas as operações que envolvem a comunicação entre a interface do usuário e a extensão são assíncronas.

Comandos assíncronos como AddColorCommand tornam isso explícito fornecendo um retorno de chamada assíncrono.

Você poderá ver o efeito disso se clicar no botão Adicionar cor várias vezes em um curto espaço de tempo: como cada execução de comando leva 2 segundos, várias execuções ocorrem em paralelo e várias cores aparecerão na lista juntas quando o atraso de 2 segundos terminar. Isso pode dar a impressão ao usuário de que o botão Adicionar cor não está funcionando.

Diagrama de execução de comando assíncrono sobreposto.

Para resolver isso, desative o botão enquanto o comando async está em execução. A maneira mais simples de fazer isso é simplesmente definir CanExecute para o comando como false:

AddColorCommand = new AsyncCommand(async (parameter, ancellationToken) =>
{
    AddColorCommand!.CanExecute = false;
    try
    {
        await Task.Delay(TimeSpan.FromSeconds(2));
        var color = new byte[3];
        random.NextBytes(color);
        Colors.Add(new MyColor(color[0], color[1], color[2]));
    }
    finally
    {
        AddColorCommand.CanExecute = true;
    }
});

Essa solução ainda tem uma sincronização imperfeita, uma vez que, quando o usuário clica no botão, o retorno de chamada do comando é executado de forma assíncrona na extensão, o retorno de chamada define CanExecute como false, que é então propagado de forma assíncrona para o contexto de dados de proxy no processo do Visual Studio, fazendo com o que o botão seja desabilitado. O usuário pode clicar no botão duas vezes em rápida sucessão antes que o botão seja desabilitado.

Uma solução melhor é usar a propriedade RunningCommandsCount dos comandos assíncronos:

<Button Content="Add color" Command="{Binding AddColorCommand}" IsEnabled="{Binding AddColorCommand.RunningCommandsCount.IsZero}" Grid.Row="1" />

RunningCommandsCount é um contador de quantas execuções assíncronas simultâneas do comando estão em andamento no momento. Esse contador é incrementado no thread da IU do usuário assim que o botão é clicado, o que permite desabilitar o botão de forma síncrona associando-se IsEnabled a RunningCommandsCount.IsZero.

Como todos os comandos da interface do usuário remota são executados de forma assíncrona, a prática recomendada é sempre usar RunningCommandsCount.IsZero para desabilitar controles quando apropriado, mesmo que a expectativa seja a conclusão rápida do comando.

Comandos assíncronos e modelos de dados

Nesta seção, você implementa o botão Remover, que permite ao usuário excluir uma entrada da lista. Podemos criar um comando assíncrono para cada objeto MyColor ou podemos ter um único comando assíncrono em MyToolWindowData e usar um parâmetro para identificar qual cor deve ser removida. A última opção é um design mais limpo, então vamos implementá-lo.

  1. Atualize o botão XAML no modelo de dados:
<Button Content="Remove" Grid.Column="2"
        Command="{Binding DataContext.RemoveColorCommand,
            RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListView}}}"
        CommandParameter="{Binding}"
        IsEnabled="{Binding DataContext.RemoveColorCommand.RunningCommandsCount.IsZero,
            RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListView}}}" />
  1. Adicione o AsyncCommand correspondente a MyToolWindowData:
[DataMember]
public AsyncCommand RemoveColorCommand { get; }
  1. Defina o retorno de chamada assíncrono do comando no construtor de MyToolWindowData:
RemoveColorCommand = new AsyncCommand(async (parameter, ancellationToken) =>
{
    await Task.Delay(TimeSpan.FromSeconds(2));

    Colors.Remove((MyColor)parameter!);
});

Esse código usa um Task.Delay para simular uma execução de comando assíncrono de execução prolongada.

Tipos de referência no contexto de dados

No código anterior, um objeto MyColor é recebido como o parâmetro de um comando assíncrono e usado como parâmetro de uma chamada List<T>.Remove que emprega a igualdade de referência (já que é MyColor um tipo de referência que não substitui Equals) para identificar o elemento a ser removido. Isso é possível porque, mesmo que o parâmetro seja recebido da interface do usuário, a instância exata de MyColor que atualmente faz parte do contexto de dados é recebida, e não uma cópia.

Os processos de

  • proxy do contexto de dados de um controle de usuário remoto;
  • enviar atualizações de INotifyPropertyChanged da extensão para o Visual Studio ou vice-versa;
  • enviar atualizações de coleções observáveis da extensão para o Visual Studio ou vice-versa;
  • enviar parâmetros de comandos assíncronos

todos honram a identidade dos objetos do tipo de referência. Com exceção das sequências, os objetos do tipo de referência nunca são duplicados quando transferidos de volta para a extensão.

Diagrama de tipos de referência de associação de dados da interface do usuário remota.

Na imagem, é possível ver como cada objeto de tipo de referência no contexto de dados (os comandos, a coleção, cada MyColor e até mesmo todo o contexto de dados) recebe um identificador exclusivo pela infraestrutura de interface do usuário remota. Quando o usuário clica no botão Remover para o objeto de cor de proxy #5, o identificador exclusivo (#5), e não o valor do objeto, é enviado de volta para a extensão. A infraestrutura de interface do usuário remota se encarrega de recuperar o objeto MyColor correspondente e passá-lo como parâmetro para o retorno de chamada do comando assíncrono.

RunningCommandsCount com várias associações e manipulação de eventos

Se você testar a extensão neste ponto, observe que, quando um dos botões Remover é clicado, todos os botões Remover são desabilitados:

Diagrama de comando assíncrono com várias associações.

Esse pode ser o comportamento desejado. Mas, suponha que você queira que apenas o botão atual seja desabilitado e permita que o usuário coloque várias cores na fila para remoção: não podemos usar a propriedade RunningCommandsCount do comando assíncrono porque temos um único comando compartilhado entre todos os botões.

Podemos atingir nosso objetivo anexando uma propriedade RunningCommandsCount a cada botão para que tenhamos um contador separado para cada cor. Esses recursos são fornecidos pelo namespace http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml, que permite consumir tipos de interface do usuário remota de XAML:

Alteramos o botão Remover para o seguinte:

<Button Content="Remove" Grid.Column="2"
        IsEnabled="{Binding Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero, RelativeSource={RelativeSource Self}}">
    <vs:ExtensibilityUICommands.EventHandlers>
        <vs:EventHandlerCollection>
            <vs:EventHandler Event="Click"
                             Command="{Binding DataContext.RemoveColorCommand, ElementName=RootGrid}"
                             CommandParameter="{Binding}"
                             CounterTarget="{Binding RelativeSource={RelativeSource Self}}" />
        </vs:EventHandlerCollection>
    </vs:ExtensibilityUICommands.EventHandlers>
</Button>

A propriedade anexada vs:ExtensibilityUICommands.EventHandlers permite atribuir comandos assíncronos a qualquer evento (por exemplo, MouseRightButtonUp) e pode ser útil em cenários mais avançados.

vs:EventHandler também pode ter um CounterTarget: um UIElement ao qual uma propriedade vs:ExtensibilityUICommands.RunningCommandsCount deve ser anexada, contando as execuções ativas relacionadas àquele evento específico. Certifique-se de usar parênteses (por exemplo, Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero) ao associar a uma propriedade anexada.

Nesse caso, usamos vs:EventHandler para anexar a cada botão seu próprio contador separado de execuções de comando ativo. Ao associar IsEnabled à propriedade anexada, somente esse botão específico é desabilitado quando a cor correspondente é removida:

Diagrama de comando assíncrono com RunningCommandsCount direcionado.

Dicionários de recursos XAML do usuário

Começando no Visual Studio 17.10, a interface do usuário remota oferece suporte a Dicionários de recursos XAML. Isso permite que vários controles de interface do usuário remota compartilhem estilos, modelos e outros recursos. Além disso, permite que você defina diferentes recursos (por exemplo, sequências) para diferentes idiomas.

Da mesma forma que um XAML de controle de interface do usuário remota, os arquivos de recursos devem ser configurados como recursos incorporados:

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

A interface do usuário remota faz referência a dicionários de recursos de uma maneira diferente do WPF: eles não são adicionados aos dicionários mesclados do controle (dicionários mesclados não são aceitos pela interface do usuário remota), mas referenciados pelo nome no arquivo .cs do controle:

internal class MyToolWindowContent : RemoteUserControl
{
    public MyToolWindowContent()
        : base(dataContext: new MyToolWindowData())
    {
        this.ResourceDictionaries.AddEmbeddedResource(
            "MyToolWindowExtension.MyResources.xaml");
    }
...

O AddEmbeddedResource usa o nome completo do recurso inserido que, por padrão, é composto pelo namespace raiz do projeto, qualquer caminho de subpasta em que ele possa estar e o nome do arquivo. É possível substituir esse nome definindo um LogicalName para o EmbeddedResource no arquivo de projeto.

O próprio arquivo de recurso é um dicionário de recursos WPF normal:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:system="clr-namespace:System;assembly=mscorlib">
  <system:String x:Key="removeButtonText">Remove</system:String>
  <system:String x:Key="addButtonText">Add color</system:String>
</ResourceDictionary>

É possível fazer referência a um recurso do dicionário de recursos no controle de interface do usuário remota usando DynamicResource:

<Button Content="{DynamicResource removeButtonText}" ...

Localizando dicionários de recursos XAML

Os dicionários de recursos de interface do usuário remota podem ser localizados da mesma forma como você localizaria recursos inseridos: você cria outros arquivos XAML com o mesmo nome e um sufixo de idioma, por exemplo MyResources.it.xaml, para recursos em italiano:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:system="clr-namespace:System;assembly=mscorlib">
  <system:String x:Key="removeButtonText">Rimuovi</system:String>
  <system:String x:Key="addButtonText">Aggiungi colore</system:String>
</ResourceDictionary>

Você pode usar curingas no arquivo de projeto para incluir todos os dicionários XAML localizados como recursos incorporados:

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

Usar tipos WPF no contexto de dados

Até agora, o contexto de dados de nosso controle de usuário remoto tem sido composto por primitivas (números, sequências, etc.), coleções observáveis e nossas próprias classes marcadas com DataContract. Às vezes, é útil incluir tipos WPF simples no contexto de dados, como pincéis complexos.

Como uma extensão VisualStudio.Extensibility pode nem mesmo ser executada no processo do Visual Studio, ela não pode compartilhar objetos WPF diretamente com sua interface do usuário. A extensão pode nem mesmo ter acesso aos tipos de WPF, uma vez que pode ter como alvo netstandard2.0 ou net6.0 (não a variante -windows).

A interface do usuário remota fornece o tipo XamlFragment, que permite incluir uma definição XAML de um objeto WPF no contexto de dados de um controle de usuário remoto:

[DataContract]
public class MyColor
{
    public MyColor(byte r, byte g, byte b)
    {
        ColorText = $"#{r:X2}{g:X2}{b:X2}";
        Color = new(@$"<LinearGradientBrush xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation""
                               StartPoint=""0,0"" EndPoint=""1,1"">
                           <GradientStop Color=""Black"" Offset=""0.0"" />
                           <GradientStop Color=""{ColorText}"" Offset=""0.7"" />
                       </LinearGradientBrush>");
    }

    [DataMember]
    public string ColorText { get; }

    [DataMember]
    public XamlFragment Color { get; }
}

Com o código acima, o valor da propriedade Color é convertido em um objeto LinearGradientBrush no proxy de contexto de dados: Captura de tela que mostra tipos WPF no contexto de dados

Interface do usuário remota e threads

Os retornos de chamada de comandos assíncronos (e retornos de chamadas INotifyPropertyChanged para valores atualizados pela interface do usuário por meio de associação de dados) são gerados em threads de pool de threads aleatórios. Os retornos de chamada são gerados um de cada vez e não se sobrepõem até que o código produza controle (usando uma expressão await).

Esse comportamento pode ser alterado pela passagem de um NonConcurrentSynchronizationContext para o construtor RemoteUserControl. Nesse caso, você pode usar o contexto de sincronização fornecido para todos os comandos assíncronos e retornos de chamada INotifyPropertyChanged relacionados a esse controle.