リモート 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 つのファイルが作成されます。
- ツール ウィンドウを開くコマンドの
.cs
ファイル、 .cs
を Visual Studio に提供するToolWindow
のRemoteUserControl
ファイル、- その XAML 定義を参照する
.cs
のRemoteUserControl
ファイル、 .xaml
のRemoteUserControl
ファイル。
その後、MVVM パターンの ViewModel を表す RemoteUserControl
のデータ コンテキストを追加します。
コマンドを更新する
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);
}
}
リモート ユーザー コントロールを作成する
次の 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 編集エクスペリエンスを向上させるために、拡張機能プロジェクトに一時的に 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.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
オブジェクトのプロキシを生成します。 DataContract
属性と DataMember
属性は、データ バインディングに関連する型とプロパティを示し、プロキシでレプリケートする必要があります。
リモート ユーザー コントロールのデータ コンテキストは、RemoteUserControl
クラスのコンストラクター パラメーターとして渡されます。RemoteUserControl.DataContext
プロパティは読み取り専用です。 これは、データ コンテキスト全体が不変であることを意味するわけではありませんが、リモート ユーザー コントロールのルート データ コンテキスト オブジェクトを置き換えることはできません。 次のセクションでは、MyToolWindowData
を変更可能で監視可能にします。
シリアル化可能な型とリモート UI データ コンテキスト
リモート UI データ コンテキストには、シリアル化可能な型のみを含めることができます。より正確には、シリアル化可能な型の DataMember
プロパティのみをデータバインドできます。
リモート UI でシリアル化できるのは、次の型のみです。
- プリミティブ データ (ほとんどの .NET 数値型、列挙型、
bool
、string
、DateTime
) DataContract
属性とDataMember
属性でマークされたエクステンダー定義型 (およびそのすべてのデータ メンバーもシリアル化可能)- IAsyncCommand を実装するオブジェクト
- XamlFragment、SolidColorBrush オブジェクト、および 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 コミュニケーション全体は、次の手順に従います。
データ コンテキストは、元の内容を使用して Visual Studio プロセス内のプロキシを介してアクセスされます。
MyToolWindowContent.xaml
から作成されるコントロールは、データ コンテキスト プロキシにバインドされたデータです。ユーザーはテキスト ボックスにテキストを入力します。このテキストは、データバインドを使用してデータ コンテキスト プロキシの
Name
プロパティに割り当てられます。Name
の新しい値がMyToolWindowData
オブジェクトに反映されます。ユーザーがボタンをクリックすると、効果がカスケードします。
- データ コンテキスト プロキシの
HelloCommand
が実行されます - エクステンダーの
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 に "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 の概念」を参照してください。