教學課程:進階遠端 UI
在本教學課程中,您會透過累加修改顯示隨機色彩清單的工具視窗,了解進階遠端 UI 概念:
您將了解:
- 多個非同步命令的執行如何並行執行,以及如何在命令執行時停用 UI 元素。
- 如何將多個按鈕繫結至相同的非同步命令。
- 如何在遠端 UI 資料內容及其 Proxy 中處理參考類型。
- 如何使用非同步命令作為事件處理常式。
- 如果多個按鈕繫結至相同的命令,如何在執行非同步命令的回撥時停用單一按鈕。
- 如何從遠端 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 處理序中的 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
中建立單一非同步命令,並使用參數來識別應該移除的色彩。 後者選項的設計更簡潔,因此讓我們來實作後者。
- 更新資料範本中的按鈕 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,反之亦然;
- 傳送非同步命令參數
全部都接受參考類型物件的身分識別。 除了字串之外,參考類型物件在傳輸回擴充功能時永遠不會重複。
在圖片中顯示了資料內容中每個參考類型物件 (命令、集合、每個 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
至附加屬性,只有在移除對應的色彩時,才會停用該特定按鈕:
使用者 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
屬性值會轉換成資料內容 Proxy 中的 LinearGradientBrush
物件:
遠端 UI 和執行緒
非同步命令 回撥 (以及 UI 透過資料繫結更新的值 INotifyPropertyChanged
回撥),會在隨機執行緒集區的執行緒上引發。 回撥會逐一引發,而且在程式碼產生控制項之前不會重疊 (使用 await
運算式)。
將 NonConcurrentSynchronizationContext 傳遞至 RemoteUserControl
建構函式,即可變更此行為。 在此情況下,您可以針對與該控制項相關的所有 非同步命令 和 INotifyPropertyChanged
回撥,使用提供的同步處理內容。