為什麼使用遠端 UI

VisualStudio.Extensibility 模型的其中一個主要目標,就是允許擴充功能在 Visual Studio 處理序之外執行。 這會造成將 UI 支援新增至擴充功能的障礙,因為大部分的 UI 架構都同處理序。

遠端 UI 是一組類別,可讓您在跨處理序擴充功能中定義 WPF 控制項,並將其顯示為 Visual Studio UI 的一部分。

遠端 UI 特別偏重 Model-View-ViewModel 設計模式,依賴 XAML 和資料繫結、命令 (而非事件),和觸發程序 (而不是與程式碼後置之樹狀結構的互動)。

雖然遠端 UI 是為了支援跨處理序擴充功能而開發,但依賴遠端 UI 的 VisualStudio.Extensibility API,例如 ToolWindow,也會使用遠端 UI 進行同處理序擴充功能。

遠端 UI 與一般 WPF 開發的主要差異如下:

  • 大部分的遠端 UI 作業,包括繫結至資料內容和命令執行,都是非同步的。
  • 定義要用於遠端 UI 資料內容的資料類型時,必須使用 DataContractDataMember 屬性為其裝飾。
  • 遠端 UI 不允許參考您的自訂控制項。
  • 遠端使用者控制項完全定義於參考單一 (但可能複雜且巢狀) 資料內容物件的單一 XAML 檔案中。
  • 遠端 UI 不支援程式碼後置或事件處理常式 (進階遠端 UI 概念文件中會說明因應措施)。
  • 遠端使用者控制項會在 Visual Studio 處理序中具現化,而不是裝載擴充功能的程式:XAML 無法參考擴充功能的類型和組件,但可以參考 Visual Studio 處理序的類型和組件。

建立遠端 UI Hello World 擴充功能

從建立最基本的遠端 UI 擴充功能開始。 遵循建立您的第一個跨處理序 Visual Studio 擴充功能中的的指示。

您現在應該有使用單一指令的可執行擴充功能,下一個步驟是新增 ToolWindowRemoteUserControlRemoteUserControl是等同於 WPF 使用者控制項的遠端 UI。

您最後會有四個檔案:

  1. 開啟工具視窗之命令的 .cs檔案,
  2. ToolWindow.cs 檔案,該檔案提供 RemoteUserControl 給 Visual Studio,
  3. 參考其 XAML 定義之 RemoteUserControl.cs 檔案,
  4. 提供給 RemoteUserControl.xaml 檔案。

稍後,您會為 RemoteUserControl 新增資料內容,此內容代表 MVVM 模式中的 ViewModel

更新命令

更新命令的程式碼,以使用 ShowToolWindowAsync 顯示工具視窗:

public override Task ExecuteCommandAsync(IClientContext context, CancellationToken cancellationToken)
{
    return Extensibility.Shell().ShowToolWindowAsync<MyToolWindow>(activate: true, cancellationToken);
}

您也可以考慮變更 CommandConfigurationstring-resources.json,以取得更適當的顯示訊息和位置:

public override CommandConfiguration CommandConfiguration => new("%MyToolWindowCommand.DisplayName%")
{
    Placements = new[] { CommandPlacement.KnownPlacements.ViewOtherWindowsMenu },
};
{
  "MyToolWindowCommand.DisplayName": "My Tool Window"
}

建立工具視窗

建立新的 MyToolWindow.cs 檔案,並定義擴充 ToolWindowMyToolWindow 類別。

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>

如先前所述,此檔案的名稱必須與遠端使用者控制項類別相同。 若要精確,擴充 RemoteUserControl 之類別的完整名稱,必須符合內嵌資源的名稱。 例如,如果遠端使用者控制項類別的完整名稱是 MyToolWindowExtension.MyToolWindowContent,則內嵌的資源名稱應該是 MyToolWindowExtension.MyToolWindowContent.xaml。 根據預設,內嵌資源會指派由專案根命名空間所組成的名稱、其下的任何子資料夾路徑,以及其檔案名稱。 如果您的 遠端使用者控制項類別 使用與專案根命名空間不同的命名空間,或 xaml 檔案不在專案的根資料夾中,可能會造成問題。 如有必要,您可以使用 LogicalName 標籤來強制內嵌資源的名稱:

<ItemGroup>
  <EmbeddedResource Include="MyToolWindowContent.xaml" LogicalName="MyToolWindowExtension.MyToolWindowContent.xaml" />
  <Page Remove="MyToolWindowContent.xaml" />
</ItemGroup>

遠端使用者控制項的 XAML 定義是描述 DataTemplate 的一般 WPF XAML。 此 XAML 會傳送至 Visual Studio,並用來填滿工具視窗內容。 我們使用遠端 UI XAML 的特殊命名空間 (xmlns屬性):http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml

將 XAML 設定為內嵌資源

最後,開啟 .csproj 檔案,並確定 XAML 檔案被視為內嵌資源:

<ItemGroup>
  <EmbeddedResource Include="MyToolWindowContent.xaml" />
  <Page Remove="MyToolWindowContent.xaml" />
</ItemGroup>

您也可以將擴充功能的目標架構從 net6.0 變更為 net6.0-windows,以便在 XAML 檔案中取得更好的自動完成。

測試擴充功能

您現在應該能夠按 F5 來偵錯擴充功能。

Screenshot showing menu and tool window.

新增主題的支援

寫入 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 其餘部分相同的主題,並在使用者切換到深色模式時自動變更色彩:

Screenshot showing themed tool window.

在這裡,xmlns 屬性會參考 Microsoft.VisualStudio.Shell.15.0 組件,這不是其中一個擴充功能相依性。 這沒問題,因為 Visual Studio 處理序會使用這個與 Shell.15 具有相依性的 XAML,而不是由擴充功能本身使用。

若要取得更好的 XAML 編輯體驗,您可以暫時PackageReference 新增至擴充功能專案的 Microsoft.VisualStudio.Shell.15.0。 稍後別忘了將其移除,因為跨處理序 VisualStudio.Extensibility 擴充功能不應該參考此套件!

新增資料內容

新增遠端使用者控制項的資料內容類別:

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

[DataContract]
internal class MyToolWindowData
{
    [DataMember]
    public string? LabelText { get; init; }
}

和更新 MyToolWindowContent.csMyToolWindowContent.xaml 以使用它:

internal class MyToolWindowContent : RemoteUserControl
{
    public MyToolWindowContent()
        : base(dataContext: new MyToolWindowData { LabelText = "Hello Binding!"})
    {
    }
<Label Content="{Binding LabelText}" />

標籤的內容現在會透過資料繫結來設定:

Screenshot showing tool window with data binding.

這裡的資料內容類型會以 DataContractDataMember 屬性標示。 這是因為 MyToolWindowData 執行個體存在於擴充功能主機處理序,而從 MyToolWindowContent.xaml 建立的 WPF 控制項則存在於 Visual Studio 處理序。 為了讓資料繫結能夠運作,遠端 UI 基礎結構會在 Visual Studio 處理序中產生 MyToolWindowData 物件的 Proxy。 DataContractDataMember 屬性會指出哪些類型和屬性與資料繫結相關,而且應該在 Proxy 中複寫。

遠端使用者控制項的資料內容會當做 RemoteUserControl 類別的建構函式參數傳遞:RemoteUserControl.DataContext 屬性是唯讀的。 這並不表示整個資料內容是不可變的,但無法取代遠端使用者控制項的根資料內容物件。 在下一節中,我們將讓 MyToolWindowData 是可變和可檢視的。

遠端使用者控制項的生命週期

您可以覆寫 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,我們可以擴充以減少重複使用的程式碼。

資料內容通常混合使用唯讀屬性和可檢視的屬性。 只要物件使用 DataContractDataMember 屬性標記,並視需要實作 INotifyPropertyChanged,資料內容就可以是物件的複雜圖形。 還可以有可檢視集合,或 ObservableList<T>,它是遠端 UI 提供的擴充 ObservableCollection<T>,也支援範圍操作,從而實現更好的效能。

我們也必須將命令新增至資料內容。 在遠端 UI 中,命令會實作 IAsyncCommand,但建立 AsyncCommand 類別的執行個體通常比較容易。

IAsyncCommandICommand 有兩點不同:

  • 因為遠端 UI 中的所有內容都是非同步的,所以 Execute 方法會被取代為 ExecuteAsync
  • 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>

Diagram of tool window with two-way binding and a command.

了解遠端 UI 中的非同步

此工具視窗的整個遠端 UI 通訊會遵循下列步驟:

  1. 資料內容是透過 Visual Studio 處理序內的 Proxy 存取其原始內容,

  2. MyToolWindowContent.xaml 建立的控制項是繫結至資料內容 Proxy 的資料,

  3. 使用者會在文字方塊輸入一些文字,這會透過資料繫結指派給資料內容 Proxy 的 Name 屬性。 Name 的新值會傳播至 MyToolWindowData 物件。

  4. 使用者按一下按鈕造成一系列效果:

    • 執行了資料內容 Proxy 中的 HelloCommand
    • 擴充項的 AsyncCommand 程式碼的非同步執行已啟動
    • HelloCommand 的非同步回撥更新了可檢視屬性 Text 的值
    • Text 的新值會傳播至資料內容 Proxy
    • 工具視窗中的文字區塊會透過資料繫結更新為 Text 的新值

Diagram of tool window two-way binding and commands communication.

使用命令參數來避免競爭狀況

涉及 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 屬性的值會從資料內容 Proxy 同步擷取,並傳送至擴充功能。 這可避免任何競爭狀況,特別是如果 HelloCommand 回撥在未來變更為暫止 (有 await 運算式)。

非同步命令會取用來自多個屬性的資料

如果命令需要取用使用者可設定的多個屬性,則使用命令參數不是選項。 例如,如果 UI 有兩個文字方塊: "First Name" 和 "Last Name。

在此情況下的解決方案是在非同步命令回撥中,擷取資料內容中所有屬性的值,然後再產生。

在以下的範例中,FirstNameLastName 屬性值會在產生前擷取 ,以確保使用了命令叫用時的值:

HelloCommand = new(async (parameter, cancellationToken) =>
{
    string firstName = FirstName;
    string lastName = LastName;
    await Task.Delay(TimeSpan.FromSeconds(1));
    Text = $"Hello {firstName} {lastName}!";
});

也請務必避免擴充功能以非同步方式更新使用者也可以更新的屬性值。 換句話說,請避免 TwoWay 資料繫結。

此處的資訊應該足以建置簡單的遠端 UI 元件。 如需更進階的案例,請參閱進階遠端 UI 概念