MVVM

Exploitation des fonctionnalités de Windows 8 avec MVVM

Brent Edwards

Télécharger l'exemple de code

Windows 8 intègre de nombreuses nouvelles fonctionnalités que les développeurs peuvent exploiter pour créer des applications fascinantes et une expérience utilisateur riche. Malheureusement, ces capacités ne sont pas toujours très compatibles avec les tests unitaires. Les fonctionnalités telles que le partage et les vignettes secondaires peuvent rendre votre application plus interactive et plus agréable, mais également moins facile à tester.

Dans cet article, je vais examiner les différentes façons de permettre à une application d'utiliser les fonctionnalités telles que le partage, les paramètres, les vignettes secondaires, les paramètres d'application et le stockage d'applications. À l'aide du modèle MVVM (Model-View-ViewModel), de l'injection de dépendances et d'un peu d'abstraction, je vais vous montrer comment tirer parti de ces fonctionnalités tout en conservant la couche de présentation compatible avec les tests unitaires.

À propos de l'exemple d'application

Pour illustrer les concepts que je vais aborder dans cette article, j'ai utilisé MVVM pour écrire un exemple d'application du Windows Store qui permet à un utilisateur d'afficher des billets de blog à partir du flux RSS de ses blogs préférés. L'application illustre comment :

  • partager des informations sur un billet de blog avec d'autres applications via l'icône Partager ;
  • modifier les blogs que l'utilisateur souhaite lire à l'aide de l'icône Paramètres ;
  • épingler un billet de blog préféré à l'écran d'accueil pour le lire plus tard avec les vignettes secondaires ;
  • enregistrer les blogs préférés pour les afficher sur tous les appareils avec les paramètres d'itinérance.

Outre l'exemple d'application, j'ai pris la fonctionnalité spécifique de Windows 8 que je vais traiter dans cet article et je l'ai extraite dans une bibliothèque open source nommée Charmed. Charmed peut être utilisée comme bibliothèque d'assistance ou simplement à titre de référence. Le rôle de Charmed est d'être une bibliothèque d'assistance MVVM interplateforme pour Windows 8 et Windows Phone 8. Je parlerai davantage de la partie Windows Phone 8 de la bibliothèque dans un prochain article. Vérifiez l'évolution de la bibliothèque Charmed à l'adresse bit.ly/17AzFxW.

Avec cet article et cet exemple de code, je vais tenter d'expliquer mon approche des applications faciles à tester avec MVVM, en utilisant certaines des nouvelles fonctionnalités offertes par Windows 8.

Présentation de MVVM

Avant de plonger dans le code et les fonctionnalités spécifiques de Windows 8, je vais examiner rapidement MVVM. MVVM est un modèle de conception qui a acquis une très grande popularité ces dernières années pour les technologies basées sur le code XAML, telles que Windows Presentation Foundation (WPF), Silverlight, Windows Phone 7, Windows Phone 8 et Windows 8 (Windows Runtime ou WinRT). MVVM divise l'architecture d'une application en trois couches logiques : Model, ViewModel et View, comme illustré dans la figure 1.

The Three Logical Layers of Model-­View-ViewModelFigure 1 Les trois couches logiques de Model-View-ViewModel

La couche Model contient la logique métier de l'application (objets métier, validation de données, accès aux données, etc.). En réalité, la couche Model est généralement divisée en un plus grand nombre de couches, voire en plusieurs niveaux. Comme le montre la figure 1, la couche Model est la partie inférieure ou la base logique de l'application.

La couche View Model contient la présentation logique de l'application, qui comprend les données à afficher, les propriétés permettant d'activer les éléments d'interface utilisateur ou de les rendre visibles, et les méthodes qui interagiront avec les couches Model et View. En fait, la couche View Model est une représentation de l'état actuel de l'interface utilisateur qui ne prête pas attention aux vues. Je dis « qui ne prête pas attention aux vues », car elle se contente de fournir les données et les méthodes avec lesquelles la vue interagit, mais elle n'impose pas la façon dont la vue représentera ces données et permettra à l'utilisateur d'interagir avec ces méthodes. Comme le montre la figure 1, la couche View Model se trouve entre la couche Model et la couche View et peut interagir avec les deux. La couche View Model contient le code qui devait se trouver auparavant dans le code-behind de la couche View.

La couche View contient la présentation réelle de l'application. Pour les applications XAML, telles que celles pour le Windows Runtime, la couche View se compose essentiellement, voire totalement, de code XAML. La couche View tire parti du puissant moteur de liaisons de données XAML pour se lier aux propriétés du modèle View, en appliquant une apparence aux données qui, autrement, n'auraient aucune représentation graphique. Comme le montre la figure 1, la couche View est la partie supérieure logique de l'application. La couche View interagit directement avec la couche View Model, mais n'a pas connaissance de la couche Model.

L'objectif principal du modèle MVVM est de séparer la présentation d'une application de ses fonctionnalités. En procédant ainsi, l'application est plus compatible avec les tests unitaires, car les fonctionnalités résident à présent dans des objets POCO (Plain Old CLR Objects), plutôt que dans des vues ayant leurs propres cycles de vie.

Contrats

Windows 8 introduit le concept de contrats, qui sont des accords entre deux ou plusieurs applications sur le système d'un utilisateur. Ces contrats garantissent une cohérence entre toutes les applications, ce qui permet aux développeurs de tirer parti de toutes les fonctionnalités qui les prennent en charge. Une application peut déclarer les contrats qu'elle prend en charge dans le fichier Package.appxmanifest, comme indiqué dans la figure 2.

Contracts in the Package.appxmanifest FileFigure 2 Contrats du fichier Package.appxmanifest

Même si la prise en charge de contrats est facultative, elle est en général très utile. Une application doit notamment prendre en charge trois contrats, à savoir les contrats de partage, de paramètres et de recherche, car ils sont toujours accessibles à partir du menu Icônes, illustré dans la figure 3.

The Charms MenuFigure 3 Menu Icônes

Je vais me concentrer sur deux types de contrat : de partage et de paramètres.

Contrat de partage

Le contrat de partage permet à une application de partager les données propres au contexte avec d'autres applications sur le système de l'utilisateur. Le contrat de partage comporte deux parties : la source et la cible. La source est l'application qui procède au partage. Elle fournit certaines données à partager, quel que soit le format requis. La cible est l'application qui reçoit les données partagées. Dans la mesure où l'icône Partager est toujours accessible pour l'utilisateur à partir du menu Icônes, je souhaite que l'exemple d'application soit, tout au moins, une source de partage. Toutes les applications ne doivent pas être une cible de partage, car toutes les applications n'ont pas besoin d'accepter des entrées d'autres sources. Toutefois, il est fort probable qu'une application donnée aura au moins une chose intéressante à partager avec d'autres applications. La plupart des applications trouveront donc certainement utile d'être une source de partage.

Lorsque l'utilisateur appuie sur l'icône Partager, un objet appelé le service Broker de partage lance le processus qui consiste à prendre les données partagées par une application (si c'est le cas) et à les envoyer vers la cible de partage comme spécifié par l'utilisateur. L'objet DataTransferManager peut être utilisé pour partager les données pendant ce processus. Le DataTransferManager comporte un événement appelé DataRequested, qui est généré lorsque l'utilisateur appuie sur l'icône Partager. Le code suivant montre comment obtenir une référence au DataTransferManager et s'inscrire à l'événement DataRequested :

public void Initialize()
{
  this.DataTransferManager = DataTransferManager.GetForCurrentView();
  this.DataTransferManager.DataRequested += 
    this.DataTransferManager_DataRequested;
}
private void DataTransferManager_DataRequested(
  DataTransferManager sender, DataRequestedEventArgs args)
{
  // Do stuff ...
}

L'appel de DataTransferManager.GetForCurrentView retourne une référence au DataTransferManager actif pour la vue actuelle. Bien qu'il soit possible de mettre ce code dans un modèle de vue, une forte dépendance se crée sur le DataTransferManager, une classe sealed qui ne peut pas être factice dans les tests unitaires. Comme je souhaite vraiment que mon application reste aussi facile à tester que possible, cette approche n'est pas idéale. Une solution plus appropriée consiste à extraire l'interaction avec le DataTransferManager dans une classe d'assistance et à définir une interface de cette classe d'assistance à implémenter.

Avant d'extraire cette interaction, je dois déterminer les parties vraiment importantes. Je m'intéresse particulièrement aux trois parties suivantes de l'interaction avec le DataTransferManager :

  1. inscription à l'événement DataRequested lorsque ma vue est activée ;
  2. annulation de l'inscription à l'événement DataRequested lorsque ma vue est désactivée ;
  3. possibilité d'ajouter des données partagées au DataPackage.

Avec ces trois points en tête, mon interface se matérialise :

public interface IShareManager
{
  void Initialize();
  void Cleanup();
  Action<DataPackage> OnShareRequested { get; set; }
}

Initialize doit obtenir une référence au DataTransferManager et inscrire à l'événement DataRequested. Cleanup doit annuler l'inscription à l'événement DataRequested. OnShareRequested est l'endroit où je peux définir la méthode qui est appelée lorsque l'événement DataRequested a été déclenché. Je peux maintenant implémenter IShareManager, comme illustré à la figure 4.

Figure 4 Implémentation d'IShareManager

public sealed class ShareManager : IShareManager
{
  private DataTransferManager DataTransferManager { get; set; }
  public void Initialize()
  {
    this.DataTransferManager = DataTransferManager.GetForCurrentView();
    this.DataTransferManager.DataRequested +=
      this.DataTransferManager_DataRequested;
  }
  public void Cleanup()
  {
    this.DataTransferManager.DataRequested -=
      this.DataTransferManager_DataRequested;
  }
  private void DataTransferManager_DataRequested(
    DataTransferManager sender, DataRequestedEventArgs args)
  {
    if (this.OnShareRequested != null)
    {
      this.OnShareRequested(args.Request.Data);
    }
  }
  public Action<DataPackage> OnShareRequested { get; set; }
}

Lorsque l'événement DataRequested est déclenché, les arguments d'événement transmis contiennent un DataPackage. Ce DataPackage est l'endroit où les données partagées réelles doivent être placées, c'est la raison pour laquelle l'Action de OnShareRequested prend un DataPackage comme paramètre. Une fois que mon interface IShareManager est définie et que le ShareManager l'a implémentée, je suis maintenant prêt à inclure le partage dans mon modèle de vue, sans sacrifier le fait de rendre possibles les tests unitaires, ce qui est mon objectif.

Une fois que j'ai utilisé le conteneur d'inversion de contrôle (IoC, Inversion of Control) de mon choix pour injecter une instance d'IShareManager dans mon modèle de vue, je peux l'utiliser, comme indiqué dans la figure 5.

Figure 5 Association d'IShareManager

public FeedItemViewModel(IShareManager shareManager)
{
  this.shareManager = shareManager;
}
public override void LoadState(
  FeedItem navigationParameter, Dictionary<string, 
  object> pageState)
{
  this.shareManager.Initialize();
  this.shareManager.OnShareRequested = ShareRequested;
}
public override void SaveState(Dictionary<string, 
  object> pageState)
{
  this.shareManager.Cleanup();
}

LoadState est appelé lorsque les modèles de page et de vue sont activés et SaveState est appelé lorsque les modèles de page et de vue sont désactivés. Maintenant que le ShareManager est complètement configuré et prêt à gérer le partage, je dois implémenter la méthode ShareRequested qui sera appelée lorsque l'utilisateur lancera le partage. Je souhaite partager quelques informations sur un billet de blog particulier (FeedItem), comme indiqué dans la figure 6.

Figure 6 Remplissage du DataPackage sur ShareRequested

private void ShareRequested(DataPackage dataPackage)
{
  // Set as many data types as possible.
  dataPackage.Properties.Title = this.FeedItem.Title;
  // Add a Uri.
  dataPackage.SetUri(this.FeedItem.Link);
  // Add a text-only version.
  var text = string.Format(
    "Check this out! {0} ({1})", 
    this.FeedItem.Title, this.FeedItem.Link);
  dataPackage.SetText(text);
  // Add an HTML version.
  var htmlBuilder = new StringBuilder();
  htmlBuilder.AppendFormat("<p>Check this out!</p>", 
    this.FeedItem.Author);
  htmlBuilder.AppendFormat(
    "<p><a href='{0}'>{1}</a></p>", 
    this.FeedItem.Link, this.FeedItem.Title);
  var html = HtmlFormatHelper.CreateHtmlFormat(htmlBuilder.ToString());
  dataPackage.SetHtmlFormat(html);
}

J'ai choisi de partager plusieurs types de données différents. C'est généralement une bonne idée, car vous n'avez aucun contrôle sur les applications installées sur le système de l'utilisateur ou sur les types de données que ces applications prennent en charge. Il est important de noter que le partage est essentiellement un scénario « lancer et oublier ». Vous n'avez aucune idée de l'application que l'utilisateur choisira pour le partage et ce que cette application fera avec les données partagées. Pour partager avec l'audience la plus large possible, je fournis un titre, un URI, une version texte uniquement et une version HTML.

Paramètres

Le contrat de paramètres permet à l'utilisateur de modifier les paramètres propres au contexte d'une application. Il peut s'agir de paramètres qui affectent l'application dans son ensemble, ou juste des éléments spécifiques liés au contexte actuel. Les utilisateurs de Windows 8 s'habitueront à utiliser l'icône Paramètres pour apporter des modifications à l'application, et je souhaite que l'exemple d'application la prenne en charge, car elle est toujours accessible pour l'utilisateur à partir du menu Icônes. En fait, si une application déclare la capacité Internet via le fichier Package.appxmanifest, elle doit implémenter le contrat de paramètres en fournissant un lien vers une déclaration de confidentialité Web quelque part dans le menu Paramètres. Dans la mesure où les applications qui utilisent les modèles Visual Studio 2012 déclarent automatiquement la capacité Internet dès le début, il s'agit là d'un élément qui ne doit pas être négligé.

Lorsqu'un utilisateur appuie sur l'icône Paramètres, le système d'exploitation commence à créer de façon dynamique le menu à afficher. Le menu et le menu volant associé sont contrôlés par le système d'exploitation. Je ne peux pas contrôler les apparences du menu et du menu volant, mais je peux ajouter des options au menu. Un objet appelé SettingsPane m'avertira quand l'utilisateur sélectionnera l'icône Paramètres via l'événement CommandsRequested. L'obtention d'une référence au SettingsPane et l'inscription à l'événement CommandsRequested sont relativement simples :

public void Initialize()
{
  this.SettingsPane = SettingsPane.GetForCurrentView();
  this.SettingsPane.CommandsRequested += 
    SettingsPane_CommandsRequested;
}
private void SettingsPane_CommandsRequested(
  SettingsPane sender, 
  SettingsPaneCommandsRequestedEventArgs args)
{
  // Do stuff ...
}

Le problème ici concerne une autre forte dépendance. Cette fois, la dépendance est le SettingsPane, une autre classe qui ne peut pas être factice. Comme je souhaite pouvoir effectuer des tests unitaires sur le modèle de vue qui utilise le SettingsPane, je dois extraire les références à ce dernier, tout comme je l'ai fait pour les références au DataTransferManager. Il se trouve que mes interactions avec le SettingsPane sont très similaires à mes interactions avec le DataTransferManager :

  1. inscription à l'événement CommandsRequested pour la vue actuelle ;
  2. annulation de l'inscription à l'événement CommandsRequested pour la vue actuelle ;
  3. ajout de mes propres objets SettingsCommand lorsque l'événement est déclenché.

L'interface que je dois extraire ressemble beaucoup à celle d'IShareManager :

public interface ISettingsManager
{
  void Initialize();
  void Cleanup();
  Action<IList<SettingsCommand>> OnSettingsRequested { get; set; }
}

Initialize doit obtenir une référence au SettingsPane et inscrire à l'événement CommandsRequested. Cleanup doit annuler l'inscription à l'événement CommandsRequested. OnShareRequested est l'endroit où je peux définir la méthode qui est appelée lorsque l'événement CommandsRequested a été déclenché. Je peux maintenant implémenter ISettingsManager, comme illustré à la figure 7.

Figure 7 Implémentation d'ISettingsManager

public sealed class SettingsManager : ISettingsManager
{
  private SettingsPane SettingsPane { get; set; }
  public void Initialize()
  {
    this.SettingsPane = SettingsPane.GetForCurrentView();
    this.SettingsPane.CommandsRequested += 
      SettingsPane_CommandsRequested;
  }
  public void Cleanup()
  {
    this.SettingsPane.CommandsRequested -= 
      SettingsPane_CommandsRequested;
  }
  private void SettingsPane_CommandsRequested(
    SettingsPane sender, SettingsPaneCommandsRequestedEventArgs args)
  {
    if (this.OnSettingsRequested != null)
    {
      this.OnSettingsRequested(args.Request.ApplicationCommands);
    }
  }
  public Action<IList<SettingsCommand>> OnSettingsRequested { get; set; }
}

Lorsque l'événement CommandsRequested est déclenché, les arguments d'événement me donne finalement accès à la liste des objets SettingsCommand qui représentent les options du menu Paramètres. Pour ajouter mes propres options du menu Paramètres, je dois juste ajouter une instance SettingsCommand à cette liste. Un objet SettingsCommand n'a pas besoin de grand chose, juste d'un identificateur unique, de texte et code d'étiquette pour s'exécuter lorsque l'utilisateur sélectionne cette option.

J'utilise mon conteneur d'inversion de contrôle (IoC, Inversion of Control) pour injecter une instance d'ISettingsManager dans mon modèle de vue, puis je le configure pour l'initialisation et le nettoyage, comme indiqué dans la figure 8.

Figure 8 Association d'ISettingsManager

public ShellViewModel(ISettingsManager settingsManager)
{
  this.settingsManager = settingsManager;
}
public void Initialize()
{
  this.settingsManager.Initialize();
  this.settingsManager.OnSettingsRequested = 
    OnSettingsRequested;
}
public void Cleanup()
{
  this.settingsManager.Cleanup();
}

Je vais utiliser les paramètres pour permettre aux utilisateurs de modifier les flux RSS qu'ils peuvent afficher avec l'exemple d'application. Je souhaite que l'utilisateur puisse faire cela depuis n'importe quel endroit dans l'application, j'ai donc inclus le ShellViewModel, qui est instancié au démarrage de l'application. Si j'avais voulu que les flux RSS soient modifiés à partir de l'une des autres vues, j'aurais inclus ce code de paramètres dans le modèle de vue associé.

La fonctionnalité intégrée de création et de maintenance d'un menu volant pour les paramètres fait défaut dans le Windows Runtime. Il existe beaucoup plus de codage manuel requis qu'il n'en faudrait pour obtenir cette fonctionnalité qui est supposée être cohérente entre toutes les applications. Par chance, je ne suis pas le seul à penser de la sorte. Tim Heuer, responsable de programme dans l'équipe XAML chez Microsoft, a créé une excellente infrastructure appelée Callisto, qui aide à résoudre ce problème complexe. Callisto est disponible sur GitHub (bit.ly/Kijr1S) et sur NuGet (bit.ly/112ehch). Je l'utilise dans l'exemple d'application et je recommande d'y jeter un œil.

Dans la mesure où le SettingsManager est complètement associé à mon modèle de vue, je dois juste fournir le code à exécuter lorsque les paramètres sont demandés, comme indiqué dans la figure 9.

Figure 9 Affichage de la SettingsView sur SettingsRequested avec Callisto

private void OnSettingsRequested(IList<SettingsCommand> commands)
{
  SettingsCommand settingsCommand =
    new SettingsCommand("FeedsSetting", "Feeds", (x) =>
  {
    SettingsFlyout settings = new Callisto.Controls.SettingsFlyout();
    settings.FlyoutWidth =
      Callisto.Controls.SettingsFlyout.SettingsFlyoutWidth.Wide;
    settings.HeaderText = "Feeds";
    var view = new SettingsView();
    settings.Content = view;
    settings.HorizontalContentAlignment = 
      HorizontalAlignment.Stretch;
    settings.VerticalContentAlignment = 
      VerticalAlignment.Stretch;
    settings.IsOpen = true;
  });
  commands.Add(settingsCommand);
}

Je crée une nouvelle SettingsCommand, en lui donnant l'id « FeedsSetting » et le texte d'étiquette « Feeds ». Le lambda que j'utilise pour le rappel, qui est appelé lorsque l'utilisateur sélectionne l'élément de menu « Feeds », tire parti du contrôle SettingsFlyout de Callisto. Le contrôle SettingsFlyout se charge entièrement du choix de l'endroit où placer le menu volant, de sa largeur et du moment où l'ouvrir et le fermer. Il me suffit de dire si je souhaite une version étendue ou limitée, de lui donner du texte d'en tête et du contenu, puis de définir IsOpen sur true pour l'ouvrir. Je recommande également de définir les HorizontalContentAlignment et VerticalContentAlignment sur Stretch. Sinon, votre contenu ne correspondra pas au SettingsFlyout.

Bus de messages

Lors de la gestion du contrat de paramètres, il est important de noter que tous les changements apportés aux paramètres sont supposés être appliqués et se refléter dans l'application immédiatement. Vous pouvez procéder de différentes façons à la diffusion des modifications apportées aux paramètres par l'utilisateur. Ma méthode préférée est celle du bus de messages (également connu sous le nom d'agrégateur d'événements). Un bus de messages est un système de publication de messages à l'échelle de l'application. Le concept de bus de messages n'est pas intégré dans le Windows Runtime, ce qui signifie que je dois en créer un ou en utiliser un à partir d'une autre infrastructure. J'ai inclus une implémentation de bus de messages que j'ai utilisée dans plusieurs projets avec l'infrastructure Charmed. Vous pouvez trouver la source à l'adresse bit.ly/12EBHrb. Vous y trouverez également plusieurs autres bonnes implémentations. Caliburn.Micro a l'EventAggregator et MVVM Light a le Messenger. Toutes les implémentations suivent généralement le même modèle, en fournissant une façon de s'inscrire, d'annuler son inscription et de publier des messages.

En utilisant le bus de messages Charmed dans le scénario de paramètres, je configure mon MainViewModel (celui qui affiche les flux) pour s'inscrire à un FeedsChangedMessage :

this.messageBus.Subscribe<FeedsChangedMessage>((message) =>
  {
    LoadFeedData();
  });

Une fois que le MainViewModel est configuré pour écouter les changements apportés aux flux, je configure le SettingsViewModel pour publier le FeedsChangedMessage lorsque l'utilisateur ajoute ou supprime un flux RSS :

this.messageBus.Publish<FeedsChangedMessage>(new FeedsChangedMessage());

Chaque fois qu'un bus de messages est impliqué, il est important que chaque partie de l'application utilise la même instance de bus de messages. J'ai donc fait en sorte de configurer mon conteneur IoC pour donner une instance de singleton de chaque requête afin de résoudre un IMessageBus.

L'exemple d'application est maintenant configuré pour permettre à l'utilisateur d'apporter des modifications aux flux RSS affichés via l'icône Paramètres et mettre à jour la vue principale pour refléter ces changements.

Paramètres d'itinérance

Une autre chose pratique introduite par Windows 8 est le concept de paramètres d'itinérance. Les paramètres d'itinérance permettent aux développeurs d'applications de transférer de petites quantités de données sur tous les appareils d'un utilisateur. Ces données doivent être inférieures à 100 Ko et doivent se limiter aux informations nécessaires pour créer une expérience utilisateur persistante et personnalisée sur tous les appareils. Dans le cas d'un exemple d'application, je veux pouvoir conserver les flux RSS que l'utilisateur souhaite lire sur tous ses appareils.

Le contrat de paramètres dont j'ai parlé plus tôt va généralement de pair avec les paramètres d'itinérance. Il est logique que les personnalisations que je permets à l'utilisateur d'effectuer à l'aide du contrat de paramètres soient conservées sur les appareils avec les paramètres d'itinérance.

L'accès aux paramètres d'itinérance est assez simple, comme tous les autres points que j'ai traités jusqu'à présent. La classe ApplicationData donne accès aux LocalSettings et RoamingSettings. Mettre quelque chose dans les RoamingSettings est aussi simple que de fournir une clé et un objet :

ApplicationData.Current.RoamingSettings.Values[key] = value;

Bien qu'il soit facile de travailler avec les ApplicationData, il s'agit d'une autre classe sealed qui ne peut pas être factice dans les tests unitaires. Afin de conserver mes modèles de vue aussi faciles à tester que possible, je dois donc extraire l'interaction avec les ApplicationData. Avant de définir une interface pour extraire la fonctionnalité de paramètres d'itinérance qui sous-tend, je dois décider ce que je souhaite faire avec :

  1. voir si une clé existe ;
  2. ajouter ou mettre à jour un paramètre ;
  3. supprimer un paramètre ;
  4. obtenir un paramètre.

J'ai maintenant ce dont j'ai besoin pour créer une interface que je vais appeler ISettings :

public interface ISettings
{
  void AddOrUpdate(string key, object value);
  bool TryGetValue<T>(string key, out T value);
  bool Remove(string key);
  bool ContainsKey(string key);
}

Mon interface définie, je dois l'implémenter, comme le montre la figure 10.

Figure 10 Implémentation d'ISettings

public sealed class Settings : ISettings
{
  public void AddOrUpdate(string key, object value)
  {
    ApplicationData.Current.RoamingSettings.Values[key] = value;
  }
  public bool TryGetValue<T>(string key, out T value)
  {
    var result = false;
    if (ApplicationData.Current.RoamingSettings.Values.ContainsKey(key))
    {
      value = (T)ApplicationData.Current.RoamingSettings.Values[key];
      result = true;
    }
    else
    {
      value = default(T);
    }
    return result;
  }
  public bool Remove(string key)
  {
    return ApplicationData.Current.RoamingSettings.Values.Remove(key);
  }
  public bool ContainsKey(string key)
  {
    return ApplicationData.Current.RoamingSettings.Values.ContainsKey(key);
  }
}

TryGetValue va vérifier en premier si une clé donnée existe et attribuer la valeur au paramètre de sortie si c'est le cas. Plutôt que de lever une exception si la clé n'est pas trouvée, elle retourne une valeur booléenne indiquant si la clé a été trouvée. Les autres méthodes sont relativement explicites.

Je peux maintenant laisser mon conteneur IoC résoudre les ISettings et les donner à mon SettingsViewModel. Une fois que j'ai fait cela, le modèle de vue va utiliser les paramètres pour charger les flux de l'utilisateur qui sont à modifier, comme indiqué dans la figure 11.

Figure 11 Chargement et enregistrement des flux de l'utilisateur

public SettingsViewModel(
  ISettings settings,
  IMessageBus messageBus)
{
  this.settings = settings;
  this.messageBus = messageBus;
  this.Feeds = new ObservableCollection<string>();
  string[] feedData;
  if (this.settings.TryGetValue<string[]>(Constants.FeedsKey, out feedData))
  {
    foreach (var feed in feedData)
    {
      this.Feeds.Add(feed);
    }
  }
}
public void AddFeed()
{
  this.Feeds.Add(this.NewFeed);
  this.NewFeed = string.Empty;
  SaveFeeds();
}
public void RemoveFeed(string feed)
{
  this.Feeds.Remove(feed);
  SaveFeeds();
}
private void SaveFeeds()
{
  this.settings.AddOrUpdate(Constants.FeedsKey, this.Feeds.ToArray());
  this.messageBus.Publish<FeedsChangedMessage>(new FeedsChangedMessage());
}

Il est bon de remarquer une chose au sujet du code dans la figure 11 : les données que j'enregistre en fait dans les paramètres sont un tableau de chaînes. Dans la mesure où les paramètres d'itinérance sont limités à 100 Ko, je dois éviter de trop compliquer les choses et respecter les types primitifs.

Vignettes secondaires

Le développement d'applications qui font réagir les utilisateurs peut être un défi suffisant. Mais comment fidéliser les utilisateurs une fois qu'ils ont installé votre application ? Les vignettes secondaires peuvent contribuer à relever ce défi. Une vignette secondaire offre la possibilité de créer un lien profond à l'application, en permettant à l'utilisateur de contourner le reste de l'application et d'accéder directement à ce qui l'intéresse le plus. Une vignette secondaire est épinglée sur l'écran d'accueil de l'utilisateur, avec l'icône de votre choix. Lorsque l'utilisateur appuie dessus, la vignette secondaire lance votre application avec des arguments qui indiquent à l'application exactement l'endroit où aller et ce qu'il faut charger. Fournir la fonctionnalité de vignettes secondaires à vos utilisateurs est une bonne façon de leur permettre de personnaliser leur expérience, en les incitant à en redemander.

Les vignettes secondaires sont plus complexes que les autres rubriques traitées dans cet article, car plusieurs choses doivent être implémentées avant que l'expérience complète de l'utilisation des vignettes secondaires fonctionne correctement.

L'épinglage d'une vignette secondaire implique l'instanciation de la classe SecondaryTile. Le constructeur SecondaryTile prend plusieurs paramètres qui lui permettront de déterminer l'apparence de la vignette, y compris un nom d'affichage, un URI vers le fichier image du logo à utiliser pour la vignette, et les arguments de chaîne qui seront donnés à l'application lorsque l'on appuie sur la vignette. Une fois la SecondaryTile instanciée, je dois appeler une méthode qui affichera au final une petite fenêtre contextuelle demandant à l'utilisateur l'autorisation d'épingler la vignette, comme indiqué dans la figure 12.

SecondaryTile Requesting Permission to Pin a Tile to the Start Screen
Figure 12 SecondaryTile demandant à l'utilisateur l'autorisation d'épingler une vignette à l'écran d'accueil

Une fois que l'utilisateur a appuyé sur Épingler à l'écran d'accueil, la première moitié du travail est faite. La deuxième partie consiste à configurer l'application pour prendre en fait en charge le lien profond en utilisant les arguments fournis par la vignette lorsque l'on appuie dessus. Avant d'aborder la deuxième partie, je vais voir comment implémenter la première partie pour qu'elle soit facile à tester.

Dans la mesure où la SecondaryTile utilise les méthodes qui interagissent uniquement avec le système d'exploitation, qui, à son tour, affiche les composants d'interface utilisateur, je ne peux pas l'utiliser directement à partir des modèles de vue sans sacrifier les tests. Je vais donc extraire une autre interface, que je vais appeler ISecondaryPinner (elle devrait me permettre d'épingler et de désépingler une vignette, et de vérifier si une vignette a déjà été épinglée) :

public interface ISecondaryPinner
{
  Task<bool> Pin(FrameworkElement anchorElement,
    Placement requestPlacement, TileInfo tileInfo);
  Task<bool> Unpin(FrameworkElement anchorElement,
    Placement requestPlacement, string tileId);
  bool IsPinned(string tileId);
}

Notez que Pin et Unpin retourne Task<bool>. Cela est dû au fait que la SecondaryTile utilise des tâches asynchrones pour inviter l'utilisateur à épingler ou à désépingler une vignette. Cela signifie également que mes méthodes ISecondaryPinner Pin et Unpin peuvent faire l'objet d'une attente.

Notez également que Pin et Unpin prennent un FrameworkElement et une valeur d'énumération Placement comme paramètres. C'est pour cette raison que la SecondaryTile nécessite un rectangle et un positionnement pour lui indiquer où placer la fenêtre contextuelle de demande d'épinglage. Je prévois que mon implémentation de SecondaryPinner calcule ce rectangle en fonction du FrameworkElement qui est passé.

Finalement, je crée une classe d'assistance appelée TileInfo pour faire circuler les paramètres requis et facultatifs utilisés par la SecondaryTile, comme indiqué dans la figure 13.

Figure 13 Classe d'assistance TileInfo

public sealed class TileInfo
{
  public TileInfo(
    string tileId,
    string shortName,
    string displayName,
    TileOptions tileOptions,
    Uri logoUri,
    string arguments = null)
  {
    this.TileId = tileId;
    this.ShortName = shortName;
    this.DisplayName = displayName;
    this.Arguments = arguments;
    this.TileOptions = tileOptions;
    this.LogoUri = logoUri;
    this.Arguments = arguments;
  }
  public TileInfo(
    string tileId,
    string shortName,
    string displayName,
    TileOptions tileOptions,
    Uri logoUri,
    Uri wideLogoUri,
    string arguments = null)
  {
    this.TileId = tileId;
    this.ShortName = shortName;
    this.DisplayName = displayName;
    this.Arguments = arguments;
    this.TileOptions = tileOptions;
    this.LogoUri = logoUri;
    this.WideLogoUri = wideLogoUri;
    this.Arguments = arguments;
  }
  public string TileId { get; set; }
  public string ShortName { get; set; }
  public string DisplayName { get; set; }
  public string Arguments { get; set; }
  public TileOptions TileOptions { get; set; }
  public Uri LogoUri { get; set; }
  public Uri WideLogoUri { get; set; }
}

TileInfo possède deux constructeurs qui peuvent être utilisés, en fonction des données. J'implémente maintenant ISecondaryPinner, comme indiqué dans la figure 14.

Figure 14 Implémentation d'ISecondaryPinner

public sealed class SecondaryPinner : ISecondaryPinner
{
  public async Task<bool> Pin(
    FrameworkElement anchorElement,
    Placement requestPlacement,
    TileInfo tileInfo)
  {
    if (anchorElement == null)
    {
      throw new ArgumentNullException("anchorElement");
    }
    if (tileInfo == null)
    {
      throw new ArgumentNullException("tileInfo");
    }
    var isPinned = false;
    if (!SecondaryTile.Exists(tileInfo.TileId))
    {
      var secondaryTile = new SecondaryTile(
        tileInfo.TileId,
        tileInfo.ShortName,
        tileInfo.DisplayName,
        tileInfo.Arguments,
        tileInfo.TileOptions,
        tileInfo.LogoUri);
      if (tileInfo.WideLogoUri != null)
      {
        secondaryTile.WideLogo = tileInfo.WideLogoUri;
      }
      isPinned = await secondaryTile.RequestCreateForSelectionAsync(
        GetElementRect(anchorElement), requestPlacement);
    }
    return isPinned;
  }
  public async Task<bool> Unpin(
    FrameworkElement anchorElement,
    Placement requestPlacement,
    string tileId)
  {
    var wasUnpinned = false;
    if (SecondaryTile.Exists(tileId))
    {
      var secondaryTile = new SecondaryTile(tileId);
      wasUnpinned = await secondaryTile.RequestDeleteForSelectionAsync(
        GetElementRect(anchorElement), requestPlacement);
    }
    return wasUnpinned;
  }
  public bool IsPinned(string tileId)
  {
    return SecondaryTile.Exists(tileId);
  }
  private static Rect GetElementRect(FrameworkElement element)
  {
    GeneralTransform buttonTransform =
      element.TransformToVisual(null);
    Point point = buttonTransform.TransformPoint(new Point());
    return new Rect(point, new Size(
      element.ActualWidth, element.ActualHeight));
  }
}

Pin va d'abord vérifier si la vignette demandée n'existe pas déjà, puis va inviter l'utilisateur à l'épingler. Unpin va d'abord vérifier que la vignette demandée existe, puis va inviter l'utilisateur à la désépingler. Les deux retourneront une valeur booléenne indiquant si l'épinglage ou le désépinglage a réussi.

Je peux maintenant injecter une instance d'ISecondaryPinner dans mon modèle de vue et l'utiliser comme indiqué dans la figure 15.

Figure 15 Épinglage et désépinglage avec ISecondaryPinner

public FeedItemViewModel(
  IShareManager shareManager,
  ISecondaryPinner secondaryPinner)
{
  this.shareManager = shareManager;
  this.secondaryPinner = secondaryPinner;
}
public async Task Pin(FrameworkElement anchorElement)
{
  var tileInfo = new TileInfo(
    FormatSecondaryTileId(),
    this.FeedItem.Title,
    this.FeedItem.Title,
    TileOptions.ShowNameOnLogo | TileOptions.ShowNameOnWideLogo,
    new Uri("ms-appx:///Assets/Logo.png"),
    new Uri("ms-appx:///Assets/WideLogo.png"),
    this.FeedItem.Id.ToString());
    this.IsFeedItemPinned = await this.secondaryPinner.Pin(
    anchorElement,
    Windows.UI.Popups.Placement.Above,
    tileInfo);
}
public async Task Unpin(FrameworkElement anchorElement)
{
  this.IsFeedItemPinned = !await this.secondaryPinner.Unpin(
    anchorElement,
    Windows.UI.Popups.Placement.Above,
    this.FormatSecondaryTileId());
}

Dans Pin, je crée une instance de l'assistance TileInfo, en lui donnant un id formaté de façon unique, le titre du flux, les URI vers le logo et le logo large, et l'id du flux comme argument de lancement. Pin prend le bouton qui a été cliqué comme élément d'ancrage pour déterminer l'emplacement de la fenêtre contextuelle de demande d'épinglage. J'utilise le résultat de la méthode SecondaryPinner.Pin pour déterminer si l'élément de flux a été épinglé.

Dans Unpin, je donne l'id formaté de façon unique de la vignette, en utilisant l'inverse du résultat pour déterminer si l'élément de flux est toujours épinglé. Une fois encore, le bouton qui a été cliqué est transmis à Unpin comme élément d'ancrage de la fenêtre contextuelle de demande de désépinglage.

Après avoir mis cela en place et utilisé cela pour épingler un billet de blog (FeedItem) sur l'écran d'accueil, je peux appuyer sur la vignette nouvellement créée pour lancer l'application. Toutefois, l'application sera lancée de la même façon qu'avant, me ramenant à la page principale, en affichant tous les billets de blog. Je veux me retrouver sur les billets de blog spécifiques que j'ai épinglés. C'est là que la deuxième partie de la fonctionnalité entre en jeu.

La deuxième partie de la fonctionnalité va dans le fichier app.xaml.cs, à partir duquel l'application est lancée, comme indiqué à la figure 16.

Figure 16 Lancement de l'application

protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
  Frame rootFrame = Window.Current.Content as Frame;
  if (rootFrame.Content == null)
  {
    Ioc.Container.Resolve<INavigator>().
      NavigateToViewModel<MainViewModel>();
  }
  if (!string.IsNullOrWhiteSpace(args.Arguments))
  {
    var storage = Ioc.Container.Resolve<IStorage>();
    List<FeedItem> pinnedFeedItems =
      await storage.LoadAsync<List<FeedItem>>(Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems != null)
    {
      int id;
      if (int.TryParse(args.Arguments, out id))
      {
        var pinnedFeedItem = pinnedFeedItems.FirstOrDefault(fi => fi.Id == id);
        if (pinnedFeedItem != null)
        {
          Ioc.Container.Resolve<INavigator>().
            NavigateToViewModel<FeedItemViewModel>(
            pinnedFeedItem);
        }
      }
    }
  }
  Window.Current.Activate();
}

J'ai ajouté du code à la fin de la méthode OnLaunched remplacée pour vérifier si les arguments ont été transmis lors du lancement. Si les arguments ont été transmis, j'analyse les arguments dans un int à utiliser comme id de flux. J'obtiens le flux avec cet id à partir des flux enregistrés et je le transmets au FeedItemViewModel à afficher. Il est important de noter que je vérifie que la page principale de l'application est déjà affichée, et que j'y accède en premier si elle n'est pas affichée. De cette manière, l'utilisateur peut appuyer sur le bouton Précédent et se retrouver sur la page principale que l'application soit exécutée ou non.

Pour résumer

Dans cet article, j'ai exposé mon approche de l'implémentation d'une application du Windows Store facile à tester avec le modèle MVVM, tout en tirant parti de certaines des fascinantes nouvelles fonctionnalités proposées par Windows 8. En particulier, j'ai présenté l'extraction du partage, des paramètres, des paramètres d'itinérance et des vignettes secondaires dans des classes d'assistance qui implémentent des interfaces pouvant être factices. À l'aide de cette technique, je peux effectuer des tests unitaires sur autant de fonctionnalités de mon modèle de vue que possible.

Dans des articles futurs, j'aborderai davantage de spécificités sur la façon dont je peux en fait écrire des tests unitaires pour ces modèles de vue maintenant que je les ai configurés pour être plus faciles à tester. J'examinerai également comment ces mêmes techniques peuvent être appliquées pour que mes modèles de vue soient interplateforme avec Windows Phone 8, tout en les conservant faciles à tester.

En planifiant un peu, vous pouvez créer une application fascinante avec une expérience utilisateur innovante qui tire parti des nouvelles fonctionnalités clés de Windows 8, sans sacrifier les meilleures pratiques ni les tests unitaires.

Brent Edwards est consultant principal associé chez Magenic, une société de développement personnalisé d'applications qui se concentre sur la pile Microsoft et le développement d'applications mobiles. Il est également cofondateur du groupe d'utilisateurs de Windows 8 Twin Cities à Minneapolis dans le Minnesota. Vous pouvez le contacter à cette adresse : brente@magenic.com.

Merci à l'expert technique suivant d'avoir relu cet article : Rocky Lhotka (Magenic)
Rockford Lhotka est directeur technique chez Magenic et créateur de l'infrastructure de développement la plus utilisée CSLA .NET. Il est l'auteur de nombreux ouvrages et s'exprime régulièrement lors de conférences majeures sur le développement dans le monde entier. Rockford Lhotka est directeur régional Microsoft et MVP. Magenic (www.magenic.com) est une société spécialisée dans la planification, la conception, la création et la maintenance des systèmes essentiels à la mission de votre entreprise. Pour plus d'informations, consultez www.lhotka.net.