リモート UI を使用する理由

VisualStudio.Extensibility モデルの主な目標の 1 つは、Visual Studio プロセスの外部で拡張機能を実行できるようにすることです。 これにより、ほとんどの UI フレームワークがインプロセスであるため、UI サポートを拡張機能に追加する際、障害が発生します。

リモート UI は、アウトプロセス拡張機能で WPF コントロールを定義し、Visual Studio UI の一部として表示できるクラスのセットです。

リモート UI は、XAML とデータ バインディング、コマンド (イベントではなく)、トリガー (コードビハインドから論理ツリーと対話するのではなく) に依存する Model-View-ViewModel 設計パターンに大きく依存します。

リモート UI はアウトプロセス拡張機能をサポートするために開発されましたが、 ToolWindow のようにリモート UI に依存する VisualStudio.Extensibility API では、インプロセス拡張機能にもリモート UI が使用されます。

リモート UI と通常の WPF 開発の主な違いは次のとおりです。

  • データ コンテキストへのバインドやコマンドの実行など、ほとんどのリモート UI 操作は非同期です。
  • リモート UI データ コンテキストで使用するデータ型を定義する場合は、DataContract 属性と DataMember 属性で修飾する必要があり、その型はリモート UI でシリアル化する必要があります (詳細については、こちらを参照してください)。
  • リモート UI では、独自のカスタム コントロールを参照できません。
  • リモート ユーザー コントロールは、単一の (ただし複雑で入れ子になった可能性がある) データ コンテキスト オブジェクトを参照する 1 つの XAML ファイルで完全に定義されます。
  • リモート UI では、コード ビハインドやイベント ハンドラーはサポートされていません (回避策については、高度なリモート UI の概念に関するドキュメントを参照してください)。
  • リモート ユーザー コントロールは、拡張機能をホストするプロセスではなく、Visual Studio プロセスでインスタンス化されます。XAML は拡張機能から型とアセンブリを参照することはできませんが、Visual Studio プロセスから型とアセンブリを参照できます。

リモート UI Hello World 拡張機能を作成する

まず、最も基本的なリモート UI 拡張機能を作成します。 最初のアウトプロセス Visual Studio 拡張機能の作成に関するページの手順に従います。

これで、1 つのコマンドで動作する拡張機能が作成されました。次の手順では ToolWindow、そして RemoteUserControl を追加します。 RemoteUserControl は、WPF ユーザー コントロールと同等のリモート UI です。

最終的に、次の 4 つのファイルが作成されます。

  1. ツール ウィンドウを開くコマンドの .cs ファイル、
  2. .cs を Visual Studio に提供する ToolWindowRemoteUserControl ファイル、
  3. その XAML 定義を参照する .csRemoteUserControl ファイル、
  4. .xamlRemoteUserControl ファイル。

その後、MVVM パターンの ViewModel を表す RemoteUserControl のデータ コンテキストを追加します。

コマンドを更新する

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 ファイルを作成し、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);
    }
}

リモート ユーザー コントロールを作成する

次の 3 つのファイルでこのアクションを実行します。

リモート ユーザー コントロール クラス

リモート ユーザー コントロール クラス (名前付き 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 定義は、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>

前述のように、XAML ファイルの名前はリモート ユーザー コントロール クラスと同じである必要があります。 正確には、RemoteUserControl を拡張するクラスのフル ネームが埋め込みリソースの名前と一致している必要があります。 たとえば、リモート ユーザー コントロール クラスのフル ネームが MyToolWindowExtension.MyToolWindowContent の場合、埋め込みリソース名は MyToolWindowExtension.MyToolWindowContent.xaml にする必要があります。 既定では、埋め込みリソースには、プロジェクトのルート名前空間、サブフォルダーのパス、およびそれらのファイル名によって構成される名前が割り当てられます。 これにより、リモート ユーザー コントロール クラスがプロジェクトのルート名前空間とは異なる名前空間を使用している場合や、xaml ファイルがプロジェクトのルート フォルダーにない場合に問題が発生する可能性があります。 必要に応じて、LogicalName タグを使用して埋め込みリソースの名前を強制できます。

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

拡張機能のテスト

これで、F5 を押して、拡張機能をデバッグすることができるようになります。

メニューとツール ウィンドウを示すスクリーンショット。

テーマ のサポートを追加する

Visual Studio をテーマにしてさまざまな色を使用できることを念頭に置いて UI を記述することをお勧めします。

Visual Studio 全体で使用されるスタイルを使用するように 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>
        <Grid.Resources>
            <Style TargetType="Label" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ThemedDialogLabelStyleKey}}" />
        </Grid.Resources>
        <Label>Hello World</Label>
    </Grid>
</DataTemplate>

これで、ラベルは Visual Studio UI の残りの部分と同じテーマを使用し、ユーザーがダーク モードに切り替えると自動的に色が変更されるようになりました。

テーマ付きツール ウィンドウを示すスクリーンショット。

ここでは、xmlns 属性は、拡張機能の依存関係の 1 つではない 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.csMyToolWindowContent.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 オブジェクトのプロキシを生成します。 DataContract 属性と DataMember 属性は、データ バインディングに関連する型とプロパティを示し、プロキシでレプリケートする必要があります。

リモート ユーザー コントロールのデータ コンテキストは、RemoteUserControl クラスのコンストラクター パラメーターとして渡されます。RemoteUserControl.DataContext プロパティは読み取り専用です。 これは、データ コンテキスト全体が不変であることを意味するわけではありませんが、リモート ユーザー コントロールのルート データ コンテキスト オブジェクトを置き換えることはできません。 次のセクションでは、MyToolWindowData を変更可能で監視可能にします。

シリアル化可能な型とリモート UI データ コンテキスト

リモート UI データ コンテキストには、シリアル化可能な型のみを含めることができます。より正確には、シリアル化可能な型の DataMember プロパティのみをデータバインドできます。

リモート UI でシリアル化できるのは、次の型のみです。

  • プリミティブ データ (ほとんどの .NET 数値型、列挙型、boolstringDateTime)
  • DataContract 属性と DataMember 属性でマークされたエクステンダー定義型 (およびそのすべてのデータ メンバーもシリアル化可能)
  • IAsyncCommand を実装するオブジェクト
  • XamlFragmentSolidColorBrush オブジェクト、および Color
  • シリアル化可能な型の Nullable<>
  • シリアル化可能な型のコレクション (監視可能なコレクションを含む)。

リモート ユーザー コントロールのライフサイクル

コントロールが WPF コンテナーに最初に読み込まれたときに通知を受け取る ControlLoadedAsync メソッドをオーバーライドできます。 実装で、データ コンテキストの状態が 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> と呼ばれる監視可能なコレクションを使用することもできます。これは、リモート UI によって提供される拡張された ObservableCollection<T> であり、範囲操作もサポートするため、パフォーマンスが向上します。

また、データ コンテキストにコマンドを追加する必要があります。 リモート UI では、コマンドが IAsyncCommand を実装しますが、多くの場合、AsyncCommand クラスのインスタンスを作成する方が簡単です。

IAsyncCommand は、次の 2 つの点で ICommand とは異なります。

  • リモート 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 プロキシを介してアクセスされるため、通常どおりデータバインドできます。

<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 コミュニケーション全体は、次の手順に従います。

  1. データ コンテキストは、元の内容を使用して Visual Studio プロセス内のプロキシを介してアクセスされます。

  2. MyToolWindowContent.xaml から作成されるコントロールは、データ コンテキスト プロキシにバインドされたデータです。

  3. ユーザーはテキスト ボックスにテキストを入力します。このテキストは、データバインドを使用してデータ コンテキスト プロキシの Name プロパティに割り当てられます。 Name の新しい値が MyToolWindowData オブジェクトに反映されます。

  4. ユーザーがボタンをクリックすると、効果がカスケードします。

    • データ コンテキスト プロキシの HelloCommand が実行されます
    • エクステンダーの AsyncCommand コードの非同期実行が開始されます
    • HelloCommand の非同期コールバックは、監視可能なプロパティ Text の値を更新します
    • Text の新しい値がデータ コンテキスト プロキシに伝達されます
    • ツール ウィンドウのテキスト ブロックが、データ バインディング経由の新しい値 Text に更新されます

ツール ウィンドウの両方向のバインドとコマンド コミュニケーションの図。

競合状態を回避するためのコマンド パラメーターの使用

Visual Studio と拡張機能 (図の青い矢印) の間のコミュニケーションを含むすべての操作は非同期です。 拡張機能の全体的な設計では、この側面を考慮することが重要です。

このため、整合性が重要な場合は、両方向のバインドではなくコマンド パラメーターを使用して、コマンドの実行時にデータ コンテキストの状態を取得することをお勧めします。

この変更を行うには、ボタンの CommandParameterName にバインドします。

<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 に "First Name" と "Last Name" という 2 つのテキスト ボックスがある場合です。

この場合の解決策は、非同期コマンド コールバックで、生成する前にデータ コンテキストからすべてのプロパティの値を取得することです。

コマンド呼び出し時の値が使用されていることを確認するために、生成する前に、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 の概念」を参照してください。