winform中更新UI控件的方案介紹

這是一個古老的話題。。。直入主題吧!

對winfrom的控件來說,多線程操作非常容易導致複雜且嚴重的bug,比如不同線程可能會因場景需要強制設置控件爲不同的狀態,進而引起併發、加鎖、死鎖、阻塞等問題。爲了避免和解決上述可能出現的問題,微軟要求必須是控件的創建線程才能操作控件資源,其它線程不允許直接操作控件。但是現代應用又不是單線程應用,無論如何肯定會存在其它線程需要更新控件的需求,於是微軟兩種方案來解決相關問題:InvokeRequired方案和BackgroundWorker方案。

 

演示程序效果圖和源碼

 

查看代碼
using System.ComponentModel;
using System.Diagnostics;
using System.Timers;
using Tccc.DesktopApp.WinForms1.BLL;

namespace Tccc.DesktopApp.WinForms1
{
    public partial class UIUpdateDemoForm : Form
    {
        /// <summary>
        /// 
        /// </summary>
        public UIUpdateDemoForm()
        {
            InitializeComponent();
            backgroundWorker1.WorkerReportsProgress = true;
            backgroundWorker1.WorkerSupportsCancellation = true;
        }
        #region 演示InvokeRequired
        /// <summary>
        /// 
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void invokeRequiredBtn_Click(object sender, EventArgs e)
        {
            Debug.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff") + "invokeRequiredBtn_Click 線程ID=" + Thread.CurrentThread.ManagedThreadId);

            new Thread(() =>
            {
                Debug.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff") + "BeginWorking_Invoke 線程ID=" + Thread.CurrentThread.ManagedThreadId);
                BLLWorker.BeginWorking_Invoke(this, "some input param");
            }).Start();
             
        }

        /// <summary>
        /// 
        /// </summary>
        public void UpdatingProgress(int progress)
        {
            if (this.InvokeRequired)
            {
                Debug.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff") + "InvokeRequired=true 線程ID=" + Thread.CurrentThread.ManagedThreadId);
                this.Invoke(new Action(() =>
                {
                    Debug.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff") + "Sleep2秒 線程ID=" + Thread.CurrentThread.ManagedThreadId);
                    Thread.Sleep(2000);//模擬UI操作慢
                    UpdatingProgress(progress);
                }));
                Debug.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff") + "after Invoke 線程ID=" + Thread.CurrentThread.ManagedThreadId);
            }
            else
            {
                Debug.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff") + "InvokeRequired=false 線程ID=" + Thread.CurrentThread.ManagedThreadId);
                richTextBox1.Text += DateTime.Now.ToString("HH:mm:ss") + ":執行進度" + progress + "%" + Environment.NewLine;
            }
        }
        #endregion

        #region 演示BackgroundWorker

        /// <summary>
        /// 
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void bgWorkerBtn_Click(object sender, EventArgs e)
        {
            new Thread(() =>
            {
                //Control.CheckForIllegalCrossThreadCalls = true;
                //richTextBox1.Text = "可以了?";
            }).Start();

            Debug.WriteLine("bgWorkerBtn_Click 線程ID=" + Thread.CurrentThread.ManagedThreadId);
            if (!backgroundWorker1.IsBusy)
            {
                richTextBox1.Text = String.Empty;
                backgroundWorker1.RunWorkerAsync("hello world");//
            }
        }
        private void bgWorkerCancelBtn_Click(object sender, EventArgs e)
        {
            Debug.WriteLine("bgWorkerCancelBtn_Click 線程ID=" + Thread.CurrentThread.ManagedThreadId);
            if (backgroundWorker1.IsBusy)
            {
                backgroundWorker1.CancelAsync();//
            }
        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
        {
            Debug.WriteLine("backgroundWorker1_DoWork 線程ID=" + Thread.CurrentThread.ManagedThreadId);

            BLLWorker.BeginWorking(sender, e);//控件遍歷傳遞到業務處理程序中

        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e)
        {
            Debug.WriteLine("backgroundWorker1_ProgressChanged 線程ID=" + Thread.CurrentThread.ManagedThreadId);
            richTextBox1.Text += Environment.NewLine + DateTime.Now.ToString("HH:mm:ss.fff") + ":執行進度" + e.ProgressPercentage + "%";
        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void backgroundWorker1_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
        {
            Debug.WriteLine("backgroundWorker1_RunWorkerCompleted 線程ID=" + Thread.CurrentThread.ManagedThreadId);
            if (e.Cancelled)
            {
                richTextBox1.Text += Environment.NewLine + DateTime.Now.ToString("HH:mm:ss.fff") + ":已取消";
            }
            else if (e.Error != null)
            {
                richTextBox1.Text += Environment.NewLine + DateTime.Now.ToString("HH:mm:ss.fff") + ":發生錯誤:" + e.Error.Message;
            }
            else
            {
                richTextBox1.Text += Environment.NewLine + DateTime.Now.ToString("HH:mm:ss.fff") + ":執行完成";
                richTextBox1.Text += Environment.NewLine + DateTime.Now.ToString("HH:mm:ss.fff") + ":執行結果=" + e.Result;
            }
        }
        #endregion

    }
        public class BLLWorker
    {
        /// <summary>
        /// 
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        public static void BeginWorking_Invoke(UIUpdateDemoForm form, string inputData)
        {
            int counter = 0;
            int max = 5;
            while (counter < max)
            {
                System.Threading.Thread.Sleep(200);
                counter++;
                form.UpdatingProgress(counter * 20);
            }
        }
        /// <summary>
        /// 模擬耗時操作(下載、批量操作等)
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        public static void BeginWorking(object sender, DoWorkEventArgs e)
        {
            BackgroundWorker worker = sender as BackgroundWorker;
            Debug.WriteLine("inputArgument=" + e.Argument as string);

            for (int i = 1; i <= 10; i++)
            {
                if (worker.CancellationPending == true)//檢測是否被取消
                {
                    e.Cancel = true;
                    break;
                }
                else
                {
                    // Perform a time consuming operation and report progress.
                    System.Threading.Thread.Sleep(200);
                    worker.ReportProgress(i * 10);
                }
            }
            e.Result = "result xxxx";

        }
    }
}

 

InvokeRequired方案

上述代碼中,this.InvokeRequired屬性就是用來判斷當前線程和this控件的創建線程是否一致。

  • 當其值=false時,代表當前執行線程就是控件的創建線程,可以直接操作控件。
  • 當其值=true時,代表當前線程不是控件的創建線程,需要調用Invoke方法來實現操作控件。

問題來了,調用Invoke()怎麼就能實現操作控件了呢?我們在演示程序中的UpdatingProgress()增加了詳細的記錄,調試輸出如下:

16:47:44.907:invokeRequiredBtn_Click 線程ID=1
16:47:44.924:BeginWorking_Invoke 線程ID=11

16:47:45.133:InvokeRequired=true 線程ID=11
16:47:45.139:Sleep2秒 線程ID=1
16:47:47.144:InvokeRequired=false 線程ID=1
16:47:47.159:after Invoke 線程ID=11

16:47:47.363:InvokeRequired=true 線程ID=11
16:47:47.371:Sleep2秒 線程ID=1
16:47:49.392:InvokeRequired=false 線程ID=1
16:47:49.407:after Invoke 線程ID=11

16:47:49.622:InvokeRequired=true 線程ID=11
16:47:49.628:Sleep2秒 線程ID=1
16:47:51.638:InvokeRequired=false 線程ID=1
16:47:51.642:after Invoke 線程ID=11

16:47:51.857:InvokeRequired=true 線程ID=11
16:47:51.863:Sleep2秒 線程ID=1
16:47:53.880:InvokeRequired=false 線程ID=1
16:47:53.888:after Invoke 線程ID=11

16:47:54.099:InvokeRequired=true 線程ID=11
16:47:54.104:Sleep2秒 線程ID=1
16:47:56.118:InvokeRequired=false 線程ID=1
16:47:56.126:after Invoke 線程ID=11

結合程序與執行日誌,可以得到以下結論:

  1. 首先,在Invoke()方法前是線程11在執行,Invoke()內的代碼就變成線程1在執行了,說明此處發生了線程切換。這也是Invoke()的核心作用:切換到UI線程(1號)來執行Invoke()內部代碼。
  2. after Invoke日誌的線程ID=11,說明Invoke()執行結束後,還是由之前的線程繼續執行後續代碼。
  3. after Invoke操作的日誌時間顯示是1號線程睡眠2秒後執行的,說明Invoke()執行期間,其後的代碼是被阻塞的。
  4. 最後,通過程序總耗時來看,由於操作控件都需要切換爲UI線程來執行,因此UI線程執行的代碼中一旦有耗時的操作(比如本例的Sleep),將直接阻塞後續其它的操作,同時伴隨着客戶端程序界面的響應卡頓現象。

BackgroundWorker方案

BackgroundWorker是一個隱形的控件,這是微軟封裝程度較高的方案,它使用事件驅動模型。

演示程序的日誌輸出爲:

bgWorkerBtn_Click 線程ID=1
backgroundWorker1_DoWork 線程ID=4
inputArgument=hello world

backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_ProgressChanged 線程ID=1
backgroundWorker1_RunWorkerCompleted 線程ID=1

通過日誌同樣可以看出:

  1. 其中DoWork事件的處理程序(本例的backgroundWorker1_DoWork)用來執行耗時的業務操作,該部分代碼由後臺線程執行,而非UI線程,也正因此,backgroundWorker1_DoWork代碼中就無法操作控件資源。
  2. BackgroundWorker的其它幾個事件處理程序,如backgroundWorker1_ProgressChanged、backgroundWorker1_RunWorkerCompleted,就都由UI線程來執行,因此也就可以直接操作控件資源。

補充1:BackgroundWorker的ReportProgress(int percentProgress, object? userState)重載方法,其中userState參數可以承載percentProgress之外的一些有用信息。在scanningWorker_ProgressChanged中通過e.UserState接收並。

補充2:雖然RunWorkerCompletedEventArgs類型定義了UserState屬性,但是其值始終爲null,因此在RunWorkerCompleted事件處理程序中需要用e.Result來傳遞"結果"數據。

通過源碼可以看到UserState沒有賦值。

Control.CheckForIllegalCrossThreadCalls是咋回事?

官方註釋:

Gets or sets a value indicating whether to catch calls on the wrong thread 
that access a control's System.Windows.Forms.Control.Handle property 
    when an application is being debugged.

When a thread other than the creating thread of a control tries to access one of that control's methods or properties, it often leads to unpredictable results. A common invalid thread activity is a call on the wrong thread that accesses the control's Handle property. Set CheckForIllegalCrossThreadCalls to true to find and diagnose this thread activity more easily while debugging.

通俗理解:

雖然微軟不建議其它線程操作控件,但是如果就這麼寫了,程序也能執行,比如下面的情況:

private void invokeRequiredBtn_Click(object sender, EventArgs e)
        {
            Debug.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff") + ":invokeRequiredBtn_Click 線程ID=" + Thread.CurrentThread.ManagedThreadId);
            new Thread(() =>
            {            
                richTextBox1.Text = DateTime.Now.ToString();
            }).Start();
        }

而Control.CheckForIllegalCrossThreadCalls這個屬性就是用來設置,是否完全禁止跨線程的操作控件。當設置true,上述操作就完全不能執行了。

注意:在VS中F5調試時,此值默認=true。報錯效果:

雙擊生成的exe執行時,此值默認=false,程序還可以執行。當Control.CheckForIllegalCrossThreadCalls設置爲true時,雙擊exe執行程序會異常退出:

建議:如果是新開發的程序,建議設置爲true,可以及早的發現隱患問題,避免程序複雜後需要付出高昂的分析成本。

 

總結

以上是winform開發的基礎中的基礎,本文在系統的查閱微軟文檔的基礎上,通過演示程序推測和驗證相關的邏輯關係。

同時聯想到:由於控件的更新都需要UI線程來執行,因此當遇到程序客戶端程序響應卡頓/卡死的情況,通過dump分析UI線程的堆棧,應該可以有所發現。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章