2
votes

INotifyPropertyChanged et propriétés dérivées sur différents objets

Récemment, j'ai hérité d'un assez gros projet développé en C # et WPF. Il utilise des liaisons avec l'interface INotifyPropertyChanged pour propager les modifications vers / depuis la vue.

Une petite préface: Dans différentes classes, j'ai des propriétés qui dépendent d'autres propriétés dans la même classe (pensez par exemple à la propriété TaxCode qui dépend de propriétés comme Name et Nom ). Avec l'aide d'un code que j'ai trouvé ici sur SO (je ne trouve pas encore la réponse), j'ai créé la classe abstraite ObservableObject et l'attribut DependsOn . La source est la suivante:

[DependsOn(nameof(House.Value), nameof(Car.Value))]
public double Wellness { get =>(this.House.Value + this.Car.Value);}

Ces classes me permettent de:

  1. Utilisez simplement this.SetValue (ref this.name, value) dans le setter de mes propriétés.
  2. Utilisez l'attribut DependsOn (nameof (Name), nameof (LastName)) sur la propriété TaxCode

De cette façon, TaxCode n'a qu'une propriété getter qui combine FirstName , LastName (et d'autres propriétés) et renvoie les propriétés correspondantes code. Même avec la liaison, cette propriété est à jour grâce à ce système de dépendances.

Donc, tant que TaxCode a des dépendances sur des propriétés qui sont dans la même classe, tout fonctionne correctement. Cependant, j'ai besoin d'avoir des propriétés qui ont une ou plusieurs dépendances sur leur objet enfant . Par exemple (je vais simplement utiliser json pour rendre la hiérarchie plus simple):

{
  Name,
  LastName,
  TaxCode,
  Wellness,
  House:
  {
    Value
  },
  Car:
  {
    Value
  }
}

Ainsi, la propriété Bien-être de la personne doit être implémentée comme ceci:

XXX

Le premier problème est que "House.Value" et "Car.Value" ne sont pas des paramètres valides pour nameof dans ce contexte. La seconde est qu'avec mon code réel, je peux élever des propriétés qui ne sont que dans le même objet donc pas de propriétés d'enfant, ni des propriétés qui sont à l'échelle de l'application (j'ai par exemple une propriété qui représente si les unités de mesure sont exprimés en métrique / impérial et le changement de celui-ci affecte la façon dont les valeurs sont affichées).

Maintenant, une solution que je pourrais utiliser pourrait être d'insérer un dictionnaire d'événements dans mon ObservableObject code > avec la clé étant le nom de la propriété et faire du registre parent un rappel. De cette façon, lorsque la propriété d'un enfant change, l'événement est déclenché avec le code pour notifier qu'une propriété du parent a changé. Cette approche m'oblige cependant à enregistrer les rappels chaque fois qu'un nouvel enfant est instancié. Ce n'est certainement pas grand-chose, mais j'ai aimé l'idée de simplement spécifier les dépendances et de laisser ma classe de base faire le travail pour moi.

Donc, pour faire court, ce que j'essaie de réaliser, c'est d'avoir un système qui peut notifier les changements de propriété dépendante même si les propriétés impliquées sont ses enfants ou ne sont pas liées à cet objet spécifique. Étant donné que la base de code est assez grande, je ne voudrais pas simplement jeter l'approche existante ObservableObject + DependsOn, et je recherche un moyen plus élégant que de simplement placer des rappels partout dans mon code. p>

Bien sûr, si mon approche est erronée / ce que je veux ne peut être réalisé avec le code que j'ai, n'hésitez pas à suggérer de meilleures façons.


0 commentaires

3 Réponses :


1
votes

Je pense que la façon dont vous utilisez avec DependendOn ne fonctionne pas pour des projets plus importants et des relations plus compliquées. (1 à n, n à m,…)

Vous devriez utiliser un modèle d'observateur. Par exemple: Vous pouvez avoir un endroit centralisé où tous les ViewModels (ObservableObjects) les enregistrent eux-mêmes et commencent à écouter les événements de changement. Vous pouvez augmenter les événements modifiés avec les informations de l'expéditeur, et chaque ViewModel obtient tous les événements et peut décider si un seul événement est intéressant.

Si votre application peut ouvrir plusieurs fenêtres / vues indépendantes, vous pouvez même commencer à étendre la portée des écouteurs, afin que les fenêtres / vues indépendantes soient séparées et n'obtiennent que les événements de leur propre étendue.

Si vous avez de longues listes d'éléments qui sont affichés dans une liste / grille virtualisée, vous pouvez vérifier si l'élément affiche vraiment une interface utilisateur en ce moment et sinon arrêter d'écouter ou simplement ne pas se soucier des événements dans ce cas.

/ p>

Et vous pouvez déclencher certains événements (par exemple, les événements qui déclencheraient un très gros changement d'interface utilisateur) avec un peu de retard, et effacer la file d'attente des événements précédents, si le même événement est à nouveau déclenché avec des paramètres différents dans le délai.

/ p>

Je pense qu'un exemple de code pour tout cela serait trop pour ce fil… Si vous avez vraiment besoin de code pour l'une des suggestions, dites-moi…


0 commentaires

1
votes

Vous pouvez laisser les événements remonter dans la hiérarchie ObservableObject . Comme suggéré, la classe de base pourrait gérer le raccordement.

using System.Windows;

namespace WpfApp
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            this.DataContext = 
                new TaxPayer(
                    new House(
                        new Safe()));
        }
    }
}

Vous pouvez maintenant compter sur un petit-enfant.

Models.cs em >

<Window x:Class="WpfApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid VerticalAlignment="Center" HorizontalAlignment="Center">
        <Grid.RowDefinitions>
            <RowDefinition />
            <RowDefinition />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="100" />
            <ColumnDefinition Width="200"/>
        </Grid.ColumnDefinitions>
        <Label Grid.Row="0" Grid.Column="0">Safe Content:</Label>
        <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding House.Safe.Value, UpdateSourceTrigger=PropertyChanged}" />

        <Label Grid.Row="1" Grid.Column="0">Tax Code:</Label>
        <TextBox Grid.Row="1" Grid.Column="1" Text="{Binding TaxCode, Mode=OneWay}" IsEnabled="False" />
    </Grid>
</Window>

MainWindow.xaml

public class TaxPayer : ObservableObject
{
    public TaxPayer(House house)
    {
        House = house;
    }

    [DependsOn("House.Safe.Value")]
    public string TaxCode => House.Safe.Value;

    private House house;
    public House House
    {
        get => house;
        set => SetField(ref house, value);
    }
}

public class House : ObservableObject
{
    public House(Safe safe)
    {
        Safe = safe;
    }

    private Safe safe;
    public Safe Safe
    {
        get => safe;
        set => SetField(ref safe, value);
    }
}

public class Safe : ObservableObject
{
    private string val;
    public string Value
    {
        get => val;
        set => SetField(ref val, value);
    }
}

MainWindow.xaml.cs

[Serializable]
public abstract class ObservableObject : INotifyPropertyChanged
{
    // ... 
    // Code left out for brevity 
    // ...

    protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
    {
        return this.SetField<T>(ref field, value, false, propertyName);
    }

    protected bool SetField<T>(ref T field, T value, bool forceUpdate, [CallerMemberName] string propertyName = null)
    {
        bool valueChanged = !EqualityComparer<T>.Default.Equals(field, value);

        if (valueChanged || forceUpdate)
        {
            RemovePropertyEventHandler(field as ObservableObject);
            AddPropertyEventHandler(value as ObservableObject);
            field = value;
            this.OnPropertyChanged(propertyName);
        }

        return valueChanged;
    }

    protected void AddPropertyEventHandler(ObservableObject observable)
    {
        if (observable != null)
        {
            observable.PropertyChanged += ObservablePropertyChanged;
        }
    }

    protected void RemovePropertyEventHandler(ObservableObject observable)
    {
        if (observable != null)
        {
            observable.PropertyChanged -= ObservablePropertyChanged;
        }
    }

    private void ObservablePropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        this.OnPropertyChanged($"{sender.GetType().Name}.{e.PropertyName}");
    }
}

Pour les dépendances à l'échelle du projet, l'approche conseillée est d'utiliser Injection de dépendances . Un vaste sujet, en bref, vous construirez l'arborescence des objets à l'aide d'abstractions, vous permettant d'échanger les implémentations au moment de l'exécution.


1 commentaires

BTW, le PropertyChangedEventArgs.PropertyName devrait contenir un nom de propriété simple, pas un chemin de propriété.



4
votes

La solution originale avec un DependsOnAttribute est une bonne idée, mais l'implémentation présente quelques problèmes de performances et de multithreading. Quoi qu'il en soit, cela n'introduit aucune dépendance surprenante dans votre classe.

class MyClass : ObservableObject
{
    private int val;
    public int Val
    {
        get => val;
        set => SetField(ref val, value);
    }

    // MyChildClass must implement INotifyPropertyChanged
    private MyChildClass child;
    public MyChildClass Child
    {
        get => child;
        set => SetField(ref child, value);
    }

    [DependsOn(typeof(MyChildClass), nameof(MyChildClass.MyProperty))]
    [DependsOn(nameof(Val))]
    public int Sum => Child.MyProperty + Val;
}

Ayant cela, vous pouvez utiliser votre MyItem n'importe où - dans votre application, dans des tests unitaires, dans une bibliothèque de classes que vous voudrez peut-être créer plus tard.

Maintenant, considérez une telle classe:

abstract class ObservableObject : INotifyPropertyChanged
{
    // We're using a ConcurrentDictionary<K,V> to ensure the thread safety.
    // The C# 7 tuples are lightweight and fast.
    private static readonly ConcurrentDictionary<(Type, string), string> dependencies =
        new ConcurrentDictionary<(Type, string), string>();

    // Here we store already processed types and also a flag
    // whether a type has at least one dependency
    private static readonly ConcurrentDictionary<Type, bool> registeredTypes =
        new ConcurrentDictionary<Type, bool>();

    protected ObservableObject()
    {
        Type thisType = GetType();
        if (registeredTypes.ContainsKey(thisType))
        {
            return;
        }

        var properties = thisType.GetProperties()
            .SelectMany(propInfo => propInfo.GetCustomAttributes<DependsOn>()
                .SelectMany(attribute => attribute.Properties
                    .Select(propName => 
                        (SourceType: attribute.Type, 
                        SourceProperty: propName, 
                        TargetProperty: propInfo.Name))));

        bool atLeastOneDependency = false;
        foreach (var property in properties)
        {
            // If the type in the attribute was not set,
            // we assume that the property comes from this type.
            Type sourceType = property.SourceType ?? thisType;

            // The dictionary keys are the event source type
            // *and* the property name, combined into a tuple     
            dependencies[(sourceType, property.SourceProperty)] =
                property.TargetProperty;
            atLeastOneDependency = true;
        }

        // There's a race condition here: a different thread
        // could surpass the check at the beginning of the constructor
        // and process the same data one more time.
        // But this doesn't really hurt: it's the same type,
        // the concurrent dictionary will handle the multithreaded access,
        // and, finally, you have to instantiate two objects of the same
        // type on different threads at the same time
        // - how often does it happen?
        registeredTypes[thisType] = atLeastOneDependency;
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected void OnPropertyChanged(string propertyName)
    {
        var e = new PropertyChangedEventArgs(propertyName);
        PropertyChanged?.Invoke(this, e);
        if (registeredTypes[GetType()])
        {
            // Only check dependent properties if there is at least one dependency.
            // Need to call this for our own properties,
            // because there can be dependencies inside the class.
            RaisePropertyChangedForDependentProperties(this, e);
        }
    }

    protected bool SetField<T>(
        ref T field, 
        T value, 
        [CallerMemberName] string propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
        {
            return false;
        }

        if (registeredTypes[GetType()])
        {
            if (field is INotifyPropertyChanged oldValue)
            {
                // We need to remove the old subscription to avoid memory leaks.
                oldValue.PropertyChanged -= RaisePropertyChangedForDependentProperties;
            }

            // If a type has some property dependencies,
            // we hook-up events to get informed about the changes in the child objects.
            if (value is INotifyPropertyChanged newValue)
            {
                newValue.PropertyChanged += RaisePropertyChangedForDependentProperties;
            }
        }

        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }

    private void RaisePropertyChangedForDependentProperties(
        object sender, 
        PropertyChangedEventArgs e)
    {
        // We look whether there is a dependency for the pair
        // "Type.PropertyName" and raise the event for the dependent property.
        if (dependencies.TryGetValue(
            (sender.GetType(), e.PropertyName),
            out var dependentProperty))
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(dependentProperty));
        }
    }
}

Cette classe a maintenant deux dépendances "surprenantes" :

  • MyDependentItem a désormais besoin de connaître une propriété particulière du type IMySubItem (alors qu'à l'origine, il n'expose qu'une instance de ce type, sans connaître ses détails) . Lorsque vous modifiez d'une manière ou d'une autre les propriétés IMySubItem , vous êtes obligé de modifier également la classe MyDependentItem .

  • De plus, MyDependentItem a besoin d'une référence à un objet global (représenté ici par un singleton).

Tout cela enfreint les principes SOLID (il s'agit de minimiser les changements de code) et rend la classe non testable. Il introduit un couplage étroit avec les autres classes et réduit la cohésion de la classe. Vous aurez des difficultés à déboguer les problèmes avec cela, tôt ou tard.

Je pense que Microsoft a été confronté aux mêmes problèmes lors de la conception du moteur de liaison de données WPF. Vous essayez en quelque sorte de le réinventer - vous recherchez un PropertyPath tel qu'il est actuellement utilisé dans les liaisons XAML. Pour prendre en charge cela, Microsoft a créé l'ensemble du concept de propriété de dépendance et un moteur de liaison de données complet qui résout les chemins de propriété, transfère les valeurs de données et observe les modifications des données. Je ne pense pas que vous vouliez vraiment quelque chose de cette complexité.

Au lieu de cela, mes suggestions seraient:

  • Pour les dépendances de propriété dans la même classe, utilisez DependsOnAttribute comme vous le faites actuellement. Je refactoriserais légèrement l'implémentation pour booster les performances et assurer la sécurité des threads.

  • Pour une dépendance à un objet externe, utilisez le principe d'inversion de dépendance de SOLID ; l'implémenter comme injection de dépendances dans les constructeurs. Pour votre exemple d'unités de mesure, je séparerais même les données et les aspects de présentation, par exemple en utilisant un modèle de vue qui a une dépendance à certains ICultureSpecificDisplay (vos unités de mesure).

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
    class DependsOnAttribute : Attribute
    {
        public DependsOnAttribute(params string[] properties)
        {
            Properties = properties;
        }
    
        public DependsOnAttribute(Type type, params string[] properties)
            : this(properties)
        {
            Type = type;
        }
    
        public string[] Properties { get; }
    
        // We now also can store the type of the PropertyChanged event source
        public Type Type { get; }
    }
    
    • Pour une dépendance dans la structure de composition de votre objet, faites-le simplement manuellement. Combien de ces propriétés dépendantes possédez-vous? Un couple dans une classe? Est-il judicieux d'inventer un cadre complet au lieu de 2 à 3 lignes de code supplémentaires?

Si je ne vous ai toujours pas convaincu - eh bien, vous pouvez bien sûr étendre votre DependsOnAttribute pour stocker non seulement les noms de propriétés mais aussi les types où ces propriétés sont déclarées. Votre ObservableObject doit également être mis à jour.

Jetons un coup d'œil. Il s'agit d'un attribut étendu qui peut également contenir la référence de type. Notez qu'il peut être appliqué plusieurs fois maintenant.

class MyItem
{
    public double Wellness { get; }
}

class MyItemViewModel : INotifyPropertyChanged
{
    public MyItemViewModel(MyItem item, ICultureSpecificDisplay display)
    {
        this.item = item;
        this.display = display;
    }

    // TODO: implement INotifyPropertyChanged support
    public string Wellness => display.GetStringWithMeasurementUnits(item.Wellness);
 }

Le ObservableObject doit s'abonner aux événements enfants:

class MyDependentItem : ObservableObject
{
    public IMySubItem SubItem { get; } // where IMySubItem offers some NestedItem property

    [DependsOn(/* some reference to this.SubItem.NestedItem.Value*/)]
    public int DependentValue { get; }

    [DependsOn(/* some reference to GlobalSingleton.Instance.Value*/)]
    public int OtherValue { get; }
}

Vous pouvez utiliser ce code comme ceci:

class MyItem : ObservableObject
{
    public int Value { get; }

    [DependsOn(nameof(Value))]
    public int DependentValue { get; }
}

La propriété Sum dépend du code Val > de la même classe et sur la propriété MyProperty de la classe MyChildClass .

Comme vous le voyez, cela n'a pas l'air génial. De plus, tout le concept dépend de l'enregistrement du gestionnaire d'événements effectué par les setters de propriété. Si vous définissez directement la valeur du champ (par exemple, child = new MyChildClass () ), alors tout ne fonctionnera pas. Je vous suggère de ne pas utiliser cette approche.


0 commentaires