[VB.net] 如何實現「非同步下載」檔案?(四)讓自製的下載方法也能做到非同步(ASYNC)。

1.若只要防止介面被鎖住,Application.DoEvents() 方法可以動態調整各工作的先後順序。
2.所以若要下載中能顯示 ProgressBar、UI 不會被鎖住、下載也不會停滯的話,就要使用以下「非同步(ASYNC)」技術了。
3.另開執行緒(跨執行緒處理 UI 更新需用 Invoke 方法配合 Delegate 委派,完成時的 Callback 回呼程序也要自理)。
4.使用工具箱的 BackgroundWorker 控制項(不需另外處理 UI 更新,也不必自理事件回呼)。

 

貼文1使用 System.Net.WebClient DownloadFileAsync() 方法從 FTP Server 下載檔案時,有些不太理想的情形:

  1. 下載過程中的 ProgressChange() 事件只傳回 e.BytesReceived (已收到資料的大小),而 e.ProgressPercentage 和  e.TotalBytesToReceive 則分別傳回了0和 -1。
  2. 缺少了計算百分比的數據造成使用 ProgressBar 顯示進度的困擾。
  3. 第一次下載的初始等待時間較長,並且這段等待間 UI 是鎖住的,直到開始下載後才進入非同步作業。

貼文2在進行 FTP 下載之前先用 FtpWebRequest 物件取得檔案長度再交給ProgressChange() 事件做為百分比的依據,結果是:

  1. FTP 下載時 ProgressBar 顯示進度OK了。
  2. 第一次下載初始等待時間並未做處理,仍較長。

貼文3改用更底層元件 Socket 和 FTP Server 溝通,先用 SIZE 命令查詢檔案大小,再建立資料連線下載檔案,結果是:

  1. 下載前等待時間過長的情形解決了。
  2. 從 FTP 下載時「非同步」的效果不見了。

 

 

接下來要針對「非同步」繼續努力(關於 多執行緒 的基本概念):

  1. 若只要防止介面被鎖住,Application.DoEvents() 方法可以動態調整各工作的先後順序。
    1. 這個 DoEvents 並不能創造出新的執行緒,但可以緩解介面被鎖住的現象。
    2. 迴圈中可以「抽空出去看一下」外部執行條件是否已有變化,或是發生了哪個事件要先行處理。
    3. 它被允許暫時放下手邊工作去忙其他,忙完了再繼續原來的工作。
       
  2. 之前的貼文(三)就用了 DoEvents(),一方面是不讓表單介面被鎖住,另方面是要監看使用者有沒有按下中斷(Cancel)鍵,可以這麼試一下:
    1. 使用前文的 [非同步下載_03_demo.rar] 從 FTP 下載一個檔案。
    2. 當 ProgressBar 有了進度之後,用滑鼠拖曳表單視窗在桌面移動看看。
    3. 表單雖可被 Move 但下載作業及進度都會暫停,直到放開滑鼠才繼續下載。
       
  3. 而在非同步下載(Async)中,主要執行緒仍可以收到表單事件並做出處理,所以視窗的拖曳和下載會同時進行。
    1. 改從 HTTP 位置下載檔案再試一次。
    2. 這次的下載中就不會發生 ProgressBar 停住的情形。
    3. 這是因為 03_demo 把 FTP 下載和其他下載分開來處理,非 FTP 的下載仍用原來 WebClient 的 DownloadFileAsync() 方法。
       
  4. 所以若要下載中能顯示 ProgressBar、UI 不會被鎖住、下載也不會停滯的話,就要使用以下「非同步(ASYNC)」技術了。
    1. 另開執行緒(跨執行緒處理 UI 更新需用 Invoke 方法配合 Delegate 委派,完成時的 Callback  回呼程序也要自理)。
    2. 使用工具箱的 BackgroundWorker 控制項(不需另外處理 UI 更新,也不必自理事件回呼)。

 

以下用直接開執行緒的方式實做看看:

  1. 加入命名空間參考 Imports System.Threading。
  2. 我的下載函式需傳入參數,所以使用可傳入參數的方法 ParameterizedThreadStart(),不過 VB 和 C# 不必強調這個方法,編譯器會自動辨識。

    MSDN 說:
    Visual Basic 和 C# 的使用者在建立執行緒時,可以省略 ThreadStartParameterizedThreadStart 委派建構函式。
    在 Visual Basic 中,當傳遞方法至 Thread 建構函式時,請使用 AddressOf 運算子,例如 Dim t As New Thread(AddressOf ThreadProc)
    在 C# 中,只需指定執行緒程序的名稱。編譯器會選擇正確的委派建構函式。

     
  3. 這部分的程式碼如下:

    
    
        '---打包參數為單一物件---
        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
    


     
  4. 無論是否從 FTP 下載都送往新開的執行緒,這可解決 DownloadFileAsync() 方法下載前等待時間的 UI 鎖住問題
  5. 這部分的程式碼:

    
    
        '--- 非同步下載檔案---
        Sub 非同步下載進入點(obj As Object)
    
            '---解出參數包內的參數---
            Dim 參數包 = CType(obj, 檔案參數結構)
            Dim 遠端檔案位置 = 參數包.遠端位置
            Dim 本機儲存位置 = 參數包.本機位置
    
            '---依下載目標選擇使用的方法---
            If 遠端檔案位置.Scheme = Uri.UriSchemeFtp Then
                自訂FTP下載(遠端檔案位置, 本機儲存位置)
            Else
                檔案下載器.DownloadFileAsync(遠端檔案位置, 本機儲存位置)
            End If
    
        End Sub
    

     
  6. 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
    



  7. 對於更新 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
    


  8. 最後,按下載鍵後 UI 已經不會被鎖住了,拖動表單時進度也不會停滯了。
  9. 完成後的模樣:

    image

貼文先告一段落,下回繼續分享把它做成「使用者控制項」的過程。

專案原始碼下載
 非同步下載_04_非同步的做法.rar

demo_執行檔下載

非同步下載_04_demo.rar

 

 

 


ku3