教程:高级远程 UI

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

显示随机颜色的工具窗口的屏幕截图。

本文内容:

  • 如何并行运行多个异步命令,以及如何在命令运行时禁用 UI 元素。
  • 如何将多个按钮绑定到同一异步命令
  • 如何在远程 UI 数据上下文及其代理中处理引用类型。
  • 如何将异步命令用作事件处理程序。
  • 如果多个按钮绑定到同一命令,如何在执行异步命令的回调时禁用单个按钮。
  • 如何通过远程 UI 控件使用 XAML 资源字典。
  • 如何在远程 UI 数据上下文中使用 WPF 类型(如复杂画笔)。
  • 远程 UI 如何处理线程处理。

本教程以远程 UI 简介文章为基础,并且假定你拥有正常工作的 VisualStudio.Extensibility 扩展插件,包括:

  1. 打开工具窗口的命令的 .cs 文件,
  2. ToolWindow 类的 MyToolWindow.cs 文件,
  3. RemoteUserControl 类的 MyToolWindowContent.cs 文件,
  4. RemoteUserControl xaml 定义的嵌入式资源文件 MyToolWindowContent.xaml
  5. 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>,也支持范围操作,从而实现性能的提升。
  • MyToolWindowDataMyColor 不实现 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 中创建单个异步命令,并使用参数来标识应删除的颜色。 后一个选项的设计更简洁,因此让我们来实现后者。

  1. 更新数据模板中的按钮 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}}}" />
  1. 将对应的 AsyncCommand 添加至 MyToolWindowData
[DataMember]
public AsyncCommand RemoveColorCommand { get; }
  1. 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 数据绑定引用类型的示意图。

图中显示了远程 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 也可以具有 CounterTargetvs:ExtensibilityUICommands.RunningCommandsCount 属性应附加至的 UIElement,用于计算与该特定事件相关的活动执行数。 绑定到附加属性(例如 Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero)时,请务必使用括号。

在这种情况下,我们使用 vs:EventHandler 向每个按钮附加其自己单独的活动命令执行计数器。 将 IsEnabled 绑定到附加属性后,仅在删除相应颜色时禁用该特定按钮:

具有目标 RunningCommandsCount 的异步命令示意图。

用户 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 采用嵌入式资源的全名,默认情况下,由项目的根命名空间、可能位于的任何子文件夹路径和文件名组成。 可以通过在项目文件中设置 EmbeddedResourceLogicalName 来覆盖此名称。

资源文件本身是普通的 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.0net6.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 对象:显示数据上下文中的 WPF 类型的屏幕截图

远程 UI 和线程

异步命令回调(以及 UI 通过数据绑定更新的值的 INotifyPropertyChanged 回调)在随机线程池线程上引发。 一次引发一个回调,并且在代码生成控件(使用 await 表达式)之前不会重叠。

通过将 NonConcurrentSynchronizationContext 传递给 RemoteUserControl 构造函数,可以改变此行为。 在这种情况下,可以将提供的同步上下文用于与该控件相关的所有异步命令INotifyPropertyChanged 回调。