On parle Windows, C#, Apple, Android, Js, …

WPF, ListView d’images, MVVM et Drag and Drop… – Partie 2

Introduction

Vu précédemment

Nous avons vu dans la première partie comment implémenter une liste de fichiers en WPF avec le pattern M-V-VM. Nous allons voir dans cette partie comment ajouter les vignettes images “Thumbnails” représentant les images miniatures de ces fichiers.

alt

L’exemple utilisé reprend l’application précédente (sans les vignettes) qui sera complétée au fur et à mesure.

Pour revoir la première partie :

WPF, ListView d’images, MVVM et Drag and Drop… – Partie 1

Ajout des vignettes

Le premier réflex est de modifier la partie View-Modèle pour exposer la lecture du fichier image. Nous avons besoin du nom de fichier complet avec le chemin sur le disque dur. Nous ajoutons pour cela une nouvelle classe ImageFileViewModel qui va hériter de FileViewModel. Cette classe va s’occuper uniquement de l’affichage des vignettes.

Ajout de ImageFileViewModel.cs dans le ViewModel

Il nous faut ensuite ajouter la propriété FileName dans la classe ImageFileViewModel. La propriété va renvoyer le nom complet de l’objet ImageFile contenu dans _imageFile.

ImageFileCollectionViewModel retourne désormais une collection d’objet ImageFileViewModel.

 

public string FileName
{
  get { return _imageFile.FileName; }
}

 

Puis dans la partie View, nous ajoutons l’image dans le UserControl ImageFileListView.xaml

Ajout de l'image dans ImageFileListView

Le Binding est fait sur la propriété Source de l’Image avec {Path=FileName}

<ListView SelectionMode="Extended"  x:Name="ListViewImage"
         ItemsSource="{Binding Path=AllImages}"  Margin="0,20,0,0">
   <ListView.ItemTemplate>
       <DataTemplate>
           <StackPanel Orientation="Horizontal">
               <Image Source="{Binding Path=FileName}" Width="25" Height="25"/>
               <TextBlock Text="{Binding Path=ShortName}" />
           </StackPanel>
       </DataTemplate>
    </ListView.ItemTemplate> 
</ListView>

Nous pouvons lancer l’application qui fonctionne…

Premier résultat catastrophique

et pleurer… SmileyCry parce que c’est très Lennnnnt…

Ce qu’il faut améliorer…

Le résultat est là mais l’interface est inutilisable avec un grand nombre d’images (Et encore moins utilisable si leur poids est important et si le support est lent comme une clé USB par exemple) :

  1. Le chargement prend du temps car WPF essaie d’afficher toutes les vignettes en même temps. Nous souhaitons un affichage des vignettes qui va apporter un Plus à l’utilisateur mais surtout conserver une interface fluide.
  2. Chaque changement avec la ScrollBar va entrainer un gèle “Freeze” de l’application en attendant que toutes les vignettes soient lues et chargées intégralement en mémoire.
  3. Chaque changement avec la ScrollBar va entrainer le chargement en mémoire de l’image pour son traitement et provoquer ensuite un engorgement en mémoire sans libération avant la fermeture de l’application.
  4. Certaines images jpg, précédemment transformées avec un outils annexe en images verticales ne sont pas visibles. Mal reconnus avec WPF?

Multi-Threading

Présentation

La première étape passe par le multi-Thread. Pendant l’affichage des vignettes, l’utilisateur doit continuer à pouvoir utiliser l’application sans être bloqué. Nous allons donc procéder à la lecture des images dans un thread en dehors du thread principal de WPF qui se charge de l’interaction Homme machine (Thread UI).

  1. Nous allons créer une propriété Thumbnail (vignette) sur l’objet ImageFileViewModel qui s’occupera de ce mécanisme et qui retournera une ImageSource prête à être utilisée par l’interface.  La classe ImageSource est suffisamment générique pour pouvoir être ensuite utilisée par n’importe quelle méthode WPF.
  2. Lors de la lecture de cette propriété, l’objet ImageFileViewModel sera ajouté à une liste “_listThumbnail” partagée entre tous les objets ImageFileViewModel (propriété déclarée en static) .
  3. Un Thread tournera en tâche de fond en désynchronisé et bouclera sur la liste (pile) en dépilant de la liste les images traitées et redimensionnées en thumbnails.
  4. Un message de notification WPF sera ensuite envoyé à la vue pour lui demander de rafraichir l’image Thumbnail prête à être affichée.

Voici un petit schéma pour essayer de résumer tout cela.

Multi Threading wpf UI graph

Détail

Création de la propriété Thumbnail dans l’objet .

Déclaration de la propriété Thumbnail en ImageSource afin de créer la vignette avec les mécanismes de WPF.
Dans le get,  si l’image n’est pas initialisée nous appelons LoadImage qui va empiler l’objet à la liste _listThumbnail

private static readonly List<ImageFileViewModel> _listThumbnail 
= new List<ImageFileViewModel>();
private ImageSource _thumbnail = null;
private bool _loading = false;
 ...
public ImageSource Thumbnail
{
  get
  {
      if (_thumbnail == null)
      {
          LoadImage();
          return null;
      }
      return _thumbnail;
  }
}

internal void LoadImage()
{
  if (!_loading)
  {
      _loading = true;
      AddPhotoToLoad(this);
  }
}

LoadImage va ajouter l’image dans la liste. _loading va permettre de savoir si la vignette est déjà chargée et éviter de refaire le traitement pour rien.

Nous sommes dans de l’appel multi-thread, afin de ne pas ajouter 2 images au même moment et de créer des conflits, nous devons verrouiller le mécanisme au moment de l’ajout et notifier aux autres Thread que l’ajout a bien été effectué.

Le verrouillage se fait avec le lock d’un objet static partagé et l’objet _loadThumbImageEvent va nous permettre de notifier un changement entre les Threads à travers un événement.

private static readonly object _lockListThumbnail = new object();
private static AutoResetEvent _loadThumbImageEvent = new AutoResetEvent(false);    
    ...

private static void AddPhotoToLoad(ImageFileViewModel p)
{
  lock (_lockListThumbnail)
  {
      _listThumbnail.Add(p);
  }
  _loadThumbImageEvent.Set();
}

Nous allons maintenant traiter les images empilées dans _listThumbnail de façon désynchronisée.

Nous avons besoin maintenant du Thread qui va boucler et traiter les éléments présents dans la liste _listThumbnail lorsque le processeur aura le temps.

La déclaration et la création du Thread ne se fait qu’une seule fois dans le constructeur static.

Le Thread s’exécute en fond de tache (IsBackground = true) afin de permettre la fermeture de l’application sans attendre la fin du Thread et en priorité basse (ThreadPriority.BelowNormal) pour ne pas pénaliser l’interface. Au lancement, le Thread exécute la méthode static LoaderThreadLoop()

 

static ImageFileViewModel()
{
  _loaderThreadThumbnail = new Thread(new ThreadStart(LoaderThreadLoop));
  _loaderThreadThumbnail.IsBackground = true; 
  _loaderThreadThumbnail.Priority = ThreadPriority.BelowNormal;
  _loaderThreadThumbnail.Start();
}

 

La méthode LoaderThreadLoop boucle de façon infinie et va traiter la pile d’image avec la méthode ProcessLoadImageStack(). L’attente de 10 ms permet de redonner la main au Thread principal WPF et de rendre ainsi  l’application plus fluide.

_LoadThumbImageEvent.WaitOne() attend une notification par Set() envoyé lors du LoadImage.

 

private static void LoaderThreadLoop()
{
    do
    {
        _loadThumbImageEvent.WaitOne();
        while (ProcessLoadImageStack())
        {
            Thread.Sleep(10);
        }
    }
    while (true);
}

 

ProcessLoadImageStack() doit maintenant récupérer le dernier élément de la liste, l’enlever de la liste puis appeler l’instruction de lecture de l’image et de génération de la vignette  DoLoadImage().

private static bool ProcessLoadImageStack()
{
  ImageFileViewModel p = null;
  if (_listThumbnail.Count > 0)
  {
      lock (_lockListThumbnail)
      {
          if (_listThumbnail.Count > 0)
          {
              p = _listThumbnail[_listThumbnail.Count - 1];
              _listThumbnail.Remove(p);
          }
      }
  }
  if (p != null)
      p.DoLoadImage();
  return true;
}

DoLoadImage() va charger l’image dans un Stream puis le traitement va être réalisé par des actions WPF.

Le chargement de l’image en Stream s’effectue dans la partie Model. Nous ajoutons le code Stream LoadImage() suivant dans l’interface

 

namespace wpfListViewSample02.Model
{
    public interface IImageFile
    {
        ...
        Stream LoadImage();
    }

}

 

Puis l’implémentation dans l’objet métier

 

namespace wpfListViewSample02.Model
{
    public class ImageFile: IImageFile
    {
        ...

        public Stream LoadImage()
        {
            byte[] buffer = File.ReadAllBytes(FileName);
            return new MemoryStream(buffer);
        }
    }
}

 

Dans DoLoadImage, nous chargeons maintenant le Stream afin d’effectuer un traitement de vignette.

Un problème se pose de nouveau, les actions WPF ne peuvent se faire que dans le thread WPF UI qui est unique. DoLoadImage est lancé par le thread asynchrone qui traite les images quand le processeur n’est pas occupé.

 

Pour raccrocher les actions WPF au thread WPF, nous utilisons l’instruction Dispatcher.Invoke qui représente le Thread unique UI de WPF de l’application. Le dispatcher utilisé est celui de l’application WPF repérable par Dispatcher.CurrentDispatcher.

Il prend comme paramètre la priorité du traitement (basse, pas besoin de se presser pour ce traitement les autres actions de WPF sont prioritaires), les paramètres échangés par le délégué ici un type delegate sans argument : NoArgumentDelegate puis le traitement dans le delegate. Le traitement appelle une autre instruction GenerateThumbnail(de l’objet Stream précédemment chargé) qui va transformer l’image Stream en vignette ImageSource avec WPF et retourner l’image dans la propriété Thumbnail.

A la fin du traitement, nous vidons le Stream et notifions à WPF que la propriété Thumbnail a changée (elle contient la vignette image générée) et donc que la vue doit être rafraichie dans l’interface visuelle WPF OnPropertyChanged(“Thumbnail”).

 

private delegate void NoArgumentDelegate();
protected virtual void DoLoadImage()
{ 
  if (!_loading || this._imageFile == null) { return; }
  if (this._imageFile.IsAvailable)
  {
      Stream mem = null;
      try
      {
          mem = this._imageFile.LoadImage();
          if ((!_loading))
          {
              mem.Dispose();
              return;
          }
          Dispatcher.CurrentDispatcher.Invoke
          (DispatcherPriority.ApplicationIdle, (NoArgumentDelegate)
              delegate()
              {
              _thumbnail = GeneratedThumbnail(mem);
              }
          );
      }
      finally
      {
          if (mem != null)
          {
              mem.Close();
          }
          _loading = false;
          this.OnPropertyChanged("Thumbnail");
      }
  }
}

Un dernier petit code pour montrer comment la vignette est générée par WPF. A noter que dans le cas où nous ne trouvons pas de vignette, nous retournons un image vide et surtout pas Null sinon nous monopolisons un Traitement qui ne finira jamais (if (Thumbnail==null) … LoadImage) .

protected ImageSource GeneratedThumbnail(Stream mem)
{
  mem.Position = 0;
  BitmapDecoder imgDecoder = BitmapDecoder.Create
      (mem, BitmapCreateOptions.None, BitmapCacheOption.None);
  if ((imgDecoder != null) 
      && (imgDecoder.Frames.Count > 0) 
      && (imgDecoder.Frames[0] != null) 
      && (imgDecoder.Frames[0].Thumbnail != null))
      return imgDecoder.Frames[0].Thumbnail;
  else
      return new WriteableBitmap(10, 10, 96, 96, PixelFormats.Bgr32, null);
}

Dans ImageFileListView.xaml, nous remplaçons la propriété Source {Path=FileName} de l’Image avec {Path=Thumbnai

 <Image Source="{Binding Path=Thumbnail}" Width="25" Height="25"/>

Voilà nous pouvons lancer de nouveau l’application.

Listview application with Thread

C’est déjà beaucoup plus fluide et utilisable. iconsmile . Les images mal reconnues (sans vignettes EXIF) s’affichent alors en noir.

Optimisons encore un peu…

Avec un gros volume d’images, nous avons encore plusieurs problèmes visuels :

  1. Toutes les images vignettes restent en mémoire, ce qui va représenter plusieurs centaines de Mo.
  2. Lorsque nous jouons plusieurs fois avec l’ascenseur de haut en bas, les images sont empilées sans limites et il faut attendre parfois longtemps avant d’avoir l’affichage de la vignette attendue.

Virtualizing

VirtualizingStackPanel

Le composant ListView contient une propriété importante VirtualizingStackPanel qui va nous permettre de résoudre la problématique de l’espace mémoire. En effet, il n’est pas utile de conserver l’ensemble des vignettes pour toutes les images parcourues et nous pouvons libérer les images non visibles.

Nous allons donc indiquer dans ImageListView.xaml

  1. L’utilisation de la virtualisation : VirtualizingStackPanel.IsVirtualizing=”True”
  2. L’événement qui sera exécuté lors du recyclage des objets VirtualizingStackPanel.CleanUpVirtualizedItem = “ListViewImage_CleanUpVirtualizedItem”

 

<ListView SelectionMode="Extended"  x:Name="ListViewImage"
         ItemsSource="{Binding Path=AllImages}"  Margin="0,20,0,0"
         VirtualizingStackPanel.IsVirtualizing="True"
         VirtualizingStackPanel.CleanUpVirtualizedItem="ListViewImage_CleanUpVirtualizedItem">
   <ListView.ItemTemplate>
       <DataTemplate>
           <StackPanel Orientation="Horizontal">
               <Image Source="{Binding Path=Thumbnail}" Width="25" Height="25"/>
               <TextBlock Text="{Binding Path=ShortName}" />
           </StackPanel>
       </DataTemplate> 
   </ListView.ItemTemplate>    
</ListView>

Dans le code de la vue nous appelons la libération de l’élément visuel de l’objet ViewModel.

if (e.Value is ImageFileViewModel)
{
 (e.Value as ImageFileViewModel).CleanUp();
}

Puis dans la partie ViewModel dans l’objet ImageFileViewModel nous implémentons CleanUp().

  1. Comme la liste est partagée dans un thread indépendant, nous validons son accès avec lock auparavant.
  2. Dans le cas où la vignette doit être supprimée car non visible, il est inutile de le traiter dans la liste. Nous l’enlevons de la liste,
  3. Nous supprimons ensuite la vignette et indiquons que le chargement n’est pas effectué (_loading = false)
    internal void CleanUp()
    {
      lock (_lockListThumbnail)
      {
           _listThumbnail.Remove(this);
           _loading = false;
           _thumbnail = null;
      }
    }

Pour vérifier combien d’éléments sont réellement chargés dans l’interface, je vous propose d’ajouter un bouton sur l’interface.

La méthode GetVisualCount parcourt l’affichage d’un objet et compte le nombre d’objets visuels WPF enfants du type .

private void BtnVirtualizeCount_Click(object sender, RoutedEventArgs e)
{
  MessageBox.Show(
  GetVisualCount<ImageFileViewModel>(this.listView1.ListViewImage).ToString()
  , "");
}

private static int GetVisualCount<T>(DependencyObject visual) where T : class
{
  int visualCount = 0;
  if (visual is ContentControl 
    && (visual as ContentControl).Content is T) visualCount++;
  int childCount = VisualTreeHelper.GetChildrenCount(visual);
  for (int i = 0; i < childCount; i++)
  {
      DependencyObject childVisual = VisualTreeHelper.GetChild(visual, i);
      visualCount += GetVisualCount<T>(childVisual);
  }
  return visualCount;
}

Voici le résultat, montrant que le nombre d’objets visuels affichés correspond au nombre d’images affichées dans l’interface. Le calcul des vignettes se fait à chaque fois, ainsi si l’on descend à la fin de la liste et que l’on revient au début, le calcul s’effectuera une deuxième fois pour les vignettes.

Remarque : Si le calcul est très long, il est encore possible de gérer une liste limitée d’images dans une structure de type collection servant alors de cache (voir dans la suite – partie 3).

Listview application with Thread and Virtualizing

Encore un peu plus rapide

Depuis WPF 3.5 SP1, un autre mode de Virtualization existe pour réutiliser les éléments WPF existants et éviter de créer puis détruire à chaque fois les objets virtualisés. Il en résulte une grande amélioration de la vélocité d’affichage pour les éléments WPF virtualisés. Dans l’interface WPF ImageListView.xaml,nous ajoutons la propriété VirtualizingStackPanel.VirtualizationMode = “Recycling” (qui est par défaut = « Standard »).

<ListView SelectionMode="Extended"  x:Name="ListViewImage"
         ItemsSource="{Binding Path=AllImages}"  Margin="0,20,0,0"
         VirtualizingStackPanel.IsVirtualizing="True"
         VirtualizingStackPanel.VirtualizationMode= "Recycling"
         VirtualizingStackPanel.CleanUpVirtualizedItem="ListViewImage_CleanUpVirtualizedItem">
   <ListView.ItemTemplate>
       <DataTemplate>
           <StackPanel Orientation="Horizontal">
               <Image Source="{Binding Path=Thumbnail}" Width="25" Height="25"/>
               <TextBlock Text="{Binding Path=ShortName}" />
           </StackPanel>  
       </DataTemplate>
   </ListView.ItemTemplate>
</ListView>

En faisant l’essai vous constaterez encore une amélioration de la rapidité non négligeable.

La gestion des vignettes sans données EXIF avec WPF

TransformedBitmap

Il nous reste à gérer  le cas où la vignette n’existe pas dans l’information EXIF de l’image. Pour cela nous modifions un peu GeneratedThumbnail(Stream mem).

GeneratedThumbnail essaie de trouver la vignette stockée ( imgDecoder.Frames[0].Thumbnail) dans le cas où on ne trouve rien, une exception est lancée et tempThumbnail est null.

Dans le cas ou frame (ImgDecoder.Frames[0] est vide, on appelle TransformedBitmap qui retaille l’image trouvée et retourne une ImageSource avec ScaleTransform. L’image retournée fait ici une hauteur ou une largeur maximale de 240 pixels.

Dans le dernier cas où l’image n’est toujours pas reconnue nous retournons le bitmap noir.

private ImageSource GeneratedThumbnail(Stream mem)
{
  ImageSource tempThumbnail = null;
  BitmapFrame frame = null;
  try
  {
      mem.Position = 0;
      BitmapDecoder imgDecoder = BitmapDecoder.Create
      (mem, BitmapCreateOptions.None, BitmapCacheOption.None);
      if (imgDecoder != null && imgDecoder.Frames.Count > 0)
          frame = imgDecoder.Frames[0];
      if (frame != null)
        tempThumbnail = frame.Thumbnail;
  }
  catch
  {
      tempThumbnail = null;
  }

  if (tempThumbnail == null && frame != null) 
      tempThumbnail = TransformedThumbnail(frame);
  if (tempThumbnail != null)
      return tempThumbnail;
  else
      return new WriteableBitmap(10, 10, 96, 96, PixelFormats.Bgr32, null);
}

private ImageSource TransformedThumbnail(BitmapFrame frame)
{
  TransformedBitmap tempThumbnail = new TransformedBitmap();
  tempThumbnail.BeginInit();
  try
  {
    tempThumbnail.Source = frame as BitmapSource;
    int decodeH = 240;
    int decodeW = 240;
    int pixelH = frame.PixelHeight;
    int pixelW = frame.PixelWidth;
    if (pixelH / pixelW > 0 && pixelH > decodeH) 
        decodeW = (frame.PixelWidth * decodeH) / pixelH;
    else
        if (pixelW > decodeW)
            decodeH = (frame.PixelHeight * decodeW) / pixelW;
    double scaleX = decodeW / (double)pixelW;
    double scaleY = decodeH / (double)pixelH;
    TransformGroup transformGroup = new TransformGroup();
    transformGroup.Children.Add(new ScaleTransform(scaleX, scaleY));
    tempThumbnail.Transform = transformGroup;
  }
  finally
  {
      tempThumbnail.EndInit();
  }
  WriteableBitmap writable = new WriteableBitmap(tempThumbnail);
  return writable;
}

Objets Freezable et Freeze() et animation d’attente

Problématique

Le traitement précédent est assez lourd et peut occuper du temps processeur dans le cas de transformation en vignettes d’images de taille importante.

Afin d’optimiser toujours un peu plus l’interface, il serait intéressant de ne pas monopoliser le thread UI WPF pour réaliser cette manipulation. Nous allons pour cela utiliser la méthode Freeze() des objets WPF dérivant de l’objet Freezable.

Implémentation de Freeze()

Cette méthode va permettre un accès en lecture seule à WPF au objets que nous manipulons dans un autre Thread.

Dans la précédente méthode DoLoadImage, La transformation GeneratedThumbnail était effectuée dans le delegate() (Dans le thread WPF UI).

Nous le déplaçons dans le Thread de chargement précédemment mis en place et utilisons un objet intermédiaire “tempImgSrc”. “tempImgSrc.Freeze()” permet de geler l’objet en écriture pour WPF et de le rendre accessible en lecture au Thread WPF.

Nous affectons ensuite tempImgSrc à la propriété Thumbnail dans le Thread WPF UI.

protected virtual void DoLoadImage()
{ 
  if (!_loading || this._imageFile == null) { return; }
  if (this._imageFile.IsAvailable)
  {
      Stream mem = null;
      try
      {
          mem = this._imageFile.LoadImage();
          if ((!_loading))
          {
              mem.Dispose();
              return;
          }
          ImageSource tempImgSrc = GeneratedThumbnail(mem);
          tempImgSrc.Freeze();  
          Dispatcher.CurrentDispatcher.Invoke
            (DispatcherPriority.ApplicationIdle, (NoArgumentDelegate)
            delegate()
            {
                // old _thumbnail = GeneratedThumbnail(mem);
                _thumbnail = tempImgSrc;
            });
      }
      finally
      {
          if (mem != null)
          {
              mem.Close();
          }
          _loading = false;
          this.OnPropertyChanged("Thumbnail");
      }           
  }
}

Le schéma général ressemble à cela maintenant :

 

WpfDragDrop06b

Animation d’attente

Dernière étape, afficher une petite animation d’attente lors de la génération des vignettes qui peut parfois prendre un peu de temps.

Afin de faciliter cette manipulation, nous allons ajouter une propriété IsLoaded dans la classe ImageFileViewModel de la couche ViewModel.

public bool IsLoaded
{
  get
  {
      return (_thumbnail!=null);
  }
}

Cette propriété retourne true si l’image est présente et false si l’image n’est pas encore chargée.

Dans le DoLoadImage, nous ajoutons une notification à WPF du changement de IsLoaded lorsque l’image est chargée :

protected virtual void DoLoadImage()
{ 
  if (!_loading || this._imageFile == null) { return; }
  if (this._imageFile.IsAvailable)
  {
      Stream mem = null;
      try
      ...
      finally
      {
          if (mem != null)
          {
              mem.Close();
          }
          _loading = false;
          OnPropertyChanged("Thumbnail");
          OnPropertyChanged("IsLoaded");
      }           
  }
}

 

Dans la partie View, nous allons afficher un sablier avec une petite animation lorsque l’image est en cours de chargement.

<StackPanel Orientation="Horizontal" Height="25">
     <Image x:Name="ThumbnailImage" 
     Visibility="Collapsed" 
     Height="20" Width="20" 
     Source="{Binding Path=Thumbnail}">
         <Image.BitmapEffect>
             <DropShadowBitmapEffect ShadowDepth="2" />
         </Image.BitmapEffect>
     </Image>
     <Image x:Name="WaitingImage" 
     Visibility="Visible" 
     Height="20" Width="20" 
     Source="/View/Hourglass.png">
     </Image>
     <TextBlock Text="{Binding Path=ShortName}" />
</StackPanel>

Deux images “ThumbnailImage” et “WaitingImage” sont affichées ou cachées en fonction de la propriété “IsLoaded”

<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Path=IsLoaded}" Value="True">
    <Setter Property="Visibility" TargetName="ThumbnailImage" Value="Visible"/>
    <Setter Property="Visibility" TargetName="WaitingImage" Value="Collapsed"/>    
</DataTrigger>

<DataTrigger Binding="{Binding Path=IsLoaded}" Value="False">
    <Setter Property="Visibility" TargetName="WaitingImage" Value="Visible"/>
    <Setter Property="Visibility" TargetName="ThumbnailImage" Value="Collapsed"/>
</DataTrigger>

Pour afficher le sablier avec une petite animation, nous ajoutons une ressource Storyboard “WaitingTimeLine” et un déclenchement de cette animation avec un arrêt de l’animation lors du changement d’état de la valeur IsLoaded.

DataTrigger.EnterActions permet de déclencher l’animation
DataTrigger.ExitActions permet d’arrêter l’action précédemment définie

<DataTemplate.Triggers>
    <DataTrigger Binding="{Binding Path=IsLoaded}" Value="True">
        <Setter Property="Visibility" TargetName="ThumbnailImage" Value="Visible"/>
        <Setter Property="Visibility" TargetName="WaitingImage" Value="Collapsed"/>
    </DataTrigger>
    
    <DataTrigger Binding="{Binding Path=IsLoaded}" Value="False">
        <Setter Property="Visibility" TargetName="WaitingImage" Value="Visible"/>
        <Setter Property="Visibility" TargetName="ThumbnailImage" Value="Collapsed"/>
        
        <DataTrigger.EnterActions>
            <BeginStoryboard x:Name="WaitingTimeline_BeginStoryboard" 
            Storyboard="{StaticResource WaitingTimeline}"/>
        </DataTrigger.EnterActions>
        <DataTrigger.ExitActions>
            <StopStoryboard BeginStoryboardName="WaitingTimeline_BeginStoryboard"/>
        </DataTrigger.ExitActions>
    </DataTrigger> 
</DataTemplate.Triggers>

L’animation Storyboard “WaitingTimeLine” est définie ainsi :

<DataTemplate.Resources>
<Storyboard x:Key="WaitingTimeline" Timeline.DesiredFrameRate="10">
    <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" RepeatBehavior="Forever" 
        Storyboard.TargetName="WaitingImage" 
        Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0]
                                   .(RotateTransform.Angle)">
        <SplineDoubleKeyFrame KeyTime="00:00:00" Value="-15"/>
        <SplineDoubleKeyFrame KeyTime="00:00:03" Value="15"/>
    </DoubleAnimationUsingKeyFrames>
</Storyboard>
</DataTemplate.Resources>

l’animation chargée en ressource va s’appliquer à la transformation « rotation” de l’image “WaitingImage”.

Ce Storyboard fait varier un nombre de –15 à +15 selon une chronologie définie. Ce nombre correspondra à une inclinaison en degré de la rotation du sablier.

Encore une petite astuce d’optimisation, les animations en WPF sont paramétrées par défaut pour générer 60 fps (frame per seconds) , ce qui est complètement inutile dans notre cas. Nous pouvons accepter une animation à 10 images par secondes, nous ajoutons donc la propriété TimeLine.DesiredFrameRate=”10”. Cela ne changera rien à l’affichage et libérera encore un peu de puissance CPU / GPU (processeur principal ou de la carte graphique).

 

<StackPanel Orientation="Horizontal" Height="25">
     <Image x:Name="ThumbnailImage" 
     Visibility="Collapsed" 
     Height="20" Width="20" 
     Source="{Binding Path=Thumbnail}">
         <Image.BitmapEffect>
             <DropShadowBitmapEffect ShadowDepth="2" />
         </Image.BitmapEffect>
     </Image>
     <Image x:Name="WaitingImage" 
     Visibility="Visible" 
     Height="20" Width="20" 
     Source="/View/Hourglass.png">
         <Image.RenderTransform>
             <TransformGroup>
                 <RotateTransform Angle="0" CenterX="10" CenterY="10"/>
             </TransformGroup>
         </Image.RenderTransform>
     </Image>
     <TextBlock Text="{Binding Path=ShortName}" />
</StackPanel>

Exécutons l’application

Application Listview with waiting animation

L’interface utilisateur est fluide et l’application consomme une charge mémoire très raisonnable, même avec un nombre très important d’images.

Smileylol

Les tests unitaires

Et les tests unitaires alors ?

Je le répète une nouvelle fois pour bien insister, toute l’organisation M-V-VM, comme toute organisation de projet en couche (MVC, MVP,…) a comme principal objectif de pouvoir automatiser les tests de l’application et donc de faire gagner beaucoup de temps sur la phase finale du développement et sur les maintenances.

Premier problème : La classe ImageFileViewModel utilise un constructeur static pour gérer la liste des Thumbnails.

Comment faire des tests unitaires avec un constructeur static et comment le réinitialiser ?

Sans réinitialisation, les tests unitaires qui s’exécutent les uns à la suite des autres vont se polluer et générer des cas d’erreurs là où il ne devrait pas y en avoir ou pire, s’exécuter avec succès alors qu’ils ne devraient chuter. Le seul moyen est de réinitialiser l’objet à chaque test, sauf que ici, petit problème… la classe à un constructeur static, donc construit à la première rencontre de cette classe par le premier test unitaire.

Un moyen rencontré est de réinitialiser les attributs static que les objets utilisent et d’utiliser la réflexion pour rappeler le constructeur static.

Avec l’attribut “[TestInitialize]”, nous nous assurons de réinitialiser un état propre à chaque début de tous les tests.

le ci.Invoke permet d’appeler le constructeur static récupéré par réflexion avec typeof puis TypeInitializer.

ImageFileViewModel_Accessor est un objet permettant d’accéder aux attributs privés de l’objet ImageFileViewModel. Grâce à cet Accessor, nous pouvons réinitialiser la liste des Thumbnails en la vidant.

[TestInitialize()]
public void MyTestInitialize()
{
  // call the static constructor and clear static attribute _ListThumbnails
  Type staticType = typeof(ImageFileViewModel);
  ConstructorInfo ci = staticType.TypeInitializer;
  object[] parameters = new object[0];
  ci.Invoke(null, parameters);
  ImageFileViewModel_Accessor._listThumbnail.Clear();
}

Avec ce mécanisme chaque objet de type ImageFileViewModel est remis à vide avant de commencer un nouveau test.

Un autre problème va concerner les tests unitaires sur les comportements multi-Thread avec WPF.

Comment tester les comportements Multi-Thread WPF avec des tests unitaires?

Le problème se pose pour tester la propriété “Thumbnail”.

Je rappelle ici le code à tester

public ImageSource Thumbnail
{
  get
  {
      if (_thumbnail == null)
      {
          LoadImage();
          return null;
      }
      return _thumbnail;
  }
}

Nous devons tester, après la fin du traitement dans le thread, que la liste des _listThumbnail est vide et que la propriété Thumbnail n’est pas null.

Pour attendre le déroulement d’un Dispatcher, la classe DispatcherFrame va venir à notre secours.

La classe DispatcherFrame

DispatcherFrame avec le Dispatcher possèdent une action d’attente du Dispatcher.CurrentDispatcher

Dispatcher.PushFrame(frame)”  va attendre jusqu’à ce que l’objet frame de type DispatcherFrame initialise la propriété “Continue = false”.

Continue = false dans notre cas devrait se dérouler lorsque la propriété Thumbnail indique à WPF qu’elle contient une information et que la View WPF doit se rafraichir, c’est à dire au moment ou “OnPropertyChanged(« Thumbnail »);” est appelé par une des méthodes lancées à la suite de LoadImage.

PropertyChangedEventHandler waitForModelHandler = new PropertyChangedEventHandler(
delegate(object sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName == "Thumbnail")
    {
        frame.Continue = false;
    }
    });
tempimageFileViewModel.PropertyChanged += waitForModelHandler;

Pour ajouter frame.Continue à ce moment, nous utilisons un EventHandler de changement de propriété PropertyChangedEventHandler et nous initialisons frame.Continue = false si la propriété “Thumbnail” est notifiée d’un changement.

Le test unitaire ressemble à ça maintenant.

Nous utilisons un fichier jpg pour initialiser le fichier image à transformer en vignette.

[TestMethod()]
[DeploymentItem(@"test01.jpg")]
public void ThumbnailTest()
{
  IImageFile imageFile = new ImageFile();
  (imageFile as ImageFile).FileName 
    = Path.Combine(Directory.GetCurrentDirectory(), @"test01.jpg");

  ImageFileViewModel tempimageFileViewModel = new ImageFileViewModel(imageFile);
  PrivateObject param0 = new PrivateObject(tempimageFileViewModel);
  ImageFileViewModel_Accessor imageFileViewModel 
    = new ImageFileViewModel_Accessor(param0);
  // check no items in _listThumbnail
  Assert.AreEqual(0, ImageFileViewModel_Accessor._listThumbnail.Count);
  // Implement DispatcherFrame for waiting and testing Dispatcher WPF process
  DispatcherFrame frame = new DispatcherFrame();
  PropertyChangedEventHandler waitForModelHandler = new PropertyChangedEventHandler(
      delegate(object sender, PropertyChangedEventArgs e)
      {
          if (e.PropertyName == "Thumbnail")
          {
              frame.Continue = false;
          }
      });
  tempimageFileViewModel.PropertyChanged += waitForModelHandler;
  // Launch Thumbnail process            
  var thumb = tempimageFileViewModel.Thumbnail;
  // Waiting frame.continue=false
  Dispatcher.PushFrame(frame);
  // Check thumbnail not null
  Assert.AreNotEqual(imageFileViewModel._thumbnail, null);
  // Check _listThumbnail empty
  Assert.AreEqual(0, ImageFileViewModel_Accessor._listThumbnail.Count);            
}

Notez l’attribut “[DeploymentItem(@ »test01.jpg »)]“qui permet d’utiliser le fichier copié test01.jpg au moment du test unitaire. Pensez également , dans ce cas, à modifier la propriété du fichier dans la solution Visual Studio pour indiquer de toujours copier ce fichier dans le répertoire de destination.

Exemple des tests unitaires

Il ne reste plus qu’à exécuter l’ensemble des tests pour terminer

Unit test results

Tout fonctionne à merveille

Smileylol

Conclusion

Nous venons de voir comment implémenter une ListView avec une interface multi-Thread en WPF, la gestion des images et des vignettes EXIF, la virtualisation et les mécanismes d’attente en WPF.

Tout cela dans une implémentation par couche M-V-VM qui nous permet de réaliser les tests unitaires sans se soucier de la partie visible XAML/WPF.

Dans les prochaines parties, nous verrons comment gérer les images en cache, le Drag & Drop avec notre ListView et comment lui apporter un look TreeView.

Voici le lien pour télécharger la solution avec le code source du projet et les tests unitaires

download32x32 wpfListView02.zip

 

Laissez un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

2 commentaires sur “WPF, ListView d’images, MVVM et Drag and Drop… – Partie 2”