Samouczek: zaawansowany zdalny interfejs użytkownika

Z tego samouczka dowiesz się więcej o zaawansowanych pojęciach zdalnego interfejsu użytkownika przez przyrostowe modyfikowanie okna narzędzia zawierającego listę losowych kolorów:

Screenshot showing random colors tool window.

Dowiesz się więcej o:

  • Jak wiele poleceń asynchronicznych może być uruchamianych równolegle i jak wyłączyć elementy interfejsu użytkownika, gdy polecenie jest uruchomione.
  • Jak powiązać wiele przycisków z tym samym poleceniem asynchronicznym.
  • Sposób obsługi typów odwołań w kontekście danych interfejsu użytkownika zdalnego i jego serwera proxy.
  • Jak używać polecenia asynchronicznego jako procedury obsługi zdarzeń.
  • Jak wyłączyć pojedynczy przycisk, gdy wywołanie zwrotne polecenia asynchronicznego jest wykonywane, jeśli wiele przycisków jest powiązanych z tym samym poleceniem.
  • Jak używać typów WPF, takich jak złożone pędzle, w kontekście danych zdalnego interfejsu użytkownika.
  • Jak zdalny interfejs użytkownika obsługuje wątki.

Ten samouczek jest oparty na artykule wprowadzającym zdalny interfejs użytkownika i oczekuje, że masz działające rozszerzenie VisualStudio.Extensibility, w tym:

  1. .cs plik polecenia, który otwiera okno narzędzia,
  2. MyToolWindow.cs plik dla ToolWindow klasy,
  3. MyToolWindowContent.cs plik dla RemoteUserControl klasy,
  4. MyToolWindowContent.xaml osadzony plik zasobu dla RemoteUserControl definicji xaml,
  5. MyToolWindowData.cs plik dla kontekstu danych .RemoteUserControl

Aby rozpocząć, zaktualizuj MyToolWindowContent.xaml widok listy i przycisk":

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

Następnie zaktualizuj klasę MyToolWindowData.cskontekstu danych :

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

W tym kodzie znajduje się kilka godnych uwagi elementów:

  • MyColor.Color jest elementem string , ale jest używany jako Brush element, gdy dane powiązane w języku XAML, jest to funkcja zapewniana przez WPF.
  • Wywołanie AddColorCommand zwrotne asynchroniczne zawiera 2-sekundowe opóźnienie w celu symulowania długotrwałej operacji.
  • Używamy funkcji ObservableList<T>, która jest rozszerzoną funkcją ObservableCollection<T> dostarczaną przez zdalny interfejs użytkownika, aby obsługiwać również operacje zakresu, co zapewnia lepszą wydajność.
  • MyToolWindowData i MyColor nie implementuj elementu INotifyPropertyChanged , ponieważ w tej chwili wszystkie właściwości są tylko do odczytu.

Obsługa długotrwałych poleceń asynchronicznych

Jedną z najważniejszych różnic między zdalnym interfejsem użytkownika a normalnym WPF jest to, że wszystkie operacje obejmujące komunikację między interfejsem użytkownika a rozszerzeniem są asynchroniczne.

Polecenia asynchroniczne , takie jak AddColorCommand to jawne, udostępniając asynchroniczne wywołanie zwrotne.

Efekt tego można zobaczyć, jeśli klikniesz przycisk Dodaj kolor wiele razy w krótkim czasie: ponieważ każde wykonanie polecenia trwa 2 sekundy, wiele wykonań równolegle, a wiele kolorów pojawi się na liście razem, gdy opóźnienie 2 sekundy się skończyło. Może to dać użytkownikowi wrażenie, że przycisk Dodaj kolor nie działa.

Diagram of overlapped async command execution.

Aby rozwiązać ten problem, wyłącz przycisk podczas wykonywania polecenia asynchronicznego. Najprostszym sposobem wykonania tej czynności jest po prostu ustawienie CanExecute polecenia na wartość 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;
    }
});

To rozwiązanie nadal ma niedoskonałą synchronizację, ponieważ gdy użytkownik kliknie przycisk, wywołanie zwrotne polecenia jest wykonywane asynchronicznie w rozszerzeniu, wywołanie zwrotne ustawia CanExecute wartość false, która jest następnie propagowana asynchronicznie do kontekstu danych serwera proxy w procesie programu Visual Studio, co powoduje wyłączenie przycisku. Użytkownik może dwukrotnie kliknąć przycisk w krótkim odstępie czasu, zanim przycisk zostanie wyłączony.

Lepszym rozwiązaniem jest użycie RunningCommandsCount właściwości poleceń asynchronicznych:

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

RunningCommandsCount to licznik liczby współbieżnych wykonań asynchronicznych polecenia, które są obecnie w toku. Ten licznik jest zwiększany w wątku interfejsu użytkownika po kliknięciu przycisku, co pozwala synchronicznie wyłączyć przycisk przez powiązanie IsEnabled z RunningCommandsCount.IsZero.

Ponieważ wszystkie polecenia zdalnego interfejsu użytkownika są wykonywane asynchronicznie, najlepszym rozwiązaniem jest zawsze używanie RunningCommandsCount.IsZero do wyłączania kontrolek w razie potrzeby, nawet jeśli polecenie ma zostać wykonane szybko.

Polecenia asynchroniczne i szablony danych

W tej sekcji zaimplementujesz przycisk Usuń , który umożliwia użytkownikowi usunięcie wpisu z listy. Możemy utworzyć jedno polecenie asynchroniczne dla każdego MyColor obiektu lub możemy mieć jedno polecenie asynchroniczne w MyToolWindowData programie i użyć parametru do określenia, który kolor należy usunąć. Ta ostatnia opcja jest czystszą konstrukcją, więc zaimplementujmy to.

  1. Zaktualizuj przycisk XAML w szablonie danych:
<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. Dodaj odpowiadający element AsyncCommand :MyToolWindowData
[DataMember]
public AsyncCommand RemoveColorCommand { get; }
  1. Ustaw asynchroniczne wywołanie zwrotne polecenia w konstruktorze MyToolWindowDatapolecenia :
RemoveColorCommand = new AsyncCommand(async (parameter, ancellationToken) =>
{
    await Task.Delay(TimeSpan.FromSeconds(2));

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

Ten kod używa klasy a Task.Delay do symulowania długotrwałego wykonywania polecenia asynchronicznego.

Typy odwołań w kontekście danych

W poprzednim kodzie MyColor obiekt jest odbierany jako parametr polecenia asynchronicznego i używany jako parametr List<T>.Remove wywołania, który stosuje równość odwołań (ponieważ MyColor jest typem odwołania, który nie zastępuje Equals) w celu zidentyfikowania elementu do usunięcia. Jest to możliwe, ponieważ nawet jeśli parametr jest odbierany z interfejsu użytkownika, dokładne wystąpienie MyColor , które jest obecnie częścią kontekstu danych, jest odbierane, a nie kopia.

Procesy

  • serwer proxy kontekstu danych zdalnego sterowania użytkownikami;
  • wysyłanie INotifyPropertyChanged aktualizacji z rozszerzenia do programu Visual Studio lub na odwrót;
  • wysyłanie obserwowanych aktualizacji kolekcji z rozszerzenia do programu Visual Studio lub na odwrót;
  • wysyłanie parametrów polecenia asynchronicznego

wszystkie honoruje tożsamość obiektów typu odwołania. Z wyjątkiem ciągów obiekty typu odwołania nigdy nie są duplikowane po przeniesieniu z powrotem do rozszerzenia.

Diagram of Remote UI data binding reference types.

Na obrazie można zobaczyć, jak każdy obiekt typu odwołania w kontekście danych (polecenia, kolekcja, każdy MyColor , a nawet cały kontekst danych) jest przypisany unikatowy identyfikator przez infrastrukturę zdalnego interfejsu użytkownika. Gdy użytkownik kliknie przycisk Usuń dla obiektu koloru serwera proxy #5, unikatowy identyfikator (#5), a nie wartość obiektu, zostanie odesłany do rozszerzenia. Infrastruktura zdalnego interfejsu użytkownika zajmuje się pobieraniem odpowiedniego MyColor obiektu i przekazywaniem go jako parametru do wywołania zwrotnego polecenia asynchronicznego.

RunningCommandsCount z wieloma powiązaniami i obsługą zdarzeń

Jeśli w tym momencie przetestujesz rozszerzenie, zwróć uwagę, że po kliknięciu jednego z przycisków Usuń wszystkie przyciski Usuń są wyłączone:

Diagram of async Command with multiple bindings.

Może to być pożądane zachowanie. Załóżmy jednak, że chcesz wyłączyć tylko bieżący przycisk i zezwolić użytkownikowi na kolejkowanie wielu kolorów do usunięcia: nie możemy użyć właściwości poleceniaRunningCommandsCount asynchronicznego, ponieważ mamy jedno polecenie współużytkowane między wszystkimi przyciskami.

Możemy osiągnąć nasz cel, dołączając RunningCommandsCount właściwość do każdego przycisku, aby mieć oddzielny licznik dla każdego koloru. Te funkcje są udostępniane przez przestrzeń nazw, która umożliwia korzystanie z typów zdalnego interfejsu http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml użytkownika z języka XAML:

Zmienimy przycisk Usuń na następujące:

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

Dołączona vs:ExtensibilityUICommands.EventHandlers właściwość umożliwia przypisywanie poleceń asynchronicznych do dowolnego zdarzenia (na przykład MouseRightButtonUp) i może być przydatne w bardziej zaawansowanych scenariuszach.

vs:EventHandler może również mieć wartość CounterTarget: , UIElement do której vs:ExtensibilityUICommands.RunningCommandsCount należy dołączyć właściwość, zliczając aktywne wykonania związane z tym konkretnym zdarzeniem. Pamiętaj, aby użyć nawiasów (na przykład Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero) podczas tworzenia powiązania z dołączoną właściwością.

W tym przypadku użyjemy polecenia vs:EventHandler , aby dołączyć do każdego przycisku własny oddzielny licznik aktywnych wykonań poleceń. IsEnabled Powiązanie z dołączoną właściwością spowoduje wyłączenie tylko tego konkretnego przycisku po usunięciu odpowiedniego koloru:

Diagram of async Command with targeted RunningCommandsCount.

Używanie typów WPF w kontekście danych

Do tej pory kontekst danych zdalnego sterowania użytkownika składał się z elementów pierwotnych (liczb, ciągów itp.), obserwowalnych kolekcji i naszych własnych klas oznaczonych jako DataContract. Czasami przydatne jest uwzględnienie prostych typów WPF w kontekście danych, takich jak złożone pędzle.

Ponieważ rozszerzenie VisualStudio.Extensibility może nawet nie być uruchamiane w procesie programu Visual Studio, nie może udostępniać obiektów WPF bezpośrednio za pomocą interfejsu użytkownika. Rozszerzenie może nawet nie mieć dostępu do typów WPF, ponieważ może być przeznaczone netstandard2.0 lub net6.0 (a nie wariant).-windows

Zdalny interfejs użytkownika udostępnia XamlFragment typ, który umożliwia uwzględnienie definicji XAML obiektu WPF w kontekście danych zdalnego sterowania użytkownikami:

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

Po powyższym Color kodzie wartość właściwości jest konwertowana na LinearGradientBrush obiekt na serwerze proxy kontekstu danych: Screenshot showing WPF types in data context

Zdalny interfejs użytkownika i wątki

Asynchroniczne wywołania zwrotne poleceń (i INotifyPropertyChanged wywołania zwrotne dla wartości zaktualizowanych przez interfejs użytkownika za pośrednictwem oferty danych) są wywoływane w wątkach puli wątków losowych. Wywołania zwrotne są wywoływane pojedynczo i nie nakładają się, dopóki kod nie zwróci kontroli (przy użyciu await wyrażenia).

To zachowanie można zmienić, przekazując element NonConcurrentSynchronizationContext do konstruktora RemoteUserControl . W takim przypadku można użyć podanego kontekstu synchronizacji dla wszystkich poleceń asynchronicznych i INotifyPropertyChanged wywołań zwrotnych związanych z tym formantem.