教程:高级远程 UI

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

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

您将了解:

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

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

  1. 一个用于打开工具窗口的命令的.cs文件,
  2. MyToolWindow.cs 类的 ToolWindow 文件,
  3. MyToolWindowContent.cs 类的 RemoteUserControl 文件,
  4. MyToolWindowContent.xaml xaml 定义的嵌入式资源文件 RemoteUserControl
  5. 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>,也支持范围操作,从而实现性能的提升。
  • 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;
    }
});

此解决方案仍然存在同步不完善的问题,因为当用户单击按钮时,命令回调在扩展中异步执行,回调会设置CanExecutefalse,然后异步传播到 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,并使用参数来标识应删除的颜色。 后一个选项是一种更简洁的设计,因此让我们实现这一点。

  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 数据绑定引用类型的示意图。

在图片中,可以看到数据上下文中每个引用类型对象(命令、集合、每个 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 绑定到附加属性后,仅在删除相应颜色时禁用该特定按钮:

具有目标 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 获取嵌入资源的全名,默认情况下,它由项目的根命名空间、它可能位于的任何子文件夹路径和文件名组成。 可以通过在项目文件中设置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.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 类型的屏幕截图

远程用户界面和线程

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

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