共用方式為


教程:進階遠端 UI

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

顯示隨機色彩工具視窗的螢幕快照。

您將了解:

  • 如何讓多個異步命令同時執行,以及在命令執行時如何停用使用者介面元素。
  • 如何將多個按鈕系結至相同的 異步命令
  • 如何在遠端 UI 資料上下文及其 Proxy 中處理參考型別。
  • 如何使用 異步命令 作為事件處理程式。
  • 如果多個按鈕系結至相同的命令,如何在 異步命令的回呼執行時停用單一按鈕。
  • 如何從遠端 UI 控制件使用 XAML 資源字典。
  • 如何在遠端 UI 的資料上下文中使用 WPF 類型,例如複雜的畫筆。
  • 遠端 UI 如何處理線程。

本教學課程是以 遠端 UI 簡介文章為基礎,並預期您有一個運作中的 VisualStudio.Extensibility 延伸模組,包括:

  1. .cs 命令開啟工具視窗的檔案,
  2. MyToolWindow.cs 類別的 ToolWindow 檔案
  3. MyToolWindowContent.cs 類別的 RemoteUserControl 檔案
  4. MyToolWindowContent.xaml 用於 RemoteUserControl xaml 定義的嵌入資源文件
  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 通常是 Brush,但當它在 XAML 中進行數據綁定時,它是 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 進程中的 Proxy 數據內容,導致按鈕停用。 用戶可以在按鈕停用之前,快速連續點擊按鈕兩次。

更好的解決方案是使用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 是不會覆寫EqualsMyColor參考型別),以識別要移除的元素。 這是可能的,因為即使從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也可以有 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 會採用內嵌資源的完整名稱,預設是由專案的根命名空間、它可能位於下的任何子資料夾路徑和檔名所組成。 可以透過在專案檔中為 LogicalName 設定 EmbeddedResource,來覆蓋這類名稱。

資源檔案本身是一般的 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 轉換成數據內容 Proxy 中的物件: 顯示資料內容中 WPF 類型的螢幕快照

遠端 UI 和線程

Async command 的回呼(以及透過資料繫結由 UI 更新值的回呼)會在隨機選擇的執行緒集區線程上引發。 回呼會逐一引發,並且在程式代碼讓出控制權之前不會重疊(使用 await 表達式)。

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