原文:[WPF/MVVM] How to deal with fast changing properties
In this article, I will describe a problem which happens in a WPF/MVVM application when the Model
is updated at a very high frequency.
It happened to me while implementing a Model
handling a lot of values coming from instruments and I wanted to display the current values, even if they were changing very quickly.
The test case
All my test are based on this simple Model
class:
public class Model
{
const long MAX_DURATION = 20000;
public double Progress { get; private set; }
public event EventHandler<double> ProgressChanged;
public double Frequency { get; private set; }
public event EventHandler<double> FrequencyChanged;
public Model()
{
Task.Run((Action)LongRunningBackgroundTask);
}
void LongRunningBackgroundTask()
{
long loopCount = 0;
long elapsed = 0;
var chrono = new Stopwatch();
chrono.Start();
while (elapsed < MAX_DURATION)
{
elapsed = chrono.ElapsedMilliseconds;
SetProgress(100.0 * elapsed / MAX_DURATION);
SetFrequency(1.0 * loopCount / elapsed);
loopCount++;
}
}
void SetProgress(double value)
{
Progress = value;
if (ProgressChanged != null)
ProgressChanged(this, value);
}
void SetFrequency(double value)
{
Frequency = value;
if (FrequencyChanged != null)
FrequencyChanged(this, value);
}
}
As you can see, it’s very straightforward. The Model
class contains two public properties Progress
and Frequency
and their associated events. During 20 seconds, it runs a loop in a background task and updates the properties as fast as it can:
Progress
will go from0.0
to100.0
.Frequency
will contains the average loop frequency so far.
The problem with the classic MVVM approach
In the classic MVVM approach, the ViewModel
is attached to the Model
’s events, so as to be updated on every change.
Usually, the ViewModel
’s event handler calls Dispatcher.BeginInvoke()
to ensure the eventPropertyChanged
is raised in the UI thread.
public ViewModel()
{
dispatcher = Dispatcher.CurrentDispatcher;
model = new Model();
model.ProgressChanged += OnModelProgressChanged;
// ... then the same for the Frequency
}
void OnModelProgressChanged(double newValue)
{
dispatcher.BeginInvoke((Action)delegate() { Progress = newValue; });
}
public double Progress
{
get { return progress; }
set
{
if( progress == value ) return;
progress = value;
RaisePropertyChanged("Progress");
}
}
// ... then the same pattern for the Frequency property
However, this approach wont be able to work with the Model
class defined earlier. The GUI is completely frozen and sometimes even throws an OutOfMemoryException
.
Here is why: Each time a Model
’s property changes, the ViewModel
calls BeginInvoke()
and therefore appends a message in the dispatcher’s event queue. But the messages are dequeued way slower than they are added, so the queue will grow over and over until the memory is full.
Also, you can see that the execution speed of the Model
’s task is really affected : only 130 kHz on average.
Solution 1 : Ignore events that are too close
The first solution that usualy comes in mind is:
Hmmm… I get too many events…
I’ll just slow them down !
OK, let’s try…
public ViewModel()
{
var dispatcher = Dispatcher.CurrentDispatcher;
var model = new Model();
Observable.FromEventPattern<double>(model, "ProgressChanged")
.Sample(TimeSpan.FromMilliseconds(5))
.ObserveOn(dispatcher)
.Subscribe(x => Progress = x.EventArgs);
}
Here, I used Reactive Framework because it offers the Sample()
method which limits the rate of the events.
In this case the GUI is perfectly responsive and the Task execution speed is better but still low.
I think it’s a viable if you already use Reactive Framework, but I wouldn’t use it in my project: it’s too complicated and the performance is not good enough.
Solution 2 : Poll with a DispatcherTimer
Let’s look a this problem from a different angle. Why don’t we loose the “push” approach and use “pull” approach instead ?
In other words, instead of attaching to the event of the Model
, the ViewModel
could periodically read the values.
The most common way to implement polling in MVVM is to instanciate a DispatcherTimer
in the ViewModel.
public ViewModel()
{
model = new Model();
var timer = new DispatcherTimer();
timer.Interval = TimeSpan.FromMilliseconds(5);
timer.Tick += OnTimerTick;
timer.Start();
}
void OnTimerTick(object sender, EventArgs e)
{
Progress = model.Progress;
Frequency = model.Frequency;
}
// ...the remaining is identical to the original ViewModel
Here you go ! No only the GUI is perfectly responsive, the execution speed of the Task is way better: 10 MHz
Solution 3 : Poll on CompositionTarget.Rendering
To make it even simpler, we can move the timer from the ViewModel
to the View
. From that place, we can use the CompositionTarget.Rendering
event and completely get rid of the DispatcherTimer
. (As a reminder this event is raised by WPF each time an animation frame is rendered, 30 or 60 times per seconds)
View’s code behind:
public MainWindow()
{
InitializeComponent();
DataContext = new ViewModel();
CompositionTarget.Rendering += OnRendering;
}
void OnRendering(object sender, EventArgs e)
{
if (DataContext is IRefresh)
((IRefresh)DataContext).Refresh();
}
ViewModel:
class ViewModel : INotifyPropertyChanged, IRefresh
{
public ViewModel()
{
model = new Model();
}
public void Refresh()
{
Progress = model.Progress;
Frequency = model.Frequency;
}
// ...the remaining is identical to the original ViewModel
}
You get almost the same result as Solution 1, and even a slightly faster execution speed.
Conclusion
I like simple solutions, that why I really prefer the last one.
Whether it respects or not the MVVM pattern is really a matter of opinion. I really like the idea of theView
being responsible of the timer logic and the ViewModel
being responsible of updating its value.
One thing I really appreciate on the polling approach is that it really decouples the Model
’s and theViewModel
’s execution threads. We can even get rid of the Model
’s events.
To conclude, here is a comparison of the memory consumptions:
PS: A word about concurrency
When using the polling technique, you should take a special care of the concurrency.
Since the properties of the Model
are accessed from several threads, you may need to add lock
blocks if the type is bigger than a processor word (in my examples I used a int
so that’s OK).
If you have a lot of changing properties in your model, you should group them in a class, likeModelState
. That way, the ViewModel
will only have one property to monitor and only this class needs to be thread safe.