バックグラウンドスレッドからでも、コントロールの設定を変更したい

スレッドセーフに扱えるようになるといいね。

目次

コントロールを操作したい場合は Invoke() を使うんダヨー

非同期処理内では、コントロールを操作しようとすると InvalidOperationException が発生します。どうしても操作したい場合は Invoke() 経由で操作します。これはもうそういうルールだからショーガナイとして覚えるしかないです。

Public Class Form1

    Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click

        Me.BackgroundWorker1.RunWorkerAsync()

    End Sub

    Private Sub BackgroundWorker1_DoWork(sender As Object, e As System.ComponentModel.DoWorkEventArgs) Handles BackgroundWorker1.DoWork

        ' できれば、ProgressChanged, RunWorkerCompleted イベント側で頑張ってほしいところ

        ' InvalidOperationException
        'Me.Button1.Text = "button1"

        ' 当初の書き方
        Dim method As SetTextDelegate = AddressOf SetText
        Me.Invoke(method, New Object() {Me.Button1, "hello"})

        ' 最近の書き方
        Dim method2 As Action(Of Control, String) = AddressOf SetText
        Me.Invoke(method2, New Object() {Me.Button1, "hello"})

        ' ラムダ式が使える場合
        Me.Invoke(Sub() Me.Button1.Text = "hello")

    End Sub

    Private Delegate Sub SetTextDelegate(ByVal ctrl As Control, ByVal value As String)
    Private Sub SetText(ByVal ctrl As Control, ByVal value As String)

        ctrl.Text = value

    End Sub

End Class

なんでこんなにいろいろ書かないといけないのよ?

バックグラウンドスレッド上からでも、さらっとコントロールを扱いたいものです。というわけで、スレッドセーフな機能を作っていきましょう!答えは拡張メソッドです。

拡張メソッドの準備

ここでのポイントは、Control.IsHandleCreated プロパティと Control.InvokeRequired プロパティですが後で説明します。Control.Invoke() を使うことで、UI スレッド上で実行することができるようになります。あとはラムダ式ジェネリックデリゲートの組み合わせです。

Imports System.Runtime.CompilerServices


Module ControlExtensions

    <Extension()>
    Public Sub SetText(ByVal self As Control, ByVal value As String)

        ' コントロールが破棄されたのであれば、そのコントロールにアクセスするのは危険なので、処理を止める
        If Not self.IsHandleCreated Then
            Exit Sub
        End If

        ' わかるならこっち
        self.Invoke(Sub() self.Text = value)

        ' よくわかんない場合はこっち
        ' InvokeRequired が True の場合(=別スレッドの中にいるので)、Invoke() してもらって、
        ' もう一回 UI スレッドで SetText() を実行するようにお願いする
        ' UI スレッドにいる場合は、いつも通りにアクセスしても大丈夫
        Dim ac As Action(Of Control, String) = AddressOf SetText
        If self.InvokeRequired Then
            self.Invoke(ac, New Object() {self, value})
        Else
            self.Text = value
        End If

    End Sub

End Module

使う側

完全に書き方同じというわけではないですが、見やすくなったと思います。

Public Class Form1

    Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click

        Me.BackgroundWorker1.RunWorkerAsync()

    End Sub

    Private Sub BackgroundWorker1_DoWork(sender As Object, e As System.ComponentModel.DoWorkEventArgs) Handles BackgroundWorker1.DoWork

        Me.Button1.SetText("hello")

    End Sub

End Class

Control.InvokeRequired プロパティ

これは、現在の実行スレッドで Invoke() する必要があるかどうか(=つまりこのメソッドが別スレッドで動いているのかどうか)を判断してくれるプロパティです。めんどいから判定しないで常に Invoke() してもいいですが、今回みたいな場面では必要です。以下のようなことにならないように使っています。

Private Sub BackgroundWorker1_DoWork(sender As Object, e As System.ComponentModel.DoWorkEventArgs) Handles BackgroundWorker1.DoWork

    Me.Button1.Text = "hello"

End Sub

実行結果

System.InvalidOperationException: '有効ではないスレッド間の操作: コントロールが作成されたスレッド以外のスレッドからコントロール 'Button1' がアクセスされました。'

Control.IsHandleCreated プロパティ

Is Handle Created(ハンドルが作成されているかどうか)を確認するプロパティです。詳しくは説明しませんが簡単に言うと、コントロールが準備完了しているかどうかを確認するためのプロパティです。例えば以下のサンプルでボタンを押して閉じた際、Disposed イベントが走るわけですが、破棄した後のコントロールを操作しようとしてしまい InvalidOperationException が発生しています。

Form1_Disposed イベントハンドラの処理内、冒頭で IsHandleCreated プロパティの確認をしていれば、例外エラーを防ぐことができます。

Public Class Form1

    Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click

        Dim frm As New Form1
        frm.ShowDialog()
        frm.Dispose()

    End Sub

    Private Sub Form1_Disposed(sender As Object, e As EventArgs) Handles Me.Disposed

        Try
            System.Threading.Thread.Sleep(100)
            Button1.Invoke(Sub() Me.Button1.Text = "hello")
        Catch ex As Exception
            Console.WriteLine(ex.ToString())
        End Try

    End Sub

End Class

実行結果

System.InvalidOperationException: ウィンドウ ハンドルが作成される前、コントロールで Invoke または BeginInvoke を呼び出せません。