チュートリアル: 高度なリモート UI

このチュートリアルでは、ランダムな色の一覧を表示するツール ウィンドウを段階的に変更することで、リモート UI の高度な概念について説明します。

ランダムな色のツール ウィンドウを示すスクリーンショット。

以下について説明します。

  • 複数の 非同期コマンド の実行を並列で実行する方法と、コマンドの実行時に UI 要素を無効にする方法。
  • 複数のボタンを同じ 非同期コマンドにバインドする方法。
  • リモート UI データ コンテキストとそのプロキシでの参照型の処理方法。
  • 非同期コマンドをイベント ハンドラーとして使用する方法。
  • 複数のボタンが同じコマンドにバインドされている場合に 非同期コマンドのコールバックが実行されているときに 1 つのボタンを無効にする方法。
  • リモート UI コントロールから XAML リソース ディクショナリを使用する方法。
  • リモート UI データ コンテキストで複雑なブラシなどの WPF 型を使用する方法。
  • リモート UI がスレッド処理を処理する方法。

このチュートリアルは、リモート UI の概要に関する記事に基づいており、次のような VisualStudio.Extensibility 拡張機能が動作することを想定しています。

  1. ツール ウィンドウを開くコマンドの .cs ファイル。
  2. MyToolWindow.cs ↗ クラス用のToolWindow ファイル
  3. RemoteUserControl ↗ クラスのための MyToolWindowContent.cs ファイル、
  4. MyToolWindowContent.xaml埋め込みリソースファイル、RemoteUserControlxaml 定義用
  5. MyToolWindowData.csのデータ コンテキストのRemoteUserControl ファイル。

開始するには、リスト ビューとボタンを表示するように 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 の最も重要な違いの 1 つは、UI と拡張機能の間の通信を含むすべての操作が非同期であるということです。

では、非同期コールバックを指定することでこれを明示的にします。

[ 色の追加 ] ボタンを短時間で複数回クリックすると、この効果が表示されます。各コマンドの実行には 2 秒かかるため、複数の実行が並列で実行され、2 秒の遅延が終了すると複数の色が一緒に一覧に表示されます。 これにより、[ 色の追加 ] ボタンが機能していないという印象をユーザーに与える可能性があります。

重複する非同期コマンド実行の図。

これに対処するには、 非同期コマンド の実行中にボタンを無効にします。 これを行う最も簡単な方法は、コマンドの CanExecute を false に設定するだけです。

AddColorCommand = new AsyncCommand(async (parameter, cancellationToken) =>
{
    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 プロセスのプロキシ データ コンテキストに非同期的に伝達され、その結果、ボタンが無効になるため、このソリューションの同期はまだ不完全です。 ユーザーは、ボタンが無効になる前に、連続してボタンを 2 回クリックできます。

より良い解決策は、RunningCommandsCountのプロパティを使用することです。

<Button Content="Add color" Command="{Binding AddColorCommand}" IsEnabled="{Binding AddColorCommand.RunningCommandsCount.IsZero}" Grid.Row="1" />

RunningCommandsCount は、現在進行中のコマンドの同時非同期実行の数のカウンターです。 このカウンターは、ボタンがクリックされるとすぐに UI スレッドでインクリメントされます。これにより、 IsEnabled を RunningCommandsCount.IsZeroにバインドすることで、ボタンを同期的に無効にすることができます。

すべてのリモート UI コマンドは非同期的に実行されるため、コマンドが迅速に完了することが予想される場合でも、 RunningCommandsCount.IsZero を常に使用して、必要に応じてコントロールを無効にすることをお勧めします。

非同期コマンド とデータ テンプレート

このセクションでは、[ 削除 ] ボタンを実装します。これにより、ユーザーはリストからエントリを削除できます。 オブジェクトごとに 1 つのMyColorを作成するか、で 1 つのMyToolWindowDataを作成し、パラメーターを使用して削除する必要がある色を識別できます。 後者のオプションは、よりクリーンなデザインなので、実装しましょう。

  1. データ テンプレートのボタン 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}}}" />
  1. 対応する AsyncCommand ↗ を MyToolWindowData に追加します。
[DataMember]
public AsyncCommand RemoveColorCommand { get; }
  1. MyToolWindowDataのコンストラクターでコマンドの非同期コールバックを設定します。
RemoveColorCommand = new AsyncCommand(async (parameter, cancellationToken) =>
{
    await Task.Delay(TimeSpan.FromSeconds(2));

    Colors.Remove((MyColor)parameter!);
});

このコードでは、 Task.Delay を使用して、実行時間の長い 非同期コマンド の実行をシミュレートします。

データ コンテキストの参照型

前のコードでは、 MyColor オブジェクトが 非同期コマンド のパラメーターとして受け取られ、 List<T>.Remove 呼び出しのパラメーターとして使用されます。この呼び出しでは、参照の等価性 ( MyColor は Equalsをオーバーライドしない参照型であるため) を使用して削除する要素を識別します。 これは、パラメーターが UI から受信された場合でも、現在データ コンテキストの一部である MyColor の正確なインスタンスが、コピーではなく受信されるためです。

〜のプロセス

  • リモート ユーザー コントロールのデータ コンテキストをプロキシ処理する。
  • Visual Studio から拡張機能へ、またはその逆にINotifyPropertyChangedの更新を送信する。
  • 拡張機能から Visual Studio への、またはその逆への監視可能なコレクション更新の送信。
  • 非同期コマンド パラメーターの送信

はすべて、参照型オブジェクトの ID を受け入れるものです。 文字列を除き、参照型オブジェクトは拡張機能に転送されるときに重複することはありません。

リモート UI データ バインディングの参照型の図。

図では、データ コンテキスト内のすべての参照型オブジェクト (コマンド、コレクション、各 MyColor 、さらにはデータ コンテキスト全体) にリモート UI インフラストラクチャによって一意の識別子が割り当てられている方法を確認できます。 ユーザーがプロキシ カラー オブジェクト #5 の [削除] ボタンをクリックすると、オブジェクトの値ではなく一意識別子 (#5) が拡張機能に返送されます。 リモート UI インフラストラクチャは、対応する MyColor オブジェクトを取得し、 それをパラメーターとして非同期コマンドのコールバックに渡します。

複数のバインディングとイベント処理を含む RunningCommandsCount

この時点で拡張機能をテストする場合は、[ 削除 ] ボタンのいずれかがクリックされると、すべての [削除 ] ボタンが無効になっていることがわかります。

複数のバインドを持つ非同期コマンドの図。

これが目的の動作である可能性があります。 ただし、現在のボタンのみを無効にし、ユーザーが複数の色をキューに登録して削除できるようにするとします。すべてのボタン間で 1 つのコマンドが共有されているため、 非同期コマンドの 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 バインドすると、対応する色が削除されるときに、その特定のボタンのみが無効になります。

ターゲットの RunningCommandsCount を使用した非同期コマンドの図。

ユーザー 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 は埋め込みリソースの完全な名前を受け取ります。既定では、プロジェクトのルート名前空間、サブフォルダーのパス、ファイル名で構成されます。 プロジェクトファイル内でLogicalNameに対してEmbeddedResourceを設定することにより、そのような名前を上書きすることができます。

リソース ファイル自体は、通常の 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 リソース ディクショナリは、埋め込みリソースをローカライズするのと同じ方法でローカライズできます。イタリア語リソースの MyResources.it.xaml など、同じ名前と言語サフィックスを持つ他の 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 プロセスで実行されない可能性があるため、WPF オブジェクトを UI と直接共有することはできません。

リモート 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 プロパティ値がデータ コンテキスト プロキシの LinearGradientBrush オブジェクトに変換されます。データ コンテキストの WPF 型を示すスクリーンショット

リモート UI とスレッド

非同期コマンド コールバック (およびデータ バインディングを介して UI によって更新された値の INotifyPropertyChanged コールバック) は、ランダムなスレッド プール スレッドで発生します。 コールバックは一度に 1 つずつ発生し、( await 式を使用して) コードが制御を生成するまで重複しません。

この動作は、 nonConcurrentSynchronizationContext ↗ を RemoteUserControl ↗ コンストラクターに渡すことによって変更できます。 その場合は、指定された同期コンテキストを、そのコントロールに関連するすべての 非同期コマンド および INotifyPropertyChanged コールバックに使用できます。