「未保存の変更があります…」をスマートに処理する!VB.NET FormClosingイベントの非同期処理とキャンセル制御の落とし穴
業務効率化ツール開発者の諸君、こんにちは。ベテランアーキテクトだ。日々の開発、お疲れ様。
今回は、Windows Forms アプリケーション設計における、地味ながらも非常に重要な「フォーム終了イベントの制御」について、特に `FormClosing` イベントにおける非同期処理とキャンセル処理に焦点を当てて、その落とし穴と、バグを生みにくい堅牢な設計パターンを伝授しよう。
「未保存の変更があります。終了しますか?」というあのダイアログ、一度は目にしたことがあるだろう。この確認ダイアログから、バックグラウンドでの自動保存処理を挟みつつ、安全にアプリケーションのシャットダウンを許可・中断させる。一見単純に見えるが、ここに潜む落とし穴は意外と深く、多くの開発者が知らず知らずのうちにバグの温床を作り込んでいる。
君たちが作成する業務自動化ツールは、ユーザーの生産性を直接左右する。だからこそ、このようなUIの挙動一つにも、妥協なき堅牢性と保守性が求められるのだ。今回は、実務でそのまま使えるプロダクションコード例を交えながら、その「なぜ」と「どう」を徹底的に解説していく。
1. なぜ `FormClosing` イベントが重要なのか?
まず基本から確認しよう。`FormClosing` イベントは、フォームが閉じられる直前に発生するイベントだ。このイベント内で、フォームを閉じる動作をキャンセルしたり、終了前に特定の処理を実行したりできる。
一般的なシナリオとしては、以下のようなものがあるだろう。
- 未保存データの保存確認: ユーザーが未保存の変更を残したままフォームを閉じようとした場合に、「保存しますか?」と確認する。
- リソースの解放: フォームが開いていた間に確保したリソース(ファイルハンドル、データベース接続など)をクリーンアップする。
- バックグラウンド処理の完了待ち: フォームを閉じる前に、完了させるべき非同期処理がある場合。
これらの処理を適切に行わないと、データ消失、リソースリーク、予期せぬエラーの原因となる。特に、「未保存の変更があります。終了しますか?」という確認ダイアログを挟む場合は、その後の処理が非常に重要になる。
2. よくある落とし穴:同期処理での「詰み」
多くの開発者が陥りがちなのは、`FormClosing` イベントハンドラ内で、同期的にユーザーへの確認を行い、その結果に基づいて保存処理などを実行しようとすることだ。
例えば、以下のようなコードを想像してみてほしい。
.net
‘ 悪い例:同期処理による問題
Private Sub MainForm_FormClosing(sender As Object, e As FormClosingEventArgs) Handles MyBase.FormClosing
If HasUnsavedChanges Then ‘ 未保存の変更があるフラグ
Dim result As DialogResult = MessageBox.Show(“未保存の変更があります。終了しますか?”, “確認”, MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question)
Select Case result
Case DialogResult.Yes
‘ ここで同期的に保存処理を実行しようとする
If SaveData() Then ‘ SaveData()が成功したと仮定
‘ 保存成功。フォームを閉じても良い。
‘ e.Cancel = False はデフォルトなので明示不要
Else
‘ 保存失敗。フォームを閉じたくない。
MessageBox.Show(“データの保存に失敗しました。終了をキャンセルします。”, “エラー”, MessageBoxButtons.OK, MessageBoxIcon.Error)
e.Cancel = True ‘ フォームを閉じるのをキャンセル
End If
Case DialogResult.No
‘ 保存せずに終了。
‘ e.Cancel = False はデフォルトなので明示不要
Case DialogResult.Cancel
‘ ユーザーがキャンセルを選択。フォームを閉じない。
e.Cancel = True ‘ フォームを閉じるのをキャンセル
End Select
End If
End Sub
‘ ダミーの未保存変更フラグ
Private HasUnsavedChanges As Boolean = True
‘ ダミーの保存処理(同期的に完了すると仮定)
Private Function SaveData() As Boolean
‘ 実際にはファイル書き込みやDB更新など
System.Threading.Thread.Sleep(2000) ‘ 時間のかかる処理をシミュレート
Console.WriteLine(“データ保存中…”)
‘ 保存処理が成功したと仮定
Return True
End Function
このコードの問題点は、`SaveData()` 関数が同期的に実行されることだ。もし `SaveData()` が時間のかかる処理(ネットワーク通信、大きなファイルの書き込み、複雑なデータベース操作など)だった場合、UIがフリーズする。ユーザーは「応答なし」状態になったフォームを操作できなくなる。
さらに、`MessageBox.Show` も同期的なメソッドであり、ユーザーが応答するまで処理をブロックする。`FormClosing` イベント内で複数の同期的なダイアログや時間のかかる処理を連鎖させると、UIの応答性が著しく低下し、ユーザーエクスペリエンスを損なうだけでなく、場合によっては「応答なし」と判断されてOSから強制終了させられる可能性すらある。
結論として、`FormClosing` イベント内で時間のかかる処理を同期的に行うのは、絶対に避けるべきだ。
3. 非同期処理の導入:`FormClosing` イベントでの正しいパターン
では、どうすれば良いのか? 答えは、非同期処理を適切に利用することだ。
`FormClosing` イベントが発生したとき、ユーザーに確認を求め、その結果として非同期で保存処理を開始する。そして、その非同期処理の完了を待つ(あるいは、完了を無視してフォームを閉じる)という流れを実装する必要がある。
ここで重要なのは、`FormClosing` イベント自体は、非同期処理が完了するのを待たずに、イベントハンドラを抜けてフォームを閉じようとするという点だ。つまり、`FormClosing` イベント内で非同期処理を開始しただけでは、その処理が終わる前にフォームが閉じてしまう可能性がある。
これを解決するために、我々は `async`/`await` を活用した非同期処理パターンと、イベント引数 `e` の `Cancel` プロパティを賢く使う必要がある。
3.1. `async` イベントハンドラと `e.Cancel` の活用
VB.NETでは、イベントハンドラを `Async` 修飾子付きで定義できる。これにより、イベントハンドラ内で `await` を使用できるようになる。
肝となるのは、`FormClosing` イベントハンドラ内で `e.Cancel = True` を設定してフォームのクローズを一旦保留し、非同期処理が完了した後に、改めてフォームを閉じるための処理(例えば `Me.Close()` を再度呼び出す)を行うことだ。
以下に、このパターンを実装したコード例を示す。
.net
‘ 堅牢な設計:FormClosingイベントでの非同期保存処理とキャンセル制御
Public Class MainForm
‘ 未保存の変更があるかどうかを示すフラグ
Private _hasUnsavedChanges As Boolean = False
‘ 保存処理が現在実行中かどうかを示すフラグ
Private _isSaving As Boolean = False
‘ 非同期保存処理のCancellationTokenSource
Private _saveCancellationTokenSource As CancellationTokenSource
‘ フォームのロード時に初期状態を設定
Private Sub MainForm_Load(sender As Object, e As EventArgs) Handles MyBase.Load
‘ 例: 初期データロード後、変更がない状態にする
_hasUnsavedChanges = False
‘ ユーザーの操作に応じて _hasUnsavedChanges を True/False に更新する
‘ 例: TextboxのTextChangedイベントなどで
‘ AddHandler MyTextBox.TextChanged, AddressOf TextBox_TextChanged
End Sub
‘ 例:Textboxの変更を検知してフラグを立てる
‘ Private Sub TextBox_TextChanged(sender As Object, e As EventArgs)
‘ _hasUnsavedChanges = True
‘ End Sub
‘ FormClosingイベントハンドラをAsyncにする
Private Async Sub MainForm_FormClosing(sender As Object, e As FormClosingEventArgs) Handles MyBase.FormClosing
‘ 既に保存処理が走っている場合は、ユーザーに通知してキャンセルする
If _isSaving Then
MessageBox.Show(“現在、データの保存処理を実行中です。保存が完了してから再度終了してください。”, “処理中”, MessageBoxButtons.OK, MessageBoxIcon.Information)
e.Cancel = True
Return
End If
‘ 未保存の変更がある場合のみ、確認と保存処理を行う
If _hasUnsavedChanges Then
‘ フォームのクローズを一旦キャンセルする
e.Cancel = True
‘ 確認ダイアログを表示
Dim result As DialogResult = MessageBox.Show(“未保存の変更があります。保存して終了しますか?”, “保存確認”, MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question)
Select Case result
Case DialogResult.Yes
‘ 保存処理を非同期で開始
_isSaving = True ‘ 保存開始フラグを立てる
_saveCancellationTokenSource = New CancellationTokenSource() ‘ 新しいCancellationTokenSourceを作成
Dim cancellationToken As CancellationToken = _saveCancellationTokenSource.Token
Try
‘ 非同期保存処理を実行(awaitで完了を待つ)
‘ SaveDataAsyncメソッドはTask(Of Boolean)を返すことを想定
Dim saveSuccess As Boolean = Await SaveDataAsync(cancellationToken)
If saveSuccess Then
‘ 保存成功。フラグをリセットし、フォームを閉じる
_hasUnsavedChanges = False
_isSaving = False ‘ 保存完了フラグをクリア
Me.Close() ‘ フォームを再度閉じる(FormClosingイベントが再度発生するが、_hasUnsavedChangesがFalseなのでクローズされる)
Else
‘ 保存失敗。エラーメッセージを表示し、フォームは開いたままにする
MessageBox.Show(“データの保存に失敗しました。後でもう一度お試しください。”, “保存エラー”, MessageBoxButtons.OK, MessageBoxIcon.Error)
_isSaving = False ‘ 保存失敗なのでフラグをクリア
‘ e.Cancel は既にTrueなので、フォームは閉じられない
End If
Catch ex As OperationCanceledException
‘ ユーザーが保存中にキャンセルした場合
MessageBox.Show(“データの保存はキャンセルされました。”, “キャンセル”, MessageBoxButtons.OK, MessageBoxIcon.Information)
_isSaving = False ‘ キャンセルなのでフラグをクリア
‘ フォームは開いたまま
Catch ex As Exception
‘ その他の保存時エラー
MessageBox.Show($”データの保存中に予期せぬエラーが発生しました: {ex.Message}”, “エラー”, MessageBoxButtons.OK, MessageBoxIcon.Error)
_isSaving = False ‘ エラーなのでフラグをクリア
‘ フォームは開いたまま
Finally
‘ CancellationTokenSourceをクリーンアップ
_saveCancellationTokenSource?.Dispose()
_saveCancellationTokenSource = Nothing
End Try
Case DialogResult.No
‘ 保存せずに終了
‘ _hasUnsavedChanges は True のまま
‘ e.Cancel は既にTrueなので、フォームは閉じられない。
‘ ここで明示的に e.Cancel = False とすれば、保存せずに閉じられる。
e.Cancel = False ‘ 保存しないことを明示し、フォームを閉じる
Case DialogResult.Cancel
‘ ユーザーがキャンセルを選択。フォームは開いたまま
‘ e.Cancel は既にTrueなので、フォームは閉じられない
‘ 何もせず、e.Cancel = True のままにする
Exit Sub ‘ イベントハンドラを終了
End Select
End If
‘ 未保存の変更がない場合は、e.CancelはFalseのままなので、フォームはそのまま閉じられる
End Sub
‘ 非同期保存処理のダミーメソッド
‘ Task(Of Boolean) を返し、成功/失敗を示す
‘ CancellationToken を受け取り、キャンセル要求に応答できるようにする
Private Async Function SaveDataAsync(token As CancellationToken) As Task(Of Boolean)
Console.WriteLine(“非同期保存処理開始…”)
‘ 時間のかかる処理をシミュレート
‘ token.ThrowIfCancellationRequested() でキャンセル要求をチェック
Await Task.Delay(3000, token) ‘ 3秒待機。キャンセルされたらOperationCanceledExceptionを投げる
‘ token.ThrowIfCancellationRequested() を複数箇所に配置すると、より細かくキャンセルに対応できる
‘ 例: ファイル書き込みのループ内など
‘ 保存処理が成功したと仮定
Console.WriteLine(“非同期保存処理完了。”)
Return True
End Function
‘ フォームのプロパティやメソッド(例:保存処理をトリガーするもの)
‘ Private Sub SaveButton_Click(sender As Object, e As EventArgs) Handles SaveButton.Click
‘ If Not _isSaving Then
‘ _hasUnsavedChanges = True ‘ 保存ボタンを押したら変更があったとみなす
‘ ‘ ここで非同期保存処理を呼び出すことも可能
‘ ‘ Task.Run(Async Sub()
‘ ‘ Await SaveDataAsync(New CancellationToken())
‘ ‘ End Sub)
‘ End If
‘ End Sub
‘ フォームのクローズをリクエストするメソッド(例:メニューから)
Private Sub ExitMenuItem_Click(sender As Object, e As EventArgs) Handles ExitMenuItem.Click
‘ フォームのクローズをリクエストする。
‘ FormClosingイベントがトリガーされる。
Me.Close()
End Sub
‘ フォームのModifiedプロパティを模倣する(例:ユーザーが変更を加えたらTrueにする)
‘ この例では、単純なフラグ (_hasUnsavedChanges) を使用しているが、
‘ 実際のアプリケーションでは、各コントロールの変更を追跡する
‘ より洗練されたメカニズムが必要になる場合がある。
Public Property HasUnsavedChanges As Boolean
Get
Return _hasUnsavedChanges
End Get
Set(value As Boolean)
_hasUnsavedChanges = value
‘ UI要素(例:タイトルバーの()表示)を更新するロジックをここに追加
‘ If value Then
‘ Me.Text = Me.Text & ” ()”
‘ Else
‘ If Me.Text.EndsWith(” ()”) Then
‘ Me.Text = Me.Text.Substring(0, Me.Text.Length – 4)
‘ End If
‘ End If
End Set
End Property
End Class
このコードのポイントは以下の通りだ。
1. `Async Sub` イベントハンドラ: `FormClosing` イベントハンドラを `Async Sub` とすることで、`await` を使用可能にする。
2. `e.Cancel = True` による保留: ユーザーが「保存しますか?」に「はい」または「キャンセル」を選択した場合、まず `e.Cancel = True` を設定してフォームのクローズを保留する。
3. `Await SaveDataAsync()`: 時間のかかる保存処理は `SaveDataAsync` という非同期メソッドとして実装し、`await` でその完了を待つ。
4. 完了後の `Me.Close()`: 非同期保存処理が成功した場合、`_hasUnsavedChanges` フラグをリセットし、再度 `Me.Close()` を呼び出す。これにより、`FormClosing` イベントが再度発生するが、`_hasUnsavedChanges` が `False` になっているため、今度は `e.Cancel = False` となり、フォームが正常に閉じられる。
5. 保存失敗時の処理: 保存に失敗した場合は、エラーメッセージを表示し、`e.Cancel` は `True` のままにしてフォームを閉じない。
6. 保存中の重複終了防止: `_isSaving` フラグを導入し、保存処理中に再度終了操作が行われた場合に、ユーザーに通知してキャンセルする。
7. `CancellationToken` の利用: `SaveDataAsync` メソッドに `CancellationToken` を渡し、必要であれば保存処理を中断できるようにする。これにより、ユーザーが「キャンセル」ボタンを押した際に、実行中の重い処理を即座に停止させることが可能になる。`Task.Delay` は `CancellationToken` に対応している。ファイルI/Oやネットワーク通信を行うAPIも、多くが `CancellationToken` をサポートしている。
3.2. ファイルやデータベース連携時の注意点
非同期処理をファイルやデータベース連携に適用する際は、いくつかの注意点がある。
- 非同期APIの利用: .NET Framework/.NET Core/.NET 5+ には、ファイルI/O (`File.ReadAllTextAsync`, `File.WriteAllTextAsync` など) やデータベースアクセス (`ADO.NET` の `ExecuteReaderAsync`, `SqlCommand.ExecuteNonQueryAsync` など、ORMを使っているならEF Coreの非同期メソッド) に非同期APIが用意されている。これらを積極的に利用しよう。
- エラーハンドリング: 非同期処理は、同期処理とは異なる方法で例外を捕捉する必要がある。`try-catch` ブロックはそのまま利用できるが、`Task` の例外は `AggregateException` としてラップされる場合がある(特に `Task.WhenAll` など)。`await` を使えば、通常は個別の例外として扱われるため、`try-catch` で適切に処理できる。
- トランザクション管理: データベース更新を行う場合、非同期処理であってもトランザクションを適切に管理する必要がある。長時間実行される非同期処理がトランザクションを保持しすぎると、データベースロックの原因になる可能性があるため、設計には注意が必要だ。
- キャンセル処理: ユーザーが保存をキャンセルした場合、実行中の非同期処理を中断させる必要がある。`CancellationToken` はこのための標準的な仕組みだ。`await` するメソッドが `CancellationToken` を受け取るように設計されているか確認し、必要であれば自身のコードでもサポートする。
4. ユーザー体験を損なわないための追加の配慮
- 進捗表示: 時間のかかる非同期保存処理中は、ユーザーに何らかのフィードバックを提供すべきだ。ステータスバーに「保存中…」と表示したり、プログレスバーを表示したりすると、アプリケーションがフリーズしているのではなく、処理が進行中であることが伝わる。
- 保存中の操作許可: 可能であれば、保存処理中も一部のUI操作(例えば、別のデータ表示の切り替えなど)を許可することで、ユーザー体験は向上する。これは、UIスレッドをブロックしないように、バックグラウンドスレッドで処理を工夫する必要がある。
- 「保存しない」選択時の警告: ユーザーが「保存しない」を選択した場合、データが失われる可能性があることを再度確認するダイアログを表示することも検討しよう。
5. まとめ:堅牢な設計のために
`FormClosing` イベントでの非同期処理とキャンセル制御は、アプリケーションの堅牢性を高める上で不可欠な要素だ。
- 時間のかかる処理は、絶対に同期的に行わない。 UIフリーズの最大の原因だ。
- `FormClosing` イベントハンドラを `Async Sub` とし、`e.Cancel = True` で保留、非同期処理完了後に `Me.Close()` を再度呼び出すパターンをマスターする。
- `CancellationToken` を活用し、ユーザーのキャンセル要求に適切に応答できるようにする。
- ファイルやデータベース連携では、非同期API、適切なエラーハンドリング、トランザクション管理、キャンセル処理に細心の注意を払う。
- ユーザーへのフィードバック(進捗表示など)を怠らない。
これらの原則を守ることで、ユーザーにストレスを与えず、データの安全性を確保した、信頼性の高い業務自動化ツールを開発できるはずだ。
君たちの現場での実装に、この知見が役立つことを願っている。進化し続けるコードを、共に追求していこう。
