Handling Performance Issues with INotifyPropertyChanged and Expensive PropertyChanged Event Handlers

Consider the case of an object which implements INotifyPropertyChanged and has properties that may be dependent on each other. You may have a circumstance where a single user command causes several properties to change, and, depending on the object’s implementation, may generate several PropertyChanged events. If an event handler for the object’s PropertyChanged event executes an expensive or UI intensive task, the multiple PropertyChanged events can cause a real performance problem.

One approach to this problem is to “batch” the PropertyChanged events, combining any duplicate notifications, and dispatch them as a single event after some quiet timeout. The quiet timeout can be very small to maintain the appearance of responsiveness while still capturing multiple change events into a single event dispatch.

I constructed a static class “AccumulatedPropertyChangeNotification” with two public methods: AddHandler, and RemoveHandler. These methods allowed an event handler to be attached to an INotifyPropertyChanged, indirectly, through the accumulator. In order to appropriately handle accumulated property changes (which could be more than one property), I created an event type which supports a sequence of changed properties.

public class AccumulatedPropertyChangedEventArgs : EventArgs 
{
    /// <summary>
    /// A distinct list of properties changed within the last notification cycle. If any notification
    /// of ""/null (meaning possibly all properties) has been submitted, then this property will be null.
    /// </summary>
    public IEnumerable<string> Properties {get;set;}
}

In order to accumulate dispatches, I used a timer which checks which events are queued and, when appropriate, dispatches them in bulk. This means that the AccumulatedPropertyChanged event is dispatched on a different thread than the object that generated the original PropertyChanged event. To address this situation, I also allow listeners to supply an optional ISynchronizeInvoke for the timer.

Adding and removing handlers are performed with these signatures:

public static void AddHandler(INotifyPropertyChanged objectToWatch, AccumulatedPropertyChangedHandler handler, ISynchronizeInvoke syncObject = null);
public static void RemoveHandler(INotifyPropertyChanged objectToWatch, AccumulatedPropertyChangedHandler handler);

When a handler is added, the handler is added to an internal notification list, and the PropertyChanged event of the objectToWatch is wired to an internal handler in the AccumulatedPropertyChangeNotification class. This handler simply puts the event, and the time it occurred, into an internal event queue.

private static List<Tuple<INotifyPropertyChanged, AccumulatedPropertyChangedHandler, ISynchronizeInvoke>> notifications;
private static List<Tuple<INotifyPropertyChanged, PropertyChangedEventArgs, DateTime>> changeNotificationsQueued;

When the timer fires, it looks at the change notification queue, groups by INotifyPropertyChanged instance, and checks for the most recent DateTime of property changed. If it is at least as old as the mandatory delay (by default, 50 milliseconds), then all the changes for that instance are batched and dispatched to all AccumulatedPropertyChangedHandler delegates associated with that object. This was a bit tricky to get correct.

The other issue that was tricky was RemoveHandler, doing a lookup on a given handler to remove it. Apparently Object.ReferenceEquals with methods doesn’t work well (or didn’t for me), so I ended up using .Equals on the delegate, which is not (still isn’t) what I expected to do.

Download implementation and unit tests. You will probably want to change the namespace to match your project.

If you’re using WPF, you’ll need a wrapper for the Dispatcher to implement ISynchronizeInvoke (come on Microsoft, get your shit together). A good implementation can be found at http://blogs.openspan.com/2010/04/hosting-openspan-a-complete-isynchronizeinvoke-wrapper-for-the-dispatcher/, though beware I found that InvokeRequired was returning false when it should have been returning true.