C# 使用 task 和 yield 通知 UI 一个 运行 进程

C# Using task and yield to keep UI informed of a running process

这样写代码是不是不好的做法。我想要完成的是用户可以按下控件上的按钮。该按钮启动某种分析过程,并且对于完成的每个项目,它都会向用户显示结果。

private IEnumerable<int> AnalyzeItems() {
  for(int i = 0; i < 1000; i++) {
    Thread.Sleep(500);
    yield return i;
  }
}


private void PerformTask_Click(object sender, EventArgs e) {
  Task.Run(() => {
    foreach (var item in AnalyzeItems()) {
      ResultLog.Invoke((Action)delegate() { ResultLog.Text += item.ToString(); });
    }
  });
}

为什么不使用Backgroundworker

首先将 backgroundworker 属性设置为:

  • WorkerReportsProgress = true
  • WorkerSupportsCancellation = true

这是代码:

public partial class Form1 : Form {
    public Form1() {
        InitializeComponent();
    }

    private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e) {
        for (int i = 0; i < 1000; i++) {
            Thread.Sleep(500);
            if (backgroundWorker1.CancellationPending) {
                e.Cancel = true;
                break;
            }
            backgroundWorker1.ReportProgress(i / 10, "step " + i);
        }
    }

    private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e) {
        label1.Text = e.UserState.ToString();
        progressBar1.Value = e.ProgressPercentage;
    }

    private void button1_Click(object sender, EventArgs e) {
        cancelButton.Focus();
        button1.Enabled = false;
        backgroundWorker1.RunWorkerAsync();
    }
    private void cancelButton_Click(object sender, EventArgs e) {
        backgroundWorker1.CancelAsync();
    }

    private void backgroundWorker1_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) {
        button1.Enabled = true;
        if (e.Error != null) {
            MessageBox.Show(e.Error.Message, "Unexpected error");
        }
        if (e.Cancelled) {
            MessageBox.Show("Process stopped by the user", "Cancelled");
        }
        label1.Text = "Press start";
        progressBar1.Value = progressBar1.Minimum;
    }

}

你的方法是不好的做法吗?这取决于。

如果您不希望 Task.Run 中的代码抛出任何异常并且您想继续做其他事情,那么您的代码是可以的。但是,如果您想捕获任何可能的异常并等待进程完成而不冻结 UI,那么您可能需要考虑使用 async/await.

private async void PerformTask_Click(object sender, EventArgs e) {
  try
  {
    await Task.Run(() => {
        foreach (var item in AnalyzeItems()) {
            ResultLog.Invoke((Action)delegate() { ResultLog.Text += item.ToString(); });
        }
    });
  }
  catch(Exception ex)
  {
    // handle...
  }
}

替代方法是使用 IProgress<T>。这允许轻松分离长时间 运行 工作和更新 UI。请注意,您不应过于频繁调用此方法,因为

  1. 这会给 UI 线程带来太多工作,导致 UI 冻结。
  2. 如果您将任何值类型传递给 IProgress<T>.Report 方法,它就会被复制。如果您过于频繁地调用它,您将冒 运行 垃圾收集器的风险,经常导致更大的冻结。

所有这些意味着您应该只将 IProgress 用于真正长时间的 运行 工作。

既然我们已经解决了所有问题,下面是一个示例,说明如何将已分析项目的进度通知用户:

private double _currentProgress;
public double CurrentProgress {
    get => _currentProgress;
    set
    {
        _currentProgress = value;
        NotifyPropertyChanged();
    }
}

private async void PerformTask_Click(object sender, EventArgs e)
{
    var progress = new Progress<double>();
    progress.ProgressChanged += (sender, p) => CurrentProgress = p;

    await Task.Run(() => AnalyzeItems(Enumerable.Range(0, 5000).ToList(), progress));
}

private void AnalyzeItems(List<int> items, IProgress<double> progress)
{
    for (int itemIndex = 0; itemIndex < items.Count; itemIndex++)
    {
        // Very long running CPU work.
        // ...
        progress.Report((double)itemIndex * 100 / items.Count);
    }
}

如果 AnalyzeItems 单个项目花费的时间少于 100 毫秒,那么您不想在每个完成的项目之后报告(参见上面的原因)。您可以像这样决定更新状态的频率:

private void AnalyzeItems(List<int> items, IProgress<double> progress)
{
    var lastReport = DateTime.UtcNow;
    for (int itemIndex = 0; itemIndex < items.Count; itemIndex++)
    {
        // Very long running work.
        Thread.Sleep(10);

        // Tell the user what the current status is every 500 milliseconds.
        if (DateTime.UtcNow - lastReport > TimeSpan.FromMilliseconds(500))
        {
            progress.Report((double)itemIndex * 100 / items.Count);
            lastReport = DateTime.UtcNow;
        }
    }
}

如果您确实有很多非常快的迭代,您可能需要考虑将 DateTime.Now 更改为其他内容。