简体   繁体   中英

How can I properly execute an asynchronous method from a ViewModel?

For the purposes of this question, I've got a simple Window with the following XAML:

<StackPanel>
    <TextBox Text="{Binding MyText}" />
    <CheckBox IsChecked="{Binding IsChecked}">Check</CheckBox>
</StackPanel>

Whenever the users enters text in the TextBox or checks the CheckBox , I'd like to perform a slow task (such as saving my model's state to disk). Here's the view model:

public class ViewModel : ViewModelBase  // using GalaSoft.MvvmLight
{
    private string _myText;
    public string MyText
    {
        get => _myText;
        set
        {
            if (Set(ref _myText, value))
                Save();
        }
    }

    private bool _isChecked;
    public bool IsChecked
    {
        get => _isChecked;
        set
        {
            if (Set(ref _isChecked, value))
                Save();
        }
    }

    private async void Save()
    {
        var id = Guid.NewGuid();
        Debug.WriteLine($"Starting save {id}");
        await Task.Delay(100);  // Simulate slow task
        Debug.WriteLine($"Finished save {id}");
    }
}

The Save method simulates a slow task, such as saving to disk. For debugging purposes, it outputs a unique ID before and after performing this operation. Also, the method is asynchronous because I don't want the UI to freeze during the operation.

The issue is that, after the user types something in the TextBox , and then checks the CheckBox , the properties are updated very quickly. This causes the following debug sample output:

Starting save 6ea6c102-cbe7-472f-b8b8-249499ff7f64
Starting save c77b4478-14ca-4243-a45b-7b35b5663d49
Finished save 6ea6c102-cbe7-472f-b8b8-249499ff7f64
Finished save c77b4478-14ca-4243-a45b-7b35b5663d49

As you can see, the first save operation (from MyText ) is not done before the second save operation (from IsChecked ) is started. This scares me a little because, I imagine, data could be saved in the wrong order and become corrupted.

Is there a good practice for dealing with this kind of issue?

I've thought about a couple of possible solutions. The first is to use something like Delay=100 in the TextBox binding. This will cause the Save method to be called after the user stops typing for 100 ms. This is an ugly solution for various reasons.

The second is to use a SemaphoreSlim . Inside the Save method, I can surround the code with a try / finally to use the semaphore as described here . This actually works, but I'm not sure if it's the best way to handle this issue.

Is there a good practice for dealing with this kind of issue?

If you want both saves to happen, then serializing them with a lock (or SemaphoreSlim ) is the way to do. If you want to prevent the second save from starting, then the normal approach is to disable those controls while the save is in progress, eg, via an IsBusy property that is data-bound to your UI.

Caveats:

  • Set your IsBusy property synchronously. This disables the controls immediately.
  • Unset IsBusy in a finally , to ensure it is always unset, even if errors occur.

As you have stated, I would also say a lock to be the best method.

I recommend using Stepehen Cleary straigthforward solution: https://github.com/StephenCleary/AsyncEx

Hence, your code would look like

private readonly AsyncLock _lock = new AsyncLock();
private async void Save()
{
    var id = Guid.NewGuid();

    using (await _lock.LockAsync())
        {
            // It's safe to await while the lock is held
            Debug.WriteLine($"Starting save {id}");

            await Task.Delay(100);  // Simulate slow task

            Debug.WriteLine($"Finished save {id}");
        }
}

which doesn't really affects that much the readability of the code, and you just end up with a queue of async methods.

This is great sample for Rx. If you don't want to use ReactiveUI (which can easily live next to MVVMLight), all you need is a signal that property changed.

Using RxUI:

this.WhenAnyValue(x => x.MyText, x => x.IsChecked) // this you will need to emulate if you don't want RxUI
.Throttle(TimeSpan.FromMilliseconds(150)) // wait for 150ms after last signal, if there isn't any, send your own further into pipeline
.Synchronize()
.Do(async _ => await Save()) // we have to await so that Synchronize can work
.Subscribe();

This will wait 150ms after last MyText change or IsChecked change, and then perform the save once.

Also, RxUI has very clever implementation of ICommand, that supports async work out of the box, including disabling the command during the work.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM