Dlaczego zdalny interfejs użytkownika

Jednym z głównych celów modelu rozszerzenia VisualStudio.Extensibility jest umożliwienie uruchamiania rozszerzeń poza procesem programu Visual Studio. Stanowi to przeszkodę dla dodawania obsługi interfejsu użytkownika do rozszerzeń, ponieważ większość struktur interfejsu użytkownika jest w toku.

Zdalny interfejs użytkownika to zestaw klas, które umożliwiają definiowanie kontrolek WPF w rozszerzeniu poza procesem i wyświetlanie ich w ramach interfejsu użytkownika programu Visual Studio.

Zdalny interfejs użytkownika opiera się mocno na wzorcu projektowania Model-View-ViewModel opartym na języku XAML i powiązaniu danych, poleceniach (zamiast zdarzeń) i wyzwalaczach (zamiast wchodzić w interakcje z drzewem logicznym z kodu za pomocą kodu ).

Chociaż interfejs użytkownika zdalnego został opracowany w celu obsługi rozszerzeń poza procesem, interfejsy API programu VisualStudio.Extensibility, które opierają się na zdalnym interfejsie użytkownika, na przykład ToolWindow, będą używać zdalnego interfejsu użytkownika dla rozszerzeń procesów.

Główne różnice między zdalnym interfejsem użytkownika i normalnym programowaniem WPF są następujące:

  • Większość zdalnych operacji interfejsu użytkownika, w tym powiązania z kontekstem danych i wykonywaniem poleceń, jest asynchroniczna.
  • Podczas definiowania typów danych, które mają być używane w kontekstach danych interfejsu użytkownika zdalnego, muszą być one ozdobione atrybutami DataContract i DataMember .
  • Zdalny interfejs użytkownika nie zezwala na odwoływanie się do własnych kontrolek niestandardowych.
  • Zdalne sterowanie użytkownikami jest w pełni zdefiniowane w jednym pliku XAML odwołującym się do pojedynczego obiektu kontekstu danych (ale potencjalnie złożonego i zagnieżdżonego).
  • Zdalny interfejs użytkownika nie obsługuje kodu za pomocą programów obsługi zdarzeń ani obsługi zdarzeń (obejścia są opisane w zaawansowanym dokumencie Pojęcia dotyczące zdalnego interfejsu użytkownika).
  • Zdalne sterowanie użytkownikami jest tworzone w procesie programu Visual Studio, a nie w procesie hostowania rozszerzenia: język XAML nie może odwoływać się do typów i zestawów z rozszerzenia, ale może odwoływać się do typów i zestawów z procesu programu Visual Studio.

Tworzenie rozszerzenia Hello World zdalnego interfejsu użytkownika

Zacznij od utworzenia najbardziej podstawowego rozszerzenia zdalnego interfejsu użytkownika. Postępuj zgodnie z instrukcjami w temacie Tworzenie pierwszego rozszerzenia programu Visual Studio poza procesem.

Teraz powinno istnieć rozszerzenie robocze z jednym poleceniem, następnym krokiem jest dodanie elementu ToolWindow i .RemoteUserControl Jest RemoteUserControl to zdalny odpowiednik interfejsu użytkownika kontrolki użytkownika WPF.

Zostaną wyświetlone cztery pliki:

  1. .cs plik polecenia, który otwiera okno narzędzia,
  2. .cs plik programu ToolWindow , który udostępnia RemoteUserControl programOwi Visual Studio,
  3. .cs plik, RemoteUserControl który odwołuje się do jego definicji XAML,
  4. plik .xaml dla pliku RemoteUserControl.

Później dodasz kontekst danych dla RemoteUserControlelementu , który reprezentuje model ViewModel we wzorcu MVVM.

Aktualizowanie polecenia

Zaktualizuj kod polecenia, aby wyświetlić okno narzędzia przy użyciu polecenia ShowToolWindowAsync:

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

Możesz również rozważyć zmianę CommandConfiguration i string-resources.json w celu uzyskania bardziej odpowiedniego komunikatu wyświetlania i umieszczania:

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

Tworzenie okna narzędzi

Utwórz nowy MyToolWindow.cs plik i zdefiniuj klasę rozszerzającą MyToolWindowToolWindowelement .

Metoda GetContentAsync ma zwrócić element IRemoteUserControl , który zostanie zdefiniowany w następnym kroku. Ponieważ zdalne sterowanie użytkownikami jest jednorazowe, należy zadbać o jego usunięcie przez zastąpienie Dispose(bool) metody.

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

Tworzenie zdalnego sterowania użytkownikami

Wykonaj tę akcję w trzech plikach:

Zdalna klasa sterowania użytkownikami

Zdalna klasa sterowania użytkownika o nazwie MyToolWindowContent, jest prosta:

namespace MyToolWindowExtension;

using Microsoft.VisualStudio.Extensibility.UI;

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

Nie potrzebujesz jeszcze kontekstu danych, więc możesz ustawić go null na teraz.

Klasa rozszerzająca RemoteUserControl automatycznie używa osadzonego zasobu XAML o tej samej nazwie. Jeśli chcesz zmienić to zachowanie, zastąpij metodę GetXamlAsync .

Definicja XAML

Następnie utwórz plik o nazwie 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>

Jak opisano wcześniej, ten plik musi mieć taką samą nazwę jak klasa zdalnego sterowania użytkownikami. Aby być precyzyjnym, pełna nazwa rozszerzonej RemoteUserControl klasy musi być zgodna z nazwą zasobu osadzonego. Jeśli na przykład pełna nazwa klasy zdalnego sterowania użytkownika to MyToolWindowExtension.MyToolWindowContent, osadzona nazwa zasobu powinna mieć wartość MyToolWindowExtension.MyToolWindowContent.xaml. Domyślnie zasoby osadzone mają przypisaną nazwę składającą się z głównej przestrzeni nazw projektu, dowolnej ścieżki podfolderu, w której znajdują się, oraz nazwy pliku. Może to spowodować problemy, jeśli klasa zdalnego sterowania użytkownika korzysta z przestrzeni nazw innej niż główna przestrzeń nazw projektu lub jeśli plik xaml nie znajduje się w folderze głównym projektu. W razie potrzeby możesz wymusić nazwę zasobu osadzonego przy użyciu tagu LogicalName :

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

Definicja XAML zdalnego sterowania użytkownika jest normalną definicją WPF XAML opisującą element DataTemplate. Ten kod XAML jest wysyłany do programu Visual Studio i używany do wypełniania zawartości okna narzędzi. Używamy specjalnej przestrzeni nazw (xmlns atrybutu) dla Zdalnego interfejsu użytkownika XAML: http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml.

Ustawianie kodu XAML jako zasobu osadzonego

Na koniec otwórz .csproj plik i upewnij się, że plik XAML jest traktowany jako zasób osadzony:

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

Możesz również zmienić strukturę docelową rozszerzenia z net6.0 na net6.0-windows , aby uzyskać lepsze autouzupełnianie w pliku XAML.

Testowanie rozszerzenia

Teraz powinno być możliwe naciśnięcie klawisza F5 , aby debugować rozszerzenie.

Screenshot showing menu and tool window.

Dodawanie obsługi motywów

Dobrym pomysłem jest napisanie interfejsu użytkownika, mając na uwadze, że program Visual Studio może mieć motywy, co powoduje, że używane są różne kolory.

Zaktualizuj język XAML, aby używać stylów i kolorów używanych w programie 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>

Etykieta używa teraz tego samego motywu co reszta interfejsu użytkownika programu Visual Studio i automatycznie zmienia kolor, gdy użytkownik przełączy się do trybu ciemnego:

Screenshot showing themed tool window.

xmlns W tym miejscu atrybut odwołuje się do zestawu Microsoft.VisualStudio.Shell.15.0, który nie jest jedną z zależności rozszerzeń. Jest to w porządku, ponieważ ten kod XAML jest używany przez proces programu Visual Studio, który ma zależność od powłoki.15, a nie przez samo rozszerzenie.

Aby uzyskać lepsze środowisko edycji XAML, możesz tymczasowo dodać element PackageReference do Microsoft.VisualStudio.Shell.15.0 projektu rozszerzenia. Nie zapomnij usunąć go później, ponieważ rozszerzenie VisualStudio.Extensibility poza procesem nie powinno odwoływać się do tego pakietu!

Dodawanie kontekstu danych

Dodaj klasę kontekstu danych dla zdalnego sterowania użytkownikami:

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

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

i zaktualizuj MyToolWindowContent.cs i MyToolWindowContent.xaml użyj go:

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

Zawartość etykiety jest teraz ustawiana za pomocą powiązania danych:

Screenshot showing tool window with data binding.

Tutaj typ kontekstu danych jest oznaczony atrybutami DataContract i DataMember . Jest to spowodowane tym, że MyToolWindowData wystąpienie istnieje w procesie hosta rozszerzenia, podczas gdy kontrolka WPF utworzona na podstawie MyToolWindowContent.xaml istnieje w procesie programu Visual Studio. Aby powiązanie danych działało, infrastruktura zdalnego interfejsu użytkownika generuje serwer proxy MyToolWindowData obiektu w procesie programu Visual Studio. Atrybuty DataContract i DataMember wskazują, które typy i właściwości są istotne dla powiązania danych i powinny być replikowane na serwerze proxy.

Kontekst danych zdalnego sterowania użytkownika jest przekazywany jako parametr RemoteUserControl konstruktora klasy: RemoteUserControl.DataContext właściwość jest tylko do odczytu. Nie oznacza to, że cały kontekst danych jest niezmienny, ale nie można zamienić obiektu kontekstu danych głównych zdalnego sterowania użytkownikami. W następnej sekcji będziemy modyfikować MyToolWindowData i obserwowalny.

Cykl życia zdalnego sterowania użytkownikami

Możesz zastąpić metodę ControlLoadedAsync , aby otrzymywać powiadomienia, gdy kontrolka zostanie po raz pierwszy załadowana w kontenerze WPF. Jeśli w implementacji stan kontekstu danych może ulec zmianie niezależnie od zdarzeń interfejsu użytkownika, ControlLoadedAsync metoda jest właściwym miejscem do zainicjowania zawartości kontekstu danych i rozpoczęcia stosowania do niego zmian.

Możesz również zastąpić metodę Dispose , aby otrzymywać powiadomienia, gdy kontrolka zostanie zniszczona i nie będzie już używana.

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

Polecenia, możliwość obserwowania i dwukierunkowe powiązanie danych

Następnie przyjrzyjmy się kontekstowi danych i dodajmy przycisk do przybornika.

Kontekst danych można zaobserwować, implementując element INotifyPropertyChanged. Alternatywnie interfejs użytkownika zdalnego zapewnia wygodną klasę abstrakcyjną , NotifyPropertyChangedObjectktórą możemy rozszerzyć w celu zmniejszenia liczby kodów standardowych.

Kontekst danych zwykle ma kombinację właściwości tylko do odczytu i obserwowalnych właściwości. Kontekst danych może być złożonym grafem obiektów, o ile są one oznaczone atrybutami DataContract i DataMember i implementują element INotifyPropertyChanged w razie potrzeby. Istnieje również możliwość obserwowalnej kolekcji lub funkcji ObservableList<T>, która jest rozszerzoną funkcją ObservableCollection<T> dostarczaną przez zdalny interfejs użytkownika do obsługi operacji zakresu, co pozwala na lepszą wydajność.

Musimy również dodać polecenie do kontekstu danych. W zdalnym interfejsie użytkownika polecenia implementują IAsyncCommand , ale często łatwiej jest utworzyć wystąpienie AsyncCommand klasy.

IAsyncCommand różni się od ICommand dwóch sposobów:

  • Metoda Execute jest zastępowana ExecuteAsync , ponieważ wszystko w zdalnym interfejsie użytkownika jest asynchroniczne!
  • Metoda CanExecute(object) jest zastępowana CanExecute przez właściwość . Klasa AsyncCommand dba o obserwowanie CanExecute .

Należy pamiętać, że zdalny interfejs użytkownika nie obsługuje procedur obsługi zdarzeń, dlatego wszystkie powiadomienia z interfejsu użytkownika do rozszerzenia muszą być implementowane za pomocą powiązania danych i poleceń.

Jest to wynikowy kod dla elementu 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; }
}

MyToolWindowContent Napraw konstruktor:

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

Zaktualizuj MyToolWindowContent.xaml , aby używać nowych właściwości w kontekście danych. To wszystko jest normalne WPF XAML. IAsyncCommand Nawet obiekt jest uzyskiwany za pośrednictwem serwera proxy wywoływanego ICommand w procesie programu Visual Studio, dzięki czemu może być powiązany ze zwykłymi danymi.

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

Opis asynchroniczności w zdalnym interfejsie użytkownika

Cała zdalna komunikacja interfejsu użytkownika dla tego okna narzędzi jest następująca:

  1. Dostęp do kontekstu danych jest uzyskiwany za pośrednictwem serwera proxy w procesie programu Visual Studio z oryginalną zawartością,

  2. Kontrolka utworzona na podstawie MyToolWindowContent.xaml to dane powiązane z serwerem proxy kontekstu danych,

  3. Użytkownik wpisze jakiś tekst w polu tekstowym, który jest przypisany do Name właściwości serwera proxy kontekstu danych za pośrednictwem powiązania danych. Nowa wartość Name elementu jest propagowana do MyToolWindowData obiektu.

  4. Użytkownik klika przycisk powodujący kaskadę efektów:

    • na HelloCommand serwerze proxy kontekstu danych jest wykonywany
    • asynchroniczne wykonywanie kodu rozszerzenia AsyncCommand jest uruchamiane
    • asynchroniczne wywołanie zwrotne dla HelloCommand aktualizacji wartości obserwowalnej właściwości Text
    • nowa wartość Text jest propagowana do serwera proxy kontekstu danych
    • blok tekstowy w oknie narzędzia jest aktualizowany do nowej wartości Text za pomocą powiązania danych

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

Używanie parametrów poleceń w celu uniknięcia warunków wyścigu

Wszystkie operacje obejmujące komunikację między programem Visual Studio i rozszerzeniem (niebieskie strzałki na diagramie) są asynchroniczne. Ważne jest, aby wziąć pod uwagę ten aspekt w ogólnym projekcie rozszerzenia.

Z tego powodu, jeśli spójność jest ważna, lepiej użyć parametrów polecenia, zamiast powiązania dwukierunkowego, aby pobrać stan kontekstu danych w czasie wykonywania polecenia.

Wprowadź tę zmianę, tworząc powiązanie przycisku CommandParameter z :Name

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

Następnie zmodyfikuj wywołanie zwrotne polecenia, aby użyć parametru :

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

Dzięki temu podejściu wartość Name właściwości jest pobierana synchronicznie z serwera proxy kontekstu danych w momencie kliknięcia przycisku i wysłana do rozszerzenia. Pozwala to uniknąć wszelkich warunków wyścigu, zwłaszcza jeśli HelloCommand wywołanie zwrotne zostanie zmienione w przyszłości w celu uzyskania (mają await wyrażenia).

Polecenia asynchroniczne używają danych z wielu właściwości

Użycie parametru polecenia nie jest opcją, jeśli polecenie musi korzystać z wielu właściwości, które można ustawić przez użytkownika. Jeśli na przykład interfejs użytkownika miał dwa pola tekstowe: "Imię" i "Nazwisko".

Rozwiązaniem w tym przypadku jest pobranie wartości wszystkich właściwości z kontekstu danych przed uzyskaniem wartości wywołania zwrotnego polecenia asynchronicznego.

Poniżej przedstawiono przykład, w którym FirstName są pobierane wartości właściwości i LastName przed uzyskaniem, aby upewnić się, że wartość w momencie wywołania polecenia jest używana:

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

Ważne jest również, aby uniknąć asynchronicznego aktualizowania wartości właściwości, które mogą być również aktualizowane przez użytkownika. Innymi słowy, unikaj powiązania danych TwoWay .

Informacje w tym miejscu powinny wystarczyć do skompilowania prostych składników interfejsu użytkownika zdalnego. Aby uzyskać bardziej zaawansowane scenariusze, zobacz Advanced Remote UI concepts (Zaawansowane pojęcia dotyczące zdalnego interfejsu użytkownika).