【実務・中級編】Windows Formsにおけるステータスバーの非同期更新:BackgroundWorker廃止に伴うIProgress(Of T)とValueTaskを活用した安全な進捗通知 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク
スポンサーリンク

BackgroundWorkerの呪縛を解け:IProgress(Of T)とValueTaskで構築する、モダンなWinForms非同期UI設計

業務自動化ツールの開発現場において、避けて通れないのが「重い処理」だ。
大量のファイル走査、巨大なデータベースへのクエリ、あるいは複雑なExcel操作。これらをメインUIスレッドで実行すれば、アプリケーションは「応答なし」となり、ユーザーの信頼を即座に失う。

かつて、我々には`BackgroundWorker`という武器があった。しかし、現代の.NETにおける非同期プログラミングの潮流において、`BackgroundWorker`を使い続けることは、最新の戦闘機にプロペラエンジンを積んで戦場に出るようなものだ。非効率であり、拡張性に欠け、何よりモダンな`async/await`構文との親和性が極めて低い。

本稿では、`BackgroundWorker`を卒業し、`IProgress(Of T)`による安全な進捗通知と、`ValueTask`による高効率な非同期処理を組み合わせた、「バグを生まない、プロフェッショナルなWinForms非同期設計」の極意を伝授する。

1. なぜ、あなたの非同期処理は「壊れる」のか

多くの開発者が陥る罠は、「スレッド間の境界線」の軽視だ。

Windows FormsにおけるUIコントロール(StatusBar, ProgressBar, Label等)は、それを作成したスレッド(メインUIスレッド)以外から操作しようとした瞬間、`InvalidOperationException`(クロススレッド操作)を吐き出して死ぬ。

これに対し、多くのエンジニアは`Control.Invoke`という「対症療法」を乱用する。

' 避けるべき、泥臭い実装例
Me.Invoke(Sub()
              StatusBar1.Text = $"処理中: {progress}%"
          End Sub)

これではコードは汚染され、ロジックとUIが密結合になり、保守性は地獄へと突き落とされる。我々が目指すべきは、「ロジック層はUIの存在を知らず、ただ進捗を報告するだけ」という、クリーンな設計思想だ。

2. モダン・アーキテクチャの三種の神器

堅牢な非同期UIを実現するために、以下の3つの要素を使い分ける。

1. `Task.Run`: 重い計算やI/O待ちをバックグラウンドスレッドへ完全に隔離する。
2. `IProgress(Of T)`: スレッド間の「安全な通信路」だ。これを使えば、バックグラウンドからUIスレッドへのコンテキスト切り替えを.NETが自動で行ってくれる。
3. `ValueTask`: 高頻度で行われる非同期I/O操作において、メモリ確保(Allocation)のオーバーヘッドを最小限に抑えるための、パフォーマンス最適化の鍵だ。

3. 実装パターン:プロダクション・クオリティのコード例

では、実際に「ファイル群をスキャンしてデータベースに登録する」という業務シナリオを想定した、堅牢な実装を見ていこう。

A. 進捗情報を格納する構造体

まずは、進捗率だけでなく「現在の状態メッセージ」も一緒に送れるよう、`Structure`を定義する。クラスではなく構造体を使うことで、GC(ガベージコレクション)への負荷を抑えるのがプロの作法だ。

''' <summary>
''' 進捗状況をカプセル化する構造体
''' </summary>
Public Structure ProgressReport
    Public Percentage As Integer
    Public Message As String

    Public Sub New(percent As Integer, msg As String)
        Me.Percentage = percent
        Me.Message = msg
    End Sub
End Structure

B. ビジネスロジック層(Workerクラス)

ここが心臓部だ。UIの存在を一切意識せず、`IProgress`を通じて「報告」を行うのみに徹する。

Imports System.Threading

Public Class DataProcessor
    ''' <summary>
    ''' 重いデータ処理を実行する
    ''' </summary>
    ''' <param name="progress">UIへの通知用インターフェース</param>
    ''' <param name="ct">キャンセル要求を受け取るトークン</param>
    Public Async Function ProcessFilesAsync(progress As IProgress(Of ProgressReport), ct As CancellationToken) As ValueTask(Of Boolean)
        Try
            ' シミュレーション:ファイルリストの取得
            Dim files = Enumerable.Range(1, 100).ToList()
            Dim total = files.Count

            For i As Integer = 0 To total - 1
                ' キャンセル要求の確認(これを忘れるとキャンセルが効かない)
                ct.ThrowIfCancellationRequested()

                ' --- 実際の重い処理を想定 (DBアクセスやファイルI/O) ---
                ' ValueTaskを活用し、非同期で待機
                Await Task.Delay(50, ct) 
                ' --------------------------------------------------

                ' 進捗の報告(UIスレッドへの切り替えはIProgressが自動で行う)
                Dim percent = CInt((i + 1) / total * 100)
                progress?.Report(New ProgressReport(percent, $"ファイル {i + 1}/{total} を処理中..."))
            Next

            Return True
        Catch ex As OperationCanceledException
            ' キャンセル時はここを通る
            Return False
        Catch ex As Exception
            ' 本来はログ出力等のエラーハンドリングを行う
            Throw
        End Try
    End Function
End Class

C. プレゼンテーション層(Windows Form)

UI側は、`Progress(Of T)`を生成してロジックに渡すだけだ。

Imports System.Threading

Public Class MainForm
    Private _cts As CancellationTokenSource
    Private ReadOnly _processor As New DataProcessor()

    ''' <summary>
    ''' 実行ボタン押下時のイベント
    ''' </summary>
    Private Async Sub btnStart_Click(sender As Object, e As EventArgs) Handles btnStart.Click
        ' UIの初期化
        btnStart.Enabled = False
        btnCancel.Enabled = True
        lblStatus.Text = "準備中..."
        pbProgress.Value = 0
        
        _cts = New CancellationTokenSource()

        ' IProgressのインスタンス化
        ' Progress(Of T)のコンストラクタに渡したアクションは、
        ' 自動的にUIスレッド(SynchronizationContext)で実行される。
        Dim progressHandler = New Progress(Of ProgressReport)(Sub(report)
                                                                pbProgress.Value = report.Percentage
                                                                lblStatus.Text = report.Message
                                                                statusStrip1.StatusLabel.Text = $"完了率: {report.Percentage}%"
                                                            End Sub)

        Try
            ' 非同期処理の開始
            Dim success = Await _processor.ProcessFilesAsync(progressHandler, _cts.Token)

            If success Then
                MessageBox.Show("すべての処理が正常に完了しました。", "成功", MessageBoxButtons.OK, MessageBoxIcon.Information)
            Else
                lblStatus.Text = "キャンセルされました。"
            End If

        Catch ex As Exception
            MessageBox.Show($"致命的なエラーが発生しました: {ex.Message}", "エラー", MessageBoxButtons.OK, MessageBoxIcon.Error)
        Finally
            ' 後処理
            btnStart.Enabled = True
            btnCancel.Enabled = False
            _cts.Dispose()
        End Try
    End Sub

    ''' <summary>
    ''' キャンセルボタン押下時
    ''' </summary>
    Private Sub btnCancel_Click(sender As Object, e As EventArgs) Handles btnCancel.Click
        _cts?.Cancel()
    End Sub
End Class

4. アーキテクトの視点:実務で絶対に外せない注意点

設計を完璧に近づけるために、以下の3点を肝に銘じてほしい。

① CancellationTokenの徹底

`Task.Run`や`Task.Delay`を呼び出す際、`CancellationToken`を伝搬させ忘れるエンジニアが後を絶たない。これを怠ると、ユーザーが「キャンセル」を押してもバックグラウンドでは処理が走り続け、ゾンビプロセスのような挙動を招く。「キャンセルは、処理の合間に自らチェックしに行くもの」である。

② I/O操作における「非同期の連鎖」

データベース(SqlClient等)やファイル操作を行う際、`File.ReadAllText`のような同期メソッドを`Task.Run`の中で使ってはいけない。それは「スレッドを占有している」だけであり、真の非同期ではない。必ず`ReadAsync`や`ExecuteNonQueryAsync`といった、Async版のAPIを連鎖(Async all the way)させて使うこと。

③ メモリリークへの警戒

`Progress(Of T)`に渡すラムダ式の中で、フォームの巨大なコントロールを直接参照しすぎると、クロージャによってフォームのライフサイクルが不適切に延長されるリスクがある。進捗通知に必要なデータは、極力最小限のプリミティブ型(Integer, String等)に絞り、構造体で渡すのが賢明だ。

結論

`BackgroundWorker`という過去の遺産を捨て、`IProgress(Of T)`と`ValueTask`を使いこなすこと。それは単なる文法の変更ではない。「UIの関心事」と「ビジネスロジックの関心事」を明確に分離する、設計思想の転換である。

この設計を導入することで、あなたの作るツールは、単なる「動くスクリプト」から、プロフェッショナルが信頼を寄せる「堅牢なアプリケーション」へと昇華するだろう。

タイトルとURLをコピーしました