为什么选择远程 UI

VisualStudio.Extensibility 模型的主要目标之一是允许扩展在 Visual Studio 进程外部运行。 这为向扩展添加 UI 支持带来了障碍,因为大多数 UI 框架都在处理中。

远程 UI 是一组类,可用于在进程外扩展中定义 WPF 控件,并将其作为 Visual Studio UI 的一部分显示。

远程 UI 主要依赖于 XAML 和数据绑定、命令(而不是事件)和触发器(而不是与代码隐藏中的逻辑树交互)的 Model-View-ViewModel 设计模式。

虽然远程 UI 是为支持进程外扩展而开发的,但依赖于远程 UI 的 VisualStudio.Extensibility API 也会将远程 UI ToolWindow用于进程内扩展。

远程 UI 和普通 WPF 开发之间的主要区别包括:

  • 大多数远程 UI 操作(包括绑定到数据上下文和命令执行)都是异步的。
  • 定义要在远程 UI 数据上下文中使用的数据类型时,必须用 DataContractDataMember 属性修饰它们。
  • 远程 UI 不允许引用自己的自定义控件。
  • 远程用户控件在引用单个(但可能复杂且嵌套)的数据上下文对象的单个 XAML 文件中完全定义。
  • 远程 UI 不支持代码隐藏或事件处理程序(高级远程 UI 概念文档中介绍了解决方法)。
  • 远程用户控件在 Visual Studio 进程中实例化,而不是托管扩展的进程:XAML 无法从扩展引用类型和程序集,但可以从 Visual Studio 进程引用类型和程序集。

创建远程 UI Hello World 扩展

首先创建最基本的远程 UI 扩展。 按照创建第一个进程外 Visual Studio 扩展的说明进行操作。

现在应该有一个包含单个命令的工作扩展,下一步是添加 a ToolWindow 和 a RemoteUserControl. RemoteUserControl是 WPF 用户控件的远程 UI 等效项。

最终将包含四个文件:

  1. 打开 .cs 工具窗口的命令的文件,
  2. 提供 .cs Visual Studio 的文件ToolWindowRemoteUserControl
  3. 引用 .cs 其 XAML 定义的文件 RemoteUserControl
  4. 的一个 .xaml 文件 RemoteUserControl

稍后,为 MVVM 模式中的 ViewModel 添加一个数据上下文RemoteUserControl

更新命令

更新命令的代码,以使用 ShowToolWindowAsync以下命令显示工具窗口:

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

还可以考虑更改 CommandConfigurationstring-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 定义是描述 a 的 DataTemplate普通 WPF XAML。 此 XAML 将发送到 Visual Studio,用于填充工具窗口内容。 我们将特殊命名空间(xmlns 属性)用于远程 UI XAML: http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml

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

添加对主题的支持

最好编写 UI,请记住 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 UI 的其余部分相同的主题,并在用户切换到深色模式时自动更改颜色:

Screenshot showing themed tool window.

此处,该 xmlns 属性引用 Microsoft.VisualStudio.Shell.15.0 程序集,该程序集不是扩展依赖项之一。 这很好,因为 Visual Studio 进程使用此 XAML,该进程依赖于 Shell.15,而不是扩展本身。

为了获得更好的 XAML 编辑体验,可以 暂时 将 A PackageReference 添加到 Microsoft.VisualStudio.Shell.15.0 扩展项目。 不要忘记稍后将其删除 ,因为进程外 VisualStudio.Extensibility 扩展插件不应引用此包!

添加数据上下文

为远程用户控件添加数据上下文类:

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

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

并更新 MyToolWindowContent.csMyToolWindowContent.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.

此处的数据上下文类型标记有 DataContract 属性和 DataMember 属性。 这是因为实例 MyToolWindowData 存在于扩展主机进程中,而从 MyToolWindowContent.xaml Visual Studio 进程中创建的 WPF 控件存在。 若要使数据绑定正常工作,远程 UI 基础结构会在 Visual Studio 进程中生成对象的 MyToolWindowData 代理。 属性DataContractDataMember指示哪些类型和属性与数据绑定相关,应在代理中副本 (replica)。

远程用户控件的数据上下文作为类的RemoteUserControl构造函数参数传递:该属性是只读的RemoteUserControl.DataContext 这并不意味着整个数据上下文是不可变的,但无法替换远程用户控件根数据上下文对象。 在下一部分中,我们将使 MyToolWindowData 可变和可观察。

远程用户控件的生命周期

可以重写 ControlLoadedAsync 在 WPF 容器中首次加载控件时要通知的方法。 如果在实现中,数据上下文的状态可能会独立于 UI 事件更改,则 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 来观察数据上下文。 或者,远程 UI 提供了一个方便的抽象类, NotifyPropertyChangedObject我们可以扩展这些类来减少样本代码。

数据上下文通常混合使用只读属性和可观察属性。 数据上下文可以是一个复杂的对象图,只要它们被标记有 DataContract 属性, DataMember 并根据需要实现 INotifyPropertyChanged 。 也可以具有可观测集合或 ObservableList<T>,这是远程 UI 提供的扩展 ObservableCollection<T> ,也支持范围操作,从而提供更好的性能。

我们还需要向数据上下文添加命令。 在远程 UI 中,命令实现 IAsyncCommand ,但通常更容易创建类的 AsyncCommand 实例。

IAsyncCommand 不同于 ICommand 以下两种方式:

  • 此方法 ExecuteExecuteAsync 替换为,因为远程 UI 中的所有内容都是异步的!
  • 该方法 CanExecute(object)CanExecute 属性替换。 该 AsyncCommand 类负责使 CanExecute 可观测。

请务必注意,远程 UI 不支持事件处理程序,因此必须通过数据绑定和命令实现从 UI 到扩展的所有通知。

这是以下代码的结果代码 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 以在数据上下文中使用新属性。 这是所有正常的 WPF XAML。 IAsyncCommand即使是通过 Visual Studio 进程中调用ICommand的代理访问对象,也可以像往常一样进行数据绑定。

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

了解远程 UI 中的异步性

此工具窗口的整个远程 UI 通信遵循以下步骤:

  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 表达式) 时。

异步命令使用来自多个属性的数据

如果命令需要使用用户可设置的多个属性,则使用命令参数不是选项。 例如,如果 UI 有两个文本框:“名字”和“姓氏”。

在这种情况下,解决方案是在异步命令回调中检索数据上下文中所有属性的值,然后再生成。

下面可以看到一个示例,其中 FirstName 在生成命令调用时检索属性值和 LastName 属性值,以确保使用命令调用时的值:

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

此外,请务必避免扩展异步更新用户也可以更新的属性值。 换句话说,请避免 使用 TwoWay 数据绑定。

此处的信息应该足以生成简单的远程 UI 组件。 有关更高级的方案,请参阅 高级远程 UI 概念