WPF ViewModel 在另一个线程中运行方法,报告进度,更改 Button 的 IsEnabled 并显示结果

WPF ViewModel running method in another thread, reporting progress, changing Button's IsEnabled and displaying result

提问人:MatrixRonny 提问时间:2/8/2018 更新时间:2/8/2018 访问量:2004

问:

我正在尝试找到一种使用在 ViewModel 中创建的线程运行现有方法的正确方法。主要目的是提供响应式 UI。我决定使用基于任务的异步模式,但我需要将其与 WPF 和 MVVM 正确集成。

到目前为止,我找到了一种方法,可以在另一个线程中运行冗长的任务并报告其进度。但是,我找不到更新启动任务的 Button 的方法,以便仅在任务未运行时才启用它。下面的 ViewModel 描述了我所做的工作:

public class MainViewModel : INotifyPropertyChanged
{
    public void NotifyPropertyChanged(string info)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(info));
    }
    public event PropertyChangedEventHandler PropertyChanged;

    // Do some time consuming work.
    int SomeTask()
    {
        //SCENARIO: Consider that it takes longer than usual to start the worker thread.
        Thread.Sleep(1000);

        // Prevent executing the task by two threads at the same time.
        lock ("IsReady")
        {
            if (IsReady == false)
                throw new ApplicationException("Task is already running");
            IsReady = false;
        }

        // The actual work that this task consists of.
        TaskProgress = 0;
        for (int i = 1; i <= 100; i++)
        {
            Thread.Sleep(50);
            TaskProgress = i;
        }

        // Mark task as completed to allow rerunning it.
        IsReady = true;

        return 123;
    }

    // True when not started or completed.
    bool _isReady = true;
    public bool IsReady
    {
        get { return _isReady; }
        set
        {
            _isReady = value;
            NotifyPropertyChanged("IsReady");
            StartTaskCommand.RaiseCanExecuteChanged();
        }
    }

    // Indicate the current progress when running SomeTask.
    int _taskProgress;
    public int TaskProgress
    {
        get { return _taskProgress; }
        set
        {
            _taskProgress = value;
            NotifyPropertyChanged("TaskProgress");
        }
    }

    // ICommand to start task asynchronously.
    RelayCommand _startTask;
    public RelayCommand StartTaskCommand
    {
        get
        {
            if (_startTask == null)
            {
                _startTask = new RelayCommand(
                    obj =>
                    {
                        Task<int> task = Task.Run((Func<int>)SomeTask);
                        task.ContinueWith(t =>
                        {
                            // SomeTask method may throw an ApplicationException.
                            if (!t.IsFaulted)
                                Result = t.Result.ToString();
                        });
                    },
                    obj => IsReady);

            }
            return _startTask;
        }
    }

    string _result;
    public string Result
    {
        get { return _result; }
        set { _result = value; NotifyPropertyChanged("Result"); }
    }
}

我使用以下 RelayCommand 实现:

public class RelayCommand : ICommand
{
    private Action<object> execute;
    private Func<object, bool> canExecute;

    public event EventHandler CanExecuteChanged
    {
        add { CommandManager.RequerySuggested += value; }
        remove { CommandManager.RequerySuggested -= value; }
    }

    public void RaiseCanExecuteChanged()
    {
        CommandManager.InvalidateRequerySuggested();
    }

    public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
    {
        this.execute = execute;
        this.canExecute = canExecute;
    }

    public bool CanExecute(object parameter)
    {
        return this.canExecute == null || this.canExecute(parameter);
    }

    public void Execute(object parameter)
    {
        this.execute(parameter);
    }
}

主要问题是执行命令的 Button 不会根据 更新其状态。我也尝试用 显式设置它,但它仍然不起作用。我发现与此问题相关的最好的文章是:Raising CanExecuteChangedIsReadyIsEnabled="{Binding IsReady}"

XAML 非常简单:

<DockPanel Margin="4">
    <TextBox DockPanel.Dock="Right" Text="{Binding Result}" Width="100"/>
    <Button DockPanel.Dock="Right" Content="Start" Margin="5,0"
            Command="{Binding StartTaskCommand}"/>
    <ProgressBar Value="{Binding TaskProgress}"/>
</DockPanel>

如何修复 IsReady 以反映在 Button 的状态中?

有没有人推荐一个简约的工作实现来满足我的需求?

感谢您抽出宝贵时间阅读。

C# WPF 多线程 MVVM

评论


答:

1赞 taquion 2/8/2018 #1

您必须在 UI 线程上更新 IsRady 标志。我修改您的示例以实现预期行为:

int SomeTask()
{


    // Prevent executing the task by two threads at the same time.
    lock ("IsReady")
    {
        if (IsReady == false)
            throw new ApplicationException("Task is already running");
        Application.Current.Dispatcher.Invoke(() => { IsReady = false; });
    }

    //SCENARIO: Consider that it takes longer than usual to start the worker thread.
    Thread.Sleep(1000);

    // The actual work that this task consists of.
    TaskProgress = 0;
    for (int i = 1; i <= 100; i++)
    {
        Thread.Sleep(50);
        TaskProgress = i;
    }

    // Mark task as completed to allow rerunning it.
    Application.Current.Dispatcher.Invoke(() => { IsReady = true; });

    return 123;
}

您从其他线程触发了 PropertyChanged 事件,这就是问题所在。

我还把线程放在锁下面,因为我得到了你的 ApplicationExceptions,哈哈

编辑

由于您正在引发 canExecuteChanged,您也可以通过在 UI 线程上引发该事件来解决此问题:

 public bool IsReady
        {
            get { return _isReady; }
            set
            {
                _isReady = value;
                NotifyPropertyChanged("IsReady");
                Application.Current.Dispatcher.Invoke(() =>
                {
                    StartTaskCommand.RaiseCanExecuteChanged(); 
                });
            }
        }

评论

0赞 MatrixRonny 2/8/2018
谢谢你的回答。事实上,这就是问题所在。我试图重构您的魔术代码,以便在 IsReady setter 中使用它一次,但它不起作用。知道为什么吗?另外,我无法弄清楚为什么TaskProgress没有遇到这个问题并且工作正常。Application.Current.Dispatcher.Invoke(() => NotifyPropertyChanged("IsReady"))
0赞 taquion 2/8/2018
@MatrixRonny,对不起,我以为按钮的 IsEnabled 已绑定,但您正在从 setter 中获取 canExecuteChanged。我已经编辑了答案以显示替代解决方案
0赞 MatrixRonny 2/8/2018
是的,我想出了相同的 IsReady 版本。还有一件事,如果我绑定,有没有更简单的方法?我读到我在 RaiseCanExecuteChanged 中使用的 InvalidateRequerySuggested 是一项成本相当高昂的操作。IsEnabled="{Binding IsReady}"
3赞 mm8 2/8/2018 #2

在启动任务之前,将属性设置为 UI 线程上,然后将其设置回任务完成后:IsReadyfalsetrue

public RelayCommand StartTaskCommand
{
    get
    {
        if (_startTask == null)
        {
            _startTask = new RelayCommand(
                obj =>
                {
                    if (IsReady)
                    {
                        //1. Disable the button on the UI thread
                        IsReady = false;
                        //2. Execute SomeTask on a background thread
                        Task.Factory.StartNew(SomeTask)
                        .ContinueWith(t =>
                        {
                            //3. Enable the button back on the UI thread
                            if (!t.IsFaulted)
                                Result = t.Result.ToString();
                            IsReady = true;
                        }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
                    }
                },
                obj => IsReady);

        }
        return _startTask;
    }
}

int SomeTask()
{
    Thread.Sleep(1000);

    TaskProgress = 0;
    for (int i = 1; i <= 100; i++)
    {
        Thread.Sleep(50);
        TaskProgress = i;
    }

    return 123;
}

评论

0赞 MatrixRonny 2/8/2018
这是摆脱 lock(“IsSelected”) 的一种更优雅的方法,因为 IsReady 属性是在创建线程之前设置的。有一个小问题,按钮在完成后无法重新启用。我错过了什么吗?我签入了调试器,确实 IsReady 从主线程设置为 true。
0赞 mm8 2/8/2018
在 ContinueWith 中将 IsReady 属性设置为 true 后,是否调用了 IsReady 属性的 getter?
0赞 MatrixRonny 2/8/2018
是的,它是。它由 NotifyPropertyChanged 调用,也来自 Main Thread。我头晕吗?
0赞 MatrixRonny 2/8/2018
它是在 VS 立即切换到 MainWindow 并再次切换回来之后调用的,所以我想这是由于窗口焦点的变化。我试过有和没有.无论哪种方式都不起作用。IsEnabled="{Binding IsReady}"
0赞 mm8 2/8/2018
VS与此有何关系?如果在未连接调试器的情况下运行它,它是否按预期工作?