为什么选择远程 UI

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

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

远程 UI 严重偏向于模型-视图-视图模型设计模式,该模式依赖 XAML 和数据绑定、命令(而不是事件)和触发器(而不是与代码隐藏中的逻辑树交互)。

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

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

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

创建远程 UI Hello World 扩展插件

从创建最基本的远程 UI 扩展插件开始。 按照创建第一个进程外 Visual Studio 扩展插件中的说明进行操作。

你现在应有包含单个命令的可正常运行的扩展插件,下一步是添加 ToolWindowRemoteUserControlRemoteUserControl 是等同于 WPF 用户控件的远程 UI。

最终你将拥有四个文件:

  1. 打开工具窗口的命令的 .cs 文件,
  2. .csToolWindow 文件,该文件向 Visual Studio 提供 RemoteUserControl
  3. 引用其 XAML 定义的 .csRemoteUserControl 文件,
  4. RemoteUserControl.xaml 文件。

稍后,为代表 MVVM 模式中的视图模型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 文件并定义扩展 ToolWindowMyToolWindow 类。

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>

远程用户控件的 XAML 定义是描述 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>

如前所述,XAML 文件必须采用与远程用户控件类相同的名称。 确切地说,扩展 RemoteUserControl 类的全名必须与嵌入式资源的名称匹配。 例如,如果远程用户控件类的全名为 MyToolWindowExtension.MyToolWindowContent,则嵌入式资源的名称应为 MyToolWindowExtension.MyToolWindowContent.xaml。 默认情况下,向嵌入式资源分配的名称由项目的根命名空间、可能位于的任何子文件夹路径及其文件名组成。 如果远程用户控件类使用的命名空间不同于项目的根命名空间,或者 xaml 文件不在项目的根文件夹中,则可能会造成问题。 如有必要,可以使用 LogicalName 标记强制指定嵌入式资源的名称:

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

测试扩展

现在,应该能够按下 F5 来调试扩展。

显示菜单和工具窗口的屏幕截图。

添加了对主题的支持

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

显示带主题的工具窗口的屏幕截图。

在这里,xmlns 属性引用 Microsoft.VisualStudio.Shell.15.0 程序集,该程序集并非扩展依赖项。 这是可以的,因为此 XAML 由 Visual Studio 进程(该进程依赖于 Shell.15)使用,而不是由扩展插件本身使用。

为了获得更好的 XAML 编辑体验,可以暂时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}" />

现在可通过数据绑定设置标签的内容:

显示具有数据绑定的工具窗口的屏幕截图。

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

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

可序列化类型和远程 UI 数据上下文

远程 UI 数据上下文只能包含可序列化类型,或者更确切地说,只有可序列化类型的 DataMember 属性可以进行数据绑定。

远程 UI 仅可序列化以下类型:

  • 基元数据(大多数 .NET 数值类型、枚举、boolstringDateTime
  • 使用 DataContractDataMember 属性标记的扩展程序定义类型(并且其所有数据成员也可序列化)
  • 实现 IAsyncCommand 的对象
  • XamlFragmentSolidColorBrush 对象和颜色
  • 可序列化类型的 Nullable<>
  • 可序列化类型的集合,包括可观察集合。

远程用户控件的生命周期

可以重写 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,我们可以通过扩展这些类来减少样本代码。

数据上下文通常混合使用只读属性和可观察属性。 只要使用 DataContractDataMember 属性进行标记,并且根据需要实现 INotifyPropertyChanged,数据上下文就可以是复杂的对象图。 还可以拥有可观察集合或 ObservableList<T>,这是远程 UI 提供的扩展插件 ObservableCollection<T>,也支持范围操作,从而实现性能的提升。

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

IAsyncCommand 在以下两个方面不同于 ICommand

  • Execute 方法被 ExecuteAsync 所取代,因为远程 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。 即使是通过在 Visual Studio 进程中名为 ICommand 的代理访问 IAsyncCommand 对象,也可以像往常一样进行数据绑定。

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

具有双向绑定和命令的工具窗口示意图。

了解远程 UI 中的异步性

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

  1. 数据上下文通过 Visual Studio 进程内的代理及其原始内容进行访问,

  2. MyToolWindowContent.xaml 创建的控件是绑定到数据上下文代理的数据,

  3. 用户在文本框中键入文本,这些文本通过数据绑定分配给数据上下文代理的 Name 属性。 Name 的新值会传播至 MyToolWindowData 对象。

  4. 用户单击该按钮会产生一系列效果:

    • 执行数据上下文代理中的 HelloCommand
    • 启动扩展程序 AsyncCommand 代码的异步执行
    • HelloCommand 的异步回调更新可观察属性 Text 的值
    • Text 的新值传播到数据上下文代理
    • 工具窗口中的文本块通过数据绑定更新为 Text 的新值

工具窗口双向绑定和命令通信示意图。

使用命令参数避免争用条件

涉及 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 回调函数更改为 yield(具有 await 表达式)时。

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

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

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

在下面的示例中,在生成之前先检索 FirstNameLastName 属性值,以确保使用调用命令时的值:

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

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

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