【问题标题】:Disable WPF buttons during longer running process, the MVVM way在较长的运行过程中禁用 WPF 按钮,MVVM 方式
【发布时间】:2013-01-03 17:09:28
【问题描述】:

我有一个 WPF/MVVM 应用程序,它由一个窗口和几个按钮组成。
每个按钮都会触发对外部设备(USB missile launcher)的调用,这需要几秒钟。

设备运行时,GUI 被冻结。
没关系,因为应用程序的唯一目的是调用 USB 设备,而您可以'在设备移动时无论如何都不要做任何其他事情!)

唯一有点难看的是,冻结的 GUI 在设备移动时仍然接受额外的点击。
当设备仍然移动并且我再次单击同一个按钮时,设备会在第一次“运行”完成后立即再次开始移动。

所以我想在单击一个按钮后立即禁用 GUI 中的所有按钮,并在按钮的命令完成运行后再次启用它们。

我找到了一个看起来符合 MVVM 的解决方案。
(至少对我来说......请注意,我仍然是 WPF/MVVM 初学者!)

问题是,当我调用与 USB 设备通信的外部库时,此解决方案不起作用(例如:按钮未禁用)。
但禁用 GUI 的实际代码是正确的,因为当我将外部库调用替换为 MessageBox.Show() 时,它确实工作。

我构建了一个重现问题的最小工作示例 (complete demo project here):

这是视图:

<Window x:Class="WpfDatabindingQuestion.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <StackPanel>
            <Button Content="MessageBox" Command="{Binding MessageCommand}" Height="50"></Button>
            <Button Content="Simulate external device" Command="{Binding DeviceCommand}" Height="50" Margin="0 10"></Button>
        </StackPanel>
    </Grid>
</Window>

...这是 ViewModel (使用 RelayCommand from Josh Smith's MSDN article

using System.Threading;
using System.Windows;
using System.Windows.Input;

namespace WpfDatabindingQuestion
{
    public class MainWindowViewModel
    {
        private bool disableGui;

        public ICommand MessageCommand
        {
            get
            {
                return new RelayCommand(this.ShowMessage, this.IsGuiEnabled);
            }
        }

        public ICommand DeviceCommand
        {
            get
            {
                return new RelayCommand(this.CallExternalDevice, this.IsGuiEnabled);
            }
        }

        // here, the buttons are disabled while the MessageBox is open
        private void ShowMessage(object obj)
        {
            this.disableGui = true;
            MessageBox.Show("test");
            this.disableGui = false;
        }

        // here, the buttons are NOT disabled while the app pauses
        private void CallExternalDevice(object obj)
        {
            this.disableGui = true;
            // simulate call to external device (USB missile launcher),
            // which takes a few seconds and pauses the app
            Thread.Sleep(3000);
            this.disableGui = false;
        }

        private bool IsGuiEnabled(object obj)
        {
            return !this.disableGui;
        }
    }
}

我怀疑打开 MessageBox 会在后台触发一些事情,而当我调用外部库时不会发生。
但我无法找到解决方案。

我也试过了:

  • 实现INotifyPropertyChanged(并将this.disableGui 设为属性,并在更改时调用OnPropertyChanged
  • 到处打电话给CommandManager.InvalidateRequerySuggested()
    (我在 SO 上对类似问题的几个答案中发现了这一点)

有什么建议吗?

【问题讨论】:

    标签: c# wpf data-binding mvvm


    【解决方案1】:

    试试这个:

    //Declare a new BackgroundWorker
    BackgroundWorker worker = new BackgroundWorker();
    worker.DoWork += (o, ea) =>
    {
        try
        {
            // Call your device
    
            // If ou need to interact with the main thread
           Application.Current.Dispatcher.Invoke(new Action(() => //your action));
        }
        catch (Exception exp)
        {
        }
    };
    
    //This event is raise on DoWork complete
    worker.RunWorkerCompleted += (o, ea) =>
    {
        //Work to do after the long process
        disableGui = false;
    };
    
    disableGui = true;
    //Launch you worker
    worker.RunWorkerAsync();
    

    【讨论】:

    • 这在我在worker.RunWorkerCompleted 末尾调用CommandManager.InvalidateRequerySuggested(); 时有效(没有调用,按钮保持禁用状态,直到我单击GUI)。这是有意的,还是应该在没有额外调用的情况下工作?
    • 并且通过使用 disableGui 作为实现 OnPropertyChanged 的​​属性?
    • 不,在这种情况下,您需要调用 CommandManager 以“通知”用户界面某些内容可能已更改,并且应该重新检查。
    • 重新评估 CanExecute ?
    【解决方案2】:

    好的,CanExecute 方法将不起作用,因为单击会立即让您进入长时间运行的任务。
    所以我会这样做:

    1. 让您的视图模型实现 INotifyPropertyChanged

    2. 添加一个类似这样的属性:

      public bool IsBusy
      {
          get
          {
              return this.isBusy;
          }
          set
          { 
              this.isBusy = value;
              RaisePropertyChanged("IsBusy");
          }
      }
      
    3. 以这种方式将您的按钮绑定到此属性:

      <Button IsEnabled="{Binding IsBusy}" .. />
      
    4. 在您的 ShowMessage/CallExternal 设备方法中添加该行

      IsBusy = true;
      

    应该做的伎俩

    【讨论】:

    • 还建议绑定 IsEnabled 属性并从视图模型中设置它。
    • 我已经在我的真实项目中尝试过,但也没有成功。你的RaisePropertyChanged 来自哪里?这是来自 .NET 框架的东西还是你自己写的?在我的真实项目中,我使用了从某处复制的this INotifyPropertyChanged implementation。也许我使用了错误的INotifyPropertyChanged 实现?
    • Catel 在其 ViewModelBase 中提供RaisePropertyChanged。如果你想使用 MVVM,我可以推荐 Catel。
    【解决方案3】:

    我觉得这样更优雅一点:

    XAML:

    <Button IsEnabled="{Binding IsGuiEnabled}" Content="Simulate external device" Command="{Binding DeviceCommand}" Height="50" Margin="0 10"></Button>
    

    C#(使用异步和等待):

    public class MainWindowViewModel : INotifyPropertyChanged
    {
        private bool isGuiEnabled;
    
        /// <summary>
        /// True to enable buttons, false to disable buttons.
        /// </summary>
        public bool IsGuiEnabled 
        {
            get
            {
                return isGuiEnabled;
            }
            set
            {
                isGuiEnabled = value;
                OnPropertyChanged("IsGuiEnabled");
            }
        }
    
        public ICommand DeviceCommand
        {
            get
            {
                return new RelayCommand(this.CallExternalDevice, this.IsGuiEnabled);
            }
        }
    
        private async void CallExternalDevice(object obj)
        {
            IsGuiEnabled = false;
            try
            {
                await Task.Factory.StartNew(() => Thread.Sleep(3000));
            }
            finally
            {
                IsGuiEnabled = true; 
            }
        }
    }
    

    【讨论】:

      【解决方案4】:

      因为您在主线程上运行CallExternalDevice(),所以在该工作完成之前,主线程没有时间更新任何 UI,这就是按钮保持启用状态的原因。您可以在单独的线程中开始长时间运行的操作,您应该会看到按钮按预期被禁用:

      private void CallExternalDevice(object obj)
      {
          this.disableGui = true;
      
          ThreadStart work = () =>
          {
              // simulate call to external device (USB missile launcher),
              // which takes a few seconds and pauses the app
              Thread.Sleep(3000);
      
              this.disableGui = false;
              Application.Current.Dispatcher.BeginInvoke(new Action(() => CommandManager.InvalidateRequerySuggested()));
          };
          new Thread(work).Start();
      }
      

      【讨论】:

      • 是的,这行得通。一个警告:在单独的线程完成后,按钮保持禁用状态。显然,当线程完成时,GUI 不会自动更新。当我单击 GUI 上的某个位置时,按钮会再次启用。有什么遗漏吗?
      • @Christian:ICommand 有责任在其 CanExecute 条件发生变化时通知视图(在您的情况下 - IsGuiEnabled 方法)。在这种情况下,您要做的是让 DeviceCommand 引发其CanExecuteChanged 事件。因为您使用 RelayCommand,而这又将所有这些东西留给了 CommandManager,我相信如果您在设置 disableGui = false 后调用 CommandManager.InvalidateRequerySuggested(),它会起作用。
      • 不,在disableGui = false 之后调用CommandManager.InvalidateRequerySuggested() 不会改变任何事情。
      • @Christian: 对.. InvalidateRequerySuggested() 需要在主 UI 线程上调用才能让 View 跟上。我已经更新了上面的示例。不过,Lyderic 的解决方案可能更简洁,因为 BackgroundWorker.RunWorkerCompleted 事件似乎将在主线程上运行。
      猜你喜欢
      • 2019-06-16
      • 2011-03-29
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2019-10-17
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多