Canalblog
Editer l'article Suivre ce blog Administration + Créer mon blog
Publicité
Fanfois et ses papillons
19 février 2012

Aspirateur 12 (MVVM, finalisation de l'IHM)

Dans ce billet nous allons finaliser notre IHM, en tout cas la mettre à un niveau qui nous permettra de pouvoir nous consacrer au fonctionnel, donc à la récupération des pages d'un site web.

Pour commencer, nous allons nous occuper des domaines connexes. Actuellement ils ne sont même pas affichés. C'est le dernier gros manque de notre IHM, il est important de s'en occuper rapidement.
Au fait, de quoi avons-nous vraiment besoin ?

Nous avons besoin de connaître tous les domaines reliés à un site et nous avons besoin de pouvoir modifier facilement le traitement que nous voulons voir appliqué à chacun de ces domaines.

Pour cela, nous pouvons reprendre le principe de la vue Maître/Détail déjà mis en œuvre pour la liste des sites web. Avantage, nous savons déjà le faire. Inconvénient, il faut de la place et nous n'en avons pas forcement beaucoup.

L'autre solution consiste à n'utiliser qu'une liste. Si nous parvenons à afficher de manière suffisamment compacte les informations utiles, toute l'information utile sera disponible dans la liste et le détail n'aura aucun intérêt.

Nous allons tenter cette seconde méthode. Dans le pire des cas nous aurons développé une liste que nous réutiliserons dans un contexte Maître/Détail, au mieux nous aurons tout bon.

Nous ajoutons donc au projet Aspirateur.Views un nouveau contrôle utilisateur ListeDeDomaines. Nous n'allons pas mettre en place un filtre sur les éléments de la liste. La raison principale n'est pas l'absence d'un critère au niveau d'Aspirateur.ServicesXML, mais le manque de place dans l'IHM. Ajouter un critère à Aspirateur.ServicesXML n'est pas compliqué, au pire une dizaine de lignes de code (le reste étant généré par Visual Studio lors de l'ajout de la classe nécessaire). Si vous ne trouvez pas l'IHM trop chargée, libre à vous d'ajouter un filtre. Sans filtre, la définition du contrôle est triviale :

<UserControl x:Class="Aspirateur.Views.ListeDeDomaines"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             mc:Ignorable="d"
             d:DesignHeight="300" d:DesignWidth="300">
    <DockPanel Margin="5">
        <ListBox Width="Auto" VerticalAlignment="Stretch" HorizontalContentAlignment="Stretch"
                 ItemsSource="{Binding Items, Mode=OneWay}" SelectionMode="Extended"
                 ItemTemplate="{DynamicResource DomainViewModelDansListeDataTemplate}"
                 SelectedIndex="0" />
    </DockPanel>
</UserControl>

La vue référence un template de données qui n'existe pas. Nous le définissons, en nous inspirant du template des sites web :

<DataTemplate x:Key="DomainViewModelDansListeDataTemplate">
    <Grid Margin="5" Background="Transparent">
        <Grid.InputBindings>
            <KeyBinding Command="{DynamicResource OuvrirFicheDomaineCommand}"
                        Gesture="CTRL+R" />
        </Grid.InputBindings>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="28" />
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="44" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="0.45*" />
            <RowDefinition Height="0.55*" />
        </Grid.RowDefinitions>
        <Rectangle Height="18" Width="18"
                   HorizontalAlignment="Center" VerticalAlignment="Center"
                   Grid.Column="0" Grid.Row="0" Grid.RowSpan="2"
                   Fill="{DynamicResource WebSiteBrush}" />
        <TextBlock Text="{Binding Domain.Authority}" FontWeight="Bold"
                   HorizontalAlignment="Left" VerticalAlignment="Stretch"
                   Grid.Column="1" Grid.Row="0" />
        <TextBlock Text="{Binding Domain.Subdirectory}"
                   HorizontalAlignment="Left" VerticalAlignment="Bottom"
                   Grid.Column="1" Grid.Row="1" />
        <Button Content="Editer"
                HorizontalAlignment="Center" VerticalAlignment="Center"
                Grid.Column="2" Grid.Row="0" Grid.RowSpan="2"
                Style="{DynamicResource ButtonHyperlinkStyle}"
                Command="{Binding OuvrirFicheDomaineCommand, Mode=OneWay}"
                CommandParameter="{Binding DataContext, RelativeSource={RelativeSource Mode=Self}}" />
    </Grid>
</DataTemplate>

Avant de nous occuper du ViewModel nécessaire pour fournir les données à afficher, nous allons mettre en place notre nouvelle vue dans la vue de détail d'un site web. A cette occasion divers repositionnements ont été réalisés (et pas que dans cette vue), voici donc le xaml complet de la vue :

<UserControl x:Class="Aspirateur.Views.DetailSite"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:Aspirateur.Views"
             mc:Ignorable="d"
             d:DesignHeight="300" d:DesignWidth="300">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <TextBlock Text="URL de départ :" TextWrapping="Wrap" Style="{DynamicResource TextBlockHeaderStyle}" Margin="5,5,0,5" HorizontalAlignment="Right" />
        <TextBlock Text="{Binding Site.BaseURL}" Grid.Column="1" TextWrapping="Wrap" Margin="5,5,10, 5" VerticalAlignment="Center" />
        <TextBlock Text="Répertoire racine :" Grid.Row="1" TextWrapping="Wrap" Style="{DynamicResource TextBlockHeaderStyle}" Margin="5,5,0,5" HorizontalAlignment="Right" />
        <TextBlock Text="{Binding Site.BaseDirectory}" Grid.Column="1" Grid.Row="1" TextWrapping="Wrap" Margin="5,5,10, 5" VerticalAlignment="Center" />
        <TextBlock Text="Nom par défaut :" Grid.Row="2" TextWrapping="Wrap" Style="{DynamicResource TextBlockHeaderStyle}" Margin="5,5,0,5" HorizontalAlignment="Right" />
        <TextBlock Text="{Binding Site.DefaultName}" Grid.Column="1" Grid.Row="2" TextWrapping="Wrap" Margin="5,5,10, 5" VerticalAlignment="Center" />
        <TextBlock Text="Extension par défaut :" Grid.Row="3" TextWrapping="Wrap" Style="{DynamicResource TextBlockHeaderStyle}" Margin="5,5,0,5" HorizontalAlignment="Right" />
        <TextBlock Text="{Binding Site.DefaultExtension, Converter={StaticResource EnumToDescriptionConverter}}" Grid.Column="1" Grid.Row="3" TextWrapping="Wrap" Margin="5,5,10, 5" VerticalAlignment="Center" />
        <local:ListeDeDomaines Grid.Row="4" Grid.ColumnSpan="2" VerticalAlignment="Stretch" />
        <Button Content="Aspire" Grid.Row="5" Grid.ColumnSpan="2" Margin="0,10,0,5" HorizontalAlignment="Center" Width="100" Command="{Binding AspirerSiteCommand, Mode=OneWay}" />
    </Grid>
</UserControl>

Pour remplir la liste des domaines, nous avons besoin d'un ViewModel. Nous créons donc le ViewModel DomainViewModel. Son code ne présente pas de difficultés particulières, il est très proche de celui de WebSiteViewModel :

using System;
using MVVM.Framework;
using MVVM.Framework.IOC;
using MVVM.Framework.Model.Services;
using Aspirateur.Model;
using Aspirateur.Model.Services;
using System.ComponentModel;
using System.Collections.ObjectModel;
using AspirateurUtil;
using System.IO;
 
namespace Aspirateur.ViewModels
{
    public class DomainViewModel : ViewModelBase
    {
        /// <summary>
        /// Etats possibles pour le ViewModel.
        /// </summary>
        public enum Etats
        {
            Parfait,
            ADesErreurs
        }
  
        #region Membres
        private Etats _etat = Etats.Parfait;
        private IDialogService _windowServices;
        private DomainModele _domain = null;
        #endregion Membres
  
        #region Proprietes
        #region EtatCourant
        public Etats EtatCourant
        {
            get { return _etat; }
            set
            {
                if (_etat == value)
                {
                    return;
                }
                _etat = value;
                RaisePropertyChanged<Etats>(() => EtatCourant);
                RaisePropertyChanged<bool>(() => IsEnabled);
            }
        }
        #endregion EtatCourant
 
        #region IsEnabled
        public bool IsEnabled
        {
            get { return _etat == Etats.Parfait; }
        }
        #endregion IsEnabled
 
        #region Domain
        /// <summary>
        /// Le domaine exposé par le ViewModel.
        /// </summary>
        public DomainModele Domain
        {
            get { return _domain; }
            set
            {
                if (_domain != value)
                {
                    _domain = value;
                    RaisePropertyChanged<DomainModele>(() => Domain);
                }
            }
        }
        #endregion Domain
 
        #region Commandes
        #region OuvrirFicheDomaineCommand
        public ProxyCommand<DomainViewModel> OuvrirFicheDomaineCommand { get; private set; }
        #endregion OuvrirFicheDomaineCommand
        #endregion Commandes
        #endregion Proprietes
 
        #region Constructeurs / destructeur
        /// <summary>
        /// Pour blend.
        /// </summary>
        public DomainViewModel()
        {
        }
 
        /// <summary>
        /// Le constructeur normal.
        /// </summary>
        /// <param name="webSiteModele">Le domaine géré.</param>
        /// <param name="synchroniseAvecLaSelection">Y a-t-il une liaison avec une liste ?</param>
        public DomainViewModel(DomainModele domainModele, bool synchroniseAvecLaSelection)
        {
            Domain = domainModele;
            domainModele.PropertyChanged += DomainModele_PropertyChanged;
            InitialisationDuMessenger(synchroniseAvecLaSelection);
        }
        #endregion Constructeurs / destructeur
 
        #region Gestionnaires d'événements
        /// <summary>
        /// Synchronisation de l'état du ViewModel avec celui du Model.
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        void DomainModele_PropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (e.PropertyName == "ADesErreurs")
            {
                EtatCourant = Domain.ADesErreurs ? Etats.ADesErreurs : Etats.Parfait;
            }
        }
        #endregion Gestionnaires d'événements
 
        #region Fonctions surchargées
        protected override void InitialisationDesServices()
        {
            _windowServices = ServiceLocator.Instance.Retrieve<IDialogService>();
        }
 
        protected override void InitialisationDesCommandes()
        {
            OuvrirFicheDomaineCommand = new ProxyCommand<DomainViewModel>(_ => OuvrirFicheDomaine(), this);
        }
        #endregion Fonctions surchargées
 
        #region Helper
        /// <summary>
        /// Initialisation des communications via le Messenger.
        /// </summary>
        /// <param name="synchroniseAvecLaSelection">Y a-t-il une liaison avec une liste ?</param>
        private void InitialisationDuMessenger(bool synchroniseAvecLaSelection)
        {
            if (synchroniseAvecLaSelection)
            {
                // Obtention du messenger via le localisateur de services.
                var messenger = ServiceLocator.Instance.Retrieve<IMessenger>();
 
                // Enregistrement sur l'événement SITE_SELECTIONNE_CHANGE.
                messenger.Register<DomainModele>(Evenements.DOMAINE_SELECTIONNE_CHANGE, (domainEvt) =>
                {
                    // Le DomainModele courant a changé, on le répercute sur le ViewModel.
                    Domain = domainEvt;
                });
            }
        }
 
        /// <summary>
        /// Fonction associée à la commande d'ouverture du descriptif du domaine.
        /// </summary>
        private void OuvrirFicheDomaine()
        {
            IDomainsService domainService = ServiceLocator.Instance.Retrieve<IDomainsService>();
            Domain.BeginEdit();
            _windowServices.OuvrirFenetreSauvegardeOuAnnulation(
                // Titre de la fenêtre.
                String.Format("Edition du domaine : {0}", Domain.Authority.ToString()),
                // Taille de la fenêtre.
                200, 500,
                // Elément à afficher
                this,
                // Méthode appelée en cas de sauvegarde, on sauvegarde effectivement.
                (_) =>
                {
                    Domain.EndEdit();
                    bool ok = domainService.MettreAJour(Domain);
                    domainService.AppliquerLesChangements();
                    return ok;
                },
                // Méthode appelée en cas d'annulation des modification.
                (_) =>
                {
                    Domain.CancelEdit();
                    return true;
                }
            );
        }
        #endregion Helper
    }
}

Ce code demande au localisateur de trouver une implémentation d'IDomainsService. Il faut donc lui indiquer qui implémente cette interface. Cela se fait dans le projet Aspirateur.Main, fichier App.xaml.cs, fonction EnregistrerServices. Nous ajoutons le code d'enregistrement du service correspondant :

IOCAlias.ServiceLocator.Instance.Register<IDomainsService>(new DomainsService());

Le code de DomainViewModel a également besoin du template d'édition d'un domaine. En nous inspirant de celui des sites web, nous ajoutons au fichier de template (projet Aspirateur.Views) le code suivant :

<DataTemplate DataType="{x:Type viewModels:DomainViewModel}">
    <Grid x:Name="grid1" Height="Auto" Width="Auto" Margin="5"
          bhv:EtatVisuel.EtatVisuel="{Binding EtatCourant}">
        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="CommonStates">
                <VisualStateGroup.Transitions>
                    <VisualTransition GeneratedDuration="0:0:1" To="ADesErreurs">
                        <Storyboard AutoReverse="False">
                            <ThicknessAnimationUsingKeyFrames
                                Storyboard.TargetProperty="(FrameworkElement.Margin)"
                                Storyboard.TargetName="grid1">
                                <EasingThicknessKeyFrame KeyTime="0:0:0.4" Value="14,5,5,5">
                                    <EasingThicknessKeyFrame.EasingFunction>
                                        <BounceEase EasingMode="EaseIn" Bounciness="-5" />
                                    </EasingThicknessKeyFrame.EasingFunction>
                                </EasingThicknessKeyFrame>
                                <EasingThicknessKeyFrame KeyTime="0:0:0.6" Value="5" />
                            </ThicknessAnimationUsingKeyFrames>
                        </Storyboard>
                    </VisualTransition>
                    <VisualTransition GeneratedDuration="0:0:1" />
                </VisualStateGroup.Transitions>
                <VisualState x:Name="ADesErreurs" />
                <VisualState x:Name="Parfait" />
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>
 
        <Grid x:Name="grid" d:LayoutOverrides="Width" VerticalAlignment="Top"
              Background="{x:Null}" Margin="0,0,5,0" DataContext="{Binding Mode=OneWay}">
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <TextBlock Text="Domaine : " Grid.Row="0" Margin="0,8,0,0" />
            <TextBox x:Name="textBoxAuthority" IsReadOnly="True"
                     Text="{Binding Domain.Authority, ValidatesOnDataErrors=true}"
                     Grid.Column="1" HorizontalAlignment="Stretch"
                     Grid.Row="0" Margin="0,5,0,0" />
            <TextBlock Text="Répertoire racine : " Grid.Row="1" Margin="0,8,0,0" />
            <TextBox x:Name="textBoxSubdirectory" IsReadOnly="True"
                     Text="{Binding Domain.Subdirectory, ValidatesOnDataErrors=true}"
                     Grid.Column="1" HorizontalAlignment="Stretch"
                     Grid.Row="1" Margin="0,5,0,0" />
            <CheckBox x:Name="checkBoxResources" IsEnabled="False"
                      IsThreeState="False" Content="Récupération des ressources ?"
                      IsChecked="{Binding Domain.Resources, ValidatesOnDataErrors=true}"
                      Grid.ColumnSpan="2" Grid.Row="2" Margin="0,5,0,0" />
            <CheckBox x:Name="checkBoxPages" IsEnabled="False"
                      IsThreeState="False" Content="Récupération des pages ?"
                      IsChecked="{Binding Domain.Pages, ValidatesOnDataErrors=true}"
                      Grid.ColumnSpan="2" Grid.Row="3" Margin="0,5,0,0" />
        </Grid>
    </Grid>
    <DataTemplate.Triggers>
        <DataTrigger Binding="{Binding Domain.EstEnEdition}" Value="True">
            <!--<Setter TargetName="grid" Property="Background" Value="PaleGreen" />-->
            <Setter TargetName="textBoxAuthority" Property="IsReadOnly" Value="False" />
            <Setter TargetName="textBoxSubdirectory" Property="IsReadOnly" Value="False" />
            <Setter TargetName="checkBoxResources" Property="IsEnabled" Value="True" />
            <Setter TargetName="checkBoxPages" Property="IsEnabled" Value="True" />
        </DataTrigger>
    </DataTemplate.Triggers>
</DataTemplate>

Pour finir nous avons besoin de modifier le ViewModel WebSiteViewModel pour que ce dernier expose la liste de ses domaines connexes. Pour cela nous utilisons la classe de base ListViewModelBase et implémentons les fonctions qu'elle requiert. Les modifications à apporter au code sont les suivantes (ne sont présentées que les parties modifiées) :

public class WebSiteViewModel : ListViewModelBase<DomainViewModel>
 
#region Membres
private IMessenger _messenger;
#endregion Membres
 
#region Fonctions surchargées
protected override void InitialisationDesServices()
{
    _windowServices = ServiceLocator.Instance.Retrieve<IDialogService>();
    _messenger = ServiceLocator.Instance.Retrieve<IMessenger>();
}
 
protected override ObservableCollection<DomainViewModel> ChargerItems()
{
    EstEnCoursDeTraitement = true;
    ObservableCollection<DomainViewModel> listeTemp = new ObservableCollection<DomainViewModel>();
    // Parcours des domaines et création des ViewModel correspondants.
    foreach (var domainModele in Site.DomainList)
    {
        DomainViewModel domainViewModel = new DomainViewModel(domainModele.Value, false);
        listeTemp.Add(domainViewModel);
    }
    EstEnCoursDeTraitement = false;
    return listeTemp;
}
 
/// <summary>
/// L'élément courant de la liste a changé.
/// </summary>
protected override void ElementCourantChanged()
{
    // On déclenche l'événement "site courant à changé".
    _messenger.NotifyColleagues(Evenements.DOMAINE_SELECTIONNE_CHANGE, ElementCourant);
}
#endregion Fonctions surchargées

Si vous lancez le programme, vous ne verrez probablement rien dans la liste des domaines. Pour voir quelque chose il faut modifier le fichier Config.xml pour qu'il contienne au moins un domaine. Je vous propose le Config.xml utilisé pour mes copies d'écran :

<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<Root xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <WebSites>
    <WebSite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Id="cb86ed32-a4fe-47e1-8dea-9b3e239419b2">
      <BaseURL>http://www.canalblog.com/</BaseURL>
      <BaseDirectory>d:\test\canalblog\</BaseDirectory>
      <DefaultName>Canalblog</DefaultName>
      <DefaultExtension>0</DefaultExtension>
      <DomainList>
        <item>
          <key>
            <string>https://storage.canalblog.com</string>
          </key>
          <value>
            <guid>3612884e-671b-451e-a3f1-a33f96a3dc87</guid>
          </value>
        </item>
      </DomainList>
    </WebSite>
    <WebSite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Id="4bf9de02-877c-4a40-8df1-17bf249cb5d9">
      <BaseURL>http://mifanfois.canalblog.com/</BaseURL>
      <BaseDirectory>d:\test\mifanfois\</BaseDirectory>
      <DefaultName>Mifanfois</DefaultName>
      <DefaultExtension>0</DefaultExtension>
      <DomainList />
    </WebSite>
    <WebSite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Id="4bf9de01-877c-4a40-8df1-17bf249cb5d9">
      <BaseURL>http://lepresentdefini.canalblog.com/</BaseURL>
      <BaseDirectory>d:\test\lepresentdefini\</BaseDirectory>
      <DefaultName>LePresentDefini.</DefaultName>
      <DefaultExtension>0</DefaultExtension>
      <DomainList />
    </WebSite>
    <WebSite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Id="4bf9de03-877c-4a40-8df1-17bf249cb5d9">
      <BaseURL>http://beaualalouche.canalblog.com/</BaseURL>
      <BaseDirectory>d:\test\beaualalouche\</BaseDirectory>
      <DefaultName>BeauALaLouche</DefaultName>
      <DefaultExtension>0</DefaultExtension>
      <DomainList />
    </WebSite>
  </WebSites>
  <Domains>
    <Domain xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Id="3612884e-671b-451e-a3f1-a33f96a3dc87">
      <Authority>https://storage.canalblog.com/</Authority>
      <Subdirectory>storage\</Subdirectory>
      <Pages>false</Pages>
      <Resources>true</Resources>
    </Domain>
  </Domains>
</Root>

Si vous lancez l'aspirateur, vous devez obtenir quelque chose de ce type :

Aspirateur 12 01

Si vous tentez d'éditer le domaine, l'IHM vous indiquera un problème sur le nom du sous-répertoire :

Aspirateur 12 02

En fait il n'y a pas d'erreur dans le nom du sous-répertoire, mais un bug dans l'expression régulière utilisée pour valider le texte représentant le sous-répertoire. Le bogue date d'une recopie dans le C# où deux blocs de deux \ n'ont pas été doublés. La bonne expression régulière est la suivante (projet Aspirateur.Model, répertoire Models, fichier DomainModele.cs, propriété Subdirectory) :

[RegularExpression("^[^\\\\/:*\"<>|]+(?:(\\\\[^\\\\/:*\"<>|]+)*)\\\\?$", ErrorMessage = "Sous-répertoire non reconnu.")]

Visuellement nous n'irons pas plus loin, dans la présentation des données, la partie MVVM est (quasiment) finie. Nous allons maintenant nous concentrer sur le cœur fonctionnel de notre programme, à savoir aspirer un site web (au cas où certains auraient un doute).

Comme j'en voie qui râlent, une petite explication sur le pourquoi de cette décision avec une liste non exhaustive des choses assez évidentes que nous aurions pu faire :

  • ajouter une commande pour supprimer un site web de la liste. Nous dirons que cela constitue un très bon exercice pour ceux qui veulent allez plus loin. En plus, compte tenu de ce que nous avons déjà vue, ce n'est pas très compliqué à faire car IServiceBase propose la méthode Supprimer. La seule chose à ne pas oublier c'est la suppression des domaines connexes au site web. En cas d'oubli, l'aspirateur fonctionnera, mais le fichier xml sera inutilement gros et potentiellement plus long à traiter par l'aspirateur ;
  • proposer un tri sur les listes. En effet lorsqu'il y aura des tonnes d'éléments dans les listes, cela pourrait aider. D'un autre côté, il y a le filtre sur les sites web.
    Pardon ?
    Quoi ?
    Ha, il n'y a rien pour les domaines et c'est là que ça serait le plus utile.
    Dommage. En fait non, à vous de jouer ;
  • refactoriser le code ! Depuis le début de ce billet, nous passons notre temps à dupliquer du code !
    Refactoriser... C'est effectivement une idée qui m'a traversée la tête. Plus, je me suis lancé dans la chose et l'ai même menée à son terme. Sauf que cette refactorisation n'est pas simple. A l'arrivée je battais haut la main mon propre record du billet le plus long. Même si certains doivent penser que ce n'est pas le genre de chose qui m'arrête, et bien si. En fait, à la fin du fin, on (re)tombe toujours sur le problème ObservedBase.cs. Donc pour vraiment refactoriser il faut commencer par s'occuper de ObservedBase.cs pour qu'il n'y en ait qu'une. Les plus vifs d'esprit, ou ceux qui ont tenté la chose, ont compris que le problème vient du fait qu'il faut utiliser MVVM.Framework.Model dans MVVM.Framework. Souhaitant vraiment recentrer cette série de billets sur l'aspiration d'un site web, je laisse tomber la refactorisation. Donc, oui, nous avons deux codes très proches et au moins l'état du ViewModel est factorisable dans une classe dérivée de ViewModelBase, voir directement dans ViewModelBase. Dans le cadre de ce billet, aucune refactorisation. Pas même celle liée à DataTemplates.xaml.

Cela dit nous n'en avons pas pour autant fini avec ce billet.
Nous allons nous occuper d'éviter le blocage de l'IHM lorsque l'utilisateur à l'idée, saugrenue, de cliquer sur le bouton "Aspire".
Le code récupéré de MVVM de la découverte à la maîtrise propose une solution, solution qui est déjà mise en œuvre lors du chargement de la liste des sites web. Pourquoi ne pas utiliser cette même solution ?

Et bien en fait, il n'y a aucune raison. Cela dit, avant de nous lancer, nous devons prendre une décision.
Si on ne bloque plus l'IHM, quelles possibilités allons-nous laisser à l'utilisateur ?
Et bien la réponse la plus logique est, AUCUNE !
Et, NON, ce choix n'est pas guidé par une problématique technique, c'est un choix fonctionnel.

Supposons qu'une personne, appelons la A, utilise notre aspirateur.
A lance donc l'aspiration du site "www.xyz.com".
Pour l'exemple, nous allons supposer que cette dernière dure un certain temps, un temps certain même.
Comme l'IHM lui permet de le faire, A se balade dans la liste des sites et décide d'en aspirer un second.
Coté code, nous devons être thread-safe pour éviter une explosion en vol latente.
Mais ce n'est pas le plus important. A un moment ou à un autre, nous devrons probablement envisager le multi-threading lors de la récupération des ressources. Maintenant où plus tard ce n'est pas la question.
Non, le problème c'est que A n'a pas doublé sa bande passante en cliquant sur notre bouton. Résultat des courses, l'aspiration du premier site n'est pas prête de se finir et la seconde non plus. Evidement si A décide d'aspirer un troisième site...
Le plus simple, pour nous et nos (nombreux) utilisateurs, c'est donc de faire comme au chargement de la liste des sites.
Les petits malins qui ont testés doivent rire sous cape. Nous y reviendrons, les autres n'ont pas besoin de s'inquiéter.
Nous souhaitons donc faire apparaître l'animation d'attente et son bouton d'annulation.
Pardon ?
Vous n'avez pas vu de bouton d'annulation.
Regardez bien le code, il devrait y en avoir un. S'il n'apparait pas c'est qu'il y a un bogue.

En fait nous avons "oune petite problaimeu".
Nous ne sommes pas dans le bon ViewModel.
Et alors, la communication entre ViewModel n'a plus aucun secret pour nous.
Nous définissons de ce pas deux nouveaux événements.

/// <summary>
/// Evénements correspondant à un abandon d'action en cours.
/// </summary>
public const String ABANDON_TRAVAIL = "ABANDON_TRAVAIL";
 
/// <summary>
/// Evénements correspondant à un début (ou une fin) de travail.
/// </summary>
public const String AU_TRAVAIL = "AU_TRAVAIL";
  • AU_TRAVAIL, permet à un ViewModel de dire "je commence un travail" ou "je viens de finir un travail". La différence se fait grâce au booléen passé en paramètre, true pour un début, false pour une fin. Ce message permettra au ViewModel responsable du blocage de l'IHM de faire ce qu'il faut. Notons que, comme nous allons bloquer l'IHM il n'est pas utile que les autres ViewModel s'abonne à cet événement pour savoir si un autre ViewModel commence un travail. En effet, ils ne pourront plus être appelé par l'utilisateur, ils n'ont pas à se poser de questions. Si nous avions décidé de ne pas tout bloquer, une décision devrait être prise au cas par cas par chaque ViewModel encore appelable par l'utilisateur. Mine de rien la chose peu vite devenir complexe.
  • ABANDON_TRAVAIL, permet de faire savoir que l'utilisateur a demandé l'abandon du travail en cours.

Avec ce mécanisme, simple, il nous est possible de gérer tout ce que nous avons besoin de gérer.
Notez que le mécanisme n'impose pas à qui que ce soit de savoir ce que fait l'autre. La seule chose connue est, "un travail est en cours". Parmi tous les ViewModel, un est responsable du blocage de l'IHM, et un autre sait ce qui se passe car c'est lui qui travaille. Les autres pourraient juste se poser la question, "si un travail est en cours, quelles actions dois-je autoriser ?". Dans notre cas, la question ne se pose pas.

Passons au codage de la chose. Nous allons commencer par rendre visible le bouton d'annulation de l'action en cours.
Il ne l'est pas pour deux raisons. Premièrement il est un poil petit et sans texte. Deuxièmement, la commande qui lui est associée répond toujours qu'elle n'est pas active.
Nous commençons par résoudre le second problème en modifiant le code de ListViewModelBase. En effet lorsqu'EstEnCoursDeTraitement est modifiée, il serait souhaitable de réévaluer les commandes qui lui sont associées. Pour cela il faut ajouter à la fin du set de la propriété la ligne de code DeclencherLocalCanExecuteChanged();. Maintenant que le bouton peut être actif, nous allons le rendre visible et lui associer une commande qui existe.
Pour cela nous corrigeons le binding dans SitesMaitreDetail.xaml. Nous allons en profiter pour utiliser un nom de commande plus approprié à ce que nous mettons en place. Nous passons donc d'AnnulerChargementCommand à AnnulerActionCommand. Cette modification nous conduit à modifier SitesMaitreDetail.xaml et WebSiteMasterDetailViewModel.cs :

<ctrl:AttenteControl x:Class="Aspirateur.Views.SitesMaitreDetail"
                     xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                     xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                     xmlns:viewModel="http://aspirateur-mvvm.fr/ViewModels"
                     xmlns:ctrl="http://aspirateur-mvvm.fr/Controls"
                     xmlns:local="clr-namespace:Aspirateur.Views"
                     Command="{Binding AnnulerActionCommand}"
                     EstEnAttente="{Binding EstEnCoursDeTraitement, Mode=TwoWay}">
#region Membres
private ProxyCommand<WebSiteMasterDetailViewModel> _annulerActionCommand = null;
#endregion Membres
 
#region Proprietes
#region Commandes
#region AnnulerActionCommand
public ProxyCommand<WebSiteMasterDetailViewModel> AnnulerActionCommand
{
    get { return _annulerActionCommand; }
    set
    {
        if (_annulerActionCommand != value)
        {
            _annulerActionCommand = value;
            RaisePropertyChanged<ProxyCommand<WebSiteMasterDetailViewModel>>(
                () => AnnulerActionCommand);
        }
    }
}
#endregion AnnulerActionCommand
#endregion Commandes
#endregion Proprietes
 
#region Fonctions surchargées
protected override void InitialisationDesCommandes()
{
    ...
    AnnulerActionCommand = new ProxyCommand<WebSiteMasterDetailViewModel>(
        _ => StopperAction(),
        _ => EstEnCoursDeTraitement,
        this);
}
 
#region Helper
private void StopperAction()
{
    this.EstEnCoursDeTraitement = false;
}
#endregion Helper

Si vous lancez l'application maintenant, vous ne voyez toujours pas le bouton d'annulation du chargement. En fait il est là, mais comme il ne possède pas de titre par défaut, il est franchement très petit. Nous allons lui ajouter un texte par défaut passe partout, "Abandon". Pour cela nous modifions AttenteControl.generic.xaml :

<Button x:Name="PART_BoutonAnnuler"
        Command="{TemplateBinding Command}"
        CommandTarget="{TemplateBinding CommandTarget}"
        CommandParameter="{TemplateBinding CommandParameter}"
        Grid.Column="1" Grid.Row="3"
        HorizontalAlignment="Center" VerticalAlignment="Top"
        Visibility="Collapsed">Abandon</Button>

A ce stade le bouton devient visible lors du chargement initial de l'application. Compte tenu du code de StopperAction, il n'est par contre pas très utile.
Pour le rendre vraiment utile, nous commençons par modifier WebSiteMasterDetailViewModel.cs pour pouvoir recevoir les indications de début ou de fin de travail et prévenir d'un éventuel abandon de la tâche en cours :

        #region Membres
        private bool _travailExterieur;
        #endregion Membres
 
        #region Fonctions surchargées
        /// <summary>
        /// Obtention et initialisation des services.
        /// </summary>
        protected override void InitialisationDesServices()
        {
            // Obtention des services à partir du ServiceLocator.
            _siteService = ServiceLocator.Instance.Retrieve<IWebSitesService>();
            _messenger = ServiceLocator.Instance.Retrieve<IMessenger>();
 
            // On en profite pour s'enregistrer auprès du messenger.
            _messenger.Register(Evenements.LISTE_SITES_CHANGE, Rafraichir);
            _travailExterieur = false;
            _messenger.Register<bool>(Evenements.AU_TRAVAIL, (auTravail) =>
            {
                EstEnCoursDeTraitement = _travailExterieur = auTravail;
            });
        }
        #endregion Fonctions surchargées
 
        #region Helper
        private void StopperAction()
        {
            if (_travailExterieur)
            {
                _messenger.NotifyColleagues(Evenements.ABANDON_TRAVAIL);
            }
            else
            {
                this.EstEnCoursDeTraitement = false;
            }
        }
        #endregion Helper

Il nous faut maintenant modifier le code d'aspiration (dans WebSiteViewModel.cs) pour qu'il prévienne quand l'aspiration commence et quand elle se termine :

private void AspireSite()
{
    //// On indique que l'on travaille.
    //Cursor oldCursor = this.Cursor;
    //this.Cursor = Cursors.Wait;
    // On indique qu'il faut bloquer l'IHM.
    _messenger.NotifyColleagues(Evenements.AU_TRAVAIL, true);
    ...
    finally
    {
        //this.Cursor = oldCursor;
        // On indique qu'il faut débloquer l'IHM.
        _messenger.NotifyColleagues(Evenements.AU_TRAVAIL, false);
    }
}

Lorsque nous lançons ce code nous avons une petite déception. Rien n'a changé, l'IHM est bloquée et aucun retour visuel n'est présenté à l'utilisateur. Pourquoi ?
Parce que nous faisons tout sur le même thread.
Pour que nous puissions avoir le résultat souhaité, nous devons faire exécuter la fonction AspireSite par un autre thread. Pour faire simple, nous allons reprendre la méthode déjà utilisée dans notre code, le réservoir d'unités d'exécution. Désolé, in English, "the thread pool". Nous ajoutons donc une méthode AspireSiteAsync qui se charge de faire exécuter AspireSite par un thread du pool :

using System.Threading;
 
#region Fonctions surchargées
protected override void InitialisationDesCommandes()
{
    OuvrirFicheSiteCommand = new ProxyCommand<WebSiteViewModel>(_ => OuvrirFicheSite(), this);
    AspirerSiteCommand = new ProxyCommand<WebSiteViewModel>(_ => AspireSiteAsync(), this);
}
#endregion Fonctions surchargées
 
#endregion Helper
/// <summary>
/// Fonction d'aspiration du site web fourni en paramètre.
/// </summary>
/// <param name="leSite">Le site web à aspirer.</param>
private void AspireSite(WebSiteModele leSite)
{
    Uri absUrl, relUrl;
    string path;
    UrlFileMatcher urlFileMatcher = new UrlFileMatcher(leSite, leSite.BaseURL);
 
    // L'utilisateur a-t-il demandé une page ?
    if (!DownloadHelper.IsPage(leSite.BaseURL))
    {
        // Génération des noms nécessaires au traitement et récupération du fichier.
        if (urlFileMatcher.ProcessUrl(leSite.BaseURL.ToString(), false, out absUrl, out relUrl, out path))
        {
            DownloadHelper.GetFile(absUrl, path);
        }
        return;
    }
 
    // On est maintenant certain qu'il s'agit d'une page.
    string strPage = DownloadHelper.GetPage(leSite.BaseURL);
 
    // On génère le nom local associé à la page récupérée.
    if (urlFileMatcher.ProcessUrl(leSite.BaseURL.ToString(), true, out absUrl, out relUrl, out path))
    {
        // On cherche les images.
        ImageProcessor imgProc = new ImageProcessor(urlFileMatcher);
        strPage = imgProc.ProcessPage(strPage);
 
        // On cherche les feuilles de style.
        LinkProcessor linkProc = new LinkProcessor(urlFileMatcher);
        strPage = linkProc.ProcessPage(strPage);
 
        // On cherche les scripts.
        ScriptProcessor scriptProc = new ScriptProcessor(urlFileMatcher);
        strPage = scriptProc.ProcessPage(strPage);
 
        // On sauve la page.
        StreamWriter sw = null;
        try
        {
            sw = new StreamWriter(path, false, System.Text.Encoding.ASCII);
            sw.WriteLine(strPage);
            sw.Close();
        }
        catch (Exception Ex)
        {
            if (null != sw)
            {
                sw.Close();
                File.Delete(path);
            }
        }
    }
}
 
/// <summary>
/// Fait exécuter la fonction AspireSite en tâche de fond.
/// </summary>
/// <returns></returns>
public virtual bool AspireSiteAsync()
{
    // On indique qu'il faut bloquer l'IHM.
    _messenger.NotifyColleagues(Evenements.AU_TRAVAIL, true);
 
    // Obtention du thread UI.
    var UISyncContext = SynchronizationContext.Current;
 
    // Définition de l'appel.
    WaitCallback waitCallBack =
        leSite =>
        {
            try
            {
                // On aspire le site courant.
                AspireSite((WebSiteModele)leSite);
            }
            finally
            {
                // On indique que c'est fini, depuis le Thread UI pour éviter tout problème potentiel.
                UISyncContext.Post(_ => _messenger.NotifyColleagues(Evenements.AU_TRAVAIL, false), null);
            }
        };
 
    // Lancement effectif du traitement.
    return ThreadPool.QueueUserWorkItem(waitCallBack, Site);
}
#endregion Helper

Une petite remarque en passant. La signature de AspireSite est devenue private void AspireSite(WebSiteModele leSite). Pourquoi avoir ajouté ce paramètre ?
Pour ne pas avoir à se soucier d'une éventuelle modification ultérieure de la liste des sites web. Actuellement rien ne permet cette modification mais rien ne dit qu'une modification future ne changera pas la donne.
Autre raison, nous pouvons maintenant mettre cette fonction ailleurs, elle n'a plus aucun lien avec le ViewModel.

Si nous lançons l'aspirateur et que nous lui demandons d'aspirer un site, nous avons maintenant une indication visuelle qu'il y a quelque chose en cours.

Aspirateur 12 03

Par contre le bouton d'annulation n'a aucun effet. C'est normal, nous ne nous en soucions pas encore au niveau de WebSiteViewModel. Nous devons nous abonner à l'événement ABANDON_TRAVAIL et trouver un moyen d'interrompre l'aspiration.
Pour ce dernier point, nous allons utiliser la méthode la plus simple, un booléen qui dit s'il faut continuer ou pas. Nous testerons régulièrement ce booléen dans la méthode Aspire pour déterminer s'il faut interrompre l'aspiration ou non. Cela nous donne :

#region Membres
private bool _abandonAspiration;
#endregion Membres
 
#region Fonctions surchargées
protected override void InitialisationDesServices()
{
    _windowServices = ServiceLocator.Instance.Retrieve<IDialogService>();
    _messenger = ServiceLocator.Instance.Retrieve<IMessenger>();
 
    // Enregistrement sur l'événement ABANDON_TRAVAIL.
    _messenger.Register(Evenements.ABANDON_TRAVAIL, () =>
    {
        _abandonAspiration = true;
    });
}
#endregion Fonctions surchargées
 
#region Helper
/// <summary>
/// Fonction d'aspiration du site web fourni en paramètre.
/// </summary>
/// <param name="leSite">Le site web à aspirer.</param>
private void AspireSite(WebSiteModele leSite)
{
    // On ne part pas pour ne rien faire.
    _abandonAspiration = false;
 
    Uri absUrl, relUrl;
    string path;
    UrlFileMatcher urlFileMatcher = new UrlFileMatcher(leSite, leSite.BaseURL);
 
    // L'utilisateur a-t-il demandé une page ?
    if (!DownloadHelper.IsPage(leSite.BaseURL))
    {
        // Génération des noms nécessaires au traitement et récupération du fichier.
        if (urlFileMatcher.ProcessUrl(leSite.BaseURL.ToString(), false, out absUrl, out relUrl, out path))
        {
            DownloadHelper.GetFile(absUrl, path);
        }
        return;
    }
 
    if (_abandonAspiration)
        return;
 
    // On est maintenant certain qu'il s'agit d'une page.
    string strPage = DownloadHelper.GetPage(leSite.BaseURL);
 
    if (_abandonAspiration)
        return;
 
    // On génère le nom local associé à la page récupérée.
    if (urlFileMatcher.ProcessUrl(leSite.BaseURL.ToString(), true, out absUrl, out relUrl, out path))
    {
        if (_abandonAspiration)
            return;
 
        // On cherche les images.
        ImageProcessor imgProc = new ImageProcessor(urlFileMatcher);
        strPage = imgProc.ProcessPage(strPage);
 
        if (_abandonAspiration)
            return;
 
        // On cherche les feuilles de style.
        LinkProcessor linkProc = new LinkProcessor(urlFileMatcher);
        strPage = linkProc.ProcessPage(strPage);
 
        if (_abandonAspiration)
            return;
 
        // On cherche les scripts.
        ScriptProcessor scriptProc = new ScriptProcessor(urlFileMatcher);
        strPage = scriptProc.ProcessPage(strPage);
 
        if (_abandonAspiration)
            return;
 
        // On sauve la page.
        StreamWriter sw = null;
        try
        {
            sw = new StreamWriter(path, false, System.Text.Encoding.ASCII);
            sw.WriteLine(strPage);
            sw.Close();
        }
        catch (Exception Ex)
        {
            //MessageBox.Show(Ex.ToString(), "btnGo_Click");
            if (null != sw)
            {
                sw.Close();
                File.Delete(path);
            }
        }
    }
}
#endregion Helper

Pour ce billet nous allons en rester là.

Certains auront remarqués que les menus sont toujours accessibles pendant les traitements.
Est-ce grave docteur ?
En fait le seul risque réside dans une modification de la liste des sites web contenue dans le fichier xml. A priori ce n'est pas grave.
Cela dit le mécanisme des commandes nous permet très facilement de résoudre ce problème. En conditionnant les commandes associées aux éléments de menu. Après, un simple abonnement de MainViewModel à AU_TRAVAIL et le tour est joué.

Moins grave, mais qui peut être signalé, le texte affiché lors de l'attente n'est pas très visible. C'est normal, nous avons modifié AttenteControl.generic.xaml dans le précédent billet en enlevant <Setter Property="Foreground" Value="#FFF4F4F5" />. A vous de jouer si vous voulez quelque chose de plus visible lors des attentes et un détail du site web toujours lisible.

Si vous souhaitez récupérer le code à jour, c'est ici

Ce billet clôt le coté MVVM de notre aspirateur, dès le prochain billet nous repartons sur le fonctionnel. Peut-être que nous commencerons par une petite réorganisation.

Publicité
Publicité
Commentaires
Fanfois et ses papillons
Publicité
Archives
Publicité