BackgroundWorkerを使った進捗表示画面の実装

## 初めに

ファイルの読み込みや大量のループといった重い処理をする際は、進捗を表示してあげることがUI的にもよろしいです。
画面ごとにプログレスバーを実装して表示してもよいのですが、`Backgroundworker`を使うことで「進捗を表示する画面」を処理と分離して実装できると分かったのでまとめました。

## TL;DR

以下のように表示側、呼び出し側のコードを実装します。
VisualStudioの自動生成分は省略してます。

### 表示側

```csharp
public partial class ProgressForm : Form
{

    private BackgroundWorker backgroundWorker;

    public ProgressForm(BackgroundWorker bw)
    {
        InitializeComponent();
        this.backgroundWorker = bw;
        if (!bw.WorkerSupportsCancellation)
        {
            //キャンセル
            this.cancelBtn.Enabled = false;
            this.cancelBtn.Visible = false;
            this.ControlBox = false;
        }
        backgroundWorker.ProgressChanged += BackgroundWorker_ProgressChanged;
        backgroundWorker.RunWorkerCompleted += BackgroundWorker_RunWorkerCompleted;            
    }

    //完了時の挙動
    private void BackgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
    {
        if((e.Error is null) == false)
        {
            //エラーの表示等の処理
            Console.WriteLine("error");
            DialogResult = DialogResult.Cancel;
        }
        else if (e.Cancelled)
        {
            //キャンセル時の処理
            Console.WriteLine("canceled");
            DialogResult = DialogResult.Cancel;                
        }
        else
        {
            //完了時の処理
            Console.WriteLine("completed");
            DialogResult = DialogResult.OK;
        }
        //フォームを閉じる
        this.Close();
    }

    //進捗の表示
    private void BackgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e)
    {
        //プログレスバーの値を変更する
        //設定されてる値の範囲外の値を渡すとエラーが発生することに注意
        if (e.ProgressPercentage < this.progressBar1.Minimum)
        {
            this.progressBar1.Value = this.progressBar1.Minimum;
        }
        else if (this.progressBar1.Maximum < e.ProgressPercentage)
        {
            this.progressBar1.Value = this.progressBar1.Maximum;
        }
        else
        {
            this.progressBar1.Value = e.ProgressPercentage;
        }
        //UserStateの内容を表示する
        progressLabel.Text = e.UserState.ToString();
    }

    private void ProgressForm_Shown(object sender, EventArgs e)
    {
        //画面表示を開始した際に動いてない場合はRunWorkerAsync()を呼ぶ
        //二重に叩かないように判定を入れる
        if (!backgroundWorker.IsBusy)
        { 
            backgroundWorker.RunWorkerAsync();
        }
    }

    private void cancelBtn_Click(object sender, EventArgs e)
    {
        //処理をキャンセルする
        if (backgroundWorker.IsBusy)
        {
            this.backgroundWorker.CancelAsync();
        }
    }

    private void ProgressForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        //×ボタンで閉じられた際の対処
        //backgroundWorkerが動いており
        if (backgroundWorker.IsBusy && !backgroundWorker.CancellationPending)
        {
            //一度Closingをキャンセルしたうえで、改めてキャンセル処理をする。
            e.Cancel = true;
            backgroundWorker.CancelAsync();                
        }
    }

    private void ProgressForm_FormClosed(object sender, FormClosedEventArgs e)
    {            
        //フォーム側でイベントハンドラーに加えた処理を外す      
        backgroundWorker.ProgressChanged -= BackgroundWorker_ProgressChanged;
        backgroundWorker.RunWorkerCompleted -= BackgroundWorker_RunWorkerCompleted;
    }
}

```

### 呼び出し側

```csharp

public partial class Form1 : Form
{
    BackgroundWorker bw;
    public Form1()
    {
        InitializeComponent();
        bw = new BackgroundWorker();
    }

    private void backgroundWorker_DoWork(object sender, DoWorkEventArgs e)
    {
        for (int i = 0; i < 100; i++)
        {
            bw.ReportProgress(i, $"count is {i}");
            if(bw.CancellationPending)
            {
                //処理がキャンセルされた場合
                //※重要：競合を防ぐため、Cancel状態をDoWorkEventArgsにも伝える。
                //この処理を省くと、 RunWorkerCompletedEventArgs.Canceledがfalseのまま伝わる。
                e.Cancel = true;
                return;
            }
            System.Threading.Thread.Sleep(100);
        }
    }

    private void startBtn_Click(object sender, EventArgs e)
    {
        //キャンセルできる処理を呼び出す
        bw.WorkerSupportsCancellation = true;
        bw.WorkerReportsProgress = true;            
        bw.DoWork += this.backgroundWorker_DoWork;
        using (var prgForm = new ProgressForm(bw))
        {
            //進捗表示フォームを呼び出す。
            var res = prgForm.ShowDialog();
            if(res == DialogResult.OK)
            {
                //正常終了時の処理
            }
            else{
                //正常終了でない場合の処理
            }
        }
        bw.DoWork -= this.backgroundWorker_DoWork;
    }

    private void NoCancelStartBtn_Click(object sender, EventArgs e)
    {
        //キャンセルできない処理を呼び出す。
        bw.WorkerSupportsCancellation = false;
        bw.WorkerReportsProgress = true;
        bw.DoWork += this.backgroundWorker_DoWork;
        using (var prgForm = new ProgressForm(bw))
        {
            //進捗表示フォームを呼び出す。
            var res = prgForm.ShowDialog();
            if (res == DialogResult.OK)
            {
                //正常終了時の処理
            }
            else
            {
                //正常終了でない場合の処理
            }
        }
        bw.DoWork -= this.backgroundWorker_DoWork;
    }
}

```

## メソッドとイベントの関係

Backgroundworkerには以下の３つのMethodが実装されており、それぞれ叩かれた瞬間にイベントを発生させます。

- `RunWorkerDoAsync()`/`RunWorkerDoAsync(object? argument)`
  - DoWorkイベントを発生させる
    - `object? argument`で渡した値が`DoWorkEventArgs.Argument`に格納されて引き渡される。
- `ReportProgress(int percentProgress)`/`ReportProgress(int percentProgress, object? userState)`
  - ProgressChangedイベントを発生させる
    - 引数で渡した値が`ProgressChangedEventArgs`に格納されて引き渡される。
      - `int percentProgress` ⇒ `ProgressChangedEventArgs.ProgressPercentage`
      - `object? userState` ⇒ `ProgressChangedEventArgs.UserState`
- `CancelAsync()`
  - `BackgroundWorker.CancellationPending`がtrueにされる。
  - RunWorkerCompletedイベントを発生させる

これらのメソッドの挙動を元に、画面ごとにイベントハンドラへイベントを追加します。

## 進捗表示画面側の処理

進捗表示画面では以下の処理を行います。

- 進捗の表示
  - ProgressChangedイベントハンドラーに表示の更新処理を追加する。

```csharp
    private void BackgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e)
    {
        //プログレスバーの値を変更する
        //設定されてる値の範囲外の値を渡すとエラーが発生することに注意
        if (e.ProgressPercentage < this.progressBar1.Minimum)
        {
            this.progressBar1.Value = this.progressBar1.Minimum;
        }
        else if (this.progressBar1.Maximum < e.ProgressPercentage)
        {
            this.progressBar1.Value = this.progressBar1.Maximum;
        }
        else
        {
            this.progressBar1.Value = e.ProgressPercentage;
        }
        //UserStateの内容を表示する
        progressLabel.Text = e.UserState.ToString();
    }
```

- キャンセルの受付
  - キャンセルボタン/×ボタンを押した際、`CancelAsync()`を呼ぶ

```csharp
    private void cancelBtn_Click(object sender, EventArgs e)
    {
        //処理をキャンセルする
        if (backgroundWorker.IsBusy)
        {
            this.backgroundWorker.CancelAsync();
        }
    }
    private void ProgressForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        //×ボタンで閉じられた際の対処
        //backgroundWorkerが動いており
        if (backgroundWorker.IsBusy && !backgroundWorker.CancellationPending)
        {
            //一度Closingをキャンセルしたうえで、改めてキャンセル処理をする。
            e.Cancel = true;
            backgroundWorker.CancelAsync();                
        }
    }
```

- 完了時の処理
  - RunWorkerCompletedイベントハンドラに表示処理を足す
    - エラーで終了する場合：エラー内容の表示等を行う
    - キャンセルされた場合：DialogResultの値を変更する

```csharp
    //完了時の挙動
    private void BackgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
    {
        if((e.Error is null) == false)
        {
            //エラーの表示等の処理
            Console.WriteLine("error");
            DialogResult = DialogResult.Cancel;
        }
        else if (e.Cancelled)
        {
            //キャンセル時の処理
            Console.WriteLine("canceled");
            DialogResult = DialogResult.Cancel;                
        }
        else
        {
            //完了時の処理
            Console.WriteLine("completed");
            DialogResult = DialogResult.OK;
        }
        //フォームを閉じる
        this.Close();
    }
```

以上を加えたフォームを呼び出すコードが舌のようになります（上記部分は省略）

```csharp
public partial class ProgressForm : Form
{

    private BackgroundWorker backgroundWorker;

    public ProgressForm(BackgroundWorker bw)
    {
        InitializeComponent();
        this.backgroundWorker = bw;
        if (!bw.WorkerSupportsCancellation)
        {
            //キャンセル
            this.cancelBtn.Enabled = false;
            this.cancelBtn.Visible = false;
            this.ControlBox = false;
        }
        backgroundWorker.ProgressChanged += BackgroundWorker_ProgressChanged;
        backgroundWorker.RunWorkerCompleted += BackgroundWorker_RunWorkerCompleted;            
    }
    private void ProgressForm_Shown(object sender, EventArgs e)
    {
        //画面表示を開始した際に動いてない場合はRunWorkerAsync()を呼ぶ
        //二重に叩かないように判定を入れる
        if (!backgroundWorker.IsBusy)
        { 
            backgroundWorker.RunWorkerAsync();
        }
    }
    private void ProgressForm_FormClosed(object sender, FormClosedEventArgs e)
    {            
        //フォーム側でイベントハンドラーに加えた処理を外す      
        backgroundWorker.ProgressChanged -= BackgroundWorker_ProgressChanged;
        backgroundWorker.RunWorkerCompleted -= BackgroundWorker_RunWorkerCompleted;
    }
}
```

注意すべき点（筆者が作ってて躓いた点）は以下のようになります

- フォームが閉じた際に、`backgroundWorker`のイベントハンドラーに足した処理を外す
  - 渡された`backgroundWorker`を呼び出し側が再利用した際に副作用が起こる危険があるため

```csharp
    private void ProgressForm_FormClosed(object sender, FormClosedEventArgs e)
    {            
        //フォーム側でイベントハンドラーに加えた処理を外す      
        backgroundWorker.ProgressChanged -= BackgroundWorker_ProgressChanged;
        backgroundWorker.RunWorkerCompleted -= BackgroundWorker_RunWorkerCompleted;
    }
```

- キャンセル時に`RunWorkerCompletedEventArgs.Result`を**呼び出さない**
  - `RunWorkerCompletedEventArgs.Cancelled`がtrueになった状態でこの値を呼ぶとInvalidOperationExceptionが発生する仕様になっているため
- キャンセルボタン以外に×ボタンを押して終了させる可能性を忘れない
  - ×ボタンそのものを非表示にする/閉じる前にイベントを検知する、等の対策を組み込む

```csharp
    private void ProgressForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        //×ボタンで閉じられた際の対処
        //backgroundWorkerが動いており
        if (backgroundWorker.IsBusy && !backgroundWorker.CancellationPending)
        {
            //一度Closingをキャンセルしたうえで、改めてキャンセル処理をする。
            e.Cancel = true;
            backgroundWorker.CancelAsync();                
        }
    }
```

- プログレスバーに値を反映する際、プログレスバー側の最大値・最小値を超えた値を設定しないようにする。
  - 範囲内に無い値を代入するとエラーが出るため。

## 呼び出し側の処理

呼び出し側では以下の処理を行います。

- 進捗の管理：DoWorkイベントハンドラーに以下の内容を含んだ処理を足す。
  - 行いたい重たい処理
  - `ReportProgress`メソッドによる進捗の報告
  - キャンセル時の挙動

```csharp

public partial class Form1 : Form
{
    BackgroundWorker bw;
    public Form1()
    {
        InitializeComponent();
        bw = new BackgroundWorker();
    }

    private void backgroundWorker_DoWork(object sender, DoWorkEventArgs e)
    {
        for (int i = 0; i < 100; i++)
        {
            bw.ReportProgress(i, $"count is {i}");
            if(bw.CancellationPending)
            {
                //処理がキャンセルされた場合
                e.Cancel = true;//※重要：競合を防ぐため、Cancel状態をDoWorkEventArgsにも伝える。
                return;
            }
            System.Threading.Thread.Sleep(100);
        }
    }

    private void startBtn_Click(object sender, EventArgs e)
    {
        //イベントハンドラーに処理を渡す。
        bw.DoWork += this.backgroundWorker_DoWork;
        using (var prgForm = new ProgressForm(bw))
        {
            //進捗表示フォームを呼び出す。
            var res = prgForm.ShowDialog();
            if(res == DialogResult.OK){
                //正常終了時の処理
            }
            else{
                //正常終了でない場合の処理
            }
        }
        bw.DoWork -= this.backgroundWorker_DoWork;
    }
```

注意すべき点（筆者が作ってて躓いた点）は以下のようになります。

- `BackgroundWorker.CancellationPending`の値と`DoWorkEventArgs.Cancel`を**必ず揃える。**
  - `DoWorkEventArgs.Cancel`の値が`RunWorkerCompletedEventArgs.Cancelled`の値になるため、この処理を挟まないと完了時のキャンセル処理が行えなくなる。

```csharp
    if(bw.CancellationPending)
    {
        //処理がキャンセルされた場合
        e.Cancel = true;//※重要：競合を防ぐため、Cancel状態をDoWorkEventArgsにも伝える。
        return;
    }
```

## 参照

以下のサイトを参考にしました。

[DOBON.Netさん](https://dobon.net/vb/dotnet/programing/progressdialogbw.html)

[MSの公式](https://docs.microsoft.com/ja-jp/dotnet/api/system.componentmodel.backgroundworker?view=netframework-4.8#constructors)