如何从另一个运行在另一个类中的线程更新UI

41

我目前正在使用C#编写我的第一个程序,对这门语言非常陌生(之前只用过C)。我已经做了很多研究,但所有的答案都太笼统了,我根本无法让它正常运行。

所以这是我的(非常普遍的)问题:

我有一个WPF应用程序,它从用户填写的几个文本框中获取输入,然后使用这些输入进行大量计算。这些计算需要大约2-3分钟,因此我想更新进度条和文本块,告诉我当前状态如何。 此外,我需要存储用户的UI输入并将它们传递给线程,所以我有一个第三个类,我用它来创建一个对象,并希望将这个对象传递给后台线程。 显然,我会在另一个线程中运行计算,这样UI就不会冻结,但我不知道如何更新UI,因为所有计算方法都属于另一个类。 经过了很多研究,我认为最好的方法是使用dispatchers和TPL而不是backgroundworker,但老实说,我不确定它们是如何工作的,在尝试了其他答案20个小时后,我决定自己提问。

这是我程序的一个非常简单的结构:

public partial class MainWindow : Window
{
    public MainWindow()
    {
        Initialize Component();
    }

    private void startCalc(object sender, RoutedEventArgs e)
    {
        inputValues input = new inputValues();

        calcClass calculations = new calcClass();

        try
        {
             input.pota = Convert.ToDouble(aVar.Text);
             input.potb = Convert.ToDouble(bVar.Text);
             input.potc = Convert.ToDouble(cVar.Text);
             input.potd = Convert.ToDouble(dVar.Text);
             input.potf = Convert.ToDouble(fVar.Text);
             input.potA = Convert.ToDouble(AVar.Text);
             input.potB = Convert.ToDouble(BVar.Text);
             input.initStart = Convert.ToDouble(initStart.Text);
             input.initEnd = Convert.ToDouble(initEnd.Text);
             input.inita = Convert.ToDouble(inita.Text);
             input.initb = Convert.ToDouble(initb.Text);
             input.initc = Convert.ToDouble(initb.Text);
         }
         catch
         {
             MessageBox.Show("Some input values are not of the expected Type.", "Wrong Input", MessageBoxButton.OK, MessageBoxImage.Error);
         }
         Thread calcthread = new Thread(new ParameterizedThreadStart(calculations.testMethod);
         calcthread.Start(input);
    }

public class inputValues
{
    public double pota, potb, potc, potd, potf, potA, potB;
    public double initStart, initEnd, inita, initb, initc;
}

public class calcClass
{
    public void testmethod(inputValues input)
    {
        Thread.CurrentThread.Priority = ThreadPriority.Lowest;
        int i;
        //the input object will be used somehow, but that doesn't matter for my problem
        for (i = 0; i < 1000; i++)
        {
            Thread.Sleep(10);
        }
    }
}

我很感激如果有人能简单解释如何从测试方法内部更新UI。由于我刚接触C#和面向对象编程,过于复杂的答案可能会让我难以理解,但我会尽力学习。
此外,如果有更好的想法(例如使用backgroundworker或其他任何方法),我也很愿意了解。

2
这是一个旧问题,已经有答案了,但足够受欢迎,所以我想为任何想要在线程之间实现非常简单的“进度报告器”的人分享这个。使用Progress<T>类。实现详细说明在Stephan Cleary的一篇很好的文章中:http://blog.stephencleary.com/2012/02/reporting-progress-from-async-tasks.html。 - SeanOB
8个回答

58

首先,您需要使用Dispatcher.Invoke从另一个线程更改UI,并且要从另一个类中进行操作,您可以使用事件。
然后,您可以在主类中注册该事件(们),并将更改分派到UI,在计算类中,当您想要通知UI时,抛出该事件:

class MainWindow : Window
{
    private void startCalc()
    {
        //your code
        CalcClass calc = new CalcClass();
        calc.ProgressUpdate += (s, e) => {
            Dispatcher.Invoke((Action)delegate() { /* update UI */ });
        };
        Thread calcthread = new Thread(new ParameterizedThreadStart(calc.testMethod));
        calcthread.Start(input);
    }
}

class CalcClass
{
    public event EventHandler ProgressUpdate;

    public void testMethod(object input)
    {
        //part 1
        if(ProgressUpdate != null)
            ProgressUpdate(this, new YourEventArgs(status));
        //part 2
    }
}

更新:
看起来这仍然是一个经常访问的问题和答案,我想用最新的.NET 4.5更新这个答案 - 这会有点长,因为我将展示一些不同的可能性:

class MainWindow : Window
{
    Task calcTask = null;

    void buttonStartCalc_Clicked(object sender, EventArgs e) { StartCalc(); } // #1
    async void buttonDoCalc_Clicked(object sender, EventArgs e) // #2
    {
        await CalcAsync(); // #2
    }

    void StartCalc()
    {
        var calc = PrepareCalc();
        calcTask = Task.Run(() => calc.TestMethod(input)); // #3
    }
    Task CalcAsync()
    {
        var calc = PrepareCalc();
        return Task.Run(() => calc.TestMethod(input)); // #4
    }
    CalcClass PrepareCalc()
    {
        //your code
        var calc = new CalcClass();
        calc.ProgressUpdate += (s, e) => Dispatcher.Invoke((Action)delegate()
            {
                // update UI
            });
        return calc;
    }
}

class CalcClass
{
    public event EventHandler<EventArgs<YourStatus>> ProgressUpdate; // #5

    public TestMethod(InputValues input)
    {
        //part 1
        ProgressUpdate.Raise(this, status); // #6 - status is of type YourStatus
        // alternative version to the extension for C# 6+:
        ProgressUpdate?.Invoke(this, new EventArgs<YourStatus>(status));
        //part 2
    }
}

static class EventExtensions
{
    public static void Raise<T>(this EventHandler<EventArgs<T>> theEvent,
                                object sender, T args)
    {
        if (theEvent != null)
            theEvent(sender, new EventArgs<T>(args));
    }
}

@1) 如何开始“同步”计算并在后台运行

@2) 如何以“异步”方式开始并“等待它”:这里的计算在方法返回之前执行和完成,但是由于使用了async/await,UI不会被阻塞(顺便说一句:这样的事件处理程序是async void唯一有效的用法,因为事件处理程序必须返回void - 在所有其他情况下使用async Task

@3) 我们现在不再使用新的Thread,而是使用Task。为了以后能够检查它的(成功)完成,我们将其保存在全局calcTask成员中。在后台,这也启动了一个新的线程并在其中运行操作,但更容易处理,并具有其他一些好处。

@4) 这里我们也开始操作,但是这次我们返回任务,以便“异步事件处理程序”可以“等待它”。我们还可以创建async Task CalcAsync(),然后await Task.Run(() => calc.TestMethod(input)).ConfigureAwait(false); (FYI:ConfigureAwait(false)是为了避免死锁,如果你使用async/await,你应该阅读相关内容,因为在此处解释太多了),这将导致相同的工作流程,但是由于Task.Run是唯一的“可等待操作”并且是最后一个操作,我们可以简单地返回任务并保存一个上下文切换,从而节省一些执行时间。

@5) 这里我现在使用一个“强类型泛型事件”,以便我们可以轻松地传递和接收我们的“状态对象”

@6) 这里我使用下面定义的扩展,它除了易于使用之外,还解决了旧示例中可能出现的竞争条件。在那里,如果事件处理程序在if检查后但在调用时在另一个线程中删除,则可能发生事件获取null的情况。但是这里不会发生,因为扩展获得事件委托的“副本”,在相同情况下,处理程序仍在Raise方法中注册。


我删除了旧评论,因为大部分已经不再适用于更新后的答案... - Christoph Fink
@Yola:是的,Dispatcher 用于在 UI 线程上调用某些内容。每个 WPF 控件都应该有一个 Dispatcher 属性... - Christoph Fink
第二个例子在.NET 4.0中无法完成,因为出现了“找不到类型或命名空间名称'Task'”的错误,但我猜这就是为什么它被标记为4.5.2示例的原因。问题在于我们的很多机器都只升级到了4.0,更不用说4.5.2了。需要一个更好的4.0示例。 - vapcguy
@vapcguy:第一个示例有两个拼写错误(现已修复,但与您的错误不同),现在可以正常工作(请参见https://dotnetfiddle.net/aHrK5G),是的,如答案中所述,第二个示例需要.NET 4.5。 - Christoph Fink
1
@StevenC.Britton “状态对象”可以是您定义的任何类型。这只是一种将数据发送到事件接收端的方式。 - Christoph Fink
显示剩余17条评论

31
我会给你带来一个曲线球。如果我说过一百次,那么就算是调用 InvokeBeginInvoke 等封送操作也不总是更新 UI 的最佳方法。
在这种情况下,通常更好的方法是让工作线程将其进度信息发布到共享数据结构中,然后 UI 线程以固定间隔轮询该结构。这有几个优点:
  • 它打破了 Invoke 强加的 UI 和工作线程之间的紧密耦合。
  • UI 线程可以控制何时更新 UI 控件...当你真正考虑它时,这应该是正确的方式。
  • 不存在超出 UI 消息队列的风险,如从工作线程使用 BeginInvoke 所发生的那样。
  • 工作线程无需等待 UI 线程的响应,如使用 Invoke 时所发生的那样。
  • 您可以在 UI 和工作线程上获得更高的吞吐量。
  • InvokeBeginInvoke 是昂贵的操作。
因此,在你的 calcClass 中,创建一个数据结构,用于保存进度信息。
public class calcClass
{
  private double percentComplete = 0;

  public double PercentComplete
  {
    get 
    { 
      // Do a thread-safe read here.
      return Interlocked.CompareExchange(ref percentComplete, 0, 0);
    }
  }

  public testMethod(object input)
  {
    int count = 1000;
    for (int i = 0; i < count; i++)
    {
      Thread.Sleep(10);
      double newvalue = ((double)i + 1) / (double)count;
      Interlocked.Exchange(ref percentComplete, newvalue);
    }
  }
}

然后在你的MainWindow类中使用DispatcherTimer定期轮询进度信息。配置DispatcherTimer在最适合你情况的时间间隔上引发Tick事件。

public partial class MainWindow : Window
{
  public void YourDispatcherTimer_Tick(object sender, EventArgs args)
  {
    YourProgressBar.Value = calculation.PercentComplete;
  }
}

1
你好,Brian。我认为你的回答简单而优雅。我想知道,在你看来,dispatchertimer tick 的触发速度有多快?根据你的经验,什么样的更新频率是可以接受的呢?谢谢。 - DoubleDunk
2
@DoubleDunk:可能在500毫秒到2000毫秒之间。如果太快,用户可能无法察觉差异。如果太慢,用户会想知道为什么进度条没有增加。决定一个适合你的应用程序和用户的最佳平衡点。 - Brian Gideon
@BrianGideon 更新速率应该取决于计算大约需要多长时间。如果每秒更新两次,而整个计算只需要大约3秒钟,那么进度条仍然会有很大的跳跃。另一方面,如果计算需要20分钟(就像我在一个案例中遇到的情况),那么500毫秒的更新频率远远太频繁了。不过,这是一个好答案,也是Invoke的一个很好的替代方案! - philkark

9
您说得对,您应该使用Dispatcher来更新UI线程上的控件,并且长时间运行的进程不应在UI线程上运行。即使您在UI线程上异步运行长时间运行的进程,它仍然可能导致性能问题。
需要注意的是Dispatcher.CurrentDispatcher将返回当前线程的分派程序,而不一定是UI线程。如果可以使用,则可以使用Application.Current.Dispatcher获取对UI线程的分派程序的引用,但如果不能使用,则必须将UI分派程序传递给后台线程。
通常,我使用任务并行库(Task Parallel Library)进行线程操作,而不是使用BackgroundWorker,因为我发现它更容易使用。
例如:
Task.Factory.StartNew(() => 
    SomeObject.RunLongProcess(someDataObject));

在哪里

void RunLongProcess(SomeViewModel someDataObject)
{
    for (int i = 0; i <= 1000; i++)
    {
        Thread.Sleep(10);

        // Update every 10 executions
        if (i % 10 == 0)
        {
            // Send message to UI thread
            Application.Current.Dispatcher.BeginInvoke(
                DispatcherPriority.Normal,
                (Action)(() => someDataObject.ProgressValue = (i / 1000)));
        }
    }
}

简单明了,正是我需要的测试工具。 - K0D4

4

与UI交互的所有内容都必须在UI线程中调用(除非它是冻结的对象)。为了做到这一点,您可以使用调度程序(dispatcher)。

var disp = /* Get the UI dispatcher, each WPF object has a dispatcher which you can query*/
disp.BeginInvoke(DispatcherPriority.Normal,
        (Action)(() => /*Do your UI Stuff here*/));

我在这里使用了BeginInvoke,通常情况下,后台工作线程不需要等待UI更新。如果您想要等待,则可以使用Invoke。但是,您应该小心不要过于频繁地调用BeginInvoke,否则可能会出现严重问题。
顺便说一下,BackgroundWorker类有助于处理此类任务。它允许报告更改,例如百分比,并自动从后台线程将其调度到UI线程。对于大多数线程<>更新UI任务,BackgroundWorker是一个非常好的工具。

1
如果这是一个长时间的计算,我建议使用后台工作者。它具有进度支持和取消支持。
http://msdn.microsoft.com/en-us/library/cc221403(v=VS.95).aspx

这里我有一个绑定内容的文本框。

    private void backgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
    {
        Debug.Write("backgroundWorker_RunWorkerCompleted");
        if (e.Cancelled)
        {
            contents = "Cancelled get contents.";
            NotifyPropertyChanged("Contents");
        }
        else if (e.Error != null)
        {
            contents = "An Error Occured in get contents";
            NotifyPropertyChanged("Contents");
        }
        else
        {
            contents = (string)e.Result;
            if (contentTabSelectd) NotifyPropertyChanged("Contents");
        }
    }

我会点赞这篇文章,因为它让我找到了正确的方向,并且找到了我需要的信息所在的MSDN页面,但是这远远不是一个完整的示例。你需要展示更多,比如在哪里实例化worker,给它哪些属性和事件处理程序,展示DoWork函数正在做什么以及如何报告其进度。这只展示了当你取消worker时该怎么做,并显示了e.Result,这可能是你有一个backgroundWorker_DoWork(object sender, DoWorkEventArgs e) { ... }函数提供给你的,但并没有展示DoWork实际上在做什么以及如何将其作为Result返回。 - vapcguy

1

更新UI,您需要返回到主线程(也称为UI线程)。任何其他尝试更新UI的线程都会导致大量异常

因此,由于您在WPF中,可以使用Dispatcher,更具体地说是在此dispatcher上执行beginInvoke。这将允许您在UI线程中执行所需操作(通常是更新UI)。

您还可以通过维护对控件/表单的引用来“注册”UI,以便您可以使用其dispatcher


1
对于WPF新手来说,语法示例会很有帮助。 - vapcguy

0
感觉有必要添加这个更好的答案,因为除了BackgroundWorker以外,似乎没有其他帮助我解决问题的东西,目前处理这方面问题的答案非常不完整。以下是更新名为MainWindow的XAML页面的方法,该页面具有如下图像标记:

<Image Name="imgNtwkInd" Source="Images/network_on.jpg" Width="50" />

使用 BackgroundWorker 进程来显示您是否连接到网络:
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;

public partial class MainWindow : Window
{
    private BackgroundWorker bw = new BackgroundWorker();

    public MainWindow()
    {
        InitializeComponent();

        // Set up background worker to allow progress reporting and cancellation
        bw.WorkerReportsProgress = true;
        bw.WorkerSupportsCancellation = true;

        // This is your main work process that records progress
        bw.DoWork += new DoWorkEventHandler(SomeClass.DoWork);

        // This will update your page based on that progress
        bw.ProgressChanged += new ProgressChangedEventHandler(bw_ProgressChanged);

        // This starts your background worker and "DoWork()"
        bw.RunWorkerAsync();

        // When this page closes, this will run and cancel your background worker
        this.Closing += new CancelEventHandler(Page_Unload);
    }

    private void bw_ProgressChanged(object sender, ProgressChangedEventArgs e)
    {
        BitmapImage bImg = new BitmapImage();
        bool connected = false;
        string response = e.ProgressPercentage.ToString(); // will either be 1 or 0 for true/false -- this is the result recorded in DoWork()

        if (response == "1")
            connected = true;

        // Do something with the result we got
        if (!connected)
        {
            bImg.BeginInit();
            bImg.UriSource = new Uri("Images/network_off.jpg", UriKind.Relative);
            bImg.EndInit();
            imgNtwkInd.Source = bImg;
        }
        else
        {
            bImg.BeginInit();
            bImg.UriSource = new Uri("Images/network_on.jpg", UriKind.Relative);
            bImg.EndInit();
            imgNtwkInd.Source = bImg;
        }
    }

    private void Page_Unload(object sender, CancelEventArgs e)
    {
        bw.CancelAsync();  // stops the background worker when unloading the page
    }
}


public class SomeClass
{
    public static bool connected = false;

    public void DoWork(object sender, DoWorkEventArgs e)
    {
        BackgroundWorker bw = sender as BackgroundWorker;

        int i = 0;
        do 
        {
            connected = CheckConn();  // do some task and get the result

            if (bw.CancellationPending == true)
            {
                e.Cancel = true;
                break;
            }
            else
            {
                Thread.Sleep(1000);
                // Record your result here
                if (connected)
                    bw.ReportProgress(1);
                else
                    bw.ReportProgress(0);
            }
        }
        while (i == 0);
    }

    private static bool CheckConn()
    {
        bool conn = false;
        Ping png = new Ping();
        string host = "SomeComputerNameHere";

        try
        {
            PingReply pngReply = png.Send(host);
            if (pngReply.Status == IPStatus.Success)
                conn = true;
        }
        catch (PingException ex)
        {
            // write exception to log
        }
        return conn;
    }
}

更多信息请参见:https://msdn.microsoft.com/zh-cn/library/cc221403(v=VS.95).aspx


0

感谢上帝,微软在WPF中解决了这个问题 :)

每个控件(如进度条、按钮、表单等)都有一个Dispatcher。您可以给Dispatcher传递需要执行的Action,它会自动在正确的线程上调用它(Action类似于函数委托)。

您可以在此处找到示例。

当然,您需要使其他类可以访问该控件,例如通过将其设置为public并将对Window的引用传递给其他类,或者仅传递对进度条的引用。


4
请注意,当您链接到示例时,不要将相关代码复制/粘贴到此处,如果该页面下线(例如今天早上)或删除其内容,则此答案将缺少某人所需的实质性内容。最好包含该内容。 - vapcguy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接