2024年6月12日 星期三

14.VB.NET 筆記 進階篇 - Invoke

VB.NET Invoke 筆記(進階篇)

VB.NET Invoke 筆記(進階篇)

在 Windows Forms 中,畫面控制項屬於建立它們的 UI 執行緒。背景執行緒若直接修改 LabelProgressBarListBoxButton,容易發生跨執行緒存取錯誤。

InvokeBeginInvokeInvokeRequired 的重點,是把背景執行緒中的 UI 更新動作,安全送回控制項所屬的 UI 執行緒。這篇會從「為什麼需要切回 UI 執行緒」開始,再用 Windows Forms 範例整理同步更新、非同步排入、共用更新方法與表單關閉時的安全處理。

先理解 Invoke 在解決什麼

背景工作不能直接碰 UI 控制項

表單畫面由 UI 執行緒管理。背景執行緒可以負責耗時工作,但更新畫面時必須切回 UI 執行緒。Invoke 的作用,就是把一段程式交給 UI 執行緒執行。

  • UI 執行緒:負責建立控制項、處理按鈕事件、重繪畫面。
  • 背景執行緒:適合處理耗時工作,例如檔案整理、資料比對、網路等待。
  • InvokeRequired:判斷目前是否需要切回控制項所屬的 UI 執行緒。
  • Invoke / BeginInvoke:把 UI 更新工作交回 UI 執行緒。

Invoke 相關語法的分工:

  1. InvokeRequired:先判斷是否跨執行緒。
  2. Invoke:同步切回 UI 執行緒,會等待 UI 更新完成。
  3. BeginInvoke:非同步排入 UI 執行緒,不等待 UI 更新完成。
  4. MethodInvoker:常用來包住沒有參數、沒有回傳值的 UI 更新動作。
項目 用途 適合情境
InvokeRequired 判斷目前是否不在控制項所屬執行緒。 寫共用 UI 更新方法時常用。
Invoke 同步執行 UI 更新。 需要等畫面更新完成後才繼續。
BeginInvoke 把 UI 更新排入佇列。 只需要通知畫面,不需要等待結果。

基本模式:InvokeRequired + Invoke

場景一:櫃台報到狀態更新

這個範例用「報到資料檢查」來示範最基本的跨執行緒 UI 更新。按下按鈕後,背景執行緒模擬檢查流程;完成時使用 InvokeRequired 判斷是否需要切回 UI 執行緒。

需要的主控項
  • ButtonCheckIn:開始檢查報到資料。
  • LabelStatus:顯示報到狀態。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private Sub ButtonCheckIn_Click(sender As Object, e As EventArgs) Handles ButtonCheckIn.Click
        LabelStatus.Text = "報到資料檢查中..."

        Dim worker As New Thread(AddressOf CheckGuestRecord)
        worker.IsBackground = True
        worker.Start()
    End Sub

    Private Sub CheckGuestRecord()
        Thread.Sleep(900)
        SetStatusText("報到完成:座位 C-12")
    End Sub

    Private Sub SetStatusText(ByVal message As String)
        If LabelStatus.InvokeRequired Then
            LabelStatus.Invoke(New MethodInvoker(Sub()
                                                     LabelStatus.Text = message
                                                 End Sub))
        Else
            LabelStatus.Text = message
        End If
    End Sub
End Class
畫面輸出結果(LabelStatus.Text)
報到資料檢查中... 報到完成:座位 C-12
邏輯解析
  • CheckGuestRecord 在背景執行緒中執行。
  • SetStatusText 集中處理 UI 更新,不讓背景工作直接改控制項。
  • InvokeRequiredTrue 時,使用 Invoke 切回 UI 執行緒。
  • MethodInvoker 包住要在 UI 執行緒執行的畫面更新程式。

進度更新與多控制項更新

場景二:整理抽屜標籤進度

這個範例模擬背景工作分段整理標籤。背景執行緒每完成一段,就把進度交回 UI 執行緒更新 ProgressBarLabel

需要的主控項
  • ButtonStartLabeling:開始整理標籤。
  • ProgressBar1:顯示進度。
  • LabelProgress:顯示進度文字。
  • ButtonStartLabeling 完成後會重新啟用。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private Sub ButtonStartLabeling_Click(sender As Object, e As EventArgs) Handles ButtonStartLabeling.Click
        ButtonStartLabeling.Enabled = False
        ProgressBar1.Value = 0
        LabelProgress.Text = "整理進度:0%"

        Dim worker As New Thread(AddressOf RunDrawerLabeling)
        worker.IsBackground = True
        worker.Start()
    End Sub

    Private Sub RunDrawerLabeling()
        For drawerNo As Integer = 1 To 5
            Thread.Sleep(350)
            Dim percent As Integer = drawerNo * 20
            UpdateLabelingProgress(percent)
        Next

        UpdateLabelingDone()
    End Sub

    Private Sub UpdateLabelingProgress(ByVal percent As Integer)
        If ProgressBar1.InvokeRequired Then
            ProgressBar1.Invoke(New MethodInvoker(Sub()
                                                      ProgressBar1.Value = percent
                                                      LabelProgress.Text = "整理進度:" & percent.ToString() & "%"
                                                  End Sub))
        Else
            ProgressBar1.Value = percent
            LabelProgress.Text = "整理進度:" & percent.ToString() & "%"
        End If
    End Sub

    Private Sub UpdateLabelingDone()
        If ButtonStartLabeling.InvokeRequired Then
            ButtonStartLabeling.Invoke(New MethodInvoker(Sub()
                                                            LabelProgress.Text = "標籤整理完成"
                                                            ButtonStartLabeling.Enabled = True
                                                        End Sub))
        Else
            LabelProgress.Text = "標籤整理完成"
            ButtonStartLabeling.Enabled = True
        End If
    End Sub
End Class
畫面輸出結果
整理進度:20% 整理進度:40% 整理進度:60% 整理進度:80% 整理進度:100% 標籤整理完成
邏輯解析
  • 背景執行緒只負責模擬整理流程。
  • 更新 ProgressBar1LabelProgress 與按鈕狀態都切回 UI 執行緒。
  • 多個控制項可以放在同一個 Invoke 區塊中一起更新。

BeginInvoke:排入 UI 更新,不等待完成

場景三:縮圖產生紀錄排入清單

若背景工作只需要把訊息丟回畫面,不需要等待畫面更新完成,就適合使用 BeginInvoke。這個範例會把縮圖產生紀錄排入 ListBox

需要的主控項
  • ButtonMakeThumbs:開始產生縮圖。
  • ListBoxLog:顯示縮圖紀錄。
  • LabelThumbStatus:顯示目前狀態。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private Sub ButtonMakeThumbs_Click(sender As Object, e As EventArgs) Handles ButtonMakeThumbs.Click
        ListBoxLog.Items.Clear()
        LabelThumbStatus.Text = "縮圖工作已啟動"

        Dim worker As New Thread(AddressOf BuildThumbnails)
        worker.IsBackground = True
        worker.Start()
    End Sub

    Private Sub BuildThumbnails()
        For imageNo As Integer = 1 To 4
            Thread.Sleep(300)
            QueueLogText("縮圖完成:圖片 " & imageNo.ToString())
        Next

        QueueStatusText("全部縮圖已完成")
    End Sub

    Private Sub QueueLogText(ByVal message As String)
        If ListBoxLog.InvokeRequired Then
            ListBoxLog.BeginInvoke(New MethodInvoker(Sub()
                                                        ListBoxLog.Items.Add(message)
                                                    End Sub))
        Else
            ListBoxLog.Items.Add(message)
        End If
    End Sub

    Private Sub QueueStatusText(ByVal message As String)
        If LabelThumbStatus.InvokeRequired Then
            LabelThumbStatus.BeginInvoke(New MethodInvoker(Sub()
                                                              LabelThumbStatus.Text = message
                                                          End Sub))
        Else
            LabelThumbStatus.Text = message
        End If
    End Sub
End Class
畫面輸出結果(ListBoxLog)
縮圖完成:圖片 1 縮圖完成:圖片 2 縮圖完成:圖片 3 縮圖完成:圖片 4
邏輯解析
  • BeginInvoke 會把 UI 更新排入 UI 執行緒佇列。
  • 背景工作不需要等待 ListBoxLog 實際完成更新。
  • 若只是顯示紀錄或狀態通知,BeginInvoke 通常比 Invoke 更自然。
比較項目 Invoke BeginInvoke
執行方式 同步。 非同步排入。
背景執行緒是否等待 會等待 UI 更新完成。 不等待,排入後繼續往下。
適合情境 需要明確完成順序。 紀錄、通知、進度顯示。
注意事項 避免互相等待造成卡住。 表單關閉時要注意控制項生命週期。

共用 UI 更新方法

場景四:通知接收紀錄集中處理

多個背景流程都要更新同一個 ListBox 時,可以把跨執行緒判斷集中在共用方法。背景流程只負責呼叫 AddNoticeLog,不需要每次重複寫 InvokeRequired

需要的主控項
  • ButtonStartReceive:開始模擬接收通知。
  • ListBoxNotice:顯示通知紀錄。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private Sub ButtonStartReceive_Click(sender As Object, e As EventArgs) Handles ButtonStartReceive.Click
        ListBoxNotice.Items.Clear()

        Dim worker As New Thread(AddressOf ReceiveNoticeMessages)
        worker.IsBackground = True
        worker.Start()
    End Sub

    Private Sub ReceiveNoticeMessages()
        Dim messages() As String = {
            "櫃台提醒:文件已補齊",
            "系統提醒:備份完成",
            "排程提醒:下一場次 10 分鐘後開始"
        }

        For Each message As String In messages
            Thread.Sleep(350)
            AddNoticeLog(message)
        Next
    End Sub

    Private Sub AddNoticeLog(ByVal message As String)
        If ListBoxNotice.InvokeRequired Then
            ListBoxNotice.BeginInvoke(New MethodInvoker(Sub()
                                                           ListBoxNotice.Items.Add(message)
                                                       End Sub))
        Else
            ListBoxNotice.Items.Add(message)
        End If
    End Sub
End Class
畫面輸出結果(ListBoxNotice)
櫃台提醒:文件已補齊 系統提醒:備份完成 排程提醒:下一場次 10 分鐘後開始
邏輯解析
  • AddNoticeLog 把跨執行緒判斷與 UI 更新封裝起來。
  • 背景流程只要呼叫同一個方法,程式會比較乾淨。
  • 這種寫法適合多處都需要更新同一個控制項的表單。

表單關閉與控制項生命週期

場景五:表單關閉時停止背景更新

背景執行緒執行期間,表單可能已經被關閉。此時若仍然呼叫 InvokeBeginInvoke,可能發生控制項已釋放的錯誤。因此更新前要檢查表單與控制項狀態,關閉時也要停止背景工作。

需要的主控項
  • ButtonStartClock:開始背景時間更新。
  • LabelClock:顯示時間文字。
範例程式碼
VB.NET / Windows Forms
Imports System.Threading

Public Class Form1
    Private clockThread As Thread
    Private keepClockRunning As Boolean = False

    Private Sub ButtonStartClock_Click(sender As Object, e As EventArgs) Handles ButtonStartClock.Click
        If keepClockRunning Then
            Return
        End If

        keepClockRunning = True
        clockThread = New Thread(AddressOf RunClock)
        clockThread.IsBackground = True
        clockThread.Start()
    End Sub

    Private Sub RunClock()
        While keepClockRunning
            Thread.Sleep(500)
            SafeSetClockText("更新時間:" & DateTime.Now.ToString("HH:mm:ss"))
        End While
    End Sub

    Private Sub SafeSetClockText(ByVal message As String)
        If Me.IsDisposed OrElse Not Me.IsHandleCreated Then
            Return
        End If

        If LabelClock.InvokeRequired Then
            Try
                LabelClock.BeginInvoke(New MethodInvoker(Sub()
                                                            If Not LabelClock.IsDisposed Then
                                                                LabelClock.Text = message
                                                            End If
                                                        End Sub))
            Catch ex As InvalidOperationException
                ' 表單關閉中,忽略尚未完成的畫面更新。
            End Try
        Else
            LabelClock.Text = message
        End If
    End Sub

    Private Sub Form1_FormClosing(sender As Object, e As FormClosingEventArgs) Handles MyBase.FormClosing
        keepClockRunning = False
    End Sub
End Class
畫面輸出結果(LabelClock.Text)
更新時間:14:05:21 更新時間:14:05:22 更新時間:14:05:23
邏輯解析
  • keepClockRunning 用來控制背景迴圈是否繼續。
  • IsDisposedIsHandleCreated 可降低表單關閉時仍更新 UI 的風險。
  • BeginInvoke 排入更新時,仍可能遇到表單關閉,因此範例用 Try...Catch 保護。

實務判斷與常見誤區

常見問題整理

  • 背景執行緒直接改 UI:例如直接寫 Label1.Text = ...,容易發生跨執行緒例外。
  • 每行 UI 更新都寫一次 Invoke:程式會變得難讀,可改成共用方法集中處理。
  • 過度頻繁更新畫面:大量進度訊息每毫秒更新一次,可能拖慢 UI。
  • 在互相等待的流程中使用 Invoke:可能造成卡住,通知型更新可考慮 BeginInvoke
  • 忽略表單關閉:背景工作尚未停止時,控制項可能已被釋放。
問題 建議做法 原因
不確定目前在哪個執行緒 先用 InvokeRequired 判斷。 避免在 UI 執行緒又重複切回。
需要等 UI 更新完成 使用 Invoke 同步等待結果。
只是顯示紀錄或通知 使用 BeginInvoke 排入 UI 更新後,背景工作可繼續。
同一控制項多處更新 建立共用方法。 集中跨執行緒判斷,降低重複程式。
表單可能關閉 檢查生命週期並停止背景工作。 避免控制項釋放後仍排入 UI 更新。

重點整理

  1. Windows Forms 控制項通常只能由建立它的 UI 執行緒安全更新。
  2. 背景執行緒要更新 UI 時,應使用 InvokeBeginInvoke 切回 UI 執行緒。
  3. InvokeRequired 用來判斷目前是否需要切回 UI 執行緒。
  4. Invoke 是同步更新,會等待 UI 更新完成。
  5. BeginInvoke 是非同步排入,不等待 UI 更新完成。
  6. 多處重複 UI 更新時,建議建立共用方法集中處理。
  7. 進度更新不宜過度頻繁,避免 UI 被大量更新拖慢。
  8. 表單關閉時需停止背景工作,並注意控制項是否已釋放。