Perché l'interfaccia utente remota

Uno degli obiettivi principali del modello VisualStudio.Extensibility è consentire l'esecuzione delle estensioni all'esterno del processo di Visual Studio. Questo introduce un ostacolo per l'aggiunta del supporto dell'interfaccia utente alle estensioni perché la maggior parte dei framework dell'interfaccia utente è in-process.

L'interfaccia utente remota è un set di classi che consentono di definire controlli WPF in un'estensione out-of-process e visualizzarli come parte dell'interfaccia utente di Visual Studio.

L'interfaccia utente remota si basa molto sul modello di progettazione Model-View-ViewModel che si basa su XAML e sul data binding, sui comandi (anziché sugli eventi) e sui trigger (anziché interagire con l'alberologico da code-behind).

Anche se l'interfaccia utente remota è stata sviluppata per supportare le estensioni out-of-process, le API VisualStudio.Extensibility che si basano sull'interfaccia utente remota, ad esempio ToolWindow, useranno anche l'interfaccia utente remota per le estensioni in-process.

Le principali differenze tra l'interfaccia utente remota e lo sviluppo normale di WPF sono:

  • La maggior parte delle operazioni remote dell'interfaccia utente, tra cui l'associazione al contesto dati e l'esecuzione dei comandi, sono asincrone.
  • Quando si definiscono i tipi di dati da usare nei contesti di dati dell'interfaccia utente remota, devono essere decorati con gli DataContract attributi e DataMember .
  • L'interfaccia utente remota non consente di fare riferimento ai propri controlli personalizzati.
  • Un controllo utente remoto è completamente definito in un singolo file XAML che fa riferimento a un singolo oggetto di contesto dati (ma potenzialmente complesso e annidato).
  • L'interfaccia utente remota non supporta code behind o gestori eventi (le soluzioni alternative sono descritte nel documento concetti avanzati dell'interfaccia utente remota).
  • Viene creata un'istanza di un controllo utente remoto nel processo di Visual Studio, non nel processo che ospita l'estensione: xaml non può fare riferimento a tipi e assembly dall'estensione, ma può fare riferimento a tipi e assembly dal processo di Visual Studio.

Creare un'estensione Hello World dell'interfaccia utente remota

Per iniziare, creare l'estensione dell'interfaccia utente remota più semplice. Seguire le istruzioni in Creazione della prima estensione out-of-process di Visual Studio.

A questo punto dovrebbe essere disponibile un'estensione funzionante con un singolo comando, il passaggio successivo consiste nell'aggiungere un ToolWindow e un .RemoteUserControl RemoteUserControl è l'equivalente dell'interfaccia utente remota di un controllo utente WPF.

Si finirà con quattro file:

  1. un .cs file per il comando che apre la finestra degli strumenti,
  2. un .cs file per che ToolWindow fornisce a RemoteUserControl Visual Studio,
  3. un .cs file per l'oggetto RemoteUserControl che fa riferimento alla relativa definizione XAML,
  4. un .xaml file per l'oggetto RemoteUserControl.

Successivamente, si aggiunge un contesto dati per , RemoteUserControlche rappresenta viewModel nel modello MVVM.

Aggiornare il comando

Aggiornare il codice del comando per visualizzare la finestra degli strumenti usando ShowToolWindowAsync:

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

È anche possibile prendere in considerazione la modifica CommandConfiguration e string-resources.json per un messaggio di visualizzazione e una posizione più appropriati:

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

Creare la finestra degli strumenti

Creare un nuovo MyToolWindow.cs file e definire una MyToolWindow classe che ToolWindowestende .

Il GetContentAsync metodo dovrebbe restituire un oggetto IRemoteUserControl che verrà definito nel passaggio successivo. Poiché il controllo utente remoto è eliminabile, è necessario eliminarlo eseguendo l'override del Dispose(bool) metodo .

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

Creare il controllo utente remoto

Eseguire questa azione in tre file:

Classe di controllo utente remoto

La classe di controllo utente remoto, denominata MyToolWindowContent, è semplice:

namespace MyToolWindowExtension;

using Microsoft.VisualStudio.Extensibility.UI;

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

Non è ancora necessario un contesto dati, quindi è possibile impostarlo su null per il momento.

Una classe che RemoteUserControl estende automaticamente usa la risorsa incorporata XAML con lo stesso nome. Se si desidera modificare questo comportamento, eseguire l'override del GetXamlAsync metodo .

Definizione XAML

Creare quindi un file denominato 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>

Come descritto in precedenza, questo file deve avere lo stesso nome della classe di controllo utente remoto. Per essere precisi, il nome completo della classe che estende RemoteUserControl deve corrispondere al nome della risorsa incorporata. Ad esempio, se il nome completo della classe di controllo utente remoto è MyToolWindowExtension.MyToolWindowContent, il nome della risorsa incorporata deve essere MyToolWindowExtension.MyToolWindowContent.xaml. Per impostazione predefinita, alle risorse incorporate viene assegnato un nome composto dallo spazio dei nomi radice per il progetto, da qualsiasi percorso della sottocartella in cui possono trovarsi e dal nome del file. Questo può causare problemi se la classe di controllo utente remoto usa uno spazio dei nomi diverso dallo spazio dei nomi radice del progetto o se il file xaml non si trova nella cartella radice del progetto. Se necessario, è possibile forzare un nome per la risorsa incorporata usando il LogicalName tag :

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

La definizione XAML del controllo utente remoto è un codice XAML WPF normale che descrive un oggetto DataTemplate. Questo codice XAML viene inviato a Visual Studio e usato per riempire il contenuto della finestra degli strumenti. Usiamo uno spazio dei nomi speciale (xmlns attributo) per XAML dell'interfaccia utente remota: http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml.

Impostazione del codice XAML come risorsa incorporata

Infine, aprire il .csproj file e assicurarsi che il file XAML venga considerato come una risorsa incorporata:

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

Puoi anche modificare il framework di destinazione per l'estensione da net6.0 a net6.0-windows per ottenere un completamento automatico migliore nel file XAML.

Test dell'estensione

A questo momento dovrebbe essere possibile premere F5 per eseguire il debug dell'estensione.

Screenshot showing menu and tool window.

Aggiungere il supporto per i temi

È consigliabile scrivere l'interfaccia utente tenendo presente che Visual Studio può essere sottoposto a tema con colori diversi.

Aggiornare il codice XAML per usare gli stili e i colori usati in 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>

L'etichetta ora usa lo stesso tema del resto dell'interfaccia utente di Visual Studio e cambia automaticamente il colore quando l'utente passa alla modalità scura:

Screenshot showing themed tool window.

In questo caso, l'attributo xmlns fa riferimento all'assembly Microsoft.VisualStudio.Shell.15.0 , che non è una delle dipendenze dell'estensione. Ciò è corretto perché questo codice XAML viene usato dal processo di Visual Studio, che ha una dipendenza da Shell.15, non dall'estensione stessa.

Per ottenere una migliore esperienza di modifica XAML, puoi aggiungere temporaneamente un oggetto PackageReference al Microsoft.VisualStudio.Shell.15.0 progetto di estensione. Non dimenticare di rimuoverlo in un secondo momento perché un'estensione VisualStudio.Extensibility out-of-process non deve fare riferimento a questo pacchetto.

Aggiungere un contesto dei dati

Aggiungere una classe di contesto dati per il controllo utente remoto:

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

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

e aggiornarlo MyToolWindowContent.cs e MyToolWindowContent.xaml usarlo:

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

Il contenuto dell'etichetta viene ora impostato tramite databinding:

Screenshot showing tool window with data binding.

Il tipo di contesto dati qui è contrassegnato con DataContract gli attributi e DataMember . Ciò è dovuto al fatto che l'istanza MyToolWindowData esiste nel processo host dell'estensione mentre il controllo WPF creato da MyToolWindowContent.xaml esiste nel processo di Visual Studio. Per consentire il funzionamento del data binding, l'infrastruttura dell'interfaccia utente remota genera un proxy dell'oggetto MyToolWindowData nel processo di Visual Studio. Gli DataContract attributi e DataMember indicano quali tipi e proprietà sono rilevanti per il data binding e devono essere replicati nel proxy.

Il contesto dati del controllo utente remoto viene passato come parametro del costruttore della RemoteUserControl classe : la RemoteUserControl.DataContext proprietà è di sola lettura. Ciò non implica che l'intero contesto dei dati non sia modificabile, ma l'oggetto contesto dati radice di un controllo utente remoto non può essere sostituito. Nella sezione successiva si renderanno MyToolWindowData modificabili e osservabili.

Ciclo di vita di un controllo utente remoto

È possibile eseguire l'override del ControlLoadedAsync metodo per ricevere una notifica quando il controllo viene caricato per la prima volta in un contenitore WPF. Se nell'implementazione, lo stato del contesto dati può cambiare indipendentemente dagli eventi dell'interfaccia utente, il ControlLoadedAsync metodo è il posto giusto per inizializzare il contenuto del contesto dati e iniziare ad applicare le modifiche.

È anche possibile eseguire l'override del Dispose metodo per ricevere una notifica quando il controllo viene eliminato definitivamente e non verrà più usato.

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

Comandi, osservabilità e data binding bidirezionale

Successivamente, rendere osservabile il contesto dei dati e aggiungere un pulsante alla casella degli strumenti.

Il contesto dei dati può essere reso osservabile implementando INotifyPropertyChanged. In alternativa, l'interfaccia utente remota fornisce una comoda classe astratta, NotifyPropertyChangedObject, che è possibile estendere per ridurre il codice boilerplate.

Un contesto dati include in genere una combinazione di proprietà di sola lettura e proprietà osservabili. Il contesto dei dati può essere un grafico complesso di oggetti, purché siano contrassegnati con gli DataContract attributi e DataMember e implementi INotifyPropertyChanged in base alle esigenze. È anche possibile avere raccolte osservabili, o ObservableList <T>, che è un oggetto ObservableCollection<T> esteso fornito dall'interfaccia utente remota per supportare anche operazioni di intervallo, consentendo prestazioni migliori.

È anche necessario aggiungere un comando al contesto dati. Nell'interfaccia utente remota i comandi implementano IAsyncCommand , ma spesso è più semplice creare un'istanza della AsyncCommand classe .

IAsyncCommand differisce da ICommand in due modi:

  • Il Execute metodo viene sostituito con ExecuteAsync perché tutto nell'interfaccia utente remota è asincrono.
  • Il CanExecute(object) metodo viene sostituito da una CanExecute proprietà . La AsyncCommand classe si occupa di rendere CanExecute osservabile.

È importante notare che l'interfaccia utente remota non supporta i gestori eventi, quindi tutte le notifiche dall'interfaccia utente all'estensione devono essere implementate tramite databinding e comandi.

Questo è il codice risultante per 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; }
}

Correggere il MyToolWindowContent costruttore:

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

Aggiornare MyToolWindowContent.xaml per usare le nuove proprietà nel contesto dati. Questo è tutto il normale XAML WPF. Anche l'oggetto IAsyncCommand è accessibile tramite un proxy chiamato ICommand nel processo di Visual Studio in modo che possa essere associato a dati come di consueto.

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

Informazioni sull'asincronità nell'interfaccia utente remota

L'intera comunicazione dell'interfaccia utente remota per questa finestra degli strumenti segue questa procedura:

  1. L'accesso al contesto dei dati viene eseguito tramite un proxy all'interno del processo di Visual Studio con il relativo contenuto originale.

  2. Il controllo creato da è costituito da MyToolWindowContent.xaml dati associati al proxy del contesto dati,

  3. L'utente digita un testo nella casella di testo, assegnato alla Name proprietà del proxy del contesto dati tramite l'associazione dati. Il nuovo valore di Name viene propagato all'oggetto MyToolWindowData .

  4. L'utente fa clic sul pulsante causando una cascata di effetti:

    • nel HelloCommand proxy del contesto dati viene eseguito
    • viene avviata l'esecuzione asincrona del codice dell'extender AsyncCommand
    • callback asincrono per HelloCommand aggiornare il valore della proprietà osservabile Text
    • il nuovo valore di Text viene propagato al proxy del contesto dati
    • il blocco di testo nella finestra degli strumenti viene aggiornato al nuovo valore di Text tramite data binding

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

Uso dei parametri di comando per evitare race condition

Tutte le operazioni che coinvolgono la comunicazione tra Visual Studio e l'estensione (frecce blu nel diagramma) sono asincrone. È importante considerare questo aspetto nella progettazione complessiva dell'estensione.

Per questo motivo, se la coerenza è importante, è preferibile usare i parametri dei comandi, anziché l'associazione bidirezionale, per recuperare lo stato del contesto dati al momento dell'esecuzione di un comando.

Apportare questa modifica associando il pulsante CommandParameter a Name:

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

Modificare quindi il callback del comando per usare il parametro :

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

Con questo approccio, il valore della Name proprietà viene recuperato in modo sincrono dal proxy del contesto dati al momento del clic del pulsante e inviato all'estensione. In questo modo si evitano le race condition, soprattutto se il HelloCommand callback viene modificato in futuro in modo da restituire (con await espressioni).

I comandi asincroni usano i dati da più proprietà

L'uso di un parametro di comando non è un'opzione se il comando deve utilizzare più proprietà che sono impostabili dall'utente. Ad esempio, se l'interfaccia utente ha due caselle di testo: "Nome" e "Cognome".

In questo caso, la soluzione consiste nel recuperare, nel callback del comando asincrono, il valore di tutte le proprietà del contesto dati prima di restituire.

Di seguito è possibile visualizzare un esempio in cui i valori delle FirstName proprietà e LastName vengono recuperati prima di restituire per assicurarsi che il valore al momento della chiamata al comando venga usato:

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

È anche importante evitare che l'estensione aggiorni in modo asincrono il valore delle proprietà che possono essere aggiornate anche dall'utente. In altre parole, evitare il data binding TwoWay .

Le informazioni qui dovrebbero essere sufficienti per compilare semplici componenti dell'interfaccia utente remota. Per scenari più avanzati, vedere Concetti avanzati relativi all'interfaccia utente remota.