😸

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

2021/05/22に公開

初めに

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

TL;DR

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

表示側

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;
    }
}

呼び出し側


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には以下の3つのMethodが実装されており、それぞれ叩かれた瞬間にイベントを発生させます。

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

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

進捗表示画面側の処理

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

  • 進捗の表示
    • ProgressChangedイベントハンドラーに表示の更新処理を追加する。
    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()を呼ぶ
    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の値を変更する
    //完了時の挙動
    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();
    }

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

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を呼び出し側が再利用した際に副作用が起こる危険があるため
    private void ProgressForm_FormClosed(object sender, FormClosedEventArgs e)
    {            
        //フォーム側でイベントハンドラーに加えた処理を外す      
        backgroundWorker.ProgressChanged -= BackgroundWorker_ProgressChanged;
        backgroundWorker.RunWorkerCompleted -= BackgroundWorker_RunWorkerCompleted;
    }
  • キャンセル時にRunWorkerCompletedEventArgs.Result呼び出さない
    • RunWorkerCompletedEventArgs.Cancelledがtrueになった状態でこの値を呼ぶとInvalidOperationExceptionが発生する仕様になっているため
  • キャンセルボタン以外に×ボタンを押して終了させる可能性を忘れない
    • ×ボタンそのものを非表示にする/閉じる前にイベントを検知する、等の対策を組み込む
    private void ProgressForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        //×ボタンで閉じられた際の対処
        //backgroundWorkerが動いており
        if (backgroundWorker.IsBusy && !backgroundWorker.CancellationPending)
        {
            //一度Closingをキャンセルしたうえで、改めてキャンセル処理をする。
            e.Cancel = true;
            backgroundWorker.CancelAsync();                
        }
    }
  • プログレスバーに値を反映する際、プログレスバー側の最大値・最小値を超えた値を設定しないようにする。
    • 範囲内に無い値を代入するとエラーが出るため。

呼び出し側の処理

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

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

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の値になるため、この処理を挟まないと完了時のキャンセル処理が行えなくなる。
    if(bw.CancellationPending)
    {
        //処理がキャンセルされた場合
        e.Cancel = true;//※重要:競合を防ぐため、Cancel状態をDoWorkEventArgsにも伝える。
        return;
    }

参照

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

DOBON.Netさん

MSの公式

Discussion