VisualStudio.Extensibility 模型的主要目標之一是允許延伸模組在 Visual Studio 進程之外執行。 這項決定會為將 UI 支援新增至延伸模組帶來障礙,因為大部分的 UI 架構都是在處理中。
遠端 UI 是一組類別,可讓您在跨進程延伸模組中定義 WPF (Windows Presentation Foundation) 控制項,並將它們顯示為 Visual Studio UI 的一部分。
遠端 UI 大量使用依賴於 XAML(可延伸應用程式標記語言)和資料繫結、命令(而不是事件)、觸發器(而不是直接從程式碼後端與邏輯樹狀結構互動)的Model-View-ViewModel設計模式。
雖然遠端 UI 是為支援跨進程延伸模組而開發的,但依賴遠端 UI 的 VisualStudio.Extensibility API (例如 ToolWindow)也會使用遠端 UI 進行同進程延伸模組。
遠端 UI 與一般 WPF 開發之間的主要差異如下:
- 大部分的遠端 UI 作業 (包括繫結至資料內容及命令執行) 都是非同步的。
- 定義要在遠端UI資料內容中使用的資料型別時,必須以和
DataContractDataMember屬性裝飾它們,且其型別必須可由遠端UI序列化(如需詳細資訊,請參閱此處)。 - 遠端 UI 不允許參考您自己的自訂控制項。
- 遠端使用者控制項是在單一 XAML 檔案中完整定義,該檔案會參考單一 (但可能複雜且巢狀) 資料內容物件。
- 遠端 UI 不支援程式碼後置或事件處理常式 (因應措施在 進階遠端 UI 概念 檔中說明)。
- 遠端使用者控制項會在 Visual Studio 進程中具現化,而不是裝載延伸模組的進程:XAML 無法從延伸模組參考類型和元件,但可以參考 Visual Studio 進程中的類型和元件。
建立遠端 UI Hello World 延伸模組
首先建立最基本的遠端 UI 擴充功能。 請遵循 建立第一個跨進程 Visual Studio 延伸模組中的指示。
您現在應該有一個可運作的擴充功能,其具備單一命令的功能。 下一步是添加 a ToolWindow 和 a RemoteUserControl。
RemoteUserControl 是相當於 WPF 使用者控制項的遠端 UI。
您最終會得到四個檔案:
-
.cs開啟工具視窗之指令的檔案, -
.cs是用於ToolWindow的檔案,提供RemoteUserControl給 Visual Studio, -
.cs檔案是供RemoteUserControl參考其 XAML 定義, - 一個
.xaml檔案給RemoteUserControl。
稍後,您會為 RemoteUserControl 新增一個資料內容,代表 MVVM(Model-View-ViewModel)模式中的 ViewModel。
更新命令
更新命令的程式碼以使用 ShowToolWindowAsync 顯示工具視窗:
public override Task ExecuteCommandAsync(IClientContext context, CancellationToken cancellationToken)
{
return Extensibility.Shell().ShowToolWindowAsync<MyToolWindow>(activate: true, cancellationToken);
}
您也可以考慮變更 CommandConfiguration , string-resources.json 以獲得更合適的顯示訊息和位置:
public override CommandConfiguration CommandConfiguration => new("%MyToolWindowCommand.DisplayName%")
{
Placements = new[] { CommandPlacement.KnownPlacements.ViewOtherWindowsMenu },
};
{
"MyToolWindowCommand.DisplayName": "My Tool Window"
}
建立工具視窗
建立一個新 MyToolWindow.cs 檔案並定義一個 MyToolWindow 擴展的 ToolWindow類別。
方法GetContentAsync應該傳回一個IRemoteUserControl,您將在下一個步驟中定義。 由於遠端使用者控制是一次性的,因此請透過覆寫 Dispose(bool) 方法來處理它。
namespace MyToolWindowExtension;
using Microsoft.VisualStudio.Extensibility;
using Microsoft.VisualStudio.Extensibility.ToolWindows;
using Microsoft.VisualStudio.RpcContracts.RemoteUI;
[VisualStudioContribution]
internal class MyToolWindow : ToolWindow
{
private readonly MyToolWindowContent content = new();
public MyToolWindow(VisualStudioExtensibility extensibility)
: base(extensibility)
{
Title = "My Tool Window";
}
public override ToolWindowConfiguration ToolWindowConfiguration => new()
{
Placement = ToolWindowPlacement.DocumentWell,
};
public override async Task<IRemoteUserControl> GetContentAsync(CancellationToken cancellationToken)
=> content;
public override Task InitializeAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
protected override void Dispose(bool disposing)
{
if (disposing)
content.Dispose();
base.Dispose(disposing);
}
}
建立遠端使用者控制項
在三個檔案中執行此動作:
遠端使用者控制類別
名為 MyToolWindowContent的遠端使用者控制類別很簡單:
namespace MyToolWindowExtension;
using Microsoft.VisualStudio.Extensibility.UI;
internal class MyToolWindowContent : RemoteUserControl
{
public MyToolWindowContent()
: base(dataContext: null)
{
}
}
您還不需要資料內容,因此現在可以將其設定為 null 。
擴充的 RemoteUserControl 類別會自動使用具有相同名稱的 XAML 內嵌資源。 如果您想要變更此行為,請覆寫方法 GetXamlAsync 。
XAML 定義
接下來,建立一個名為 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">
<Label>Hello World</Label>
</DataTemplate>
遠端使用者控制項的 XAML 定義是一個正常的 WPF XAML,其中包含對 DataTemplate 的描述。 此 XAML 會傳送至 Visual Studio,並用來填入工具視窗內容。 我們針對遠端使用者介面 XAML 使用一個特殊命名空間(xmlns 屬性):http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml。
將 XAML 設定為內嵌資源
最後,開啟檔案 .csproj ,並確定 XAML 檔案被視為內嵌資源:
<ItemGroup>
<EmbeddedResource Include="MyToolWindowContent.xaml" />
<Page Remove="MyToolWindowContent.xaml" />
</ItemGroup>
如先前所述,XAML 檔案必須與 遠端使用者控制類別 具有相同的名稱。 準確地說,擴充 RemoteUserControl 類別的完整名稱必須與內嵌資源的名稱相符。 例如,如果 遠端使用者控制類別 的完整名稱是 MyToolWindowExtension.MyToolWindowContent,則內嵌資源名稱應該是 MyToolWindowExtension.MyToolWindowContent.xaml。 依預設,內嵌資源會指派一個名稱,該名稱由專案的根命名空間、它們可能位於的任何子資料夾路徑及其檔案名稱組成。 如果您的 遠端使用者控制類別 使用與專案根命名空間不同的命名空間,或 xaml 檔案不在專案的根資料夾中,這可能會產生問題。 如有必要,您可以使用 LogicalName 標籤來設定內嵌資源的名稱。
<ItemGroup>
<EmbeddedResource Include="MyToolWindowContent.xaml" LogicalName="MyToolWindowExtension.MyToolWindowContent.xaml" />
<Page Remove="MyToolWindowContent.xaml" />
</ItemGroup>
測試延伸模組
您現在應該能夠按下 F5 來調試擴展。
新增對佈景主題的支持
最好在撰寫 UI 時記住,Visual Studio 可以設定主題,導致使用不同的色彩。
更新 XAML 以使用 Visual Studio 中使用的 樣式 和 色彩 :
<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>
<Grid.Resources>
<Style TargetType="Label" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ThemedDialogLabelStyleKey}}" />
</Grid.Resources>
<Label>Hello World</Label>
</Grid>
</DataTemplate>
標籤現在會使用與 Visual Studio UI 其餘部分相同的主題,並在使用者切換至深色模式時自動變更色彩:
在這裡, xmlns 屬性會參考 Microsoft.VisualStudio.Shell.15.0 元件,這不是其中一個延伸模組相依性。 這很好,因為此 XAML 是由 Visual Studio 進程使用,該進程相依於 Shell.15,而不是延伸模組本身。
為了獲得更好的 XAML 編輯體驗,您可以 暫時 將 PackageReferenceMicrosoft.VisualStudio.Shell.15.0 新增至擴充專案。
請勿忘記稍後移除它 ,因為進程外的 VisualStudio.Extensibility 延伸模組不應該參考此套件!
新增資料內容
新增遠端使用者控制項的資料內容類別:
using System.Runtime.Serialization;
namespace MyToolWindowExtension;
[DataContract]
internal class MyToolWindowData
{
[DataMember]
public string? LabelText { get; init; }
}
然後更新 MyToolWindowContent.cs 和 MyToolWindowContent.xaml 以使用它:
internal class MyToolWindowContent : RemoteUserControl
{
public MyToolWindowContent()
: base(dataContext: new MyToolWindowData { LabelText = "Hello Binding!"})
{
}
<Label Content="{Binding LabelText}" />
標籤的內容現在是透過資料繫結來設定的:
此處的資料環境定義類型會標示為 DataContract 和 DataMember 屬性。 這是因為 MyToolWindowData 實例存在於延伸模組主機進程中,而從中 MyToolWindowContent.xaml 建立的 WPF 控制項存在於 Visual Studio 進程中。 若要讓資料繫結運作,遠端 UI 基礎結構會在 Visual Studio 程序中產生MyToolWindowData物件的 Proxy。
DataContract and DataMember 屬性指出哪些類型和屬性與資料繫結相關,且應該在 Proxy 中複寫。
遠端使用者控制項的資料內容會以類別的RemoteUserControl建構子參數形式傳遞:屬性RemoteUserControl.DataContext是唯讀的。 這並不表示整個資料內容是不可變的,但 遠端使用者控制項 的根數據內容物件無法取代。 在下一節中,我們將使 MyToolWindowData 可變和可觀察。
可序列化類型和遠端 UI 資料內容
遠端 UI 資料內容只能包含 可序列化 類型,或者更準確地說,只能 DataMember 將可序列化類型的屬性資料繫結至。
只有下列型別可由遠端使用者介面序列化:
- 基本資料 (大部分的 .NET 數值類型、列舉、
bool、string、DateTime) - 由 Extender 定義的類型,使用
DataContract和DataMember的屬性,且其所有的資料成員也都是可序列化的 - 實作 IAsyncCommand 的物件
- XamlFragment 和 SolidColorBrush 物件,以及 Color 值
-
Nullable<>可序列化類型的值 - 可序列化類型的集合,包括可觀察的集合。
遠端使用者控制的生命週期
您可以覆寫 ControlLoadedAsync 方法,當控制項首次載入至 WPF 容器時,您將收到通知。 如果在您的實作中,資料內容的狀態可能會獨立於 UI 事件變更,則 ControlLoadedAsync 方法是初始化資料內容並開始套用變更的正確選擇。
您也可以覆寫 Dispose 方法,以在控制項被銷毀且不再使用時收到通知。
internal class MyToolWindowContent : RemoteUserControl
{
public MyToolWindowContent()
: base(dataContext: new MyToolWindowData())
{
}
public override async Task ControlLoadedAsync(CancellationToken cancellationToken)
{
await base.ControlLoadedAsync(cancellationToken);
// Your code here
}
protected override void Dispose(bool disposing)
{
// Your code here
base.Dispose(disposing);
}
}
命令、可觀察性和雙向資料繫結
接下來,讓我們讓資料上下文可觀察,並在工具箱中新增一個按鈕。
實作 INotifyPropertyChanged 可以讓資料內容可觀察。 或者,遠端 UI 提供了一個方便的抽象類別, NotifyPropertyChangedObject我們可以擴展該類別以減少樣板程式碼。
資料內容通常具有唯讀屬性和可觀察屬性的混合。 資料內容可以是物件的複雜關聯圖,只要它們被標記為具有 DataContract 和 DataMember 屬性,並在必要時實作 INotifyPropertyChanged。 也可以有可觀察的集合,或 ObservableList<T>,這是一種由 Remote UI 提供的 ObservableCollection<T> 擴展版本,支持範圍操作,以提升性能。
我們還需要在資料上下文中新增一個命令。 在遠端 UI 中,命令會實作 IAsyncCommand ,但建立類別的 AsyncCommand 實例通常更容易。
IAsyncCommand 有 ICommand 兩個不同之處:
- 該
Execute方法被替換為ExecuteAsync因為遠端UI中的所有內容都是非同步的! -
CanExecute(object)方法被CanExecute屬性取代。AsyncCommand類負責使CanExecute變得可觀察。
請務必注意,遠端 UI 不支援事件處理常式,因此從 UI 到擴充功能的所有通知都必須透過資料繫結和命令來實作。
這是產生的程式碼 MyToolWindowData:
[DataContract]
internal class MyToolWindowData : NotifyPropertyChangedObject
{
public MyToolWindowData()
{
HelloCommand = new((parameter, cancellationToken) =>
{
Text = $"Hello {Name}!";
return Task.CompletedTask;
});
}
private string _name = string.Empty;
[DataMember]
public string Name
{
get => _name;
set => SetProperty(ref this._name, value);
}
private string _text = string.Empty;
[DataMember]
public string Text
{
get => _text;
set => SetProperty(ref this._text, value);
}
[DataMember]
public AsyncCommand HelloCommand { get; }
}
修正 MyToolWindowContent 建構函式:
public MyToolWindowContent()
: base(dataContext: new MyToolWindowData())
{
}
更新 MyToolWindowContent.xaml,以在資料內容中使用新屬性。 這都是一般的 WPF XAML。 甚至物件 IAsyncCommand 也會透過 Visual Studio 進程中呼叫 ICommand 的 Proxy 來存取,因此可以像往常一樣繫結資料。
<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>
<Grid.Resources>
<Style TargetType="Label" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ThemedDialogLabelStyleKey}}" />
<Style TargetType="TextBox" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.TextBoxStyleKey}}" />
<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.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Label Content="Name:" />
<TextBox Text="{Binding Name}" Grid.Column="1" />
<Button Content="Say Hello" Command="{Binding HelloCommand}" Grid.Column="2" />
<TextBlock Text="{Binding Text}" Grid.ColumnSpan="2" Grid.Row="1" />
</Grid>
</DataTemplate>
瞭解遠端UI中的非同步性
此工具視窗的整個遠端 UI 通訊遵循下列步驟:
資料內容是透過 Visual Studio 進程內的 Proxy 及其原始內容來存取,
由
MyToolWindowContent.xaml建立的控制項已繫結到資料上下文代理。使用者在文字方塊中輸入一些文字,這些文字會透過資料繫結指派給資料內容代理的
Name屬性。 的新Name值會傳播到MyToolWindowData物件。使用者按一下按鈕,會產生一連串的效果:
- 在資料內容中執行
HelloCommandProxy - 擴展器代碼
AsyncCommand的非同步執行已啟動 -
HelloCommand的非同步回呼會更新可觀察屬性Text的值 - 新
Text的值會傳播至資料上下文代理 - 工具視窗中的文字區塊會透過資料繫結更新為新的
Text值
- 在資料內容中執行
使用指令參數來避免競爭狀況
所有涉及 Visual Studio 與延伸模組之間通訊的作業 (圖表中的藍色箭號) 都是非同步的。 在擴展的整體設計中考慮這一方面非常重要。
因此,如果一致性很重要,最好使用命令參數,而不是雙向繫結,在執行命令時擷取資料內容狀態。
請將按鈕的 CommandParameter 綁定到 Name 來完成此變更。
<Button Content="Say Hello" Command="{Binding HelloCommand}" CommandParameter="{Binding Name}" Grid.Column="2" />
然後,修改命令的回呼以使用參數:
HelloCommand = new AsyncCommand((parameter, cancellationToken) =>
{
Text = $"Hello {(string)parameter!}!";
return Task.CompletedTask;
});
使用此方法時,會在按一下按鈕時從資料內容代理同步擷取 Name 屬性的值,然後傳送至延伸模組。 這避免了任何競爭條件,特別是如果 HelloCommand 將來回呼變更為產生(有 await 表達式)。
非同步命令會取用來自多個屬性的資料
如果命令需要取用使用者可設定的多個屬性,那麼使用指令參數不是一個可行的選擇。 例如,如果 UI 有兩個文字方塊:「名字」和「姓氏」。
在此情況下,解決方案是在 非同步命令 回呼中,在產生之前從資料內容擷取所有屬性的值。
您可以在下方看到一個範例,此範例中會先擷取 FirstName 和 LastName 屬性值,然後再產生,以確保使用命令呼叫時的值:
HelloCommand = new(async (parameter, cancellationToken) =>
{
string firstName = FirstName;
string lastName = LastName;
await Task.Delay(TimeSpan.FromSeconds(1));
Text = $"Hello {firstName} {lastName}!";
});
避免延伸模組以非同步方式更新使用者也可以更新的屬性值也很重要。 換句話說,避免 TwoWay 資料繫結。
相關內容
此處的資訊應該足以建立簡單的遠端 UI 元件。 如需與使用遠端 UI 模型相關的其他主題,請參閱 其他遠端 UI 概念。 如需更進階的案例,請參閱 進階遠端使用者介面概念。