可以将文章内容翻译成中文,广告屏蔽插件可能会导致该功能失效(如失效,请关闭广告屏蔽插件后再试):
问题:
Recently I inherited a pretty big project developed in C# and WPF.
It uses bindings along with the INotifyPropertyChanged interface to propagate changes to/from the View.
A little preface:
In different classes I have properties that depend on other properties in the same class (think for example the property "TaxCode" that depends on properties like "Name" and "Lastname").
With the help of some code I found here on SO (can't find again the answer though) I created the abstract class "ObservableObject" and the attribute "DependsOn".
The source is the following:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace TestNameSpace
{
[AttributeUsage(AttributeTargets.Property, Inherited = false)]
public sealed class DependsOn : Attribute
{
public DependsOn(params string[] properties)
{
this.Properties = properties;
}
public string[] Properties { get; private set; }
}
[Serializable]
public abstract class ObservableObject : INotifyPropertyChanged
{
private static Dictionary<Type, Dictionary<string, string[]>> dependentPropertiesOfTypes = new Dictionary<Type, Dictionary<string, string[]>>();
[field: NonSerialized]
public event PropertyChangedEventHandler PropertyChanged;
private readonly bool hasDependentProperties;
public ObservableObject()
{
DependsOn attr;
Type type = this.GetType();
if (!dependentPropertiesOfTypes.ContainsKey(type))
{
foreach (PropertyInfo pInfo in type.GetProperties())
{
attr = pInfo.GetCustomAttribute<DependsOn>(false);
if (attr != null)
{
if (!dependentPropertiesOfTypes.ContainsKey(type))
{
dependentPropertiesOfTypes[type] = new Dictionary<string, string[]>();
}
dependentPropertiesOfTypes[type][pInfo.Name] = attr.Properties;
}
}
}
if (dependentPropertiesOfTypes.ContainsKey(type))
{
hasDependentProperties = true;
}
}
public virtual void OnPropertyChanged(string propertyName)
{
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
if (this.hasDependentProperties)
{
//check for any computed properties that depend on this property
IEnumerable<string> computedPropNames = dependentPropertiesOfTypes[this.GetType()].Where(kvp => kvp.Value.Contains(propertyName)).Select(kvp => kvp.Key);
if (computedPropNames != null && !computedPropNames.Any())
{
return;
}
//raise property changed for every computed property that is dependant on the property we did just set
foreach (string computedPropName in computedPropNames)
{
//to avoid stackoverflow as a result of infinite recursion if a property depends on itself!
if (computedPropName == propertyName)
{
throw new InvalidOperationException("A property can't depend on itself");
}
this.OnPropertyChanged(computedPropName);
}
}
}
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)
{
field = value;
this.OnPropertyChanged(propertyName);
}
return valueChanged;
}
}
}
These classes allow me to:
- Use just
this.SetValue(ref this.name, value)
inside the setter of my properties.
- Use the attribute
DependsOn(nameof(Name), nameof(LastName))
on the property TaxCode
This way TaxCode only has a getter property that combines FirstName, LastName (and other properties) and returns the corresponding code. Even with binding this property is up to date thanks to this dependency system.
So, as long as TaxCode has dependencies on properties that are in the same class, everything works correctly. However I'm in the need to have properties that have one or more dependencies on their child object. For example (I'll just use json to make the hierarchy more simple):
{
Name,
LastName,
TaxCode,
Wellness,
House:
{
Value
},
Car:
{
Value
}
}
So, the Property Wellness of person sould be implemented like this:
[DependsOn(nameof(House.Value), nameof(Car.Value))]
public double Wellness { get =>(this.House.Value + this.Car.Value);}
The first problem is that "House.Value" and "Car.Value" are not valid parameters for nameof in that context.
The second is that with my actual code I can raise properties that are only in the same object so no properties of childs, nor properties that are application wide (I have for example a property that represents if the units of measurement are expressed in metric/imperial and the change of it affects how values are shown).
Now a solution I could use could be to insert a dictionary of events in my ObservableObject with the key being the name of the property and make the parent register a callback. This way when the property of a child changes the event is fired with the code to notify that a property in the parent has changed. This approach however forces me to register the callbacks everytime a new child is instantiated. It is certainly not much, but I liked the idea of just specifying dependencies and let my base class do the work for me.
So, long story short, what I'm trying to achive is to have a system that can notify dependent property changes even if the properties involved are its childs or are unrelated to that specific object. Since the codebase is quite big I'd like not to just throw away the existing ObservableObject + DependsOn approach, and I'm looking for a more elegant way than just place callbacks all over my code.
Of course If my approach is wrong / what I want cannot be achived with the code I have, please DO feel free to suggest better ways. I'm not a WPF expert and I'm trying to learn as much as I can about it.
Thanks in advance.
回答1:
The original solution with a DependsOnAttribute
is a nice idea, but the implementation has a couple of performance and multithreading issues. Anyway, it doesn't introduce any surprising dependencies to your class.
class MyItem : ObservableObject
{
public int Value { get; }
[DependsOn(nameof(Value))]
public int DependentValue { get; }
}
Having this, you can use your MyItem
anywhere - in your app, in unit tests, in a class library you might be willing to create later.
Now, consider such a class:
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; }
}
This class has two "surprising" dependencies now:
MyDependentItem
now needs to know a particular property of the IMySubItem
type (whereas originally, it only exposes an instance of that type, without knowing its details). When you change the IMySubItem
properties somehow, you are forced to change the MyDependentItem
class too.
Additionally, MyDependentItem
needs a reference to a global object (represented as a singleton here).
All this breaks the SOLID principles (it's all about to minimize changes in code) and makes the class not testable. It introduces a tight coupling to other classes and lowers the class' cohesion. You will have troubles debugging the issues with that, sooner or later.
I think, Microsoft faced same issues when they designed the WPF Data Binding Engine. You're somehow trying to reinvent it - you're looking for a PropertyPath
as it is currently being used in XAML bindings. To support this, Microsoft created the whole dependency property concept and a comprehensive Data Binding Engine that resolves the property paths, transfers the data values and observes the data changes. I don't think you really want something of that complexity.
Instead, my suggestions would be:
For the property dependencies in the same class, use the DependsOnAttribute
as you're currently doing. I would slightly refactor the implementation to boost the performance and to ensure the thread safety.
For a dependency to an external object, use the Dependency Inversion Principle of SOLID; implement it as dependency injection in constructors. For your measurement units example, I would even separate the data and the presentation aspects, e.g. by using a view-model that has a dependency to some ICultureSpecificDisplay
(your measurement units).
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);
}
- For a dependency in the composition structure of your object, just do it manually. How many such dependent properties do you have? A couple in a class? Does it make sense to invent a comprehensive framework instead of additional 2-3 lines of code?
If I still didn't convince you - well, you can of course extend your DependsOnAttribute
to store not only property names but also the types where those properties are declared. Your ObservableObject
needs to be updated too.
Let's take a look.
This is an extended attribute that also can hold the type reference. Note that it can be applied multiple times now.
[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; }
}
The ObservableObject
needs to subscribe to the children events:
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));
}
}
}
You can use that code like this:
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;
}
The Sum
property depends on the Val
property of the same class and on the MyProperty
property of the MyChildClass
class.
As you see, this doesn't look that great. Furthermore, the whole concept depends on the event handler registration performed by the property setters. If you happen to set the field value directly (e.g. child = new MyChildClass()
), then it all won't work. I would suggest you not to use this approach.
回答2:
I think, the way, you are using with DependendOn is not working for bigger projects and more complicated relations. (1 to n, n to m, …)
You should use am observer pattern. E.g.: You can have a centralized place where all ViewModels (ObservableObjects) register them self and start listening to change events. You can raise the changed events with sender information, and every ViewModel gets all events and can decide if a single event is interesting.
If your application can open multiple independent windows / views, you can even start to scope the listeners, so independent windows / views are separated and get only events of their own scope.
If you have long lists of items which are shown in a virtualized list / grid you can check if the item is really displaying any UI right now and if not stop listening or just don’t care about events in this case.
And you can raise certain events (e.g. those events which would trigger a really big UI change) with a little delay, and clear the queue of previous events, if the same event is raised again with different parameters within the delay.
I think sample code for all this would be to much for this thread… If you really need some code for one of the suggestions, tell me…
回答3:
You can let the events bubble up through the ObservableObject
hierarchy. As suggested, the base class could handle the hook up.
[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}");
}
}
Now you can depend on a grandchild.
Models.cs
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
<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.cs
using System.Windows;
namespace WpfApp
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
this.DataContext =
new TaxPayer(
new House(
new Safe()));
}
}
}
For project wide dependencies the advised approach is to use Dependency Injection. A broad topic, in short you'd build the object tree with the help of abstractions, allowing you to swap implementations at runtime.