共用方式為


教學課程:進階遠端 UI

在本教學課程中,您會透過累加修改顯示隨機色彩清單的工具視窗,了解進階遠端 UI 概念:

顯示隨機色彩工具視窗的螢幕擷取畫面。

您將了解:

  • 多個非同步命令的執行如何並行執行,以及如何在命令執行時停用 UI 元素。
  • 如何將多個按鈕繫結至相同的非同步命令
  • 如何在遠端 UI 資料內容及其 Proxy 中處理參考類型。
  • 如何使用非同步命令作為事件處理常式。
  • 如果多個按鈕繫結至相同的命令,如何在執行非同步命令的回撥時停用單一按鈕。
  • 如何從遠端 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.Colorstring,但當資料繫結於 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 處理序中的 Proxy 資料內容,導致按鈕停用。 使用者可以在按鈕停用之前,連續按一下按鈕兩次。

使用非同步命令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 資料繫結參考類型的圖表。

在圖片中顯示了資料內容中每個參考類型物件 (命令、集合、每個 MyColor,甚至整個資料內容) 如何由遠端 UI 基礎結構指派唯一識別碼。 當使用者按一下 Proxy 色彩物件的移除按鈕 #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也可以有 CounterTargetUIElement應該附加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 會採用內嵌資源的全名,其中,根據預設,該名稱由專案的根命名空間、其可能位於的任何子資料夾路徑以及檔案名稱組成。 藉由在專案檔中為 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.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 屬性值會轉換成資料內容 Proxy 中的 LinearGradientBrush 物件:顯示資料內容中 WPF 類型的螢幕擷取畫面

遠端 UI 和執行緒

非同步命令 回撥 (以及 UI 透過資料繫結更新的值 INotifyPropertyChanged 回撥),會在隨機執行緒集區的執行緒上引發。 回撥會逐一引發,而且在程式碼產生控制項之前不會重疊 (使用 await 運算式)。

NonConcurrentSynchronizationContext 傳遞至 RemoteUserControl 建構函式,即可變更此行為。 在此情況下,您可以針對與該控制項相關的所有 非同步命令INotifyPropertyChanged 回撥,使用提供的同步處理內容。