Gründe für eine Remotebenutzeroberfläche

Eines der Standard Ziele des VisualStudio.Extensibility-Modells besteht darin, Erweiterungen außerhalb des Visual Studio-Prozesses auszuführen. Dies führt zu einem Hindernis für das Hinzufügen von UI-Unterstützung zu Erweiterungen, da die meisten Benutzeroberflächenframeworks in Bearbeitung sind.

Remote-UI ist eine Reihe von Klassen, mit denen Sie WPF-Steuerelemente in einer Out-of-Process-Erweiterung definieren und als Teil der Visual Studio-Benutzeroberfläche anzeigen können.

Die Remotebenutzeroberfläche lehnt sich stark dem Entwurfsmuster "Model-View-ViewModel" zu, das sich auf XAML und Datenbindung, Befehle (anstelle von Ereignissen) und Trigger stützt (anstatt mit der logischen Struktur aus CodeBehind zu interagieren).

Während Remote-UI entwickelt wurde, um Out-of-Process-Erweiterungen zu unterstützen, verwenden VisualStudio.Extensibility-APIs, die auf Remote-UI basieren, z ToolWindow. B. Remote-UI für In-Process-Erweiterungen.

Die Standard Unterschiede zwischen Remote-UI und normaler WPF-Entwicklung sind:

  • Die meisten Remote-UI-Vorgänge, einschließlich der Bindung an den Datenkontext und die Ausführung von Befehlen, sind asynchron.
  • Beim Definieren von Datentypen, die in Remote-UI-Datenkontexten verwendet werden sollen, müssen sie mit den DataContract Und-Attributen DataMember versehen werden.
  • Die Remote-UI lässt keinen Verweis auf ihre eigenen benutzerdefinierten Steuerelemente zu.
  • Ein Remotebenutzersteuerelement ist in einer einzelnen XAML-Datei vollständig definiert, die auf ein einzelnes (aber potenziell komplexes und geschachteltes) Datenkontextobjekt verweist.
  • Die Remotebenutzeroberfläche unterstützt codeBehind oder Ereignishandler nicht (Problemumgehungen werden im Dokument für erweiterte Remote-UI-Konzepte beschrieben).
  • Ein Remotebenutzersteuerelement wird im Visual Studio-Prozess instanziiert, nicht der Prozess, der die Erweiterung hostet: Der XAML-Code kann nicht auf Typen und Assemblys aus der Erweiterung verweisen, sondern auf Typen und Assemblys aus dem Visual Studio-Prozess verweisen.

Erstellen einer Remote-UI-Hallo Welt-Erweiterung

Erstellen Sie zunächst die einfachste Remote-UI-Erweiterung. Befolgen Sie die Anweisungen zum Erstellen Ihrer ersten out-of-process Visual Studio-Erweiterung.

Sie sollten nun über eine funktionierende Erweiterung mit einem einzigen Befehl verfügen, der nächste Schritt besteht darin, ein ToolWindow und eins RemoteUserControlhinzuzufügen. Dies RemoteUserControl ist die Entsprechung der Remote-Benutzeroberfläche eines WPF-Benutzersteuerelements.

Sie werden mit vier Dateien enden:

  1. eine .cs Datei für den Befehl, der das Toolfenster öffnet,
  2. eine .cs Datei für die, die ToolWindowRemoteUserControl Visual Studio bereitstellt,
  3. eine .cs Datei für die XAML-Definition, die RemoteUserControl auf die XAML-Definition verweist,
  4. eine .xaml Datei für die RemoteUserControl.

Später fügen Sie einen Datenkontext für den RemoteUserControl, der das ViewModel im MVVM-Muster darstellt.

Aktualisieren des Befehls

Aktualisieren Sie den Code des Befehls, um das Toolfenster mithilfe ShowToolWindowAsyncvon :

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

Sie können auch eine Änderung CommandConfiguration und eine geeignetere Anzeigemeldung und string-resources.json Platzierung in Betracht ziehen:

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

Erstellen des Toolfensters

Erstellen Sie eine neue MyToolWindow.cs Datei, und definieren Sie eine MyToolWindow Klassenerweiterung ToolWindow.

Die GetContentAsync Methode soll einen IRemoteUserControl Wert zurückgeben, den Sie im nächsten Schritt definieren werden. Da die Fernbedienung einweg ist, kümmern Sie sich darum, sie zu entsorgen, indem Sie die Dispose(bool) Methode überschreiben.

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

Erstellen der Remotebenutzersteuerung

Führen Sie diese Aktion in drei Dateien aus:

Remotebenutzersteuerungsklasse

Die Remotebenutzersteuerungsklasse namens MyToolWindowContent", ist einfach:

namespace MyToolWindowExtension;

using Microsoft.VisualStudio.Extensibility.UI;

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

Sie benötigen noch keinen Datenkontext, damit Sie ihn null für den jetzigen Zeitkontext festlegen können.

Eine Klasse, die erweitert wird RemoteUserControl , verwendet automatisch die eingebettete XAML-Ressource mit demselben Namen. Wenn Sie dieses Verhalten ändern möchten, setzen Sie die GetXamlAsync Methode außer Kraft.

XAML-Definition

Erstellen Sie als Nächstes eine Datei mit dem Namen 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>

Wie zuvor beschrieben, muss diese Datei denselben Namen wie die Remotebenutzersteuerungsklasse haben. Um genau zu sein, muss der vollständige Name der Klassenerweiterung RemoteUserControl mit dem Namen der eingebetteten Ressource übereinstimmen. Wenn beispielsweise der vollständige Name der Remotebenutzersteuerungsklasse lautetMyToolWindowExtension.MyToolWindowContent, sollte der name der eingebetteten Ressource seinMyToolWindowExtension.MyToolWindowContent.xaml. Standardmäßig werden eingebettete Ressourcen einem Namen zugewiesen, der aus dem Stammnamespace für das Projekt besteht, alle Unterordnerpfade, unter denen sie stehen können, und deren Dateiname. Dies kann Probleme verursachen, wenn Ihre Remotebenutzersteuerungsklasse einen Namespace verwendet, der sich vom Stammnamespace des Projekts unterscheidet oder wenn sich die XAML-Datei nicht im Stammordner des Projekts befindet. Bei Bedarf können Sie mithilfe des LogicalName Tags einen Namen für die eingebettete Ressource erzwingen:

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

Die XAML-Definition der Remotebenutzersteuerung ist ein normaler WPF-XAML-Code, der eine DataTemplateBeschreibung beschreibt. Dieser XAML-Code wird an Visual Studio gesendet und zum Ausfüllen des Toolfensterinhalts verwendet. Wir verwenden einen speziellen Namespace (xmlns Attribut) für Remote-UI-XAML: http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml.

Festlegen des XAML-Codes als eingebettete Ressource

Öffnen Sie schließlich die .csproj Datei, und stellen Sie sicher, dass die XAML-Datei als eingebettete Ressource behandelt wird:

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

Sie können auch das Zielframework für Ihre Erweiterung von net6.0 zu net6.0-windows " ändern, um eine bessere AutoVervollständigen in der XAML-Datei zu erhalten.

Testen der Erweiterung

Sie sollten jetzt in F5 der Lage sein, die Erweiterung zu debuggen.

Screenshot showing menu and tool window.

Hinzufügen von Unterstützung für Designs

Es empfiehlt sich, die Benutzeroberfläche zu schreiben, wobei Sie berücksichtigen, dass Visual Studio designiert werden kann, sodass unterschiedliche Farben verwendet werden.

Aktualisieren Sie den XAML-Code, um die formatvorlagen und Farben zu verwenden, die in Visual Studio verwendet werden:

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

Die Beschriftung verwendet jetzt das gleiche Design wie die restliche Visual Studio-Benutzeroberfläche und ändert die Farbe automatisch, wenn der Benutzer in den dunklen Modus wechselt:

Screenshot showing themed tool window.

Hier verweist das xmlns Attribut auf die Microsoft.VisualStudio.Shell.15.0-Assembly , die keine der Erweiterungsabhängigkeiten ist. Dies ist in Ordnung, da dieser XAML-Code vom Visual Studio-Prozess verwendet wird, der eine Abhängigkeit von Shell.15 hat, nicht von der Erweiterung selbst.

Um eine bessere XAML-Bearbeitung zu erzielen, können Sie dem Erweiterungsprojekt vorübergehend einen Microsoft.VisualStudio.Shell.15.0PackageReference hinzufügen. Vergessen Sie nicht, es später zu entfernen, da eine out-of-process VisualStudio.Extensibility-Erweiterung nicht auf dieses Paket verweisen sollte!

Hinzufügen eines Datenkontexts

Fügen Sie eine Datenkontextklasse für die Remotebenutzersteuerung hinzu:

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

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

und aktualisieren MyToolWindowContent.cs und MyToolWindowContent.xaml verwenden Sie es:

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

Der Inhalt der Bezeichnung wird jetzt über die Datenbindung festgelegt:

Screenshot showing tool window with data binding.

Der hier aufgeführte Datentyp ist mit DataContract und DataMember Attributen gekennzeichnet. Dies liegt daran, dass die MyToolWindowData Instanz im Erweiterungshostprozess vorhanden ist, während das aus dem Visual Studio-Prozess erstellte MyToolWindowContent.xaml WPF-Steuerelement vorhanden ist. Damit die Datenbindung funktioniert, generiert die Remote-UI-Infrastruktur einen Proxy des MyToolWindowData Objekts im Visual Studio-Prozess. Die DataContract Attribute DataMember geben an, welche Typen und Eigenschaften für die Datenbindung relevant sind und im Proxy repliziert werden sollen.

Der Datenkontext der Remotebenutzersteuerung wird als Konstruktorparameter der RemoteUserControl Klasse übergeben: Die RemoteUserControl.DataContext Eigenschaft ist schreibgeschützt. Dies bedeutet nicht, dass der gesamte Datenkontext unveränderlich ist, aber das Stammdatenkontextobjekt einer Remotebenutzersteuerung kann nicht ersetzt werden. Im nächsten Abschnitt werden MyToolWindowData wir änderbar und feststellbar machen.

Lebenszyklus einer Remotebenutzersteuerung

Sie können die ControlLoadedAsync Methode außer Kraft setzen, die benachrichtigt werden soll, wenn das Steuerelement zum ersten Mal in einem WPF-Container geladen wird. Wenn sich der Status des Datenkontexts in Ihrer Implementierung unabhängig von UI-Ereignissen ändern kann, ist die ControlLoadedAsync Methode der richtige Ort, um den Inhalt des Datenkontexts zu initialisieren und änderungen darauf anzuwenden.

Sie können die Dispose Methode auch überschreiben, die benachrichtigt werden soll, wenn das Steuerelement zerstört wird und nicht mehr verwendet wird.

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

Befehle, Observability und bidirektionale Datenbindung

Als Nächstes machen wir den Datenkontext feststellbar und fügen der Toolbox eine Schaltfläche hinzu.

Der Datenkontext kann durch die Implementierung von INotifyPropertyChanged beobachtet werden. Alternativ bietet remote UI eine bequeme abstrakte Klasse, NotifyPropertyChangedObjectdie wir erweitern können, um Codebausteine zu reduzieren.

Ein Datenkontext verfügt in der Regel über eine Mischung aus readonly-Eigenschaften und feststellbaren Eigenschaften. Der Datenkontext kann ein komplexes Diagramm von Objekten sein, solange sie mit den DataContract Und-Attributen DataMember gekennzeichnet sind und INotifyPropertyChanged bei Bedarf implementieren. Es ist auch möglich, feststellbare Sammlungen oder ein ObservableList<T> zu haben, bei dem es sich um ein erweitertes ObservableCollection<T> handelt, das von remote UI bereitgestellt wird, um auch Bereichsvorgänge zu unterstützen und eine bessere Leistung zu ermöglichen.

Außerdem müssen wir dem Datenkontext einen Befehl hinzufügen. In remote UI implementieren IAsyncCommand Befehle, aber häufig ist es einfacher, eine Instanz der AsyncCommand Klasse zu erstellen.

IAsyncCommand unterscheidet sich von ICommand zwei Arten:

  • Die Execute Methode wird ersetzt, ExecuteAsync da alles in der Remote-UI asynchron ist!
  • Die CanExecute(object) Methode wird durch eine CanExecute Eigenschaft ersetzt. Die AsyncCommand Klasse kümmert sich darum, observierbar zu machen CanExecute .

Es ist wichtig zu beachten, dass die Remote-Benutzeroberfläche keine Ereignishandler unterstützt. Daher müssen alle Benachrichtigungen von der Benutzeroberfläche an die Erweiterung über Datenbindung und Befehle implementiert werden.

Dies ist der resultierende Code für 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; }
}

Korrigieren Sie den MyToolWindowContent Konstruktor:

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

Aktualisieren, MyToolWindowContent.xaml um die neuen Eigenschaften im Datenkontext zu verwenden. Dies ist alles normale WPF-XAML. Auch auf das IAsyncCommand Objekt wird über einen Proxy zugegriffen, der im Visual Studio-Prozess aufgerufen ICommand wird, sodass es wie gewohnt datengebunden werden kann.

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

Grundlegendes zur Asynchronität in der Remote-UI

Die gesamte Remote-UI-Kommunikation für dieses Toolfenster führt die folgenden Schritte aus:

  1. Auf den Datenkontext wird über einen Proxy innerhalb des Visual Studio-Prozesses mit seinem ursprünglichen Inhalt zugegriffen,

  2. Das erstellte MyToolWindowContent.xaml Steuerelement ist Daten, die an den Datenkontextproxy gebunden sind,

  3. Der Benutzer gibt Text in das Textfeld ein, der der Name Eigenschaft des Datenkontextproxys über die Datenbindung zugewiesen ist. Der neue Wert wird Name an das MyToolWindowData Objekt weitergegeben.

  4. Der Benutzer klickt auf die Schaltfläche, was zu einer Weitergabe von Effekten führt:

    • der HelloCommand Im Datenkontextproxy ausgeführt wird
    • Die asynchrone Ausführung des Erweiterungscodes AsyncCommand wird gestartet.
    • der asynchrone Rückruf zum HelloCommand Aktualisieren des Werts der observable-Eigenschaft Text
    • der neue Wert, der Text an den Datenkontextproxy weitergegeben wird
    • Der Textblock im Toolfenster wird auf den neuen Wert der Text Datenbindung aktualisiert.

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

Verwenden von Befehlsparametern, um Rennbedingungen zu vermeiden

Alle Vorgänge, die die Kommunikation zwischen Visual Studio und der Erweiterung (blaue Pfeile im Diagramm) umfassen, sind asynchron. Es ist wichtig, diesen Aspekt im Gesamtentwurf der Erweiterung zu berücksichtigen.

Aus diesem Grund ist es besser, Befehlsparameter anstelle von bidirektionalem Binden zu verwenden, um den Datenkontextstatus zum Zeitpunkt der Ausführung eines Befehls abzurufen, wenn die Konsistenz wichtig ist.

Nehmen Sie diese Änderung vor, indem Sie die Schaltfläche CommandParameter an Namefolgendes binden:

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

Ändern Sie dann den Rückruf des Befehls so, dass er den Parameter verwendet:

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

Bei diesem Ansatz wird der Wert der Name Eigenschaft synchron vom Datenkontextproxy zum Zeitpunkt des Klickens auf die Schaltfläche abgerufen und an die Erweiterung gesendet. Dies vermeidet rennbezogene Bedingungen, insbesondere, wenn der HelloCommand Rückruf in Zukunft geändert wird, um Ausbeute zu erzielen (ausdrücke haben await ).

Asynchrone Befehle nutzen Daten aus mehreren Eigenschaften

Die Verwendung eines Befehlsparameters ist keine Option, wenn der Befehl mehrere Eigenschaften nutzen muss, die vom Benutzer festgelegt werden können. Wenn beispielsweise auf der Benutzeroberfläche zwei Textfelder vorhanden sind: "Vorname" und "Nachname".

Die Lösung in diesem Fall besteht darin, im asynchronen Befehlsrückruf den Wert aller Eigenschaften aus dem Datenkontext abzurufen, bevor sie zurückgegeben werden.

Unten sehen Sie ein Beispiel, in dem die FirstName Werte und LastName Eigenschaften abgerufen werden, bevor Sie sicherstellen, dass der Wert zum Zeitpunkt des Befehlsaufrufs verwendet wird:

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

Außerdem ist es wichtig, dass die Erweiterung den Wert von Eigenschaften asynchron aktualisiert, die auch vom Benutzer aktualisiert werden können. Mit anderen Worten: Vermeiden Sie die TwoWay-Datenbindung .

Die hier aufgeführten Informationen sollten ausreichen, um einfache Remote-UI-Komponenten zu erstellen. Weitere erweiterte Szenarien finden Sie unter Advanced Remote UI concepts.