Comment “cuisiner” une application Windows 8 avec XAMLet C# en une semaine–Jour 2 – Fast and Fluid

Comme d’habitude vous trouverez le code source liée à cet article ici : https://aka.ms/oitxqi

 

Je ne sais pas si vous avez pu le remarquer, mais la fluidité des écrans et de la navigation, n’est pas réellement au rendez-vous,
et est d’autant plus exacerbé lorsqu’on affiche un nombre de carte importante
dans la vue Expansions.

Il faut attendre que l’intégralité des cartes visibles à l’écran soient chargées par le GridView.
En résumé plus il y a de cartes visibles (70 dans notre exemple plus haut), plus le temps de d’affichage sera long, car il faudra que ce même GridView fasse un traitement sur chaque
image et ceci de manière synchrone.
La conséquence intrinsèque de ce phénomène est que, je ne peux pas naviguer d’une vue à une autre tant que le GridView charge les images

Bien évidemment, avant de commencer à "essayer" d’optimiser le code, il faut d’abord vérifier que ce qui
est fait ne satisfait pas à vos besoins et donc mesurer les performances globales de l’application.

Si vous n’affichez que 20 cartes à l’écran le résultat est sans doute satisfaisant, mais pour ma part, je vise non seulement les
performances, mais également que l’interface soit plus réactive aux sollicitations de l’utilisateur, c’est-à-dire que je puisse naviguer dans les
différentes vues sans attendre que l’intégralité des images ou du contenu en général soit chargé.

Sur cette figure, non seulement le chargement des images est asynchrone (elles n’arrivent plus d’un seul bloc), mais tous les éléments
de la GridView sont déjà actif. De plus le scrolling vertical est plus fluide, comme vous pourrez le constater dans l’exemple fournit.

 

Alors comment rendre les écrans plus réactifs ?

 Rappelez-vous, j’avais développé un contrôle Image personnalisé qui permettait je me site "Couplé avec la Virtualisation de la GridView, qui ne monte en mémoire
que les contrôles visibles à l'écran, on gagne ainsi en performance et en fluidité, en évitant que toutes les images soient téléchargées d'un
seul bloc lors de l'activation de la vue".

Ce qui était bon hier, ne l’est plus aujourd’hui. En effet comme je le disais si le nombre de carte visible est important, cette
dernière stratégie n’est plus réellement efficace, et le scrolling reste saccadé. De plus même si le progressRing
indique un téléchargement en cours et que cela me paraissait une bonne idée de départ,
il est plus consommateur de ressources qu’autre chose, je l’ai donc remplacé par un seul progressRing au niveau de la vue.

L’idée de base pour améliorer l’application est très simple et j’aurai du y penser plutôt !!. Je vais déférer le
Binding des images dans nos collections de données et m’appuyer sur INotifyPropertyChanged pour notifier le
gestionnaire de Binding que l’image est disponible. La GridView, affichera dans un premier temps un contrôle Image vide, puis sera notifiée que l’image est
disponible, d’une simplicité !!

Remarque : Je prends comme exemple, la vue Expansions,
mais cela s’applique bien évidement aux autres vues également.

1) La vue Expansions, est liée à une collection de la classe URZACard
(ObservableCollection<URZACard> ), j’ai donc rajouté dans la classe URZACard, la propriété Picture de type BitmapImage qui
me permettra de faire du Binding avec un contrôle Image.

public class URZACard : UrzaGatherer.Common.BindableBase, IURZACommon

    {

        public URZACard()

        {

        }

        private BitmapImage _pictureCard;

        public BitmapImage Picture

        {

            get { return _pictureCard; }

            set

            {

                this.SetProperty(ref this._pictureCard, value);

            }

 

        }

Remarque : Cette classe dérive de BindableBase qui implémente INotifyPropertyChanged.

2) Nous allons "Binder" dans le XAML, cette nouvelle propriété avec un contrôle Image, ou directement avec le contrôle URZAImage auquel j’ai ajouté une propriété BitmapSource.

<UrzaControl:UrzaImage BitmapSource="{Binding Picture}" VerticalAlignment="Stretch"

                               HorizontalAlignment="Stretch">                          

</UrzaControl:UrzaImage>

Remarque : Dans cet exemple nous ne déferons que les images, mais rien n’empêche de faire
de même pour d’autres champs.

3) Passons au code !!, l’idée est donc d’itérer sur la collection ObservableCollection<URZACard>,
afin d’alimenter, la propriété Picture de chaque instance de la classe URZACard.

Un code classique serait donc le suivant :

On démarre un nouveau thread, et on itère sur la collection

            Task.Run(() =>

                {

                    var lcards = this.CurrentExpansion.cards;

                    foreach (var card in lcards)

                    {

                        this.Dispatcher.RunAsync(CoreDispatcherPriority.Low, new DispatchedHandler(() =>

                        {

                            card.Picture = new BitmapImage(new Uri(card.ImageInfo.RemotePath));

                        }));                   
                     }

                });

A noter que comme nous sommes dans un thread , j’utilise le Dispatcher,
pour se synchroniser avec le thread qui a créé l’objet Picture. Sinon => WRONG_THREAD

Notez que la priorité du Dispatcher est à Low, permettant ainsi une meilleure réactivité de l’interface, si l’utilisateur,
veut par exemple fermer l’application, ou la mettre en mode snap. Si vous mettez à High, les messages souris ou touch ne sont pas traités en priorités

une alternative serait d’utiliser le pendant parallèle du foreach, la méthode Parallel.ForEach

Parallel.ForEach(blocks, async (block, loopState) =>

                            {

this.Dispatcher.RunAsync(CoreDispatcherPriority.Low, new DispatchedHandler(() =>

                        {

                            card.Picture = new BitmapImage(new Uri(card.ImageInfo.RemotePath));

                        }));

});

Néanmoins, utiliser la version parallèle, peut sembler moins rapide pour l’utilisateur, car elle partitionne les données en n
partitions (n correspondant au nombre de processeurs virtuelles), et n’affiche pas forcement les cartes dans un ordre bien défini alors que la version
séquentielle les affiches les unes après les autres ce qui peut engendrer une sensation que le code séquentiel est plus rapide. Ce qui d’ailleurs peut être
le cas, en fonction du nombre de cartes à télécharger. En effet il peut dans certain cas, être plus judicieux de rester en séquentiel si le nombre de cartes
est insuffisante. La version parallèle pouvant engendré des surcharges du à son fonctionnement interne. Il est donc important de mesurer et de choisir un
scénario en fonction d’un volume de données, ou de s’adapter dynamiquement en fonction de ce même volume.

Mais le point ou la version parallèle est plus rapide, c’est quand dans URZAGatherer on décide de sauvegarder les images en locale.

En effet, sur un volume de 350 cartes, la version séquentielle met environ 50 secondes pour les télécharger et les sauvegarder,
alors que la version parallèle met 15 secondes, soit environ une accélération de 3.3. Ce qui est en phase avec le nombre de processeurs de ma configuration qui
est de 4.

Remarque : On pourrait s’attendre à une accélération de 4, mais c’est sans compter le
bruit et les frictions propres au système d’exploitation et aux ressources utilisées à un instant T.

Dans le code que vous retrouverez dans la solution,
vous y trouverez entre autre dans la classe VueData, la méthode MapPictureCardsAsync()

      public Task MapPictureCardsAsync()

        {                    

            InitParallelOptions(System.Environment.ProcessorCount);

            return Task.Run( async () =>

            {

                try

                {                   

                    var  cards = this.CurrentExpansion.cards;

#if FOREACH

                    foreach (var card in cards)

                    {

                        if (card.Picture == null) //No need to rebind

                            await BindPictureAsync(card, card.ImageInfo, TokenSource.Token);

                    }

#else

                    Parallel.ForEach(cards, _parallelOptions, async (card, loopState) =>

                        {

                            if (_parallelOptions.CancellationToken.IsCancellationRequested)

                            {                               
                                 loopState.Stop();

                            }

                            if (!loopState.ShouldExitCurrentIteration)

                            {

                                if (card.Picture == null) //No need to rebind

                                    await BindPictureAsync(card, card.ImageInfo, TokenSource.Token);

                            }

                        });

#endif

                }

                catch (OperationCanceledException)

                {

                    RaisePicturesLoadedAsync();

                }

                             

            if (!TokenSource.IsCancellationRequested)

                    RaisePicturesLoadedAsync();              

            },TokenSource.Token);

        }

Cette méthode démarre une nouvelle tâche (Task.Run() ), en lui passant comme
paramètre CancellationToken, qui nous sert à arrêter la tâche et la boucle parallèle si on revient à la vue Home. En effet, pas la peine de
continuer à télécharger les cartes, si l’utilisateur, veut visualiser d’autres cartes. Par contre cette tâche n’est pas arrêtée si l’utilisateur veut voir le
détail d’une carte.

L’arrêt de la tâche ce fait dans la méthode CancelAsync() de la classe VueData.

La boucle parallèle se fait à l’aide de la méthode statique Parallel.ForEach(),
ou je lui passe comme paramètre des options qui sont initialisées dans la méthode VueData.InitParallelOption() .
Dans cette méthode, j’instancie le CancellationToken, ainsi que le degré de parallélisme que je souhaite, c’est-à-dire le nombre de
processeurs virtuels à utiliser. Parfois, il est plus judicieux de ne pas utiliser tous les processeurs disponibles, et d'en laisser pour d'autres tâches.

  void InitParallelOptions(int maxDegreeOfParallelism)

        {           
           TokenSource = new CancellationTokenSource();

           _parallelOptions = new ParallelOptions();

           _parallelOptions.CancellationToken = TokenSource.Token;

           _parallelOptions.MaxDegreeOfParallelism = maxDegreeOfParallelism;

        }

Je passe également le paramètre loopState, qui nous permet de déterminer l’état d’une itération à un instant T (par exemple si je dois sortir
de la boucle loopState.ShouldExitCurrentIteration) ou arrêter la boucle loopstate.Stop() si une demande d’arrêt est en cours.

Ensuite Si le Binding a déjà été effectué, pas la peine de binder une nouvelle fois. Dans le cas contraire, j’appelle la méthode BindPictureAsync() .

private async Task BindPictureAsync(IURZACommon item, URZAImageInfo imageInfo,CancellationToken token)

        {

                //Get the remote picture

                if (!URZASettings.IsOffLineModeOn() && NetworkInterface.GetIsNetworkAvailable())

                {                   

                        BindToPicture(item,imageInfo.RemotePath);

                        return ;

                }

                if (NetworkInterface.GetIsNetworkAvailable())

                {

                    if (!await Helper.IsFileExistAsync(imageInfo.LocalFolder,imageInfo.FileName))

                    {

                            //I don't want to wait until the picture was downloaded and saved to disk;

                            BindToPicture(item, imageInfo.RemotePath);

                            Helper.DownloadPicture2Async(token, imageInfo);

                            return;

                    }

                    else

                    {

                        BindToPicture(item, imageInfo.LocalPath);

                        return;

                    }

                }

 

                if (await Helper.IsFileExistAsync(imageInfo.LocalFolder, imageInfo.FileName))

                {

                    BindToPicture(item, imageInfo.LocalPath);                   

                }

                else

                {

                    BindToPicture(item, @"ms-appx:/Assets/widelogo.png");                   

                }                    

        }

Pour télécharger les images, je m’appuie désormais sur le BackGroundDownloader
et non plus sur HTTPClient.

static BackgroundDownloader _downloader = new BackgroundDownloader();

        public async static void DownloadPicture2Async(CancellationToken token, URZAImageInfo imageinfo)

        {

                    IStorageFile file = null;

                    try

                    {

                        file= await imageinfo.LocalFolder.CreateFileAsync(imageinfo.FileName, CreationCollisionOption.ReplaceExisting);                       

                        DownloadOperation downloadOperation = _downloader.CreateDownload(new Uri(imageinfo.RemotePath),file);                   

                        await downloadOperation.StartAsync().AsTask(token, null);

                    }                                      

                    catch (Exception ex)

                    {

                        if (file !=null)

                            file.DeleteAsync(StorageDeleteOption.PermanentDelete);

                    }

       }

Remarque : la constante de compilation FOREACH, permet de compiler la version séquentielle ou parallèle du code, ceci vous
permettra de vous faire une idée et choisir entre l’une ou l’autre des stratégies, voir d'utiliser les deux !!

Pour vérifier que le code parallèle est quand même intrinsèquement plus performant, j’ai ajouté une méthode Helper.Run() qui permet de mesurer le
temps d’exécution d’une méthode par rapport à une autre.

public async static void Run(Func<Task> func,String message)

{           

System.Diagnostics.Stopwatch watch = new System.Diagnostics.Stopwatch();

            watch.Start();

            await func();

            StorageFile storageFile = await ApplicationData.Current.LocalFolder.CreateFileAsync("log.txt", CreationCollisionOption.OpenIfExists);

            String Message = String.Format("\n\r {0} : ElapsedMilliseconds {1} \n\r", message, watch.ElapsedMilliseconds);

            await Windows.Storage.FileIO.AppendTextAsync(storageFile, Message);           

            watch.Stop();

        }

Et pour appeler la méthode MapPictureCardsasync() c’est simple.

String Message = "REMOTE + SAV : PARALLEL.FOREACH(8) : Nombre d'elements : " + _vueData.CurrentExpansion.cards.Count.ToString();

            Helper.Run(() =>

                {

                    return _vueData.MapPictureCardsAsync();               
               }, Message);

Cette méthode Helper.Run() sauvegarde un fichier log.txt dans le répertoire courant de l’application dont voici quelques extraits.

Image En Remote : FOREACH : Nombre d'éléments : 350 : ElapsedMilliseconds 10703

Image En Remote : PARALLEL.FOREACH(8) : Nombre d'éléments : 350 : ElapsedMilliseconds 9646

Sauvegarde des images : FOREACH : Nombre d'éléments : 350 : ElapsedMilliseconds 48687

Sauvegarde des images: PARALLEL.FOREACH(8) : Nombre d'éléments : 350 : ElapsedMilliseconds 14693

Ouverture image local : FOREACH : Nombre d'éléments : 350 : ElapsedMilliseconds 11014

Ouverture image locale : PARALLEL.FOREACH(8) : Nombre d'éléments : 350 : ElapsedMilliseconds 10767

 

Ont peut constater que la différence notable se fait essentiellement lorsqu'il faut télécharger et sauvegarder les images en locales, que pour le reste

la différence est minime mais présente. Encore une fois c'est à vous de vous faire une idée selon vos attentes.

Eric Vernié