1. 程式人生 > >在 WinForm 中使用進度條展示長時間任務的執行進度

在 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();
    }

}
複製程式碼