Почему удаленный пользовательский интерфейс

Одной из основных целей модели VisualStudio.Extensibility является разрешение расширений выполняться за пределами процесса Visual Studio. Это представляет собой препятствие для добавления поддержки пользовательского интерфейса в расширения, так как большинство платформ пользовательского интерфейса являются внутрипроцессными.

Удаленный пользовательский интерфейс — это набор классов, позволяющих определять элементы управления WPF в расширении вне процесса и отображать их как часть пользовательского интерфейса Visual Studio.

Удаленный пользовательский интерфейс сильно опирается на шаблон конструктора Model-View-ViewModel , основанный на привязке XAML и данных, командах (вместо событий) и триггерах (вместо взаимодействия с логическим деревом из кода позади кода).

Хотя удаленный пользовательский интерфейс был разработан для поддержки расширений вне процесса, API-интерфейсы VisualStudio.Extensibility, использующие удаленный пользовательский интерфейс, например ToolWindow, будут использовать удаленный пользовательский интерфейс для расширений внутри процесса.

Основными различиями между удаленным пользовательским интерфейсом и обычной разработкой WPF являются:

  • Большинство операций удаленного пользовательского интерфейса, включая привязку к контексту данных и выполнению команд, являются асинхронными.
  • При определении типов данных, используемых в контекстах данных удаленного пользовательского интерфейса, они должны быть украшены DataContract атрибутами и DataMember атрибутами.
  • Удаленный пользовательский интерфейс не позволяет ссылаться на собственные пользовательские элементы управления.
  • Удаленный пользовательский элемент управления полностью определен в одном XAML-файле, который ссылается на один (но потенциально сложный и вложенный) объект контекста данных.
  • Удаленный пользовательский интерфейс не поддерживает код позади или обработчики событий (обходные пути описаны в документе о расширенных концепциях удаленного пользовательского интерфейса).
  • Удаленный пользовательский элемент управления создается в процессе Visual Studio, а не в процессе размещения расширения: XAML не может ссылаться на типы и сборки из расширения, но может ссылаться на типы и сборки из процесса Visual Studio.

Создание расширения Hello World для удаленного пользовательского интерфейса

Начните с создания самого базового расширения удаленного пользовательского интерфейса. Следуйте инструкциям по созданию первого внепроцессного расширения Visual Studio.

Теперь у вас должно быть рабочее расширение с одной командой, следующим шагом является добавление ToolWindow и расширение RemoteUserControl. Это RemoteUserControl эквивалент удаленного пользовательского интерфейса пользовательского элемента управления WPF.

В конечном итоге вы получите четыре файла:

  1. .cs файл для команды, открывающей окно средства,
  2. .cs файл, ToolWindow предоставляющий RemoteUserControl Visual Studio,
  3. .cs файл, ссылающийся на RemoteUserControl определение XAML,
  4. .xaml файл для RemoteUserControl.

Далее вы добавите контекст данных для объекта RemoteUserControl, который представляет ViewModel в шаблоне MVVM.

Обновление команды

Обновите код команды, чтобы отобразить окно инструментов с помощью ShowToolWindowAsync:

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

Вы также можете рассмотреть возможность изменения CommandConfiguration и string-resources.json более подходящего отображаемого сообщения и размещения:

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

Создание окна средства

Создайте файл MyToolWindow.cs и определите MyToolWindow расширение ToolWindowкласса.

Этот GetContentAsync метод должен вернуть IRemoteUserControl значение, которое вы определите на следующем шаге. Так как удаленный пользовательский элемент управления является удаленным, заботьтесь о его удалении, переопределяя Dispose(bool) метод.

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

Создание удаленного пользовательского элемента управления

Выполните это действие в трех файлах:

Класс удаленного управления пользователем

Класс удаленного элемента управления пользователем с именем MyToolWindowContentпрост:

namespace MyToolWindowExtension;

using Microsoft.VisualStudio.Extensibility.UI;

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

Контекст данных еще не нужен, поэтому его null можно задать сейчас.

Класс, расширяющийся RemoteUserControl автоматически, использует внедренный ресурс XAML с тем же именем. Если вы хотите изменить это поведение, переопределите GetXamlAsync метод.

Определение XAML

Теперь создайте файл с именем 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>

Как описано ранее, этот файл должен иметь то же имя, что и класс удаленного пользовательского элемента управления . Чтобы быть точным, полное имя расширения класса RemoteUserControl должно соответствовать имени внедренного ресурса. Например, если полное имя класса удаленного пользовательского элемента управления имеет значениеMyToolWindowExtension.MyToolWindowContent, внедренное имя ресурса должно бытьMyToolWindowExtension.MyToolWindowContent.xaml. По умолчанию внедренные ресурсы назначают имя, состоящее из корневого пространства имен для проекта, любой вложенный путь, в который они могут находиться, и их имя файла. Это может создать проблемы, если класс удаленного пользовательского элемента управления использует пространство имен, отличное от корневого пространства имен проекта, или если файл XAML не находится в корневой папке проекта. При необходимости можно принудительно принудить имя внедренного ресурса с помощью тега LogicalName :

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

Определение XAML удаленного пользовательского элемента управления — это обычный КОД WPF XAML, описывающий объект DataTemplate. Этот XAML отправляется в Visual Studio и используется для заполнения содержимого окна инструментов. Для XAML http://schemas.microsoft.com/visualstudio/extensibility/2022/xamlудаленного пользовательского интерфейса используется специальное пространство имен (xmlnsатрибут).

Установка XAML в качестве внедренного ресурса

Наконец, откройте .csproj файл и убедитесь, что XAML-файл рассматривается как внедренный ресурс:

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

Вы также можете изменить целевую платформу для расширения, net6.0net6.0-windows чтобы улучшить автозавершение в XAML-файле.

Тестирование расширения

Теперь вы сможете нажать клавишу F5 для отладки расширения.

Screenshot showing menu and tool window.

Добавление поддержки тем

Рекомендуется написать пользовательский интерфейс с учетом того, что Visual Studio может быть темами, в результате чего используются различные цвета.

Обновите XAML, чтобы использовать стили и цвета, используемые в 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>

Теперь метка использует ту же тему, что и остальная часть пользовательского интерфейса Visual Studio, и автоматически изменяет цвет при переходе пользователя в темный режим:

Screenshot showing themed tool window.

xmlns Здесь атрибут ссылается на сборку Microsoft.VisualStudio.Shell.15.0, которая не является одной из зависимостей расширения. Это хорошо, так как этот XAML используется процессом Visual Studio, который имеет зависимость от Shell.15, а не самого расширения.

Чтобы улучшить возможности редактирования XAML, можно временно добавить в PackageReferenceMicrosoft.VisualStudio.Shell.15.0 проект расширения. Не забудьте удалить его позже, так как расширение VisualStudio.Extensibility не должно ссылаться на этот пакет!

Добавление контекста данных

Добавьте класс контекста данных для удаленного пользовательского элемента управления:

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

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

и обновите MyToolWindowContent.cs и MyToolWindowContent.xaml используйте его:

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

Содержимое метки теперь устанавливается с помощью привязки данных:

Screenshot showing tool window with data binding.

Тип контекста данных здесь отмечен и DataContractDataMember атрибутами. Это связано с тем, что MyToolWindowData экземпляр существует в процессе узла расширения, а элемент управления WPF, созданный из MyToolWindowContent.xaml существующего в процессе Visual Studio. Чтобы сделать привязку данных работой, инфраструктура удаленного пользовательского MyToolWindowData интерфейса создает прокси-сервер объекта в процессе Visual Studio. Атрибуты DataContract указываютDataMember, какие типы и свойства относятся к привязке данных и должны быть реплика в прокси-сервере.

Контекст данных удаленного пользовательского элемента управления передается в качестве параметра конструктора RemoteUserControl класса: RemoteUserControl.DataContext свойство доступно только для чтения. Это не означает, что весь контекст данных неизменяем, но корневой объект контекста данных удаленного пользовательского элемента управления нельзя заменить. В следующем разделе мы сделаем MyToolWindowData изменяемые и наблюдаемые.

Жизненный цикл удаленного пользовательского элемента управления

Метод можно переопределить ControlLoadedAsync , чтобы получать уведомления при первой загрузке элемента управления в контейнер WPF. Если в реализации состояние контекста данных может изменяться независимо от событий пользовательского интерфейса, ControlLoadedAsync метод является правильным местом для инициализации содержимого контекста данных и начала применения изменений к нему.

Вы также можете переопределить Dispose метод, который будет уведомляться при уничтожении элемента управления и больше не будет использоваться.

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

Команды, наблюдаемость и двусторонняя привязка данных

Затем давайте сделаем контекст данных наблюдаемым и добавим кнопку на панель элементов.

Контекст данных можно сделать наблюдаемым, реализуя INotifyPropertyChanged. Кроме того, удаленный пользовательский интерфейс предоставляет удобный абстрактный класс, NotifyPropertyChangedObjectкоторый можно расширить, чтобы уменьшить стандартный код.

Контекст данных обычно имеет сочетание свойств чтения и наблюдаемых свойств. Контекст данных может быть сложным графом объектов, если они помечены атрибутами DataContract и DataMember реализуют INotifyPropertyChanged по мере необходимости. Также можно иметь наблюдаемые коллекции или объект ObservableList<T, который является расширенным методом ObservableCollection<T>>, предоставляемым удаленным пользовательским интерфейсом, для поддержки операций диапазона, что позволяет повысить производительность.

Кроме того, необходимо добавить команду в контекст данных. В удаленном пользовательском интерфейсе команды реализуются IAsyncCommand , но часто проще создать экземпляр AsyncCommand класса.

IAsyncCommand Отличается от ICommand двух способов:

  • Метод Execute заменяется, ExecuteAsync так как все в удаленном пользовательском интерфейсе асинхронно!
  • Метод CanExecute(object) заменяется свойством CanExecute . Класс AsyncCommand заботится о том, чтобы сделать CanExecute наблюдаемым.

Важно отметить, что удаленный пользовательский интерфейс не поддерживает обработчики событий, поэтому все уведомления из пользовательского интерфейса в расширение должны быть реализованы с помощью привязки данных и команд.

Это результирующий код для 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 :

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

Обновите MyToolWindowContent.xaml новые свойства в контексте данных. Это все нормальное XAML WPF. IAsyncCommand Даже к объекту осуществляется доступ через прокси-сервер, вызываемый ICommand в процессе 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}}" />
            <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.

Общие сведения об асинхронности в удаленном пользовательском интерфейсе

Вся связь с удаленным пользовательским интерфейсом для этого окна инструментов выполняет следующие действия.

  1. Контекст данных осуществляется через прокси-сервер в процессе Visual Studio с исходным содержимым.

  2. Элемент управления, созданный из MyToolWindowContent.xaml данных, привязан к прокси-серверу контекста данных,

  3. Пользователь вводит некоторый текст в текстовом поле, который назначается Name свойству прокси-сервера контекста данных через привязку данных. Новое значение Name распространяется на MyToolWindowData объект.

  4. Пользователь нажимает кнопку, вызывающую каскад эффектов:

    • HelloCommand Выполняется прокси-сервер контекста данных
    • запускается асинхронное выполнение кода расширителя AsyncCommand
    • асинхронный обратный вызов для HelloCommand обновления значения наблюдаемого свойства Text
    • новое значение Text распространяется на прокси-сервер контекста данных
    • Текстовый блок в окне инструмента обновляется до нового значения привязки Text данных.

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

Использование параметров команды для предотвращения условий гонки

Все операции, связанные с взаимодействием между Visual Studio и расширением (синие стрелки на схеме), являются асинхронными. Важно учитывать этот аспект в общей структуре расширения.

По этой причине, если согласованность важна, лучше использовать параметры команд вместо двусторонней привязки, чтобы получить состояние контекста данных во время выполнения команды.

Внесите это изменение, привязав кнопку CommandParameter к Name:

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

Затем измените обратный вызов команды, чтобы использовать параметр:

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

При таком подходе значение Name свойства извлекается синхронно из прокси-сервера контекста данных во время нажатия кнопки и отправляется в расширение. Это позволяет избежать каких-либо условий гонки, особенно если HelloCommand обратный вызов изменяется в будущем, чтобы получить (имеют await выражения).

Асинхронные команды используют данные из нескольких свойств

Использование параметра команды не является параметром, если команда должна использовать несколько свойств, которые задаются пользователем. Например, если в пользовательском интерфейсе есть два текстовых поля: "Имя" и "Фамилия".

Решением в этом случае является получение в асинхронном вызове команды значение всех свойств из контекста данных перед получением.

Ниже приведен пример, в котором FirstName извлекаются значения свойств и LastName значения свойств перед получением, чтобы убедиться, что значение во время вызова команды используется:

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

Также важно избежать асинхронного обновления расширения значения свойств, которые также могут быть обновлены пользователем. Другими словами, избегайте привязки данных TwoWay .

Сведения здесь должны быть достаточно для создания простых компонентов удаленного пользовательского интерфейса. Дополнительные сценарии см. в разделе "Дополнительные понятия удаленного пользовательского интерфейса".