在 WinForm 中使用进度条展示长时间任务的执行进度
今天有人问道如何在 WinForm 程序中,使用进度条显示长时间任务的执行进度。
这个问题是一个开发中很常见的问题,正好也整理和总结一下。
这个问题我们从两个部分来看,第一,长时间执行的任务如何暴露出其执行进度,第二,WinForm 窗体如何显示执行进度。
第一部分. 对象如何提供其处理进度
先看第一个问题,如果希望一个长时间执行的任务能够展示其执行进度,显然它必须提供当前执行的进度值。但是,一般来说,一个任务通常是一个方法,执行完也就完了,怎么能在一个方法的执行过程中,向外界提供其执行的进度呢?
答案就是设计模式中的观察者模式。我们可以将任务的执行者看作观察者模式中的主题,而窗体就是观察者了。在方法的执行过程中,主题不断改变其状态,而观察者通过观察主题的状态来显示其执行进度。
在 .NET 中,典型的观察者模式是通过事件来实现的。事件参数则用来提供主题的状态,System.EventArgs 为事件参数提供了基类,我们实现的事件参数应当从这个基类派生,提供自定义的额外属性。
首先定义进度状态的事件参数类,其属性 Value 表示当前进度的百分比。
// 定义事件的参数类 public class ValueEventArgs : EventArgs { public int Value { set; get;} }
然后,定义事件所使用的委托。这个委托使用事件参数对象作为方法的参数。
// 定义事件使用的委托 public delegate void ValueChangedEventHandler( object sender, ValueEventArgs e);
最后,方法不能单独存在,我们定义业务对象,包含需要长时间执行的方法。
class LongTimeWork { // 定义一个事件来提示界面工作的进度 public event ValueChangedEventHandler ValueChanged; // 触发事件的方法 protected void OnValueChanged( ValueEventArgs e) { if( this.ValueChanged != null) { this.ValueChanged( this, e); } } public void LongTimeMethod() { for (int i = 0; i < 100; i++) { // 进行工作 System.Threading.Thread.Sleep(1000); // 触发事件 ValueEventArgs e = new ValueEventArgs() { Value = i+1}; this.OnValueChanged(e); } } }
注意,在这个类中,我们使用了典型的事件模式,OnValueChanged 在类中用来触发事件,将当前的进度状态提供给观察者。在 LongTimeMethod 方法中,通过调用这个方法将当前的进度提供给窗体。这个方法中通过使用 Sleep,共需花费 100 秒以上的时间才能执行完毕。
第二部分 窗体与线程问题
在项目中创建一个窗体,放置一个进度条和一个按钮。
双击按钮,就可以开始界面编程了。
在按钮的处理事件中,写下如下的代码,通过事件来获取主题的通知,在 ValueChanged 事件的处理方法中更新进度条。
private void button1_Click(object sender, EventArgs e) { // 禁用按钮 this.button1.Enabled = false; // 实例化业务对象 LongTime.Business.LongTimeWork workder = new Business.LongTimeWork(); workder.ValueChanged += new Business.ValueChangedEventHandler(workder_ValueChanged); workder.LongTimeMethod(); }
下面是 ValueChanged 事件的处理方法。
// 进度发生变化之后的回调方法 private void workder_ValueChanged(object sender, Business.ValueEventArgs e) { this.progressBar1.Value = e.Value; }
点击按钮,看起来执行正常呀,在窗体上点一下鼠标,或者在标题栏拖动一下窗口,马上就会看到界面失去了反应。
为什么会这样的?我们使用的就是典型的事件处理模式呀?
问题出在界面的线程问题上,整个界面的操作运行在一个线程上,在 Win32 时代被称为消息循环,你可以将系统对窗体的处理看成一个无限的循环,不断地获取消息,处理消息。但是,不要忘了,在一个循环中,如果一个步骤卡在了那里,其它的步骤就不会有机会执行了。
对于我们这个长时间执行的方法来说,在开始调用这句代码的时候
workder.LongTimeMethod();
就已经阻塞了这个窗体的循环,使得 Windows 没有机会来处理用户的操作,不能处理按钮,不能处理菜单,也不能拖动,通常我们成为冻结了。
显然,我们不希望这样的结果。
解决的办法就是将这个长时间执行的方法在另外一个线程上执行,而不要占用我们窗体界面处理的宝贵时间。
在 .NET 实现异步的基本方式就是委托,我们可以将这个方法表示为一个委托,然后通过委托的 BeginXXX 来实现异步调用。这样按钮的点击事件处理就成为了下面的样子。
private void button1_Click(object sender, EventArgs e) { // 禁用按钮 this.button1.Enabled = false; // 实例化业务对象 LongTime.Business.LongTimeWork workder = new Business.LongTimeWork(); workder.ValueChanged += new Business.ValueChangedEventHandler(workder_ValueChanged); // 使用异步方式调用长时间的方法 Action handler = new Action(workder.LongTimeMethod); handler.BeginInvoke( new AsyncCallback(this.AsyncCallback), handler ); }
这里使用了系统定义的 Action 委托。由于使用 BeginInvoke 必须配合 EndInvoke , 而 EndInvoke 需要借助于开始的委托,所以在第二个参数中,将委托对象传递出去。
这里的 AsyncCallback 是异步处理完成之后的回调方法,如下所示
// 结束异步操作 private void AsyncCallback(IAsyncResult ar) { // 标准的处理步骤 Action handler = ar.AsyncState as Action; handler.EndInvoke(ar); MessageBox.Show("工作完成!"); this.button1.Enabled = true; }
再次执行程序,看起来还不错。
不过,别高兴的太早,没准你现在就已经看到了这个异常。如果还没有看到,就在调试模式下看一看。
第三部分 回到 UI 线程
现在,我们的方法正在一步一步的进行,但是需要注意的是它工作在一个线程上,而 UI 工作在自己的线程上,这两个线程可能是同一个线程,更可能不是同一个线程。
在 Windows 中规定,对于窗体的处理,例如修改窗体控件的属性,必须在窗体的线程上才允许进行,不仅 Windows 界面,几乎所有的图形界面皆是如此,这关系到效率问题。
当我们在另外一个线程上修改窗体控件的属性的时候,异常被抛了出来。
难道还要回到 UI 线程上来执行我们长时间的方法吗?当然不是,Control 基类就提供了两个方法 Invoke 和 BeginInvoke ,允许我们以委托的形式将需要进行的处理排到 UI 的线程处理列表中,等待 UI 线程在适当的时候来执行。
使用什么委托呢?是委托都可以,Windows Forms 中提供了一个专用的委托,可以考虑使用一下。
public delegate void MethodInvoker()
其实跟 Action 一样,不过看起来专业一点,我们就使用它了。
不过,也有可能我们的线程与 UI 的线程正好是同一个线程,那我们就没有必要这么麻烦了,Control 还定义了一个属性 InvokeRequired 用来检查是否在同一个线程之上,不是则返回真,需要使用委托进行,否则返回假,可以直接处理控件。
[BrowsableAttribute(false)] public bool InvokeRequired { get; }
这样,我们的方法,就可以修改为下面的形式
// 进度发生变化之后的回调方法 private void workder_ValueChanged(object sender, Business.ValueEventArgs e) { System.Windows.Forms.MethodInvoker invoker = ()=>this.progressBar1.Value = e.Value; if (this.progressBar1.InvokeRequired) { this.progressBar1.Invoke(invoker); } else { invoker(); } }
同样,结束异步的回调函数中,需要将按钮的状态重新启用,也如法炮制。
// 结束异步操作 private void AsyncCallback(IAsyncResult ar) { // 标准的处理步骤 Action handler = ar.AsyncState as Action; handler.EndInvoke(ar); MessageBox.Show("工作完成!"); // 重新启用按钮 System.Windows.Forms.MethodInvoker invoker = ()=>this.button1.Enabled = true; if (this.InvokeRequired) { this.Invoke(invoker); } else { invoker(); } }