1.若只要防止介面被鎖住,Application.DoEvents() 方法可以動態調整各工作的先後順序。
2.所以若要下載中能顯示 ProgressBar、UI 不會被鎖住、下載也不會停滯的話,就要使用以下「非同步(ASYNC)」技術了。
3.另開執行緒(跨執行緒處理 UI 更新需用 Invoke 方法配合 Delegate 委派,完成時的 Callback 回呼程序也要自理)。
4.使用工具箱的 BackgroundWorker 控制項(不需另外處理 UI 更新,也不必自理事件回呼)。
貼文1使用 System.Net.WebClient DownloadFileAsync() 方法從 FTP Server 下載檔案時,有些不太理想的情形:
- 下載過程中的 ProgressChange() 事件只傳回 e.BytesReceived (已收到資料的大小),而 e.ProgressPercentage 和 e.TotalBytesToReceive 則分別傳回了0和 -1。
- 缺少了計算百分比的數據造成使用 ProgressBar 顯示進度的困擾。
- 第一次下載的初始等待時間較長,並且這段等待間 UI 是鎖住的,直到開始下載後才進入非同步作業。
貼文2在進行 FTP 下載之前先用 FtpWebRequest 物件取得檔案長度再交給ProgressChange() 事件做為百分比的依據,結果是:
- FTP 下載時 ProgressBar 顯示進度OK了。
- 第一次下載初始等待時間並未做處理,仍較長。
貼文3改用更底層元件 Socket 和 FTP Server 溝通,先用 SIZE 命令查詢檔案大小,再建立資料連線下載檔案,結果是:
- 下載前等待時間過長的情形解決了。
- 從 FTP 下載時「非同步」的效果不見了。
接下來要針對「非同步」繼續努力(關於 多執行緒 的基本概念):
-
若只要防止介面被鎖住,Application.DoEvents() 方法可以動態調整各工作的先後順序。
- 這個 DoEvents 並不能創造出新的執行緒,但可以緩解介面被鎖住的現象。
- 它在迴圈中可以「抽空出去看一下」外部執行條件是否已有變化,或是發生了哪個事件要先行處理。
-
它被允許暫時放下手邊工作去忙其他,忙完了再繼續原來的工作。
-
之前的貼文(三)就用了 DoEvents(),一方面是不讓表單介面被鎖住,另方面是要監看使用者有沒有按下中斷(Cancel)鍵,可以這麼試一下:
- 使用前文的 [非同步下載_03_demo.rar] 從 FTP 下載一個檔案。
- 當 ProgressBar 有了進度之後,用滑鼠拖曳表單視窗在桌面移動看看。
-
表單雖可被 Move 但下載作業及進度都會暫停,直到放開滑鼠才繼續下載。
-
而在非同步下載(Async)中,主要執行緒仍可以收到表單事件並做出處理,所以視窗的拖曳和下載會同時進行。
- 改從 HTTP 位置下載檔案再試一次。
- 這次的下載中就不會發生 ProgressBar 停住的情形。
-
這是因為 03_demo 把 FTP 下載和其他下載分開來處理,非 FTP 的下載仍用原來 WebClient 的 DownloadFileAsync() 方法。
-
所以若要下載中能顯示 ProgressBar、UI 不會被鎖住、下載也不會停滯的話,就要使用以下「非同步(ASYNC)」技術了。
- 另開執行緒(跨執行緒處理 UI 更新需用 Invoke 方法配合 Delegate 委派,完成時的 Callback 回呼程序也要自理)。
- 使用工具箱的 BackgroundWorker 控制項(不需另外處理 UI 更新,也不必自理事件回呼)。
以下用直接開執行緒的方式實做看看:
- 加入命名空間參考 Imports System.Threading。
-
我的下載函式需傳入參數,所以使用可傳入參數的方法 ParameterizedThreadStart(),不過 VB 和 C# 不必強調這個方法,編譯器會自動辨識。
MSDN 說:
Visual Basic 和 C# 的使用者在建立執行緒時,可以省略 ThreadStart 或 ParameterizedThreadStart 委派建構函式。
在 Visual Basic 中,當傳遞方法至 Thread 建構函式時,請使用 AddressOf 運算子,例如 Dim t As New Thread(AddressOf ThreadProc)。
在 C# 中,只需指定執行緒程序的名稱。編譯器會選擇正確的委派建構函式。
-
這部分的程式碼如下:
'---打包參數為單一物件--- Structure 檔案參數結構 Dim 遠端位置 As Uri Dim 本機位置 As String End Structure '---當按下下載按鈕--- Private Sub btn_下載_Click() Handles btn_下載.Click If Not (檔案下載器.IsBusy Or FTP下載中) Then '---介面環境布置--- 使用者中斷 = False lbl_下載進度.Text = "..." lbl_狀態.Text = "正在連接伺服器..." : lbl_狀態.Update() Dim URI = New Uri(cmb_下載檔名.Text) Dim 儲存位置 = 建立本機全路徑(txt_儲存位置.Text & txt_儲存檔名.Text) LinkLabel2.Tag = 儲存位置 '---裝填參數包傳入執行緒--- Dim 參數包 As New 檔案參數結構 With {.遠端位置 = URI, .本機位置 = 儲存位置} Dim 執行緒 = New Thread(AddressOf 非同步下載進入點) 執行緒.Start(參數包) End If End Sub
- 無論是否從 FTP 下載都送往新開的執行緒,這可解決 DownloadFileAsync() 方法下載前等待時間的 UI 鎖住問題。
-
這部分的程式碼:
'--- 非同步下載檔案--- Sub 非同步下載進入點(obj As Object) '---解出參數包內的參數--- Dim 參數包 = CType(obj, 檔案參數結構) Dim 遠端檔案位置 = 參數包.遠端位置 Dim 本機儲存位置 = 參數包.本機位置 '---依下載目標選擇使用的方法--- If 遠端檔案位置.Scheme = Uri.UriSchemeFtp Then 自訂FTP下載(遠端檔案位置, 本機儲存位置) Else 檔案下載器.DownloadFileAsync(遠端檔案位置, 本機儲存位置) End If End Sub
-
FTP 下載程式碼改成這樣:
'--- FTP 下載自己寫--- Sub 自訂FTP下載(遠端檔案位置 As Uri, 本機儲存位置 As String) Dim 命令連線 As New Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) Dim 遠端_IP As IPAddress = Dns.GetHostEntry(遠端檔案位置.Host).AddressList(0) Dim 遠端位址埠號 As New IPEndPoint(遠端_IP, 遠端檔案位置.Port) Dim 下載檔案 As String = 遠端檔案位置.AbsolutePath Dim 檔案長度 As Long = 0 Dim 回應字串 As String = "" Dim 作業結果 As String = "" Dim 例外 = Nothing Dim 資料連線 As Socket = Nothing Dim 本機檔案 As FileStream = Nothing Try '---登入 FTP Server --- FTP下載中 = True 進行連線(命令連線, 遠端位址埠號) 送出命令並等待回應(命令連線, "USER " & "anonymous") 送出命令並等待回應(命令連線, "PASS " & "a@a") 送出命令並等待回應(命令連線, "TYPE I ") '---取回檔案長度--- 回應字串 = 送出命令並等待回應(命令連線, "SIZE " & 下載檔案, 550, 213) Select Case CInt(Mid(回應字串, 1, 3)) Case 213 : 檔案長度 = Int64.Parse(Mid(回應字串, 4)) Case 550 : Throw New System.Exception("找不到目錄或檔案!") Case Else : Throw New System.Exception("伺服器回應不正確!") End Select '---建立本地檔案並接收檔案--- 本機檔案 = New FileStream(本機儲存位置, FileMode.Create, FileAccess.Write) 資料連線 = 建立Data連線(命令連線) '---讀取 Binary Data 到檔案--- 送出命令並等待回應(命令連線, "RETR " & 下載檔案) Dim 已下載長度 As Long = 0 Dim 陣列(2047) As Byte Dim len As Integer = 0 While 本機檔案.Length < 檔案長度 len = 資料連線.Receive(陣列, 陣列.Length, SocketFlags.None) 本機檔案.Write(陣列, 0, len) 已下載長度 = 本機檔案.Length 顯示進度(ProgressBar1, 已下載長度, 檔案長度) '---這裡會出去更新 UI --- Application.DoEvents() '---這裡去看使用者是否中斷--- If 使用者中斷 Then Throw New System.Exception("便者者中斷了下載作業!") End While Catch ex As Exception 作業結果 = ex.Message 例外 = New System.Exception(作業結果) Finally '---關閉資料連線--- If Not IsNothing(資料連線) Then 資料連線.Close() 資料連線 = Nothing End If '---關閉本機檔案--- If Not IsNothing(本機檔案) Then 本機檔案.Flush() 本機檔案.Close() End If '---若發生例外就刪掉已下載的部分--- If Not IsNothing(例外) Then If FileIO.FileSystem.FileExists(本機儲存位置) Then FileIO.FileSystem.DeleteFile(本機儲存位置) End If End If 回應字串 = 送出命令並等待回應(命令連線, "QUIT") 命令連線.Close() '---清除忙碌旗號--- FTP下載中 = False '---組織事件內容並送給名為 '顯示結果' 的程序去處理--- 顯示結果(New System.ComponentModel.AsyncCompletedEventArgs(例外, 使用者中斷, Nothing)) End Try End Sub
-
對於更新 UI 的部分,也要改寫配合跨執行緒呼叫(保留註解內單執行緒做法可對照)。
#Region "---Callback 處理---" '---宣告兩個委派程序用來更新 UI--- Delegate Sub cb_顯示進度(bar As ProgressBar, 已收到位元組 As Long, 位元組總數 As Long) Delegate Sub cb_顯示結果(e As System.ComponentModel.AsyncCompletedEventArgs) '---顯示結果(多執行緒的做法)--- Sub 顯示結果(e As System.ComponentModel.AsyncCompletedEventArgs) If InvokeRequired Then Dim callback As New cb_顯示結果(AddressOf 顯示結果) Invoke(callback, New Object() {e}) Else If e.Cancelled Then lbl_狀態.Text = ("使用者中斷了作業!") Else If Not IsNothing(e.Error) Then lbl_狀態.Text = ("下載失敗!") MsgBox(e.Error.GetBaseException.Message) Else lbl_狀態.Text = ("下載成功。") End If End If End If End Sub ''---顯示結果(單執行緒的做法)--- 'Sub 顯示結果(e As System.ComponentModel.AsyncCompletedEventArgs) ' If e.Cancelled Then ' lbl_狀態.Text = ("使用者中斷了作業!") ' Else ' If Not IsNothing(e.Error) Then ' lbl_狀態.Text = ("下載失敗!") ' MsgBox(e.Error.GetBaseException.Message) ' Else ' lbl_狀態.Text = ("下載成功。") ' End If ' End If 'End Sub '---顯示進度(多執行緒的做法)--- Sub 顯示進度(bar As ProgressBar, 已收到位元組 As Long, 位元組總數 As Long) If InvokeRequired Then Dim callback As New cb_顯示進度(AddressOf 顯示進度) Invoke(callback, New Object() {bar, 已收到位元組, 位元組總數}) Else Dim PercenTage As Single = If(位元組總數 > 0, (已收到位元組 / 位元組總數 * 100), 0) bar.Value = CInt(PercenTage) lbl_下載進度.Text = "已收到 " & 已收到位元組 & " Bytes. ( " & Format(PercenTage / 100, "percent") & ")" lbl_狀態.Text = "資料傳送中..." End If End Sub ''---顯示進度(單執行緒的做法)--- 'Sub 顯示進度(bar As ProgressBar, 已收到位元組 As Long, 位元組總數 As Long) ' Dim PercenTage As Single = If(位元組總數 > 0, (已收到位元組 / 位元組總數 * 100), 0) ' bar.Value = CInt(PercenTage) ' lbl_下載進度.Text = "已收到 " & 已收到位元組 & " Bytes. ( " & Format(PercenTage / 100, "percent") & ")" ' lbl_狀態.Text = "資料傳送中..." 'End Sub #End Region
- 最後,按下載鍵後 UI 已經不會被鎖住了,拖動表單時進度也不會停滯了。
-
完成後的模樣:
貼文先告一段落,下回繼續分享把它做成「使用者控制項」的過程。
專案原始碼下載
非同步下載_04_非同步的做法.rar
demo_執行檔下載
非同步下載_04_demo.rar