[WPF] Utiliser Linq pour filtrer, trier et grouper les données dans une CollectionView

WPF offre un mécanisme assez simple pour la mise en forme de collections de données, via l’interface ICollectionView et ses propriétés Filter, SortDescriptions et GroupDescriptions :

// Collection à laquelle la vue est liée
public ObservableCollection People { get; private set; }
...

// Vue par défaut de la collection People
ICollectionView view = CollectionViewSource.GetDefaultView(People);

// Uniquement les adultes
view.Filter = o => ((Person)o).Age >= 18;

// Tri par nom et prénom
view.SortDescriptions.Add(new SortDescription("LastName", ListSortDirection.Ascending));
view.SortDescriptions.Add(new SortDescription("FirstName", ListSortDirection.Ascending));

// Groupement par pays
view.GroupDescriptions.Add(new PropertyGroupDescription("Country"));

Bien que cette technique ne soit pas très difficile à mettre en œuvre, elle présente certains inconvénients :

  • La syntaxe un peu lourde et pas très naturelle : le fait que le paramètre du filtre soit un object alors qu’on sait que les éléments sont de type Person réduit la lisibilité, et l’ajout des tris et descriptions comporte beaucoup de répétitions
  • Le fait de spécifier les noms des propriétés sous forme de chaine introduit des risques d’erreur, puisqu’ils ne sont pas vérifiés par le compilateur.

Depuis quelques années, on a pris l’habitude d’utiliser Linq pour faire ce genre de choses… il serait donc pratique de pouvoir le faire aussi pour définir le filtre, le tri et le groupement d’une ICollectionView.

Voyons donc quelle syntaxe on pourrait utiliser pour faire ça avec Linq… quelque chose comme ça, par exemple ?

People.Where(p => p.Age >= 18)
      .OrderBy(p => p.LastName)
      .ThenBy(p => p.FirstName)
      .GroupBy(p => p.Country);

Ou encore, en utilisant la syntaxe de requête Linq :

from p in People
where p.Age >= 18
orderby p.LastName, p.FirstName
group p by p.Country;

Bon, évidemment ça ne suffit pas : ce code ne fait rien d’autre que créer une requête Linq sur la collection, il ne modifie pas la CollectionView… mais avec un tout petit peu de travail supplémentaire on peut obtenir le résultat voulu :

var query =
    from p in People.ShapeView()
    where p.Age >= 18
    orderby p.LastName, p.FirstName
    group p by p.Country;

query.Apply();

La méthode ShapeView renvoie un wrapper qui encapsule la vue par défaut de la collection, et expose des méthodes Where, OrderBy et GroupBy avec les signatures appropriées pour définir la mise en forme. Créer la requête n’a pas d’effet direct, c’est la méthode Apply qui permet d’appliquer les changements à la vue : en effet, il vaut mieux tous les appliquer en même temps à l’aide de ICollectionView.DeferRefresh, pour ne provoquer un rafraichissement de la vue à chaque nouvelle clause de la requête. Lors de l’appel à Apply, on observe que la vue est bien mise à jour pour refléter les clauses de la requête.

Cette solution permet de conserver le typage fort pour le filtrage, le tri et le groupement, avec pour bénéfice immédiat la vérification par le compilateur. C’est également plus concis et plus lisible que le code d’origine… Attention quand même à une chose : certaines requêtes qui seront correctes du point de vue de C# ne seront en fait pas applicables à une CollectionView. Par exemple, si vous essayez de grouper par la première lettre du nom (p.LastName.Substring(0, 1)), la méthode GroupBy échouera, car seules les propriétés sont supportées par PropertyGroupDescription.

Notez que le wrapper n’écrase pas les propriétés courantes de la CollectionView si vous ne spécifiez pas la clause Linq correspondante, il est donc possible de modifier une vue existante sans devoir tout spécifier à nouveau. Si nécessaire, des méthodes ClearFilter, ClearSort et ClearGrouping permettent de réinitialiser le filtre, le tri et le regroupement :

// Suppression du regroupement et ajout d'un tri :
People.ShapeView()
      .ClearGrouping()
      .OrderBy(p => p.LastName);
      .Apply();

Notez que comme pour une requête Linq “normale”, on peut au choix utiliser la syntaxe de requête ou appeler directement les méthodes, puisqu’il s’agit simplement d’une transformation syntaxique effectuée par le compilateur.

Pour finir, voici le code complet du wrapper et les méthodes d’extension associées :

    public static class CollectionViewShaper
    {
        public static CollectionViewShaper<TSource> ShapeView<TSource>(this IEnumerable<TSource> source)
        {
            var view = CollectionViewSource.GetDefaultView(source);
            return new CollectionViewShaper<TSource>(view);
        }

        public static CollectionViewShaper<TSource> Shape<TSource>(this ICollectionView view)
        {
            return new CollectionViewShaper<TSource>(view);
        }
    }

    public class CollectionViewShaper<TSource>
    {
        private readonly ICollectionView _view;
        private Predicate<object> _filter;
        private readonly List<SortDescription> _sortDescriptions = new List<SortDescription>();
        private readonly List<GroupDescription> _groupDescriptions = new List<GroupDescription>();

        public CollectionViewShaper(ICollectionView view)
        {
            if (view == null)
                throw new ArgumentNullException("view");
            _view = view;
            _filter = view.Filter;
            _sortDescriptions = view.SortDescriptions.ToList();
            _groupDescriptions = view.GroupDescriptions.ToList();
        }

        public void Apply()
        {
            using (_view.DeferRefresh())
            {
                _view.Filter = _filter;
                _view.SortDescriptions.Clear();
                foreach (var s in _sortDescriptions)
                {
                    _view.SortDescriptions.Add(s);
                }
                _view.GroupDescriptions.Clear();
                foreach (var g in _groupDescriptions)
                {
                    _view.GroupDescriptions.Add(g);
                }
            }
        }
            
        public CollectionViewShaper<TSource> ClearGrouping()
        {
            _groupDescriptions.Clear();
            return this;
        }

        public CollectionViewShaper<TSource> ClearSort()
        {
            _sortDescriptions.Clear();
            return this;
        }

        public CollectionViewShaper<TSource> ClearFilter()
        {
            _filter = null;
            return this;
        }

        public CollectionViewShaper<TSource> ClearAll()
        {
            _filter = null;
            _sortDescriptions.Clear();
            _groupDescriptions.Clear();
            return this;
        }

        public CollectionViewShaper<TSource> Where(Func<TSource, bool> predicate)
        {
            _filter = o => predicate((TSource)o);
            return this;
        }

        public CollectionViewShaper<TSource> OrderBy<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            return OrderBy(keySelector, true, ListSortDirection.Ascending);
        }

        public CollectionViewShaper<TSource> OrderByDescending<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            return OrderBy(keySelector, true, ListSortDirection.Descending);
        }

        public CollectionViewShaper<TSource> ThenBy<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            return OrderBy(keySelector, false, ListSortDirection.Ascending);
        }

        public CollectionViewShaper<TSource> ThenByDescending<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            return OrderBy(keySelector, false, ListSortDirection.Descending);
        }

        private CollectionViewShaper<TSource> OrderBy<TKey>(Expression<Func<TSource, TKey>> keySelector, bool clear, ListSortDirection direction)
        {
            string path = GetPropertyPath(keySelector.Body);
            if (clear)
                _sortDescriptions.Clear();
            _sortDescriptions.Add(new SortDescription(path, direction));
            return this;
        }

        public CollectionViewShaper<TSource> GroupBy<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            string path = GetPropertyPath(keySelector.Body);
            _groupDescriptions.Add(new PropertyGroupDescription(path));
            return this;
        }

        private static string GetPropertyPath(Expression expression)
        {
            var names = new Stack<string>();
            var expr = expression;
            while (expr != null && !(expr is ParameterExpression) && !(expr is ConstantExpression))
            {
                var memberExpr = expr as MemberExpression;
                if (memberExpr == null)
                    throw new ArgumentException("The selector body must contain only property or field access expressions");
                names.Push(memberExpr.Member.Name);
                expr = memberExpr.Expression;
            }
            return String.Join(".", names.ToArray());
        }
    }

Publié dans WPF. Tags : , , . 1 commentaire »

[WPF] Créer des styles paramétrables à l’aide des propriétés attachées

Je voudrais aujourd’hui partager avec vous une petite astuce que j’utilise souvent depuis quelques mois. Supposons que pour améliorer l’apparence de votre application, vous ayez créé des styles personnalisés pour les contrôles standards :

Bon, je ne suis pas designer, hein… mais ça fera parfaitement l’affaire pour illustrer mon propos ;) . Ces styles sont très simples, ce sont les styles par défaut des CheckBox et RadioButton dans lesquels j’ai seulement modifié les templates pour remplacer les BulletChrome par ces superbes marques bleues. Voilà le code :

        <Style x:Key="{x:Type CheckBox}" TargetType="{x:Type CheckBox}">
            <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
            <Setter Property="Background" Value="{StaticResource CheckBoxFillNormal}"/>
            <Setter Property="BorderBrush" Value="{StaticResource CheckBoxStroke}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="FocusVisualStyle" Value="{StaticResource EmptyCheckBoxFocusVisual}"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type CheckBox}">
                        <BulletDecorator Background="Transparent"
                                         SnapsToDevicePixels="true">
                            <BulletDecorator.Bullet>
                                <Border BorderBrush="{TemplateBinding BorderBrush}"
                                        Background="{TemplateBinding Background}"
                                        BorderThickness="1"
                                        Width="11" Height="11" Margin="0,1,0,0">
                                    <Grid>
                                        <Path Name="TickMark"
                                              Fill="Blue"
                                              Data="M0,4 5,9 9,0 4,5"
                                              Visibility="Hidden" />
                                        <Rectangle Name="IndeterminateMark"
                                                   Fill="Blue"
                                                   Width="7" Height="7"
                                                   HorizontalAlignment="Center"
                                                   VerticalAlignment="Center"
                                                   Visibility="Hidden" />
                                    </Grid>
                                </Border>
                            </BulletDecorator.Bullet>
                            <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                              Margin="{TemplateBinding Padding}"
                                              RecognizesAccessKey="True"
                                              SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                                              VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
                        </BulletDecorator>
                        <ControlTemplate.Triggers>
                            <Trigger Property="HasContent" Value="true">
                                <Setter Property="FocusVisualStyle" Value="{StaticResource CheckRadioFocusVisual}"/>
                                <Setter Property="Padding" Value="4,0,0,0"/>
                            </Trigger>
                            <Trigger Property="IsEnabled" Value="false">
                                <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}"/>
                            </Trigger>
                            <Trigger Property="IsChecked" Value="True">
                                <Setter TargetName="TickMark" Property="Visibility" Value="Visible" />
                            </Trigger>
                            <Trigger Property="IsChecked" Value="{x:Null}">
                                <Setter TargetName="IndeterminateMark" Property="Visibility" Value="Visible" />
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style x:Key="{x:Type RadioButton}" TargetType="{x:Type RadioButton}">
            <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
            <Setter Property="Background" Value="#F4F4F4"/>
            <Setter Property="BorderBrush" Value="{StaticResource CheckBoxStroke}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type RadioButton}">
                        <BulletDecorator Background="Transparent">
                            <BulletDecorator.Bullet>
                                <Grid VerticalAlignment="Center" Margin="0,1,0,0">
                                    <Ellipse Width="11" Height="11"
                                             Stroke="{TemplateBinding BorderBrush}"
                                             StrokeThickness="1"
                                             Fill="{TemplateBinding Background}" />
                                    <Ellipse Name="TickMark"
                                             Width="7" Height="7"
                                             Fill="Blue"
                                             Visibility="Hidden" />
                                    <Ellipse Name="IndeterminateMark"
                                             Width="3" Height="3"
                                             Fill="Blue"
                                             Visibility="Hidden" />
                                </Grid>
                            </BulletDecorator.Bullet>
                            <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                              Margin="{TemplateBinding Padding}"
                                              RecognizesAccessKey="True"
                                              VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
                        </BulletDecorator>
                        <ControlTemplate.Triggers>
                            <Trigger Property="HasContent" Value="true">
                                <Setter Property="FocusVisualStyle" Value="{StaticResource CheckRadioFocusVisual}"/>
                                <Setter Property="Padding" Value="4,0,0,0"/>
                            </Trigger>
                            <Trigger Property="IsEnabled" Value="false">
                                <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}"/>
                            </Trigger>
                            <Trigger Property="IsChecked" Value="True">
                                <Setter TargetName="TickMark" Property="Visibility" Value="Visible" />
                            </Trigger>
                            <Trigger Property="IsChecked" Value="{x:Null}">
                                <Setter TargetName="IndeterminateMark" Property="Visibility" Value="Visible" />
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>

Vous avez donc maintenant de magnifiques contrôles qui vont faire de l’application un grand succès, le management est content, tout va pour le mieux dans le meilleur des mondes… Et là, vous réalisez que dans un autre écran de l’application, les contrôles doivent avoir le même style, mais en vert !

La première solution qui vient à l’esprit est de dupliquer le style en mettant du vert à la place du bleu. Mais comme vous êtes un bon développeur soucieux des bonnes pratiques, vous savez que la duplication de code, c’est mal : si vous devez un jour modifier le style de la CheckBox bleue, il faudra aussi modifier celui de la verte… et peut-être aussi la rouge, la noire, etc. Bref, ça deviendrait vite ingérable. Il faut donc refactoriser, mais comment ? Il faudrait pouvoir passer la couleur en paramètre du style, mais un style n’est pas une méthode à laquelle on peut passer des paramètres…

Il faudrait donc avoir une propriété supplémentaire pour indiquer la couleur des “ticks”, et se binder sur cette propriété dans le template. Une approche possible est de créer des contrôles personnalisés hérités de CheckBox et RadioButton, avec une propriété supplémentaire TickBrush… mais personnellement je n’aime pas beaucoup cette approche : je préfère éviter de créer de nouveaux contrôles quand on peut s’en sortir avec les contrôles standard.

En fait, il y a une solution plus simple : il suffit de créer une classe, qu’on appelle par exemple ThemeProperties, et de déclarer dedans une propriété attachée de type Brush:

    public static class ThemeProperties
    {
        public static Brush GetTickBrush(DependencyObject obj)
        {
            return (Brush)obj.GetValue(TickBrushProperty);
        }

        public static void SetTickBrush(DependencyObject obj, Brush value)
        {
            obj.SetValue(TickBrushProperty, value);
        }

        public static readonly DependencyProperty TickBrushProperty =
            DependencyProperty.RegisterAttached(
                "TickBrush",
                typeof(Brush),
                typeof(ThemeProperties),
                new FrameworkPropertyMetadata(Brushes.Black));
    }

On modifie un peu nos templates pour remplacer la couleur en dur par un binding sur cette propriété :

                                ...

                                <!-- CheckBox -->
                                        <Path Name="TickMark"
                                              Fill="{TemplateBinding my:ThemeProperties.TickBrush}"
                                              Data="M0,4 5,9 9,0 4,5"
                                              Visibility="Hidden" />
                                        <Rectangle Name="IndeterminateMark"
                                                   Fill="{TemplateBinding my:ThemeProperties.TickBrush}"
                                                   Width="7" Height="7"
                                                   HorizontalAlignment="Center"
                                                   VerticalAlignment="Center"
                                                   Visibility="Hidden" />

                                ...

                                <!-- RadioButton -->
                                    <Ellipse Name="TickMark"
                                             Width="7" Height="7"
                                             Fill="{TemplateBinding my:ThemeProperties.TickBrush}"
                                             Visibility="Hidden" />
                                    <Ellipse Name="IndeterminateMark"
                                             Width="3" Height="3"
                                             Fill="{TemplateBinding my:ThemeProperties.TickBrush}"
                                             Visibility="Hidden" />

Et quand on utilise les contrôles, on précise la couleur qu’on veut pour le tick :

<CheckBox Content="Checked" IsChecked="True" my:ThemeProperties.TickBrush="Blue" />

On peut donc maintenant avoir des contrôles qui partagent le même style, mais en changeant la couleur d’un élément du template :

On a donc effectivement rendu les styles paramétrables ! Il reste cependant un petit souci : étant donné que tous les contrôles d’un même éran utilisent tous la même couleur, il n’est pas très pratique de devoir la répéter sur chaque contrôle. L’idéal serait de pouvoir indiquer à la racine de la vue la couleur à utiliser pour tous les contrôles… et justement, les dependency properties (et donc les propriétés attachées) offrent une fonctionnalité qui permet de faire exactement ça : l’héritage de valeur. Il suffit d’indiquer le flag Inherits lors de la déclaration de la propriété TickBrush :

        public static readonly DependencyProperty TickBrushProperty =
            DependencyProperty.RegisterAttached(
                "TickBrush",
                typeof(Brush),
                typeof(ThemeProperties),
                new FrameworkPropertyMetadata(Brushes.Black, FrameworkPropertyMetadataOptions.Inherits));

Avec cette modification, la propriété devient “ambiante” : il suffit d’indiquer sa valeur sur un contrôle parent (par exemple la racine de la vue) pour que tous les descendants prennent en compte cette valeur. On peut donc très facilement faire des écrans avec des contrôles qui partagent le même style, mais en appliquant des couleurs différentes.

Le concept peut bien sûr être étendu à d’autres cas : en fait, dès qu’un élément du template doit varier selon un critère arbitraire, cette technique peut s’appliquer. Cela évite bien souvent de devoir dupliquer le template pour ne changer qu’un petit détail.

[WPF 4.5] Abonnement à un évènement à l’aide d’une markup extension

Voilà un certain temps que je n’avais plus parlé des markup extensions… J’y reviens à l’occasion de la sortie de Visual Studio 11 Developer Preview, qui introduit un certain nombre de nouveautés dans WPF. La nouveauté dont je vais parler n’est sans doute pas la plus spectaculaire, mais elle vient combler un manque des versions précédentes : le support des markup extensions pour les évènements.

Jusqu’ici, il était possible d’utiliser une markup extension en XAML pour affecter une valeur à une propriété, mais on ne pouvait pas faire la même chose pour s’abonner à un évènement. Dans WPF 4.5, c’est désormais possible. Voilà donc un petit exemple de ce que cela permet de faire…

Quand on utilise le pattern MVVM, on associe souvent des commandes du ViewModel à des contrôles de la vue, via le mécanisme de binding. Cette approche fonctionne généralement assez bien, mais elle présente certains inconvénients :

  • cela introduit beaucoup de code de “plomberie” dans le ViewModel
  • tous les contrôles n’ont pas une propriété Command (en fait, la plupart ne l’ont pas), et quand cette propriété existe, elle ne correspond qu’à un seul évènement du contrôle (par exemple le clic sur un bouton). Il n’y a pas de moyen vraiment simple de relier les autres évènements à des commandes du ViewModel.

Il serait plus pratique de pouvoir lier directement l’évènement à une méthode du ViewModel de la façon suivante:

        <Button Content="Click me"
                Click="{my:EventBinding OnClick}" />

Avec la méthode OnClick définie dans le ViewModel:
        public void OnClick(object sender, EventArgs e)
        {
            MessageBox.Show("Hello world!");
        }

Eh bien cela est désormais possible ! Voilà donc une petite preuve de concept… La classe EventBindingExtension présentée ci-dessous obtient d’abord le DataContext du contrôle, puis recherche la méthode spécifiée dans le DataContext, et renvoie un delegate pour cette méthode:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Windows;
using System.Windows.Markup;


    public class EventBindingExtension : MarkupExtension
    {
        public EventBindingExtension() { }

        public EventBindingExtension(string eventHandlerName)
        {
            this.EventHandlerName = eventHandlerName;
        }

        [ConstructorArgument("eventHandlerName")]
        public string EventHandlerName { get; set; }

        public override object ProvideValue(IServiceProvider serviceProvider)
        {
            if (string.IsNullOrEmpty(EventHandlerName))
                throw new ArgumentException("The EventHandlerName property is not set", "EventHandlerName");

            var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));

            EventInfo eventInfo = target.TargetProperty as EventInfo;
            if (eventInfo == null)
                throw new InvalidOperationException("The target property must be an event");
            
            object dataContext = GetDataContext(target.TargetObject);
            if (dataContext == null)
                throw new InvalidOperationException("No DataContext found");

            var handler = GetHandler(dataContext, eventInfo, EventHandlerName);
            if (handler == null)
                throw new ArgumentException("No valid event handler was found", "EventHandlerName");

            return handler;
        }

        #region Helper methods

        static object GetHandler(object dataContext, EventInfo eventInfo, string eventHandlerName)
        {
            Type dcType = dataContext.GetType();

            var method = dcType.GetMethod(
                eventHandlerName,
                GetParameterTypes(eventInfo));
            if (method != null)
            {
                if (method.IsStatic)
                    return Delegate.CreateDelegate(eventInfo.EventHandlerType, method);
                else
                    return Delegate.CreateDelegate(eventInfo.EventHandlerType, dataContext, method);
            }

            return null;
        }

        static Type[] GetParameterTypes(EventInfo eventInfo)
        {
            var invokeMethod = eventInfo.EventHandlerType.GetMethod("Invoke");
            return invokeMethod.GetParameters().Select(p => p.ParameterType).ToArray();
        }

        static object GetDataContext(object target)
        {
            var depObj = target as DependencyObject;
            if (depObj == null)
                return null;

            return depObj.GetValue(FrameworkElement.DataContextProperty)
                ?? depObj.GetValue(FrameworkContentElement.DataContextProperty);
        }

        #endregion
    }

Cette classe est utilisable comme dans l’exemple présenté plus haut.

En l’état, cette markup extension présente une limitation un peu gênante : le DataContext doit être défini avant l’appel à ProvideValue, sinon il n’est pas possible de trouver la méthode qui gère l’évènement. Une solution pourrait être de s’abonner à l’évènement DataContextChanged pour s’abonner à l’évènement plus tard, mais en attendant il faut quand même renvoyer une valeur… et si on renvoie null, on obtient une exception car on ne peut pas s’abonner à un évènement avec un handler null. Il faudrait donc renvoyer un handler “bidon” généré dynamiquement en fonction de la signature de l’évènement. Voilà qui complique un peu les choses… mais ça reste faisable.

Voici une deuxième version qui implémente cette amélioration :

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using System.Windows;
using System.Windows.Markup;

    public class EventBindingExtension : MarkupExtension
    {
        private EventInfo _eventInfo;

        public EventBindingExtension() { }

        public EventBindingExtension(string eventHandlerName)
        {
            this.EventHandlerName = eventHandlerName;
        }

        [ConstructorArgument("eventHandlerName")]
        public string EventHandlerName { get; set; }

        public override object ProvideValue(IServiceProvider serviceProvider)
        {
            if (string.IsNullOrEmpty(EventHandlerName))
                throw new ArgumentException("The EventHandlerName property is not set", "EventHandlerName");

            var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));

            var targetObj = target.TargetObject as DependencyObject;
            if (targetObj == null)
                throw new InvalidOperationException("The target object must be a DependencyObject");

            _eventInfo = target.TargetProperty as EventInfo;
            if (_eventInfo == null)
                throw new InvalidOperationException("The target property must be an event");

            object dataContext = GetDataContext(targetObj);
            if (dataContext == null)
            {
                SubscribeToDataContextChanged(targetObj);
                return GetDummyHandler(_eventInfo.EventHandlerType);
            }

            var handler = GetHandler(dataContext, _eventInfo, EventHandlerName);
            if (handler == null)
            {
                Trace.TraceError(
                    "EventBinding: no suitable method named '{0}' found in type '{1}' to handle event '{2'}",
                    EventHandlerName,
                    dataContext.GetType(),
                    _eventInfo);
                return GetDummyHandler(_eventInfo.EventHandlerType);
            }

            return handler;
            
        }

        #region Helper methods

        static Delegate GetHandler(object dataContext, EventInfo eventInfo, string eventHandlerName)
        {
            Type dcType = dataContext.GetType();

            var method = dcType.GetMethod(
                eventHandlerName,
                GetParameterTypes(eventInfo.EventHandlerType));
            if (method != null)
            {
                if (method.IsStatic)
                    return Delegate.CreateDelegate(eventInfo.EventHandlerType, method);
                else
                    return Delegate.CreateDelegate(eventInfo.EventHandlerType, dataContext, method);
            }

            return null;
        }

        static Type[] GetParameterTypes(Type delegateType)
        {
            var invokeMethod = delegateType.GetMethod("Invoke");
            return invokeMethod.GetParameters().Select(p => p.ParameterType).ToArray();
        }

        static object GetDataContext(DependencyObject target)
        {
            return target.GetValue(FrameworkElement.DataContextProperty)
                ?? target.GetValue(FrameworkContentElement.DataContextProperty);
        }

        static readonly Dictionary<Type, Delegate> _dummyHandlers = new Dictionary<Type, Delegate>();

        static Delegate GetDummyHandler(Type eventHandlerType)
        {
            Delegate handler;
            if (!_dummyHandlers.TryGetValue(eventHandlerType, out handler))
            {
                handler = CreateDummyHandler(eventHandlerType);
                _dummyHandlers[eventHandlerType] = handler;
            }
            return handler;
        }

        static Delegate CreateDummyHandler(Type eventHandlerType)
        {
            var parameterTypes = GetParameterTypes(eventHandlerType);
            var returnType = eventHandlerType.GetMethod("Invoke").ReturnType;
            var dm = new DynamicMethod("DummyHandler", returnType, parameterTypes);
            var il = dm.GetILGenerator();
            if (returnType != typeof(void))
            {
                if (returnType.IsValueType)
                {
                    var local = il.DeclareLocal(returnType);
                    il.Emit(OpCodes.Ldloca_S, local);
                    il.Emit(OpCodes.Initobj, returnType);
                    il.Emit(OpCodes.Ldloc_0);
                }
                else
                {
                    il.Emit(OpCodes.Ldnull);
                }
            }
            il.Emit(OpCodes.Ret);
            return dm.CreateDelegate(eventHandlerType);
        }

        private void SubscribeToDataContextChanged(DependencyObject targetObj)
        {
            DependencyPropertyDescriptor
                .FromProperty(FrameworkElement.DataContextProperty, targetObj.GetType())
                .AddValueChanged(targetObj, TargetObject_DataContextChanged);
        }

        private void UnsubscribeFromDataContextChanged(DependencyObject targetObj)
        {
            DependencyPropertyDescriptor
                .FromProperty(FrameworkElement.DataContextProperty, targetObj.GetType())
                .RemoveValueChanged(targetObj, TargetObject_DataContextChanged);
        }

        private void TargetObject_DataContextChanged(object sender, EventArgs e)
        {
            DependencyObject targetObj = sender as DependencyObject;
            if (targetObj == null)
                return;

            object dataContext = GetDataContext(targetObj);
            if (dataContext == null)
                return;

            var handler = GetHandler(dataContext, _eventInfo, EventHandlerName);
            if (handler != null)
            {
                _eventInfo.AddEventHandler(targetObj, handler);
            }
            UnsubscribeFromDataContextChanged(targetObj);
        }

        #endregion
    }

Voilà donc un exemple du genre de choses qu’on peut faire grâce à cette nouvelle fonctionnalité de WPF. On pourrait aussi imaginer un système de “behavior” similaire à ce qu’on peut faire avec des propriétés attachées, par exemple pour réaliser une action standard lorsqu’un évènement se produit. Les possiblités sont sans doute nombreuses, je vous laisse le soin de les trouver ;)

Récursion terminale en C#

Quel que soit le langage de programmation utilisé, certains traitements s’implémentent naturellement sous forme d’un algorithme récursif (même si ce n’est pas toujours la solution la plus optimale). Le problème de l’approche récursive, c’est qu’elle consomme potentiellement beaucoup d’espace sur la pile : à partir d’un certain niveau de “profondeur” de la récursion, l’espace alloué pour la pile d’exécution du thread est épuisé, et on obtient une erreur de type “débordement de la pile” (StackOverflowException en .NET).

Lire la suite »

[WPF] Afficher une image GIF animée

WPF est une technologie géniale, mais parfois on a l’impression qu’il lui manque certaines fonctionnalités assez basiques… Un exemple souvent cité est l’absence de support pour les images GIF animées. En fait, le format GIF proprement dit est supporté, mais le contrôle Image n’affiche que la première image de l’animation.

De nombreuses solutions à ce problème ont été proposées sur les forums et blogs techniques, généralement des variantes autour des approches suivantes :

  • Utiliser le contrôle MediaElement : malheureusement ce contrôle ne supporte que les URI de type file:// ou http://, et non le schéma d’URI pack:// utilisé pour les ressources WPF ; l’image ne peut donc pas être inclue dans les ressources, elle doit être dans un fichier à part. De plus, la transparence n’est pas supportée, si bien que le résultat final est assez laid
  • Utiliser le contrôle PictureBox de Windows Forms, via un WindowsFormsHost : personnellement j’ai horreur d’utiliser des contrôles Windows Forms en WPF, ça me donne l’impression de faire quelque chose de mal :P
  • Créer un contrôle dérivé de Image qui gère l’animation. Pour l’implémentation, certaines solutions tirent partie de la classe ImageAnimator de System.Drawing (GDI), d’autres utilisent une animation WPF pour changer de frame. C’est une approche assez “propre”, mais qui oblige à utiliser un contrôle spécifique pour les GIF. De plus la solution utilisant ImageAnimator se révèle assez peu fluide.

Comme vous l’aurez deviné, aucune de ces solutions ne me satisfait vraiment… De plus, aucune ne gère proprement la durée de chaque frame, et suppose simplement que toutes les frames durent 100ms (c’est presque toujours le cas, mais le presque fait toute la différence…). Je n’ai donc gardé que les meilleures idées dans les approches ci-dessus pour créer ma propre solution. Les objectifs que je souhaitais atteindre sont les suivants :

  • Ne pas dépendre de Windows Forms ou de GDI
  • Afficher l’image animée dans un contrôle Image standard
  • Pouvoir utiliser le même code XAML pour une image fixe ou animée
  • Supporter la transparence
  • Tenir compte de la durée réelle de chaque frame de l’image

Pour arriver à ce résultat, je suis parti d’une idée simple, voire évidente : pour animer l’image, il suffit d’appliquer une animation à la propriété Source du contrôle Image. Or WPF fournit tous les outils nécessaires pour réaliser ce type d’animation ; en l’occurrence la classe ObjectAnimationUsingKeyFrames répond parfaitement au besoin : on peut spécifier à quel instant exact affecter une valeur donnée à la propriété, ce qui permet de tenir compte de la durée des frames.

Le problème suivant est d’extraire les différentes frames de l’image : heureusement ce scénario est prévu dans WPF, et la classe BitmapDecoder fournit une propriété Frames qui sert à ça. Donc, pas de difficulté majeure à ce niveau…

Enfin, dernier obstacle : extraire la durée de chaque frame. C’est finalement la partie qui m’a demandé le plus de recherche… J’ai d’abord cru qu’il faudrait lire manuellement le fichier pour trouver cette information, en décodant directement les données binaires. Mais la solution est finalement assez simple, et tire partie de la classe BitmapMetadata. La seule difficulté a été de localiser le “chemin” de la métadonnée qui contient cette information, mais après quelques tâtonnements, la voilà : /grctlext/Delay.

La solution finale est implémentée sous forme d’une propriété attachée AnimatedSource applicable au contrôle Image, qui s’utilise en lieu et place de Source :

<Image Stretch="None" my:ImageBehavior.AnimatedSource="/Images/animation.gif" />

On peut également affecter une image fixe à cette propriété, elle s’affichera normalement ; on peut donc utiliser cette propriété sans se soucier de savoir si l’image à afficher sera fixe ou animée.

Au final, tous les objectifs fixés au départ sont donc atteints, et il y a même une cerise sur le gâteau : cette solution fonctionne également dans le designer (du moins dans Visual Studio 2010), on voit donc directement l’animation quand on affecte la propriété AnimatedSource :)

Sans plus attendre, voilà le code complet :

    public static class ImageBehavior
    {
        #region AnimatedSource

        [AttachedPropertyBrowsableForType(typeof(Image))]
        public static ImageSource GetAnimatedSource(Image obj)
        {
            return (ImageSource)obj.GetValue(AnimatedSourceProperty);
        }

        public static void SetAnimatedSource(Image obj, ImageSource value)
        {
            obj.SetValue(AnimatedSourceProperty, value);
        }

        public static readonly DependencyProperty AnimatedSourceProperty =
            DependencyProperty.RegisterAttached(
              "AnimatedSource",
              typeof(ImageSource),
              typeof(ImageBehavior),
              new UIPropertyMetadata(
                null,
                AnimatedSourceChanged));

        private static void AnimatedSourceChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
        {
            Image imageControl = o as Image;
            if (imageControl == null)
                return;

            var oldValue = e.OldValue as ImageSource;
            var newValue = e.NewValue as ImageSource;
            if (oldValue != null)
            {
                imageControl.BeginAnimation(Image.SourceProperty, null);
            }
            if (newValue != null)
            {
                imageControl.DoWhenLoaded(InitAnimationOrImage);
            }
        }

        private static void InitAnimationOrImage(Image imageControl)
        {
            BitmapSource source = GetAnimatedSource(imageControl) as BitmapSource;
            if (source != null)
            {
                var decoder = GetDecoder(source) as GifBitmapDecoder;
                if (decoder != null && decoder.Frames.Count > 1)
                {
                    var animation = new ObjectAnimationUsingKeyFrames();
                    var totalDuration = TimeSpan.Zero;
                    BitmapSource prevFrame = null;
                    FrameInfo prevInfo = null;
                    foreach (var rawFrame in decoder.Frames)
                    {
                        var info = GetFrameInfo(rawFrame);
                        var frame = MakeFrame(
                            source,
                            rawFrame, info,
                            prevFrame, prevInfo);

                        var keyFrame = new DiscreteObjectKeyFrame(frame, totalDuration);
                        animation.KeyFrames.Add(keyFrame);
                        
                        totalDuration += info.Delay;
                        prevFrame = frame;
                        prevInfo = info;
                    }
                    animation.Duration = totalDuration;
                    animation.RepeatBehavior = RepeatBehavior.Forever;
                    if (animation.KeyFrames.Count > 0)
                        imageControl.Source = (ImageSource)animation.KeyFrames[0].Value;
                    else
                        imageControl.Source = decoder.Frames[0];
                    imageControl.BeginAnimation(Image.SourceProperty, animation);
                    return;
                }
            }
            imageControl.Source = source;
            return;
        }

        private static BitmapDecoder GetDecoder(BitmapSource image)
        {
            BitmapDecoder decoder = null;
            var frame = image as BitmapFrame;
            if (frame != null)
                decoder = frame.Decoder;

            if (decoder == null)
            {
                var bmp = image as BitmapImage;
                if (bmp != null)
                {
                    if (bmp.StreamSource != null)
                    {
                        decoder = BitmapDecoder.Create(bmp.StreamSource, bmp.CreateOptions, bmp.CacheOption);
                    }
                    else if (bmp.UriSource != null)
                    {
                        Uri uri = bmp.UriSource;
                        if (bmp.BaseUri != null && !uri.IsAbsoluteUri)
                            uri = new Uri(bmp.BaseUri, uri);
                        decoder = BitmapDecoder.Create(uri, bmp.CreateOptions, bmp.CacheOption);
                    }
                }
            }

            return decoder;
        }

        private static BitmapSource MakeFrame(
            BitmapSource fullImage,
            BitmapSource rawFrame, FrameInfo frameInfo,
            BitmapSource previousFrame, FrameInfo previousFrameInfo)
        {
            DrawingVisual visual = new DrawingVisual();
            using (var context = visual.RenderOpen())
            {
                if (previousFrameInfo != null && previousFrame != null &&
                    previousFrameInfo.DisposalMethod == FrameDisposalMethod.Combine)
                {
                    var fullRect = new Rect(0, 0, fullImage.PixelWidth, fullImage.PixelHeight);
                    context.DrawImage(previousFrame, fullRect);
                }

                context.DrawImage(rawFrame, frameInfo.Rect);
            }
            var bitmap = new RenderTargetBitmap(
                fullImage.PixelWidth, fullImage.PixelHeight,
                fullImage.DpiX, fullImage.DpiY,
                PixelFormats.Pbgra32);
            bitmap.Render(visual);
            return bitmap;
        }

        private class FrameInfo
        {
            public TimeSpan Delay { get; set; }
            public FrameDisposalMethod DisposalMethod { get; set; }
            public double Width { get; set; }
            public double Height { get; set; }
            public double Left { get; set; }
            public double Top { get; set; }

            public Rect Rect
            {
                get { return new Rect(Left, Top, Width, Height); }
            }
        }

        private enum FrameDisposalMethod
        {
            Replace = 0,
            Combine = 1,
            RestoreBackground = 2,
            RestorePrevious = 3
        }

        private static FrameInfo GetFrameInfo(BitmapFrame frame)
        {
            var frameInfo = new FrameInfo
            {
                Delay = TimeSpan.FromMilliseconds(100),
                DisposalMethod = FrameDisposalMethod.Replace,
                Width = frame.PixelWidth,
                Height = frame.PixelHeight,
                Left = 0,
                Top = 0
            };

            BitmapMetadata metadata;
            try
            {
                metadata = frame.Metadata as BitmapMetadata;
                if (metadata != null)
                {
                    const string delayQuery = "/grctlext/Delay";
                    const string disposalQuery = "/grctlext/Disposal";
                    const string widthQuery = "/imgdesc/Width";
                    const string heightQuery = "/imgdesc/Height";
                    const string leftQuery = "/imgdesc/Left";
                    const string topQuery = "/imgdesc/Top";

                    var delay = metadata.GetQueryOrNull<ushort>(delayQuery);
                    if (delay.HasValue)
                        frameInfo.Delay = TimeSpan.FromMilliseconds(10 * delay.Value);

                    var disposal = metadata.GetQueryOrNull<byte>(disposalQuery);
                    if (disposal.HasValue)
                        frameInfo.DisposalMethod = (FrameDisposalMethod) disposal.Value;

                    var width = metadata.GetQueryOrNull<ushort>(widthQuery);
                    if (width.HasValue)
                        frameInfo.Width = width.Value;

                    var height = metadata.GetQueryOrNull<ushort>(heightQuery);
                    if (height.HasValue)
                        frameInfo.Height = height.Value;

                    var left = metadata.GetQueryOrNull<ushort>(leftQuery);
                    if (left.HasValue)
                        frameInfo.Left = left.Value;

                    var top = metadata.GetQueryOrNull<ushort>(topQuery);
                    if (top.HasValue)
                        frameInfo.Top = top.Value;
                }
            }
            catch (NotSupportedException)
            {
            }

            return frameInfo;
        }

        private static T? GetQueryOrNull<T>(this BitmapMetadata metadata, string query)
            where T : struct
        {
            if (metadata.ContainsQuery(query))
            {
                object value = metadata.GetQuery(query);
                if (value != null)
                    return (T) value;
            }
            return null;
        }

        #endregion
    }

Et voici la méthode d’extension DoWhenLoaded utilisée dans le code ci-dessus :

public static void DoWhenLoaded<T>(this T element, Action<T> action)
    where T : FrameworkElement
{
    if (element.IsLoaded)
    {
        action(element);
    }
    else
    {
        RoutedEventHandler handler = null;
        handler = (sender, e) =>
        {
            element.Loaded -= handler;
            action(element);
        };
        element.Loaded += handler;
    }
}

Cette classe sera inclue dans la prochaine version de la librairie Dvp.NET, dont j’avais déjà parlé il y quelque temps.

Mise à jour : le code qui récupère la durée d’une frame ne fonctionne que sous Windows Seven, et sous Windows Vista si la Platform Update est installée (non testé). La durée par défaut (100ms) sera utilisée sur les autres versions de Windows. Je mettrai à jour l’article si je trouve une solution qui fonctionne sur tous les systèmes (je sais que je pourrais utiliser System.Drawing.Bitmap, mais je préfèrerais éviter…)

Mise à jour 2 : comme Klaus l’a signalé dans un commentaire sur la version anglaise de mon blog, la classe ImageBehavior ne gérait pas certains attributs importants des frames : la méthode de destruction (est-ce qu’une frame doit simplement remplacer la frame précédente, ou être combinée avec elle), et la position des frames (Left/Top/Width/Height). J’ai mis à jour le code pour gérer ces attributs correctement. Merci Klaus !

Mise à jour 3 : encore un petit bug corrigé, la récupération du décodeur à partir d’une URI relative ne fonctionnait pas. Merci à l’anonyme qui l’a signalé!

Publié dans WPF. Tags : , , . 16 Commentaires »

[WPF] Comment faire un binding dans les cas où on n’hérite pas du DataContext

La propriété DataContext de WPF est extrêmement pratique, car elle est automatiquement héritée par tous les enfants de l’élément où elle est définie ; il n’est donc pas nécessaire de la redéfinir pour chaque élément qu’on veut lier aux données. Cependant, il arrive que le DataContext ne soit pas accessible pour certains éléments : c’est le cas des éléments qui n’appartiennent pas à l’arbre visuel ni à l’arbre logique. Il devient alors très difficile de définir une propriété ce ces éléments par un binding…

Prenons un exemple simple : on veut afficher une liste de produits dans un DataGrid. Dans la grille, on veut pouvoir afficher où masquer la colonne du prix, en fonction d’une propriété ShowPrice exposée par le ViewModel. L’approche évidente consiste à binder la propriété Visibility de la colonne à la propriété ShowPrice :

<DataGridTextColumn Header="Price" Binding="{Binding Price}" IsReadOnly="False"
                    Visibility="{Binding ShowPrice,
                        Converter={StaticResource visibilityConverter}}"/>

Malheureusement, changer la valeur de la propriété ShowPrice n’a aucun effet, et la colonne reste toujours affichée… pourquoi ? Si on regarde la fenêtre de sortie de Visual Studio, on remarque la ligne suivante :

System.Windows.Data Error: 2 : Cannot find governing FrameworkElement or FrameworkContentElement for target element. BindingExpression:Path=ShowPrice; DataItem=null; target element is ‘DataGridTextColumn’ (HashCode=32685253); target property is ‘Visibility’ (type ‘Visibility’)

Derrière cet obscur charabia se cache une explication toute simple : WPF ne sait pas quel FrameworkElement utiliser pour récupérer le DataContext, car la colonne n’appartient pas à l’arbre visuel ni à l’arbre logique du DataGrid.

On peut toujours essayer de “triturer” le binding pour obtenir le résultat voulu, par exemple en essayant de binder par rapport au DataGrid lui-même :

<DataGridTextColumn Header="Price" Binding="{Binding Price}" IsReadOnly="False"
                    Visibility="{Binding DataContext.ShowPrice,
                        Converter={StaticResource visibilityConverter},
                        RelativeSource={RelativeSource FindAncestor, AncestorType=DataGrid}}"/>

Ou encore, en ajoutant une CheckBox bindée sur ShowPrice et en essayant de binder la visibilité de la colonne sur la propriété IsChecked, en spécifiant le nom de l’élément :

<DataGridTextColumn Header="Price" Binding="{Binding Price}" IsReadOnly="False"
                    Visibility="{Binding IsChecked,
                        Converter={StaticResource visibilityConverter},
                        ElementName=chkShowPrice}"/>

Mais rien à faire, on obtient toujours le même résultat…

A ce stade, il semble que la seule approche qui pourrait marcher est de passer par le code-behind, ce qu’on préfère généralement éviter quand on suit le pattern MVVM… mais ce serait dommage d’abandonner aussi vite ;)

La solution est en fait assez simple, et se base sur la classe Freezable. La vocation première de cette classe est de définir des objets qui ont un état modifiable et un état non modifiable. Mais en l’occurrence, la caractéristique qui nous intéresse est qu’un objet qui hérite de Freezable peut hériter du DataContext, bien qu’il ne s’agisse pas d’un élément visuel. Je ne connais pas le mécanisme exact qui permet d’obtenir ce comportement, mais toujours est-il que cela va nous permettre d’arriver au résultat voulu…

L’idée est de créer une classe, que j’ai appelée BindingProxy, qui hérite de Freezable et dans laquelle on va déclarer une dependency property Data :

    public class BindingProxy : Freezable
    {
        #region Overrides of Freezable

        protected override Freezable CreateInstanceCore()
        {
            return new BindingProxy();
        }

        #endregion

        public object Data
        {
            get { return (object)GetValue(DataProperty); }
            set { SetValue(DataProperty, value); }
        }

        // Using a DependencyProperty as the backing store for Data.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty DataProperty =
            DependencyProperty.Register("Data", typeof(object), typeof(BindingProxy), new UIPropertyMetadata(null));
    }

On va ensuite déclarer une instance de cette classe dans les ressources du DataGrid, et binder la propriété Data sur le DataContext courant :

<DataGrid.Resources>
    <local:BindingProxy x:Key="proxy" Data="{Binding}" />
</DataGrid.Resources>

Il suffit ensuite de spécifier que la source de notre binding est cet objet BindingProxy, facilement accessible puisqu’il est déclaré comme ressource :

<DataGridTextColumn Header="Price" Binding="{Binding Price}" IsReadOnly="False"
                    Visibility="{Binding Data.ShowPrice,
                        Converter={StaticResource visibilityConverter},
                        Source={StaticResource proxy}}"/>

Remarquez qu’on a aussi préfixé le chemin du binding par “Data”, puisque le chemin est maintenant relatif à l’objet BindingProxy.

Le binding fonctionne maintenant comme prévu, moyennant une solution relativement simple à mettre en oeuvre…

[C# 5] Programmation asynchrone avec C# 5

Depuis quelque temps, les spéculations allaient bon train sur les fonctionnalités de la future version 5 du langage C#… Très peu d’informations officielles avaient filtré à ce sujet, la seule chose plus ou moins certaine était l’introduction du concept de “compilateur en temps que service”, qui permettrait de tirer parti du compilateur à partir du code. A part ça, silence radio de la part de Microsoft…

Lors de la PDC jeudi dernier, un coin du voile a enfin été levé, mais pas du tout sur ce qu’on attendait ! Anders Hejlsberg, le créateur de C#, a bien consacré quelques minutes à la notion de “compiler as a service”, mais l’essentiel de sa présentation portait sur quelque chose de complètement différent : la programmation asynchrone en C#.

Il est bien sûr déjà possible d’effectuer des traitements asynchrones en C#, mais c’est généralement assez pénible et peu intuitif… On est souvent obligé de passer par des callbacks pour indiquer ce qui doit être exécuté à la fin du traitement asynchrone, et on se retrouve rapidement avec un code difficile à relire et à comprendre, et donc à maintenir. Pour une démonstration de ce problème, je vous invite à lire l’excellent article d’Eric Lippert à ce sujet, il explique ça beaucoup mieux que moi…

Cet article (et la série qu’il conclut) était en fait un prélude à l’annonce faite à la PDC : C# 5 intègrera une nouvelle syntaxe permettant d’écrire du code asynchrone de façon beaucoup plus naturelle, avec l’introduction de deux nouveaux mots-clés : async et await. Le code à écrire pour réaliser un traitement asynchrone sera quasiment identique à celui d’un traitement synchrone : toute la complexité sera masquée par cette nouvelle fonctionnalité du langage.

Puisqu’un exemple vaut mieux qu’un long discours, je vais reprendre l’exemple utilisé par Anders Hejlsberg pendant sa présentation, en le simplifiant un peu. Supposons qu’on veuille rechercher des titres de films par leur année de sortie. Pour simplifier, on utilisera le service OData de Netflix. Le code suivant effectue la recherche de façon synchrone, en récupérant les résultats 10 par 10 :

        private void btnSearch_Click(object sender, RoutedEventArgs e)
        {
            int year;
            if (!int.TryParse(txtYear.Text, out year))
            {
                MessageBox.Show("L'année saisie est incorrecte");
                return;
            }
            SearchMovies(year);
        }

        private void SearchMovies(int year)
        {
            var netflixUri = new Uri("http://odata.netflix.com/Catalog/");
            var catalog = new Netflix.NetflixCatalog(netflixUri);
            lstTitles.Items.Clear();
            int count = 0;
            int pageSize = 10;
            while (true)
            {
                var movies = SearchMoviesBatch(catalog, year, count, pageSize);
                if (movies.Length == 0)
                    break;
                foreach (var title in movies)
                {
                    lstTitles.Items.Add(title.Name);
                }
                count += movies.Length;
            }
        }

        private Title[] SearchMoviesBatch(NetflixCatalog catalog, int year, int count, int pageSize)
        {
            var query = from title in catalog.Titles
                            where title.ReleaseYear == year
                            orderby title.Name
                            select title;
            return query.Skip(count).Take(pageSize).ToArray();
        }

Ce code a le mérite d’être assez simple, mais il suffit de l’exécuter pour se rendre compte qu’il y a un problème : la récupération des résultats peut prendre un certain temps, pendant lequel l’interface reste figée. Il faut donc effectuer la recherche de façon asynchrone, pour que l’interface reste réactive. Voici une approche possible, avec la version actuelle de C# :

        private void SearchMoviesAsync(int year)
        {
            lstTitles.Items.Clear();
            Thread t = new Thread(() =>
            {
                var netflixUri = new Uri("http://odata.netflix.com/Catalog/");
                var catalog = new Netflix.NetflixCatalog(netflixUri);
                int count = 0;
                int pageSize = 10;
                while (true)
                {
                    var movies = SearchMoviesBatch(catalog, year, count, pageSize);
                    if (movies.Length == 0)
                        break;
                    foreach (var title in movies)
                    {
                        Dispatcher.Invoke(new Action(() => lstTitles.Items.Add(title.Name)));
                    }
                    count += movies.Length;
                }
            });
            t.Start();
        }

(Les deux autres méthodes sont inchangées)

On voit que le code commence déjà à être moins clair, à cause de l’expression lambda passée au constructeur du thread, et de l’utilisation de Dispatcher.Invoke pour mettre à jour l’interface graphique. Imaginez un peu ce que ça donnerait dans un scénario plus complexe, avec plusieurs tâches asynchrones interdépendantes (comme dans l’article d’Eric Lippert mentionné plus haut).

Avec la nouvelle syntaxe introduite par C# 5, voici comment on pourrait écrire ce code :

        private async void SearchMoviesAsync(int year)
        {
            var netflixUri = new Uri("http://odata.netflix.com/Catalog/");
            var catalog = new Netflix.NetflixCatalog(netflixUri);
            lstTitles.Items.Clear();
            int count = 0;
            int pageSize = 10;
            while (true)
            {
                var movies = await SearchMoviesBatchAsync(catalog, year, count, pageSize);
                if (movies.Length == 0)
                    break;
                foreach (var title in movies)
                {
                    lstTitles.Items.Add(title.Name);
                }
                count += movies.Length;
            }
        }

        private async Task<Title[]> SearchMoviesBatchAsync(NetflixCatalog catalog, int year, int count, int pageSize)
        {
            var query = from title in catalog.Titles
                        where title.ReleaseYear == year
                        orderby title.Name
                        select title;
            return await query.Skip(count).Take(pageSize).ToArrayAsync();
        }

Remarquez que les deux méthodes ont un nouveau modificateur async, qui indique qu’elles s’exécutent de façon asynchrone. Lors de l’appel à une autre méthode asynchrone, l’appel est précédé du mot-clé await. Lorsque la méthode SearchMoviesAsync est appelée, elle commence à s’exécuter normalement, jusqu’au mot-clé await. A partir de là, deux scénarios sont possibles

  • soit l’appel à SearchMoviesBatchAsync se termine de façon synchrone, auquel cas l’exécution continue normalement
  • soit il s’exécute de façon asynchrone, dans ce cas le contrôle est rendu à la méthode qui appelle SearchMoviesAsync (en l’occurrence btnSearch_Click). Quand l’appel à SearchMoviesBatchAsync se termine, l’exécution de SearchMoviesAsync reprend là où elle en était (de ce point de vue, await fonctionne un peu comme yield return)

Un peu comme pour les blocs itérateurs, le compilateur réécrit le code de la méthode en créant un delegate avec le code qui suit l’appel asynchrone, et appelle ce delegate quand la tâche asynchrone se termine. Remarquez d’ailleurs que ce delegate est appelé sur le même thread, celui du dispatcher en l’occurrence : on n’a donc pas besoin de Dispatcher.Invoke pour mettre à jour l’interface graphique.

En pratique, tout ce système se base sur la classe Task introduite dans .NET 4. Remarquez d’ailleurs que le type de retour de la méthode SearchMoviesBatchAsync est Task<Title[]>. Pourtant, quand on appelle cette méthode à partir de SearchMoviesAsync, on récupère bien un objet de type Title[], et non Task<Title[]>. C’est l’autre effet du mot-clé await : il récupère le résultat d’une tâche une fois qu’elle est terminée.

Encore une chose : j’ai utilisé dans le code une méthode d’extension ToArrayAsync, voici son code :

        public static Task<T[]> ToArrayAsync<T>(this IQueryable<T> source)
        {
            return TaskEx.Run(() => source.ToArray());
        }

Voilà pour l’introduction à cette future nouvelle fonctionnalité de C#. J’espère que c’était à peu près compréhensible et que je n’ai pas dit trop de bêtises… tout n’est pas encore complètement clair dans ma têteClignement d'œil. Pour en savoir plus, voici quelques liens utiles :

Pour les adeptes de VB.NET, sachez que cette fonctionnalité sera aussi inclue dans la prochaine version de Visual Basic, ainsi que les itérateurs, qui n’existaient qu’en C# jusqu’à maintenant.

Publié dans C# 5.0. Tags : , , . 7 Commentaires »

[Entity Framework] Utiliser Include avec des expressions lambda

Je travaille en ce moment sur un projet qui utilise Entity Framework 4. Bien que le lazy loading soit activé, j’utilise généralement la méthode ObjectQuery.Include pour charger les entités associées en une seule fois, de façon à éviter des appels supplémentaires à la base de données lors de l’accès à ces entités :

var query =
    from ord in db.Orders.Include("OrderDetails")
    where ord.Date >= DateTime.Today
    select ord;

Ou encore, pour inclure aussi le produit :

var query =
    from ord in db.Orders.Include("OrderDetails.Product")
    where ord.Date >= DateTime.Today
    select ord;

Il y a quelque chose qui m’ennuie avec cette méthode Include : le fait de devoir spécifier le chemin de la propriété sous forme de chaine de caractères. En effet cette approche présente deux inconvénients majeurs :

  • Elle comporte un risque d’erreur important : on a vite fait de faire une faute de frappe dans le chemin de la propriété, et puisque c’est une chaine de caractères, le compilateur ne remarque rien. On a donc une erreur à l’exécution alors que ça aurait pu être vérifié dès la compilation.
  • On ne profite plus de l’assistance de l’IDE : pas d’intellisense ni de refactoring. Si on renomme une propriété du modèle, le refactoring automatique ne prend pas en compte le contenu des chaines de caractères. Il faut donc aller modifier manuellement les appels à Include qui font référence à cette propriété, avec le risque non négligeable d’en oublier au passage…

Il serait donc plus pratique d’utiliser une expression lambda pour spécifier le chemin de la propriété à inclure. Le principe est connu, et fréquemment utilisé pour éviter de passer une chaine quand on veut spécifier le nom d’une propriété.

Le cas “de base”, dans lequel on ne charge qu’une propriété directement liée à la source, est assez simple à gérer, et on trouve des implémentations un peu partout sur le net. Il suffit d’utiliser une méthode qui extrait le nom de la propriété à partir de l’expression :

    public static class ObjectQueryExtensions
    {
        public static ObjectQuery<T> Include<T>(this ObjectQuery<T> query, Expression<Func<T, object>> selector)
        {
            string propertyName = GetPropertyName(selector);
            return query.Include(propertyName);
        }

        private static string GetPropertyName<T>(Expression<Func<T, object>> expression)
        {
            MemberExpression memberExpr = expression.Body as MemberExpression;
            if (memberExpr == null)
                throw new ArgumentException("Expression body must be a member expression");
            return memberExpr.Member.Name;
        }
    }

En utilisant cette méthode d’extension, on peut réécrire le code du premier exemple de la façon suivante :

var query =
    from ord in db.Orders.Include(o => o.OrderDetails)
    where ord.Date >= DateTime.Today
    select ord;

Ce code fonctionne, mais seulement pour les cas simples… dans le deuxième exemple, on veut aussi inclure la propriété OrderDetail.Product, et le code ci-dessus ne permet pas de gérer ce cas. En effet, l’expression qu’il faudrait écrire pour inclure la propriété Product serait du type o.OrderDetails.Select(od => od.Product), or la méthode GetPropertyName ne sait gérer que les propriétés, pas les appels de méthode…

Pour obtenir le chemin complet de la propriété à inclure, il faut parcourir tout l’arbre d’expression pour en extraire les propriétés. Bien que cela puisse paraitre assez complexe, il existe une classe qui peut nous y aider : ExpressionVisitor. Cette classe, introduite en .NET 4.0, implémente le design pattern Visiteur pour parcourir tous les noeuds de l’arbre. L’implémentation de base ne fait rien de particulier, elle se contente de visiter chaque noeud. Tout ce que nous avons à faire, c’est en hériter pour spécialiser certaines méthodes de façon à extraire les propriétés utilisées dans l’expression. On va donc redéfinir les méthodes suivantes :

  • VisitMember : c’est la méthode appelée pour visiter l’accès à une propriété ou à un champ
  • VisitMethodCall : la méthode appelée pour visiter les appels de méthode. Bien que ce cas ne nous intéresse pas directement a priori, on doit modifier son comportement dans le cas des opérateurs Linq : l’implémentation par défaut visite les paramètres dans l’ordre normal, mais pour les méthodes d’extension comme Select ou SelectMany, on doit visiter le premier paramètre (le paramètre this) en dernier, de façon à conserver l’ordre voulu pour le chemin de la propriété

Voici donc la nouvelle implémentation de la méthode d’extension Include :

    public static class ObjectQueryExtensions
    {
        public static ObjectQuery<T> Include<T>(this ObjectQuery<T> query, Expression<Func<T, object>> selector)
        {
            string path = new PropertyPathVisitor().GetPropertyPath(expression);
            return query.Include(path);
        }

        class PropertyPathVisitor : ExpressionVisitor
        {
            private Stack<string> _stack;

            public string GetPropertyPath(Expression expression)
            {
                _stack = new Stack<string>();
                Visit(expression);
                return _stack
                    .Aggregate(
                        new StringBuilder(),
                        (sb, name) =>
                            (sb.Length > 0 ? sb.Append(".") : sb).Append(name))
                    .ToString();
            }

            protected override Expression VisitMember(MemberExpression expression)
            {
                if (_stack != null)
                    _stack.Push(expression.Member.Name);
                return base.VisitMember(expression);
            }

            protected override Expression VisitMethodCall(MethodCallExpression expression)
            {
                if (IsLinqOperator(expression.Method))
                {
                    for (int i = 1; i < expression.Arguments.Count; i++)
                    {
                        Visit(expression.Arguments[i]);
                    }
                    Visit(expression.Arguments[0]);
                    return expression;
                }
                return base.VisitMethodCall(expression);
            }

            private static bool IsLinqOperator(MethodInfo method)
            {
                if (method.DeclaringType != typeof(Queryable) && method.DeclaringType != typeof(Enumerable))
                    return false;
                return Attribute.GetCustomAttribute(method, typeof(ExtensionAttribute)) != null;
            }
        }
    }

J’ai déjà parlé plus haut de la méthode VisitMethodCall, je ne reviens donc pas dessus. L’implémentation de VisitMember est très simple : on se contente d’ajouter le nom de la propriété sur une pile. Au fait, pourquoi une pile ? Parce que la visite de l’expression ne se déroule pas dans l’ordre auquel on pense intuitivement. Par exemple, dans une expression du type o.OrderDetails.Select(od => od.Product), le premier noeud examiné n’est pas o, mais l’appel à Select, car ce qui précède (o.OrderDetails) est en fait un paramètre de la méthode statique Select… Pour obtenir les propriétés dans l’ordre voulu, on les place donc sur une pile de façon à les relire ensuite dans l’ordre inverse.

La méthode GetPropertyPath est elle aussi assez facile à comprendre : elle initialise la pile, visite l’expression, et reconstitue le chemin à partir de la pile.

On peut donc maintenant réécrire le code du deuxième exemple de la façon suivante :

var query =
    from ord in db.Orders.Include(o => OrderDetails.Select(od => od.Product))
    where ord.Date >= DateTime.Today
    select ord;

Cette méthode fonctionne aussi pour des cas plus complexes. Ajoutons un peu de piment à notre exemple : une ou plusieurs remises peuvent être appliquées à chaque article commandé, et chaque remise est associée à une campagne promotionnelle. Si on veut inclure les remises et les campagnes associées dans les résultats de la requête, on peut écrire quelque chose comme ça :

var query =
    from ord in db.Orders.Include(o => OrderDetails.Select(od => od.Discounts.Select(d => d.Campaign)))
    where ord.Date >= DateTime.Today
    select ord;

Le résultat est le même que si on avait passé à Include le chemin “OrderDetails.Discounts.Campaign”. Comme les Select imbriqués réduisent la lisibilité du code, on peut écrire l’expression un peu différemment :

var query =
    from ord in db.Orders.Include(o => o.OrderDetails
                                        .SelectMany(od => od.Discounts)
                                        .Select(d => d.Campaign))
    where ord.Date >= DateTime.Today
    select ord;

Pour finir, deux remarques sur cette solution :

  • Une méthode d’extension similaire est inclue dans le Entity Framework Feature CTP4 (voir cet article pour plus de détails). Il est donc probable qu’elle finisse par être intégrée dans le Framework (peut-être dans un service pack pour .NET 4.0 ?)
  • Bien que cette solution cible Entity Framework 4.0, il est a priori possible de l’adapter à EF 3.5. La classe ExpressionVisitor n’est pas disponible en 3.5, mais le LINQKit de Joseph Albahari en fournit une implémentation. Je n’ai pas essayé, mais ça devrait fonctionner de la même façon…

[WPF] Une grille simplifiée utilisant des attributs XAML

Le composant Grid est l’un des contrôles les plus utilisés en WPF. Il permet de disposer facilement des éléments selon des lignes et des colonnes. Malheureusement le code pour l’utiliser, bien que simple à écrire, est relativement lourd :

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="5"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="60" />
        <ColumnDefinition Width="*" />
    </Grid.ColumnDefinitions>
    
    <Label Content="Name" Grid.Row="0" Grid.Column="0" />
    <TextBox Text="Hello world" Grid.Row="0" Grid.Column="1"/>
    <Rectangle Fill="Black" Grid.Row="1" Grid.ColumnSpan="2"/>
    <Label Content="Image" Grid.Row="2" Grid.Column="0" />
    <Image Source="Resources/Desert.jpg" Grid.Row="2" Grid.Column="1" />
</Grid>

Dans cet exemple, plus de la moitié du code est constitué de la définition de la grille ! Bien que cette syntaxe offre une certaine souplesse et permette de contrôler assez finement la disposition, dans la plupart des cas on a seulement besoin de définir la hauteur des lignes ou la largeur des colonnes… il serait donc beaucoup plus simple de pouvoir déclarer la grille de cette façon :

<Grid Rows="Auto,5,*" Columns="60,*">
    ...
</Grid>

La suite de cet article démontre comment atteindre précisément ce résultat, en créant une classe SimpleGrid héritée de Grid.

Pour commencer, notre classe aura besoin de deux nouvelles propriétés : Rows et Columns. Ces propriétés définissent respectivement les hauteurs et largeurs des lignes et des colonnes. Ces dimensions ne sont pas de simples nombres : en effet, des valeurs comme "*", "2*" ou "Auto" sont des dimensions valides. Il existe en WPF un type dédié pour représenter ces dimensions : la structure GridLength. Nos deux propriétés seront donc des collections de GridLength. Voilà donc la signature de la classe SimpleGrid :

public class SimpleGrid : Grid
{
    public IList<GridLength> Rows { get; set; }
    public IList<GridLength> Columns { get; set; }
}

Puisque ce sont ces propriétés qui vont contrôler les lignes et colonnes de la grille, il faut qu’elles modifient les RowDefinitions et ColumnDefinitions de la classe de base. Voilà donc comment les implémenter pour obtenir le résultat voulu :

        private IList<GridLength> _rows;
        public IList<GridLength> Rows
        {
            get { return _rows; }
            set
            {
                _rows = value;
                RowDefinitions.Clear();
                if (_rows == null)
                    return;
                foreach (var length in _rows)
                {
                    RowDefinitions.Add(new RowDefinition { Height = length });
                }
            }
        }

        private IList<GridLength> _columns;
        public IList<GridLength> Columns
        {
            get { return _columns; }
            set
            {
                _columns = value;
                ColumnDefinitions.Clear();
                if (_columns == null)
                    return;
                foreach (var length in _columns)
                {
                    ColumnDefinitions.Add(new ColumnDefinition { Width = length });
                }
            }
        }

Notre classe SimpleGrid est d’ores et déjà utilisable… à partir du code C#, ce qui ne nous aide pas beaucoup quand il s’agit de simplifier le code XAML. Il nous faut donc trouver un moyen de déclarer dans un attribut les valeurs de ces propriétés, ce qui n’est pas évident dans la mesure où ce sont des collections…

En XAML, tous les attributs sont écrits sous forme de chaines de caractères. Pour convertir ces chaines en valeurs du type voulu, WPF fait appel à des convertisseurs, qui sont des classes héritées de TypeConverter, associées à chaque type qui supporte les conversions de et vers d’autres types. Par exemple, le type GridLength a pour convertisseur le type GridLengthConverter, qui permet de convertir des nombres ou des chaines de caractères en GridLength, et inversement. Le mécanisme de conversion est décrit dans cet article sur MSDN.

Nous allons donc devoir créer un convertisseur et l’associer au type de nos propriétés. Comme nous n’avons pas la main sur le type IList<T>, nous allons d’abord créer un type spécifique GridLengthCollection qu’on utilisera à la place de IList<GridLength>, et lui associer un convertisseur GridLengthCollectionConverter :

    [TypeConverter(typeof(GridLengthCollectionConverter))]
    public class GridLengthCollection : ReadOnlyCollection<GridLength>
    {
        public GridLengthCollection(IList<GridLength> lengths)
            : base(lengths)
        {
        }
    }

Pourquoi une collection en lecture seule ? Tout bêtement parce que permettre d’ajouter ou de supprimer des lignes ou des colonnes compliquerait l’implémentation, et n’apporterait rien pour l’objectif qui nous intéresse, à savoir simplifier la déclaration de la grille en XAML. Donc, restons dans la simplicité… Pour éviter de réinventer la roue, on hérite de la classe ReadOnlyCollection<T>, qui correspond parfaitement à notre besoin.

Notez aussi l’utilisation de l’attribut TypeConverter : il sert à indiquer le convertisseur associé à un type. Il nous reste donc simplement à implémenter ce convertisseur :

    public class GridLengthCollectionConverter : TypeConverter
    {
        public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
        {
            if (sourceType == typeof(string))
                return true;
            return base.CanConvertFrom(context, sourceType);
        }

        public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
        {
            if (destinationType == typeof(string))
                return true;
            return base.CanConvertTo(context, destinationType);
        }

        public override object ConvertFrom(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value)
        {
            string s = value as string;
            if (s != null)
                return ParseString(s, culture);
            return base.ConvertFrom(context, culture, value);
        }

        public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
        {
            if (destinationType == typeof(string) && value is GridLengthCollection)
                return ToString((GridLengthCollection)value, culture);
            return base.ConvertTo(context, culture, value, destinationType);
        }

        private string ToString(GridLengthCollection value, CultureInfo culture)
        {
            var converter = new GridLengthConverter();
            return string.Join(",", value.Select(v => converter.ConvertToString(v)));
        }

        private GridLengthCollection ParseString(string s, CultureInfo culture)
        {
            var converter = new GridLengthConverter();
            var lengths = s.Split(',').Select(p => (GridLength)converter.ConvertFromString(p.Trim()));
            return new GridLengthCollection(lengths.ToArray());
        }
    }

Ce convertisseur est capable de convertir une GridLengthCollection de et vers une chaine de caractères, dans laquelle les dimensions sont séparées par des virgules. Notez l’utilisation du convertisseur GridLengthConverter : puisqu’il existe déjà un convertisseur pour les éléments de notre collection, autant s’en servir…

Toutes les pièces du puzzle étant en place, il ne nous reste plus qu’à utiliser notre nouvelle grille simplifiée :

<my:SimpleGrid Rows="Auto,5,*" Columns="60,*">
    <Label Content="Name" Grid.Row="0" Grid.Column="0" />
    <TextBox Text="Hello world" Grid.Row="0" Grid.Column="1"/>
    <Rectangle Fill="Black" Grid.Row="1" Grid.ColumnSpan="2"/>
    <Label Content="Image" Grid.Row="2" Grid.Column="0" />
    <Image Source="Resources/Desert.jpg" Grid.Row="2" Grid.Column="1" />
</my:SimpleGrid>

On obtient donc un résultat beaucoup plus concis et lisible qu’en utilisant une Grid normale, l’objectif est donc atteint :)

On pourrait bien sûr envisager des améliorations, par exemple déclarer les propriétés Rows et Columns comme des DependencyProperty afin de permettre le binding, ou encore gérer l’ajout et la suppression de colonnes. Cependant, cette grille s’adresse à des scénarios simples où la grille est définie une fois pour toutes et n’est pas modifiée à l’exécution (ce qui correspond a priori au cas d’utilisation le plus fréquent), il semble donc plus judicieux de la garder la plus simple possible. Pour des besoins plus spécifiques, par exemple si l’on veut utiliser les propriétés MinWidth, MaxWidth ou encore SharedSizeGroup, il faudra donc revenir à la Grid standard.

Pour référence, voici le code final de la classe SimpleGrid :

    public class SimpleGrid : Grid
    {
        private GridLengthCollection _rows;
        public GridLengthCollection Rows
        {
            get { return _rows; }
            set
            {
                _rows = value;
                RowDefinitions.Clear();
                if (_rows == null)
                    return;
                foreach (var length in _rows)
                {
                    RowDefinitions.Add(new RowDefinition { Height = length });
                }
            }
        }

        private GridLengthCollection _columns;
        public GridLengthCollection Columns
        {
            get { return _columns; }
            set
            {
                _columns = value;
                if (_columns == null)
                    return;
                ColumnDefinitions.Clear();
                foreach (var length in _columns)
                {
                    ColumnDefinitions.Add(new ColumnDefinition { Width = length });
                }
            }
        }
    }

Publié dans Code sample, WPF. Tags : , , . 1 commentaire »

[C#] Une implémentation du pattern WeakEvent

Comme vous le savez peut-être, la mauvaise utilisation des évènements est l’une des principales causes de fuites mémoires dans une application .NET : en effet, un évènement garde des références aux objets qui y sont abonnés (via le delegate), ce qui empêche le garbage collector de collecter ces objets quand ils ne sont plus utilisés. Le problème est particulièrement vrai pour un évènement statique, puisque les références sont conservées pendant toute l’exécution de l’application. Si on crée de nombreux objets qui s’abonnent à un évènement statique et qu’on ne les désabonne pas, ils restent indéfiniment en mémoire, même si on n’en a plus besoin depuis longtemps, ce qui peut finir par saturer la mémoire.

La solution “évidente” au problème est bien sûr de désabonner les objets qui ne sont plus utilisés. Malheureusement, il n’y a pas toujours de moyen simple de savoir à quel moment on peut désabonner un objet. Une autre approche est d’implémenter le pattern WeakEvent, qui permet de ne garder qu’une référence faible vers les objets abonnés à l’évènement, de façon à ne pas empêcher le garbage collector de les collecter. Microsoft inclut dans WPF des éléments pour implémenter le pattern WeakEvent, et explique comment créer ses propres évènements selon ce pattern, à l’aide de la classe WeakEventManager et de l’interface IWeakEventListener. Cependant, cette technique est assez lourde à mettre en œuvre, aussi bien pour exposer un tel évènement (il faut créer une nouvelle classe dédiée) que pour s’abonner à l’évènement (implémentation de IWeakEventListener).

J’ai donc réfléchi à une autre solution, permettant d’implémenter plus facilement le pattern WeakEvent. Ma première idée était d’utiliser une liste de WeakReference pour stocker la liste des delegates abonnés à l’évènement. Malheureusement, lorsqu’on s’abonne à un évènement, on écrit généralement quelque chose comme ça :

myObject.MyEvent += new EventHandler(myObject_MyEvent);

On crée donc un delegate, mais on ne garde aucune référence dessus. Puisque l’évènement ne référence ce delegate que via une WeakReference, rien n’empêche le garbage collector de le collecter… et c’est effectivement ce qui arrive. Au bout d’un temps variable (pas plus de quelques secondes d’après mes observations), le delegate est collecté et n’est donc plus appelé quand l’évènement est déclenché.

Plutôt que de conserver une référence faible vers le delegate lui même, une meilleure solution serait de faire une référence faible sur l’objet qui implémente la méthode (Delegate.Target). J’ai donc créé une classe WeakDelegate<TDelegate> pour gérer cela :

    public class WeakDelegate<TDelegate> : IEquatable<TDelegate>
    {
        private WeakReference _targetReference;
        private MethodInfo _method;

        public WeakDelegate(Delegate realDelegate)
        {
            if (realDelegate.Target != null)
                _targetReference = new WeakReference(realDelegate.Target);
            else
                _targetReference = null;
            _method = realDelegate.Method;
        }

        public TDelegate GetDelegate()
        {
            return (TDelegate)(object)GetDelegateInternal();
        }

        private Delegate GetDelegateInternal()
        {
            if (_targetReference != null)
            {
                return Delegate.CreateDelegate(typeof(TDelegate), _targetReference.Target, _method);
            }
            else
            {
                return Delegate.CreateDelegate(typeof(TDelegate), _method);
            }
        }

        public bool IsAlive
        {
            get { return _targetReference == null || _targetReference.IsAlive; }
        }


        #region IEquatable<TDelegate> Members

        public bool Equals(TDelegate other)
        {
            Delegate d = (Delegate)(object)other;
            return d != null
                && d.Target == _targetReference.Target
                && d.Method.Equals(_method);
        }

        #endregion

        internal void Invoke(params object[] args)
        {
            Delegate handler = (Delegate)(object)GetDelegateInternal();
            handler.DynamicInvoke(args);
        }
    }

Il ne reste plus qu’à gérer une liste de WeakDelegate<TDelegate>, ce que fait la classe WeakEvent<TDelegate> :

    public class WeakEvent<TEventHandler>
    {
        private List<WeakDelegate<TEventHandler>> _handlers;

        public WeakEvent()
        {
            _handlers = new List<WeakDelegate<TEventHandler>>();
        }

        public virtual void AddHandler(TEventHandler handler)
        {
            Delegate d = (Delegate)(object)handler;
            _handlers.Add(new WeakDelegate<TEventHandler>(d));
        }

        public virtual void RemoveHandler(TEventHandler handler)
        {
            // also remove "dead" (garbage collected) handlers
            _handlers.RemoveAll(wd => !wd.IsAlive || wd.Equals(handler));
        }

        public virtual void Raise(object sender, EventArgs e)
        {
            var handlers = _handlers.ToArray();
            foreach (var weakDelegate in handlers)
            {
                if (weakDelegate.IsAlive)
                {
                    weakDelegate.Invoke(sender, e);
                }
                else
                {
                    _handlers.Remove(weakDelegate);
                }
            }
        }

        protected List<WeakDelegate<TEventHandler>> Handlers
        {
            get { return _handlers; }
        }
    }

Cette classe gère automatiquement la suppression des handlers “morts” (collectés), et fournit une méthode Raise pour faciliter le déclenchement de l’évènement. Elle peut s’utiliser de la façon suivante :

        private WeakEvent<EventHandler> _myEvent = new WeakEvent<EventHandler>();
        public event EventHandler MyEvent
        {
            add { _myEvent.AddHandler(value); }
            remove { _myEvent.RemoveHandler(value); }
        }

        protected virtual void OnMyEvent()
        {
            _myEvent.Raise(this, EventArgs.Empty);
        }

C’est un peu plus long à écrire qu’un évènement “classique”, mais ce n’est finalement pas grand chose par rapport aux avantages que ça apporte… D’ailleurs, on peut facilement créer un “code snippet” pour Visual Studio, qui permet de créer un “évènement faible” en un rien de temps, avec seulement 3 informations à renseigner :

<?xml version="1.0" encoding="utf-8" ?>
<CodeSnippets  xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
  <CodeSnippet Format="1.0.0">
    <Header>
      <Title>wevt</Title>
      <Shortcut>wevt</Shortcut>
      <Description>Code snippet for a weak event</Description>
      <Author>Thomas Levesque</Author>
      <SnippetTypes>
        <SnippetType>Expansion</SnippetType>
      </SnippetTypes>
    </Header>
    <Snippet>
      <Declarations>
        <Literal>
          <ID>type</ID>
          <ToolTip>Event type</ToolTip>
          <Default>EventHandler</Default>
        </Literal>
        <Literal>
          <ID>event</ID>
          <ToolTip>Event name</ToolTip>
          <Default>MyEvent</Default>
        </Literal>
        <Literal>
          <ID>field</ID>
          <ToolTip>Name of the field holding the registered handlers</ToolTip>
          <Default>_myEvent</Default>
        </Literal>
      </Declarations>
      <Code Language="csharp">
        <![CDATA[private WeakEvent<$type$> $field$ = new WeakEvent<EventHandler>();
        public event $type$ $event$
        {
            add { $field$.AddHandler(value); }
            remove { $field$.RemoveHandler(value); }
        }

        protected virtual void On$event$()
        {
            $field$.Raise(this, EventArgs.Empty);
        }
	$end$]]>
      </Code>
    </Snippet>
  </CodeSnippet>
</CodeSnippets>

Ce qui donne dans Visual Studio le résultat suivant :

Code snippet pour implémenter un WeakEvent

Suivre

Get every new post delivered to your Inbox.