Esercitazione: Interfaccia utente remota avanzata

In questa esercitazione vengono illustrati i concetti avanzati dell'interfaccia utente remota modificando in modo incrementale una finestra degli strumenti che mostra un elenco di colori casuali:

Screenshot showing random colors tool window.

Verranno fornite informazioni su:

  • Come eseguire più comandi asincroni in parallelo e come disabilitare gli elementi dell'interfaccia utente quando un comando è in esecuzione.
  • Come associare più pulsanti allo stesso comando asincrono.
  • Modalità di gestione dei tipi di riferimento nel contesto dei dati dell'interfaccia utente remota e nel relativo proxy.
  • Come usare un comando asincrono come gestore eventi.
  • Come disabilitare un singolo pulsante quando il callback del comando asincrono viene eseguito se più pulsanti sono associati allo stesso comando.
  • Come usare tipi WPF, ad esempio pennelli complessi, nel contesto dei dati dell'interfaccia utente remota.
  • Modalità di gestione del threading da parte dell'interfaccia utente remota.

Questa esercitazione si basa sull'articolo introduttivo sull'interfaccia utente remota e prevede di avere un'estensione VisualStudio.Extensibility funzionante, tra cui:

  1. un .cs file per il comando che apre la finestra degli strumenti,
  2. un MyToolWindow.cs file per la ToolWindow classe ,
  3. un MyToolWindowContent.cs file per la RemoteUserControl classe ,
  4. un MyToolWindowContent.xaml file di risorse incorporato per la RemoteUserControl definizione xaml,
  5. un MyToolWindowData.cs file per il contesto dati dell'oggetto RemoteUserControl.

Per iniziare, aggiornare per visualizzare una visualizzazione elenco e un pulsante:To start, update MyToolWindowContent.xaml to show a list view and a button":

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

Aggiornare quindi la classe MyToolWindowData.csdel contesto dati :

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

In questo codice sono presenti solo alcuni aspetti importanti:

  • MyColor.Color è un string ma viene usato come oggetto Brush quando i dati associati in XAML, questa è una funzionalità fornita da WPF.
  • Il AddColorCommand callback asincrono contiene un ritardo di 2 secondi per simulare un'operazione a esecuzione prolungata.
  • Usiamo ObservableList<T>, che è un oggetto ObservableCollection<T> esteso fornito dall'interfaccia utente remota per supportare anche le operazioni di intervallo, consentendo prestazioni migliori.
  • MyToolWindowData e MyColor non implementare INotifyPropertyChanged perché, al momento, tutte le proprietà sono di sola lettura.

Gestire i comandi asincroni a esecuzione prolungata

Una delle differenze più importanti tra l'interfaccia utente remota e il normale WPF è che tutte le operazioni che coinvolgono la comunicazione tra l'interfaccia utente e l'estensione sono asincrone.

Comandi asincroni come AddColorCommand rendere esplicito questo metodo fornendo un callback asincrono.

È possibile visualizzare l'effetto di questo se si fa clic sul pulsante Aggiungi colore più volte in breve tempo: poiché ogni esecuzione del comando richiede 2 secondi, più esecuzioni si verificano in parallelo e più colori verranno visualizzati nell'elenco insieme quando il ritardo di 2 secondi è finito. Ciò potrebbe dare l'impressione all'utente che il pulsante Aggiungi colore non funziona.

Diagram of overlapped async command execution.

Per risolvere questo problema, disabilitare il pulsante durante l'esecuzione del comando asincrono. Il modo più semplice per eseguire questa operazione consiste nell'impostare semplicemente il comando su CanExecute 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;
    }
});

Questa soluzione ha ancora una sincronizzazione imperfetta perché, quando l'utente fa clic sul pulsante, il callback del comando viene eseguito in modo asincrono nell'estensione, il callback imposta CanExecute su false, che viene quindi propagato in modo asincrono al contesto dei dati proxy nel processo di Visual Studio, con conseguente disabilitazione del pulsante. L'utente può fare doppio clic sul pulsante in rapida successione prima che il pulsante sia disabilitato.

Una soluzione migliore consiste nell'usare la RunningCommandsCount proprietà dei comandi asincroni:

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

RunningCommandsCount è un contatore del numero di esecuzioni asincrone simultanee del comando attualmente in corso. Questo contatore viene incrementato sul thread dell'interfaccia utente non appena si fa clic sul pulsante, che consente di disabilitare in modo sincrono il pulsante associandolo IsEnabled a RunningCommandsCount.IsZero.

Poiché tutti i comandi dell'interfaccia utente remota vengono eseguiti in modo asincrono, la procedura consigliata consiste nell'usare RunningCommandsCount.IsZero sempre per disabilitare i controlli quando appropriato, anche se si prevede che il comando venga completato rapidamente.

Comandi asincroni e modelli di dati

In questa sezione si implementa il pulsante Rimuovi , che consente all'utente di eliminare una voce dall'elenco. È possibile creare un comando asincrono per ogni MyColor oggetto oppure un singolo comando asincrono in MyToolWindowData e usare un parametro per identificare il colore da rimuovere. Quest'ultima opzione è una progettazione più pulita, quindi è possibile implementarlo.

  1. Aggiornare il codice XAML del pulsante nel modello di dati:
<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. Aggiungere l'oggetto corrispondente AsyncCommand a MyToolWindowData:
[DataMember]
public AsyncCommand RemoveColorCommand { get; }
  1. Impostare il callback asincrono del comando nel costruttore di MyToolWindowData:
RemoveColorCommand = new AsyncCommand(async (parameter, ancellationToken) =>
{
    await Task.Delay(TimeSpan.FromSeconds(2));

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

Questo codice usa un Task.Delay oggetto per simulare un'esecuzione di comando asincrona a esecuzione prolungata.

Tipi di riferimento nel contesto dati

Nel codice precedente, un MyColor oggetto viene ricevuto come parametro di un comando asincrono e usato come parametro di una List<T>.Remove chiamata, che usa l'uguaglianza dei riferimenti (poiché MyColor è un tipo di riferimento che non esegue l'override Equals) per identificare l'elemento da rimuovere. Ciò è possibile perché, anche se il parametro viene ricevuto dall'interfaccia utente, l'istanza esatta di MyColor che fa attualmente parte del contesto di dati viene ricevuta, non una copia.

Processi di

  • proxying del contesto dati di un controllo utente remoto;
  • invio INotifyPropertyChanged di aggiornamenti dall'estensione a Visual Studio o viceversa;
  • invio di aggiornamenti di raccolta osservabili dall'estensione a Visual Studio o viceversa;
  • invio di parametri di comando asincroni

tutti rispettano l'identità degli oggetti di tipo riferimento. Ad eccezione delle stringhe, gli oggetti tipo riferimento non vengono mai duplicati quando vengono trasferiti di nuovo all'estensione.

Diagram of Remote UI data binding reference types.

Nell'immagine è possibile vedere in che modo a ogni oggetto tipo di riferimento nel contesto dati (i comandi, la raccolta, ogni MyColor contesto dati e persino l'intero contesto di dati) viene assegnato un identificatore univoco dall'infrastruttura dell'interfaccia utente remota. Quando l'utente fa clic sul pulsante Rimuovi per l'oggetto colore proxy #5, l'identificatore univoco (#5), non il valore dell'oggetto, viene restituito all'estensione. L'infrastruttura dell'interfaccia utente remota si occupa del recupero dell'oggetto corrispondente MyColor e del passaggio come parametro al callback del comando asincrono.

RunningCommandsCount con più associazioni e gestione degli eventi

Se si testa l'estensione a questo punto, si noti che quando si fa clic su uno dei pulsanti Rimuovi , tutti i pulsanti Rimuovi sono disabilitati:

Diagram of async Command with multiple bindings.

Potrebbe trattarsi del comportamento desiderato. Si supponga tuttavia di voler disabilitare solo il pulsante corrente e consentire all'utente di accodare più colori per la rimozione: non è possibile usare la proprietà del RunningCommandsCount comando asincrono perché è disponibile un singolo comando condiviso tra tutti i pulsanti.

È possibile raggiungere l'obiettivo associando una RunningCommandsCount proprietà a ogni pulsante in modo da avere un contatore separato per ogni colore. Queste funzionalità vengono fornite dallo http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml spazio dei nomi , che consente di utilizzare tipi di interfaccia utente remota da XAML:

Il pulsante Rimuovi viene modificato nel modo seguente:

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

La vs:ExtensibilityUICommands.EventHandlers proprietà associata consente di assegnare comandi asincroni a qualsiasi evento ,ad esempio MouseRightButtonUp, e può essere utile in scenari più avanzati.

vs:EventHandler può anche avere un oggetto CounterTarget: a UIElement cui deve essere associata una vs:ExtensibilityUICommands.RunningCommandsCount proprietà, contando le esecuzioni attive correlate a tale evento specifico. Assicurarsi di usare le parentesi (ad esempio Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero) quando si esegue l'associazione a una proprietà associata.

In questo caso, viene usato vs:EventHandler per allegare a ogni pulsante il proprio contatore separato di esecuzioni di comandi attive. Tramite l'associazione IsEnabled alla proprietà associata, solo il pulsante specifico viene disabilitato quando viene rimosso il colore corrispondente:

Diagram of async Command with targeted RunningCommandsCount.

Usare i tipi WPF nel contesto dei dati

Fino ad ora, il contesto dei dati del controllo utente remoto è costituito da primitive (numeri, stringhe e così via), raccolte osservabili e classi personalizzate contrassegnate con DataContract. a volte è utile includere tipi WPF semplici nel contesto dati, ad esempio pennelli complessi.

Poiché un'estensione VisualStudio.Extensibility potrebbe non essere eseguita nemmeno nel processo di Visual Studio, non può condividere gli oggetti WPF direttamente con l'interfaccia utente. L'estensione potrebbe anche non avere accesso ai tipi WPF perché può essere di destinazione netstandard2.0 o net6.0 (non la -windows variante).

L'interfaccia utente remota fornisce il XamlFragment tipo , che consente di includere una definizione XAML di un oggetto WPF nel contesto dati di un controllo utente 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; }
}

Con il codice precedente, il valore della Color proprietà viene convertito in un LinearGradientBrush oggetto nel proxy del contesto dati: Screenshot showing WPF types in data context

Interfaccia utente remota e thread

I callback dei comandi asincroni (e INotifyPropertyChanged i callback per i valori aggiornati dall'interfaccia utente tramite l'offerta di dati) vengono generati su thread del pool di thread casuali. I callback vengono generati uno alla volta e non si sovrappongono finché il codice non restituisce il controllo (usando un'espressione await ).

Questo comportamento può essere modificato passando un oggetto NonConcurrentSynchronizationContext al RemoteUserControl costruttore. In tal caso, è possibile usare il contesto di sincronizzazione fornito per tutti i callback e INotifyPropertyChanged i comandi asincroni correlati a tale controllo.