教程:高级远程 UI
在本教程中,你将通过增量修改显示随机颜色列表的工具窗口来了解高级远程 UI 概念:
本文内容:
- 如何并行运行多个异步命令,以及如何在命令运行时禁用 UI 元素。
- 如何将多个按钮绑定到同一异步命令。
- 如何在远程 UI 数据上下文及其代理中处理引用类型。
- 如何将异步命令用作事件处理程序。
- 如果多个按钮绑定到同一命令,如何在执行异步命令的回调时禁用单个按钮。
- 如何通过远程 UI 控件使用 XAML 资源字典。
- 如何在远程 UI 数据上下文中使用 WPF 类型(如复杂画笔)。
- 远程 UI 如何处理线程处理。
本教程以远程 UI 简介文章为基础,并且假定你拥有正常工作的 VisualStudio.Extensibility 扩展插件,包括:
- 打开工具窗口的命令的
.cs
文件, ToolWindow
类的MyToolWindow.cs
文件,RemoteUserControl
类的MyToolWindowContent.cs
文件,RemoteUserControl
xaml 定义的嵌入式资源文件MyToolWindowContent.xaml
,RemoteUserControl
的数据上下文的MyToolWindowData.cs
文件。
若要开始,请更新 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 线程上递增,这样就可以通过将 IsEnabled
绑定到 RunningCommandsCount.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,反之亦然;
- 发送异步命令参数
均遵循引用类型对象的标识。 除字符串外,引用类型对象在传输回扩展插件时永远不会重复。
图中显示了远程 UI 基础结构如何为数据上下文中的每个引用类型对象(命令、集合、每个 MyColor
,甚至整个数据上下文)分配唯一标识符。 当用户单击代理颜色对象 #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
:vs:ExtensibilityUICommands.RunningCommandsCount
属性应附加至的 UIElement
,用于计算与该特定事件相关的活动执行数。 绑定到附加属性(例如 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
采用嵌入式资源的全名,默认情况下,由项目的根命名空间、可能位于的任何子文件夹路径和文件名组成。 可以通过在项目文件中设置 EmbeddedResource
的 LogicalName
来覆盖此名称。
资源文件本身是普通的 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>
可以使用 DynamicResource
从远程 UI 控件中的资源字典引用资源:
<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
对象:
远程 UI 和线程
异步命令回调(以及 UI 通过数据绑定更新的值的 INotifyPropertyChanged
回调)在随机线程池线程上引发。 一次引发一个回调,并且在代码生成控件(使用 await
表达式)之前不会重叠。
通过将 NonConcurrentSynchronizationContext 传递给 RemoteUserControl
构造函数,可以改变此行为。 在这种情况下,可以将提供的同步上下文用于与该控件相关的所有异步命令和 INotifyPropertyChanged
回调。