[WPF] Binding sur une collection asynchrone

Très mauvaisMauvaisMoyenBonExcellent (1 votes) 
Loading...

Comme je l’avais évoqué dans mon précédent post, on ne peut pas ajouter des éléments à une ObservableCollection à partir d’un autre thread si une vue est bindée sur la collection : cela provoque une NotSupportedException. Prenons l’exemple d’une ListBox bindée sur une collection de chaines de caractères appartenant au ViewModel :

        private ObservableCollection<string> _strings = new ObservableCollection<string>();
        public ObservableCollection<string> Strings
        {
            get { return _strings; }
            set
            {
                _strings = value;
                OnPropertyChanged("Strings");
            }
        }
<ListBox ItemsSource="{Binding Strings}"/>

Si on ajoute des éléments à cette collection hors du thread principal, on obtient l’exception citée plus haut. Une solution est de créer une nouvelle liste, puis de l’affecter à la propriété Strings quand elle est remplie, mais dans ce cas l’interface graphique ne reflète pas la progression : les éléments de la liste apparaissent tous à la fois quand la liste est remplie, et non au fur et à mesure que les éléments sont ajoutés. Si la liste correspond aux résultats d’une recherche, par exemple, ça peut être assez gênant car l’utilisateur s’attend à voir les résultats apparaître au fur et à mesure qu’ils sont trouvés (comme dans la recherche Windows).

Un moyen simple d’obtenir le comportement voulu est de créer une classe héritée de ObservableCollection qui déclenche les évènements CollectionChanged et PropertyChanged sur le thread principal au lieu du thread courant. La classe AsyncOperation se prête parfaitement à cet objectif : elle permet de “poster” un évènement sur le thread qui l’a créée. Elle est notamment utilisée par le composant BackgroundWorker et de nombreuses méthodes asynchrones du framework (PictureBox.LoadAsync, WebClient.DownloadAsync, etc…).

Voici donc le code d’une collection AsyncObservableCollection qui peut être modifiée à partir de n’importe quel thread tout en notifiant l’interface graphique lors d’une modification :

    public class AsyncObservableCollection<T> : ObservableCollection<T>
    {
        private AsyncOperation asyncOp = null;

        public AsyncObservableCollection()
        {
            CreateAsyncOp();
        }

        public AsyncObservableCollection(IEnumerable<T> list)
            : base(list)
        {
            CreateAsyncOp();
        }

        private void CreateAsyncOp()
        {
            // Create the AsyncOperation to post events on the creator thread
            asyncOp = AsyncOperationManager.CreateOperation(null);
        }

        protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
        {
            // Post the CollectionChanged event on the creator thread
            asyncOp.Post(RaiseCollectionChanged, e);
        }

        private void RaiseCollectionChanged(object param)
        {
            // We are in the creator thread, call the base implementation directly
           base.OnCollectionChanged((NotifyCollectionChangedEventArgs)param);
        }

        protected override void OnPropertyChanged(PropertyChangedEventArgs e)
        {
            // Post the PropertyChanged event on the creator thread
            asyncOp.Post(RaisePropertyChanged, e);
        }

        private void RaisePropertyChanged(object param)
        {
            // We are in the creator thread, call the base implementation directly
            base.OnPropertyChanged((PropertyChangedEventArgs)param);
        }
    }

La seule contrainte est de créer les instances de cette collection sur le thread de l’interface graphique, afin que les évènements soient bien déclenchés sur ce thread.

Si on reprend le code de l’exemple précédent, la seule chose à changer pour pouvoir modifier la collection à partir d’un autre thread est l’instantiation de la collection dans le ViewModel :

private ObservableCollection<string> _strings = new AsyncObservableCollection<string>();

La ListBox peut maintenant refléter en temps réel les changements intervenus dans la collection.

Enjoy 😉

Mise à jour : Je viens de remarquer un bug dans mon implémentation : dans certains cas le fait de passer par un Post pour lever l’évènement alors que la collection est modifiée à partir du thread principal peut produire un comportement inattendu. Dans ce cas il faut évidemment lever l’évènement directement, en vérifiant que le SynchronizationContext courant est le même que celui dans lequel a été créée la collection. Et puisqu’on en est à se préoccuper du SynchronizationContext, autant l’utiliser directement et se passer de l’AsyncOperation, qui finalement n’apporte rien. Voici donc la nouvelle implémentation :

    public class AsyncObservableCollection<T> : ObservableCollection<T>
    {
        private SynchronizationContext _synchronizationContext = SynchronizationContext.Current;

        public AsyncObservableCollection()
        {
        }

        public AsyncObservableCollection(IEnumerable<T> list)
            : base(list)
        {
        }

        protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
        {
            if (SynchronizationContext.Current == _synchronizationContext)
            {
                // Raise the CollectionChanged event on the current thread
                RaiseCollectionChanged(e);
            }
            else
            {
                // Raise the CollectionChanged event on the creator thread
                _synchronizationContext.Send(RaiseCollectionChanged, e);
            }
        }

        private void RaiseCollectionChanged(object param)
        {
            // We are in the creator thread, call the base implementation directly
            base.OnCollectionChanged((NotifyCollectionChangedEventArgs)param);
        }

        protected override void OnPropertyChanged(PropertyChangedEventArgs e)
        {
            if (SynchronizationContext.Current == _synchronizationContext)
            {
                // Raise the PropertyChanged event on the current thread
                RaisePropertyChanged(e);
            }
            else
            {
                // Raise the PropertyChanged event on the creator thread
                _synchronizationContext.Send(RaisePropertyChanged, e);
            }
        }

        private void RaisePropertyChanged(object param)
        {
            // We are in the creator thread, call the base implementation directly
            base.OnPropertyChanged((PropertyChangedEventArgs)param);
        }
    }

Mise à jour : modifié le code pour utiliser Send plutôt que Post. L’utilisation de Post faisait que l’évènement était déclenché de façon asynchrone sur le thread UI, ce qui pouvait causer une race condition si la collection était modifiée à nouveau avant que l’évènement ne soit géré.

9 Comments

  1. cyril says:

    Salut,

    D”abord, un grand merci pour tes publications. Etant tout nouveau dans le monde wpf, tes post m”ont été d”une grande utilité.
    Je suis tombé sur un disfonctionnement il me semble lorsque l”ont “remove” un élément de la liste.
    Ma liste étant bornée, préalablement à l”ajout d”un élément, je retire le premier de la liste (RemoveAt(0)).
    J”obtiens alors une exception sur le base.OnCollectionChanged : ” Added item does not appear at given index x”. x correspondant à la dernière position dans ma liste.
    Malgré quelques jours de debuggage, je ne comprend pas ce qu”il se passe.
    Aurais une piste ?

  2. Salut,

    Apparemment ce truc n”est pas encore complètement au point :(… Je me demande si ce n”est pas à cause du “_synchronizationContext.Post”. Cette instruction est non bloquante : on n”attend pas que le thread destinataire ait traité le message, ce qui peut poser des problèmes de synchronisation si les éléments sont supprimés plus vite que le thread d”UI ne peut traiter les évènements… Essaie de remplacer “_synchronizationContext.Post” par “_synchronizationContext.Send”, je pense que ça devrait régler le problème (Send est bloquant et attend le traitement du message par le thread destinataire).
    Je ne savais pas vraiment s”il fallait utiliser l”un ou l”autre, mais ton problème pourrait apporter une réponse 😉

    • Arnaud DANEELS says:

      Bonjour,

      J”ai eu le même problème lors de modifications dans la collection.

      Ceci règle effectivement le problème (et la rapidité d”exécution est au rendez-vous).

      Merci pour ce source très très utile.

  3. James says:

    Bonjour,

    j”avais effectivement ce probleme de propriete de la vue par le Thread d”interface.
    Merci beaucoup pour ce bout d”code =)

    Pour le bon fonctionnement de cette classe, precisons l”ajout de :
    using System.ComponentModel;
    using System.Collections.Specialized;

  4. Alexandre says:

    Bonjour, d’abord merci pour l’article qui m’a été très utile pour plusieurs utilisations.

    Cependant j’aimerais savoir si il n’existe pas une solution pour permettre l’initialisation de la liste sans être sur le thread de l’UI ?

    J’avais pensé à garder dans un champs statique le context que j’initialise à la création de ma fenêtre principale, mais ça ne fonctionne pas.
    Idem si j’utilise Dispatcher.Invoke(new System.Action(() => mSynchronizationContext = SynchronizationContext.Current));

    • Thomas Levesque says:

      Il suffit de créer la liste (sur n’importe quel thread) avant de la binder.

      quick and dirty :

      var theList = await Task.Run(() => CreateTheList());
      ThePropertyForBinding = theList;
      • Alexandre says:

        Effectivement cela fonctionne, mais dans mon cas actuel cela sera difficile à implementer.
        De plus cette liste est mise à jour par la suite donc le problème se repose.

        N’y a t-il aucun moyen de stocker par avance le contexte afin de le recuperer lorsqu’on instancie l’AsyncObservableCollection ?
        Une autre idée ?

        Merci

        • Thomas Levesque says:

          Ah ok, je vois le problème. Le SyncContext est déterminé dans le constructeur, donc en fait il faudrait ajouter un constructeur qui prend en paramètre le SyncContext, pour pouvoir en passer un autre. (difficile d’en dire plus sans voir ton code)

  5. Alexandre says:

    Bon grâce à tes réponses j’ai pu avancer et enfait le problème vient de ListCollectionView qui sont aussi mises à jour en arrière-plan.
    Or, au vu d’autres forums (dans lesquels tu as également répondu :)) il semble qu’il n’y ait aucun moyen de le faire (=Avoir des ListCollectionView qui peuvent être mises à jour en arrière plan par un quelconque moyen ?) .
    Donc je crois que je vais devoir faire plus de changements que prévu dans mon code existant…

    Merci en tout cas !

Leave a comment

css.php