在本教程中,你将通过增量修改显示随机颜色列表的工具窗口来了解高级远程 UI 概念:

您将了解:
- 如何并行运行多个 异步命令 ,以及如何在命令运行时禁用 UI 元素。
- 如何将多个按钮绑定到同 一异步命令。
- 如何在远程 UI 数据上下文及其代理中处理引用类型。
- 如何将 异步命令 用作事件处理程序。
- 如果多个按钮绑定到同一命令,如何在执行异步命令的回调时禁用单个按钮。
- 如何通过远程 UI 控件使用 XAML 资源字典。
- 如何在远程 UI 数据上下文中使用 WPF 类型(如复杂画笔)。
- 远程 UI 如何处理线程处理。
本教程以远程 UI 简介文章为基础,并且假定你拥有正常工作的 VisualStudio.Extensibility 扩展插件,包括:
- 一个用于打开工具窗口的命令的
.cs文件, MyToolWindow.cs类的ToolWindow文件,MyToolWindowContent.cs类的RemoteUserControl文件,MyToolWindowContent.xamlxaml 定义的嵌入式资源文件RemoteUserControl,MyToolWindowData.cs的数据上下文的RemoteUserControl文件。
若要开始,请更新 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"
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>
然后,更新数据上下文类 MyToolWindowData.cs:
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; }
}
}
此代码中只有一些值得注意的事情:
MyColor.Color是一个string,当在 XAML 中进行数据绑定时被用作Brush,这是由 WPF 提供的一项功能。AddColorCommand异步回调包含 2 秒的延迟,用于模拟长时间运行的操作。- 我们使用 ObservableList<T>,这是远程 UI 提供的扩展插件 ObservableCollection<T>,也支持范围操作,从而实现性能的提升。
MyToolWindowData和MyColor不实现 INotifyPropertyChanged,因为目前所有属性都是只读属性。
处理长时间运行的异步命令
远程 UI 和普通 WPF 之间最重要的区别之一是,涉及 UI 与扩展之间的通信的所有作都是异步的。
异步命令 例如 AddColorCommand,通过提供异步回调来明确表达其功能。
如果在短时间内多次单击 “添加颜色 ”按钮,则可以看到此效果:由于每个命令执行需要 2 秒,多个执行并行进行,当 2 秒延迟结束时,多个颜色将一起显示在列表中。 这可能会向用户显示 “添加颜色 ”按钮不起作用。

若要解决此问题,请在 执行异步命令 时禁用该按钮。 执行此操作最简单的方法是将命令的 CanExecute 设置为 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;
}
});
此解决方案仍然存在同步不完善的问题,因为当用户单击按钮时,命令回调在扩展中异步执行,回调会设置CanExecute至false,然后异步传播到 Visual Studio 进程中的代理数据上下文,导致按钮被禁用。 在禁用按钮之前,用户可以连续两次单击该按钮。
更好的解决方案是使用RunningCommandsCount异步命令的属性:
<Button Content="Add color" Command="{Binding AddColorCommand}" IsEnabled="{Binding AddColorCommand.RunningCommandsCount.IsZero}" Grid.Row="1" />
RunningCommandsCount 是计算当前正在进行的命令异步并发执行数量的计数器。 单击按钮后,UI 线程上就会递增此计数器,这样就可以通过绑定到IsEnabledRunningCommandsCount.IsZero同步禁用该按钮。
由于所有远程 UI 命令以异步方式执行,因此最佳做法是始终在适当情况下禁用 RunningCommandsCount.IsZero 控件,即使命令应快速完成也是如此。
异步命令 和数据模板
在本部分中,你将实现 “删除 ”按钮,该按钮允许用户从列表中删除条目。 我们可以为每个对象创建一个MyColor,也可以在其中创建一个异步命令MyToolWindowData,并使用参数来标识应删除的颜色。 后一个选项是一种更简洁的设计,因此让我们实现这一点。
- 更新数据模板中的按钮 XAML:
<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}}}" />
- 将相应的
AsyncCommand添加到MyToolWindowData中。
[DataMember]
public AsyncCommand RemoveColorCommand { get; }
- 在构造函数
MyToolWindowData中设置命令的异步回调:
RemoveColorCommand = new AsyncCommand(async (parameter, ancellationToken) =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
Colors.Remove((MyColor)parameter!);
});
此代码使用 Task.Delay 来模拟异步命令的长时间执行。
数据上下文中的引用类型
在前面的代码中, MyColor 对象作为 异步命令 的参数接收,并用作调用的参数 List<T>.Remove ,该参数采用引用相等性(因为 MyColor 是不重写 Equals的引用类型)来标识要删除的元素。 这是可能的,因为即使从 UI 接收参数,也会收到目前属于数据上下文一部分的 MyColor 的确切实例,而不是副本。
以下流程:
- 代理远程用户控件的数据上下文;
- 将
INotifyPropertyChanged更新从扩展插件发送到 Visual Studio,反之亦然; - 将可观察集合更新从扩展发送到 Visual Studio,反之亦然;
- 发送异步命令参数
均遵循引用类型对象的标识。 除了字符串,引用类型对象在传输回扩展时永远不会重复。

在图片中,可以看到数据上下文中每个引用类型对象(命令、集合、每个 MyColor 甚至整个数据上下文)如何由远程 UI 基础结构分配唯一标识符。 当用户单击代理颜色对象的 “删除 ”按钮 #5 时,将返回扩展的唯一标识符(#5),而不是该对象的值。 远程 UI 基础结构负责检索相应的 MyColor 对象并将其作为参数传递给 异步命令的回调。
具有多个绑定和事件处理的 RunningCommandsCount
如果此时测试扩展,请注意单击“ 删除 ”按钮之一时,将禁用所有 “删除 ”按钮:

这可能是期望的行为。 但是,假设你只希望禁用当前按钮,并允许用户排队多个颜色进行删除:我们无法使用异步命令RunningCommandsCount的属性,因为我们在所有按钮之间共享了单个命令。
我们可以通过将属性附加到 RunningCommandsCount 每个按钮来实现我们的目标,以便为每个颜色设置单独的计数器。 这些功能由 http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml 命名空间提供,使你能够从 XAML 使用远程 UI 类型:
我们将 “删除 ”按钮更改为以下内容:
<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>
vs:ExtensibilityUICommands.EventHandlers附加属性允许将异步命令分配给任何事件(例如MouseRightButtonUp),并且可用于更高级的方案。
vs:EventHandler还可以有一个CounterTarget:即一个UIElement,需要附加一个vs:ExtensibilityUICommands.RunningCommandsCount属性,用于计算与该特定事件相关的活动执行次数。 绑定到附加属性时,请确保使用括号(例如 Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero)。
在这种情况下,我们使用 vs:EventHandler 来为每个按钮附加其自身的活动命令执行计数器。 将 IsEnabled 绑定到附加属性后,仅在删除相应颜色时禁用该特定按钮:

用户 XAML 资源字典
从 Visual Studio 17.10 开始,远程 UI 支持 XAML 资源字典。 这允许多个远程 UI 控件共享样式、模板和其他资源。 它还允许为不同的语言定义不同的资源(例如字符串)。
与远程 UI 控件 XAML 类似,资源文件必须配置为嵌入的资源:
<ItemGroup>
<EmbeddedResource Include="MyResources.xaml" />
<Page Remove="MyResources.xaml" />
</ItemGroup>
远程 UI 以与 WPF 不同的方式引用资源字典:它们不会添加到控件的合并字典(远程 UI 根本不支持合并字典),但在控件的.cs文件中按名称引用:
internal class MyToolWindowContent : RemoteUserControl
{
public MyToolWindowContent()
: base(dataContext: new MyToolWindowData())
{
this.ResourceDictionaries.AddEmbeddedResource(
"MyToolWindowExtension.MyResources.xaml");
}
...
AddEmbeddedResource 获取嵌入资源的全名,默认情况下,它由项目的根命名空间、它可能位于的任何子文件夹路径和文件名组成。 可以通过在项目文件中设置LogicalNameEmbeddedResource名称来替代此类名称。
资源文件本身是正常的 WPF 资源字典:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="removeButtonText">Remove</system:String>
<system:String x:Key="addButtonText">Add color</system:String>
</ResourceDictionary>
可以使用以下命令从远程 UI 控件 DynamicResource中的资源字典引用资源:
<Button Content="{DynamicResource removeButtonText}" ...
本地化 XAML 资源字典
远程 UI 资源字典可以本地化方式与本地化嵌入资源的方式相同:创建具有相同名称和语言后缀的其他 XAML 文件,例如 MyResources.it.xaml ,对于意大利语资源:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="removeButtonText">Rimuovi</system:String>
<system:String x:Key="addButtonText">Aggiungi colore</system:String>
</ResourceDictionary>
可以使用项目文件中的通配符将所有本地化的 XAML 字典作为嵌入资源包含在内:
<ItemGroup>
<EmbeddedResource Include="MyResources.*xaml" />
<Page Remove="MyResources.*xaml" />
</ItemGroup>
在数据上下文中使用 WPF 类型
到目前为止, 远程用户控件 的数据上下文由基元(数字、字符串等)组成,可观测集合和标有 DataContract我们自己的类。 有时,在数据上下文中包括简单的 WPF 类型(如复杂画笔)会很有用。
由于 VisualStudio.Extensibility 扩展性 扩展甚至可能不会在 Visual Studio 进程中运行,因此无法直接与其 UI 共享 WPF 对象。 该扩展甚至可能无权访问 WPF 类型,因为它可以面向 netstandard2.0 或 net6.0 (而不是 -windows 变体)。
远程 UI 提供类型 XamlFragment ,该类型允许在 远程用户控件的数据上下文中包含 WPF 对象的 XAML 定义:
[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; }
}
使用上面的代码, Color 属性值将转换为 LinearGradientBrush 数据上下文代理中的对象: 
远程用户界面和线程
异步命令回调(以及 INotifyPropertyChanged 通过数据绑定由 UI 更新的值的回调)在随机线程池线程上引发。 一次引发一个回调,并且在代码生成控件(使用 await 表达式)之前不会重叠。
通过将 NonConcurrentSynchronizationContext 传递给 RemoteUserControl 构造函数,可以更改此行为。 在这种情况下,可以对与该控件相关的所有 异步命令 和 INotifyPropertyChanged 回调使用提供的同步上下文。