今天有人問道如何在 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(); } }
完整的代碼可以在這里下載:輕輕地點一下就可以下載了!