Share via


Présentation de l’interface utilisateur distante

L’un des principaux objectifs du modèle VisualStudio.Extensibility est de permettre aux extensions de s’exécuter en dehors du processus Visual Studio. Cela présente un obstacle à l’ajout de la prise en charge de l’interface utilisateur aux extensions, car la plupart des infrastructures d’interface utilisateur sont en cours de traitement.

L’interface utilisateur distante est un ensemble de classes qui vous permettent de définir des contrôles WPF dans une extension hors processus et de les afficher dans le cadre de l’interface utilisateur de Visual Studio.

L’interface utilisateur distante s’appuie fortement sur le modèle de conception Model-View-ViewModel en s’appuyant sur la liaison XAML et les données, les commandes (au lieu d’événements) et les déclencheurs (au lieu d’interagir avec l’arborescence logique à partir du code-behind).

Bien que l’interface utilisateur distante ait été développée pour prendre en charge les extensions hors processus, les API VisualStudio.Extensibility qui s’appuient sur l’interface utilisateur distante, comme ToolWindow, utiliseront également l’interface utilisateur distante pour les extensions in-process.

Les principales différences entre l’interface utilisateur distante et le développement WPF normal sont les suivantes :

  • La plupart des opérations d’interface utilisateur distantes, y compris la liaison au contexte de données et à l’exécution de commandes, sont asynchrones.
  • Lors de la définition des types de données à utiliser dans les contextes de données de l’interface utilisateur distante, ils doivent être décorés avec les attributs et DataMember les DataContract attributs.
  • L’interface utilisateur distante n’autorise pas le référencement de vos propres contrôles personnalisés.
  • Un contrôle utilisateur distant est entièrement défini dans un fichier XAML unique qui fait référence à un objet de contexte de données unique (mais potentiellement complexe et imbriqué).
  • L’interface utilisateur distante ne prend pas en charge le code behind ou les gestionnaires d’événements (les solutions de contournement sont décrites dans le document de concepts avancés de l’interface utilisateur distante).
  • Un contrôle utilisateur distant est instancié dans le processus Visual Studio, et non dans le processus hébergeant l’extension : le code XAML ne peut pas référencer les types et les assemblys de l’extension, mais peut référencer des types et des assemblys à partir du processus Visual Studio.

Créer une extension Hello World de l’interface utilisateur distante

Commencez par créer l’extension d’interface utilisateur distante la plus simple. Suivez les instructions de création de votre première extension Visual Studio hors processus.

Vous devez maintenant disposer d’une extension de travail avec une seule commande, l’étape suivante consiste à ajouter un ToolWindow et un RemoteUserControl. Il RemoteUserControl s’agit de l’équivalent de l’interface utilisateur distante d’un contrôle utilisateur WPF.

Vous obtiendrez quatre fichiers :

  1. un .cs fichier pour la commande qui ouvre la fenêtre outil,
  2. un .cs fichier pour celui ToolWindow qui fournit visual RemoteUserControl Studio,
  3. un .cs fichier pour celui RemoteUserControl qui fait référence à sa définition XAML,
  4. un .xaml fichier pour le RemoteUserControl.

Plus tard, vous ajoutez un contexte de données pour l’objet RemoteUserControlViewModel dans le modèle MVVM.

Mettre à jour la commande

Mettez à jour le code de la commande pour afficher la fenêtre outil à l’aide ShowToolWindowAsyncde :

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

Vous pouvez également envisager de modifier CommandConfiguration et string-resources.json d’afficher un message d’affichage et un positionnement plus appropriés :

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

Créer la fenêtre outil

Créez un MyToolWindow.cs fichier et définissez une MyToolWindow classe qui s’étend ToolWindow.

La GetContentAsync méthode est censée retourner un IRemoteUserControl élément que vous allez définir à l’étape suivante. Étant donné que le contrôle utilisateur distant est jetable, veillez à le supprimer en remplaçant la Dispose(bool) méthode.

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);
    }
}

Créer le contrôle utilisateur distant

Effectuez cette action sur trois fichiers :

Classe de contrôle utilisateur à distance

La classe de contrôle utilisateur à distance, nommée MyToolWindowContent, est simple :

namespace MyToolWindowExtension;

using Microsoft.VisualStudio.Extensibility.UI;

internal class MyToolWindowContent : RemoteUserControl
{
    public MyToolWindowContent()
        : base(dataContext: null)
    {
    }
}

Vous n’avez pas encore besoin d’un contexte de données. Vous pouvez donc le définir pour l’instant null .

Une classe qui s’étend RemoteUserControl utilise automatiquement la ressource incorporée XAML avec le même nom. Si vous souhaitez modifier ce comportement, remplacez la GetXamlAsync méthode.

Définition XAML

Créez ensuite un fichier nommé 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>

Comme décrit précédemment, ce fichier doit avoir le même nom que la classe de contrôle utilisateur distante. Pour être précis, le nom complet de la classe étendue RemoteUserControl doit correspondre au nom de la ressource incorporée. Par exemple, si le nom complet de la classe de contrôle utilisateur distant est MyToolWindowExtension.MyToolWindowContent, le nom de la ressource incorporée doit être MyToolWindowExtension.MyToolWindowContent.xaml. Par défaut, les ressources incorporées se voient attribuer un nom composé par l’espace de noms racine du projet, tout chemin d’accès de sous-dossier sous-dossier sous lequel ils peuvent être placés et leur nom de fichier. Cela peut créer des problèmes si votre classe de contrôle utilisateur à distance utilise un espace de noms différent de l’espace de noms racine du projet ou si le fichier xaml ne se trouve pas dans le dossier racine du projet. Si nécessaire, vous pouvez forcer un nom pour la ressource incorporée à l’aide de la LogicalName balise :

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

La définition XAML du contrôle utilisateur distant est un XAML WPF normal décrivant un DataTemplate. Ce code XAML est envoyé à Visual Studio et utilisé pour remplir le contenu de la fenêtre outil. Nous utilisons un espace de noms spécial (xmlns attribut) pour XAML de l’interface utilisateur distante : http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml.

Définition du code XAML en tant que ressource incorporée

Enfin, ouvrez le .csproj fichier et vérifiez que le fichier XAML est traité comme une ressource incorporée :

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

Vous pouvez également modifier l’infrastructure cible de net6.0 votre extension pour net6.0-windows obtenir une meilleurecomplétion dans le fichier XAML.

Test de l’extension

Vous devez maintenant pouvoir appuyer F5 pour déboguer l’extension.

Screenshot showing menu and tool window.

Ajouter la prise en charge des thèmes

Il est judicieux d’écrire l’interface utilisateur en gardant à l’esprit que Visual Studio peut être thème, ce qui entraîne l’utilisation de différentes couleurs.

Mettez à jour le code XAML pour utiliser les styles et les couleurs utilisés dans 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>

L’étiquette utilise désormais le même thème que le reste de l’interface utilisateur de Visual Studio et change automatiquement de couleur lorsque l’utilisateur passe en mode sombre :

Screenshot showing themed tool window.

Ici, l’attribut xmlns fait référence à l’assembly Microsoft.VisualStudio.Shell.15.0 , qui n’est pas l’une des dépendances d’extension. Cela est correct, car ce code XAML est utilisé par le processus Visual Studio, qui a une dépendance sur Shell.15, et non par l’extension elle-même.

Pour bénéficier d’une meilleure expérience d’édition XAML, vous pouvez ajouter temporairement un PackageReference élément au Microsoft.VisualStudio.Shell.15.0 projet d’extension. N’oubliez pas de le supprimer plus tard, car une extension VisualStudio.Extensibility hors processus ne doit pas référencer ce package !

Ajouter un contexte de données

Ajoutez une classe de contexte de données pour le contrôle utilisateur distant :

using System.Runtime.Serialization;

namespace MyToolWindowExtension;

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

et mettre à jour MyToolWindowContent.cs et MyToolWindowContent.xaml l’utiliser :

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

Le contenu de l’étiquette est maintenant défini par le biais de la liaison de données :

Screenshot showing tool window with data binding.

Le type de contexte de données ici est marqué avec DataContract et DataMember attributs. Cela est dû au fait que l’instance MyToolWindowData existe dans le processus hôte d’extension alors que le contrôle WPF créé à partir d’existe MyToolWindowContent.xaml dans le processus Visual Studio. Pour que la liaison de données fonctionne, l’infrastructure d’interface utilisateur distante génère un proxy de l’objet MyToolWindowData dans le processus Visual Studio. Les DataContract attributs et DataMember les types indiquent quels types et propriétés sont pertinents pour la liaison de données et doivent être répliqués dans le proxy.

Le contexte de données du contrôle utilisateur distant est passé en tant que paramètre de constructeur de la RemoteUserControl classe : la RemoteUserControl.DataContext propriété est en lecture seule. Cela n’implique pas que le contexte de données entier est immuable, mais l’objet de contexte de données racine d’un contrôle utilisateur distant ne peut pas être remplacé. Dans la section suivante, nous allons rendre MyToolWindowData mutable et observable.

Cycle de vie d’un contrôle utilisateur distant

Vous pouvez remplacer la ControlLoadedAsync méthode à avertir lorsque le contrôle est chargé pour la première fois dans un conteneur WPF. Si dans votre implémentation, l’état du contexte de données peut changer indépendamment des événements d’interface utilisateur, la ControlLoadedAsync méthode est le bon endroit pour initialiser le contenu du contexte de données et commencer à y appliquer des modifications.

Vous pouvez également remplacer la Dispose méthode pour être averti lorsque le contrôle est détruit et ne sera plus utilisé.

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);
    }
}

Commandes, observabilité et liaison de données bidirectionnelle

Ensuite, nous allons rendre le contexte de données observable et ajouter un bouton à la boîte à outils.

Le contexte de données peut être observable en implémentant INotifyPropertyChanged. L’interface utilisateur distante fournit également une classe abstraite pratique, NotifyPropertyChangedObjectque nous pouvons étendre pour réduire le code réutilisable.

Un contexte de données comporte généralement une combinaison de propriétés en lecture seule et de propriétés observables. Le contexte de données peut être un graphique complexe d’objets tant qu’ils sont marqués avec les DataContract attributs et DataMember implémentent INotifyPropertyChanged si nécessaire. Il est également possible d’avoir des collections observables ou un T ObservableList<>, qui est un T observableCollection>< étendu fourni par l’interface utilisateur distante pour prendre également en charge les opérations de plage, ce qui permet de meilleures performances.

Nous devons également ajouter une commande au contexte de données. Dans l’interface utilisateur distante, les commandes implémentent IAsyncCommand , mais il est souvent plus facile de créer une instance de la AsyncCommand classe.

IAsyncCommand diffère de deux ICommand façons :

  • La Execute méthode est remplacée par ExecuteAsync parce que tout ce qui se trouve dans l’interface utilisateur distante est asynchrone !
  • La CanExecute(object) méthode est remplacée par une CanExecute propriété. La AsyncCommand classe s’occupe de rendre CanExecute observable.

Il est important de noter que l’interface utilisateur distante ne prend pas en charge les gestionnaires d’événements. Par conséquent, toutes les notifications de l’interface utilisateur vers l’extension doivent être implémentées via la liaison de données et les commandes.

Il s’agit du code résultant pour 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; }
}

Corrigez le MyToolWindowContent constructeur :

public MyToolWindowContent()
    : base(dataContext: new MyToolWindowData())
{
}

Mettez à jour MyToolWindowContent.xaml pour utiliser les nouvelles propriétés dans le contexte de données. Il s’agit de tous les XAML WPF normaux. Même l’objet IAsyncCommand est accessible via un proxy appelé ICommand dans le processus Visual Studio afin qu’il puisse être lié aux données comme d’habitude.

<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.

Présentation de l’asynchronité dans l’interface utilisateur distante

L’ensemble de la communication de l’interface utilisateur distante pour cette fenêtre d’outil suit les étapes suivantes :

  1. Le contexte de données est accessible via un proxy à l’intérieur du processus Visual Studio avec son contenu d’origine,

  2. Le contrôle créé à partir des MyToolWindowContent.xaml données est lié au proxy de contexte de données,

  3. L’utilisateur tape du texte dans la zone de texte, qui est affecté à la Name propriété du proxy de contexte de données par le biais de la liaison de données. La nouvelle valeur de Name est propagée à l’objet MyToolWindowData .

  4. L’utilisateur clique sur le bouton à l’origine d’une cascade d’effets :

    • le HelloCommand proxy de contexte de données est exécuté
    • l’exécution asynchrone du code de l’extendeur AsyncCommand est démarrée
    • le rappel asynchrone pour HelloCommand mettre à jour la valeur de la propriété observable Text
    • la nouvelle valeur de est propagée au proxy de contexte de Text données
    • le bloc de texte dans la fenêtre outil est mis à jour vers la nouvelle valeur du biais de la liaison de Text données

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

Utilisation des paramètres de commande pour éviter les conditions de concurrence

Toutes les opérations qui impliquent la communication entre Visual Studio et l’extension (flèches bleues dans le diagramme) sont asynchrones. Il est important de considérer cet aspect dans la conception globale de l’extension.

Pour cette raison, si la cohérence est importante, il est préférable d’utiliser des paramètres de commande, au lieu de liaison bidirectionnelle, pour récupérer l’état du contexte de données au moment de l’exécution d’une commande.

Apportez cette modification en liant le bouton CommandParameter à Name:

<Button Content="Say Hello" Command="{Binding HelloCommand}" CommandParameter="{Binding Name}" Grid.Column="2" />

Ensuite, modifiez le rappel de la commande pour utiliser le paramètre :

HelloCommand = new AsyncCommand((parameter, cancellationToken) =>
{
    Text = $"Hello {(string)parameter!}!";
    return Task.CompletedTask;
});

Avec cette approche, la valeur de la Name propriété est récupérée de façon synchrone à partir du proxy de contexte de données au moment du clic du bouton et envoyée à l’extension. Cela évite toute condition de concurrence, en particulier si le HelloCommand rappel est modifié à l’avenir pour produire (avoir await des expressions).

Les commandes asynchrones consomment des données à partir de plusieurs propriétés

L’utilisation d’un paramètre de commande n’est pas une option si la commande doit consommer plusieurs propriétés qui sont définies par l’utilisateur. Par exemple, si l’interface utilisateur avait deux zones de texte : « Prénom » et « Nom ».

Dans ce cas, la solution consiste à récupérer, dans le rappel de commande asynchrone, la valeur de toutes les propriétés du contexte de données avant de générer.

Vous pouvez voir ci-dessous un exemple où les valeurs de propriété et LastName les FirstName valeurs sont récupérées avant de générer pour vous assurer que la valeur au moment de l’appel de commande est utilisée :

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

Il est également important d’éviter la mise à jour asynchrone de l’extension de la valeur des propriétés qui peuvent également être mises à jour par l’utilisateur. En d’autres termes, évitez la liaison de données TwoWay .

Les informations fournies ici doivent suffire pour créer des composants d’interface utilisateur distant simples. Pour des scénarios plus avancés, consultez les concepts avancés de l’interface utilisateur distante.