VisualStudio.Extensibility 模型的主要目标之一是允许扩展在 Visual Studio 进程外部运行。 此决定引入了向扩展添加 UI 支持的障碍,因为大多数 UI 框架都在处理中。
远程 UI 是一组类,可用于在进程外扩展中定义 WPF(Windows Presentation Foundation)控件,并将其作为 Visual Studio UI 的一部分显示。
远程 UI 大量采用 Model-View-ViewModel 设计模式,依赖于 XAML(可扩展应用程序标记语言)和数据绑定,使用命令(而非事件)和触发器(而非从代码隐藏中与逻辑树交互)。
虽然远程 UI 是为支持进程外扩展而开发的,但依赖于远程 UI 的 VisualStudio.Extensibility API 也 ToolWindow使用远程 UI 进行进程内扩展。
远程 UI 和普通 WPF 开发之间的主要区别包括:
- 大多数远程 UI 操作,包括绑定到数据上下文和命令执行,都是异步的。
- 定义要在远程 UI 数据上下文中使用的数据类型时,必须使用属性
DataContract修饰DataMember它们,并且其类型必须由远程 UI 序列化(有关详细信息,请参阅此处)。 - 远程 UI 不允许引用自己的自定义控件。
- 远程用户控件在引用单个(但可能复杂且嵌套)的数据上下文对象的单个 XAML 文件中完全定义。
- 远程 UI 不支持代码隐藏或事件处理程序( 高级远程 UI 概念 文档中介绍了解决方法)。
- 远程用户控件在 Visual Studio 进程中实例化,而不是托管扩展的进程:XAML 无法从扩展引用类型和程序集,但可以从 Visual Studio 进程引用类型和程序集。
创建远程 UI "Hello World" 扩展
首先创建最基本的远程 UI 扩展。 按照 创建第一个外部进程 Visual Studio 扩展的说明进行操作。
现在,您应该有一个包含单个命令的有效扩展。 下一步是添加 a ToolWindow 和 a RemoteUserControl.
RemoteUserControl是 WPF 用户控件的远程 UI 等效项。
最终有四个文件:
- 用于打开
.cs工具窗口的命令的文件, - 用于
ToolWindow的.cs文件,提供RemoteUserControl给Visual Studio, - 为
.cs引用其 XAML 定义的RemoteUserControl文件, - 一个
.xaml文件,用于RemoteUserControl。
稍后,您为表示 ViewModel 的 RemoteUserControl 添加数据上下文,该 RemoteUserControl 是 MVVM(Model-View-ViewModel)模式中的一部分。
更新命令
更新命令的代码,使用 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>
远程用户控件的 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 程序集,该程序集不是扩展依赖项之一。 这很好,因为 Visual Studio 进程使用此 XAML,该进程依赖于 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.cs 和 MyToolWindowContent.xaml 以使用它:
internal class MyToolWindowContent : RemoteUserControl
{
public MyToolWindowContent()
: base(dataContext: new MyToolWindowData { LabelText = "Hello Binding!"})
{
}
<Label Content="{Binding LabelText}" />
标签的内容现在通过数据绑定进行设置:
此处的数据上下文类型标记有 DataContract 属性和 DataMember 属性。 这是因为 MyToolWindowData 实例存在于扩展主机进程中,而从 MyToolWindowContent.xaml 创建的 WPF 控件存在于 Visual Studio 进程中。 若要使数据绑定正常工作,远程 UI 基础结构会在 Visual Studio 进程中生成对象的 MyToolWindowData 代理。 属性DataContractDataMember指示哪些类型和属性与数据绑定相关,应在代理中复制。
远程用户控制的数据上下文作为RemoteUserControl类的构造函数参数传递;RemoteUserControl.DataContext属性是只读的。 这并不意味着整个数据上下文是不可变的,但无法替换 远程用户控件 的根数据上下文对象。 在下一部分中,我们将使 MyToolWindowData 可变和可观察。
可序列化类型和远程 UI 数据上下文
远程 UI 数据上下文只能包含 可序列化 的类型,或者更精确,只有 DataMember 可序列化类型的属性可以绑定到其中。
远程 UI 仅可序列化以下类型:
- 基元数据(大多数 .NET 数值类型、枚举、
bool、string、DateTime) - 用
DataContract和DataMember属性标记的扩展程序定义的类型(及其所有数据成员也都是可序列化的) - 实现 IAsyncCommand 的对象
- XamlFragment、 SolidColorBrush 对象和 Color 值
-
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我们可以扩展这些类来减少样本代码。
数据上下文通常混合使用只读属性和可观察属性。 数据上下文可以是一个复杂的对象图,只要它们被同时标记有 DataContract 和 DataMember 属性,并根据需要实现 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。
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>
了解远程 UI 中的异步性
此工具窗口的整个远程 UI 通信遵循以下步骤:
数据上下文通过 Visual Studio 进程内的代理访问其原始内容,
从
MyToolWindowContent.xaml中创建的控件是绑定到数据上下文代理的数据,用户键入文本框中的某些文本,该文本通过数据绑定分配给
Name数据上下文代理的属性。 新值Name传播到MyToolWindowData对象。用户单击按钮,引起级联效果:
- 执行数据上下文代理中的
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 回调更改为生成(包含 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 模型相关的其他主题,请参阅 其他远程 UI 概念。 有关更高级的方案,请参阅 高级远程 UI 概念。