鷲ノ巣

C# とか PowerShell とか。当ブログの記事は(特に公開直後は)頻繁に改定される場合があることをご了承ください。

【WinUI3】ウィンドウが閉じるときに保存確認する

最近、ちょっと Windows アプリでも作ろうかと思っており、WinUI3 に挑戦しています。
私が最後に Windows の GUI フレームワークを触ったのは .NET Framework 時代の Windows Forms であり、WPF も UWP も経験していないため四苦八苦しています。

さて、例えば単純なメモ帳のようなアプリケーションを作ることを考えましょう。
ファイルを開いて、何か書き換えます。
その状態でアプリケーションを閉じようとしたら、保存確認ダイアログを出しますよね。そのやり方についてです。

完成品

先に完成品のコードを載せちゃいましょう。こんな感じに書いてみました。どうでしょうか。

partial class MainWindow
{
    public MainWindow()
    {
        this.InitializeComponent();

        this.Closed += this.OnClosed;
    }

    private bool _closing;

    private async void OnClosed(object sender, WindowEventArgs args)
    {
        if (this._closing)
        {
            this.Closed -= this.OnClosed;
            return;
        }

        var task = this.ViewModel.CloseAsync();
        if (task.IsCompleted)
        {
            args.Handled = !task.Result;
            return;
        }

        args.Handled = true;

        if (await task)
        {
            this._closing = true;
            this.Close();
        }
    }
}

ウィンドウが閉じられるときには Window.Closed イベントが発生します。
WindowEventArgs.Handled プロパティtrue をセットすると、既定の処理(ウィンドウの破棄)を中断することができます。

仕様

実現したい仕様はこんな感じです。

  • 内容が変更されており保存されていなければ、ユーザーに保存確認をする。
  • 保存確認には「保存する」「保存しない」「キャンセル」の3つの選択肢がある。
  • 「保存する」を選んだら、ファイルを保存してアプリケーションを終了する。
  • 「保存しない」を選んだら、ファイルを保存せず(変更を破棄して)アプリケーションを終了する。
  • 「キャンセル」を選んだら、アプリケーションを終了しない。
  • 上記のロジックは ViewModel に書く。
  • ViewModel.CloseAsync メソッドは、アプリを終了してよければ true を返し、だめなら(ユーザーが「キャンセル」を選んだら)false を返す。
  • 内容が変更されていなければ、確認せずに true を返す。

実際にはダイアログを表示して確認することになると思います。
判断をするのは ViewModel ですが、ダイアログを出すのは View の仕事です。ここをどう繋ぐかというのも面白い課題なのですが、本記事ではその方法には深入りしません。
ダイアログを表示する典型的な方法は ContentDialog.ShowAsync メソッドです。
これが非同期メソッドであるため、CloseAsync メソッドも非同期メソッドになっています。

第一案:キャンセルが効かない

最初はシンプルにこんな風に書いてみました。

partial class MainWindow
{
    private async void OnClosed(object sender, WindowEventArgs args)
    {
        if (!await this.ViewModel.CloseAsync())
        {
            // ユーザーが「キャンセル」を選んだら終了しない
            args.Handled = true;
        }
    }
}

これだとどうなるでしょう?
ダイアログは出るかもしれませんが、「キャンセル」を押してもウィンドウは閉じ、アプリケーションは終了されてしまいます。

なぜでしょうか。ポイントは、Closed イベントの戻り値が void だということです。
async void なメソッドはイベント ハンドラでよく使われますが、呼び出し元がその終了を待機することができません。
非同期メソッドは、最初の await に差し掛かったところで、一旦制御を呼び出し元に戻してしまいます。
呼び出し元では非同期処理が進行中である(ユーザーがダイアログに応答するのを待っている)ということがわからないため、その時点で Handled プロパティが true にセットされていなければ、ウィンドウは破棄されてしまうのです。

つまり、await を通過した後で Handled プロパティをセットしても、もう遅いというわけです。

第二案:ウィンドウが閉じない

というわけで次の実装はこう。

partial class MainWindow
{
    private bool _closing;

    private async void OnClosed(object sender, WindowEventArgs args)
    {
        // _closing フラグが立っていたら終了してよい
        if (this._closing)
        {
            this.Closed -= this.OnClosed;
            return;
        }

        // 非同期メソッドを呼ぶ前に一律で Handled プロパティをセットする
        args.Handled = true;

        if (!await this.ViewModel.CloseAsync())
        {
            return;
        }

        // 終了してよいのでもう一度 Close メソッドを呼ぶ
        this._closing = true;
        this.Close();
    }
}

非同期メソッドを呼ぶ前に一律で Handled プロパティに true をセットしています。
終了してよい場合はあらためて Window.Close メソッドを呼びます。するともう一度 Closed イベントが発生します。
この時は既に終了確認は済んでいるため、_closing フラグを使って、確認の要否を判断しています。

ところが、これもダメでした。どんな問題があるでしょうか?
答えは「変更がなかったらウィンドウが閉じない」です。

ViewModel.CloseAsync メソッドの実装はこんな感じになっています。ConfirmAsync メソッドの中身は省略しますが、この先でダイアログを出していると想定してください。

class ViewModel
{
    public async ValueTask<bool> CloseAsync()
    {
        if (!this.IsDirty)
        {
            // 変更がなければ確認せずに true を返す
            return true;
        }

        var confirmationResult = await this.ConfirmAsync();
        switch (confirmationResult)
        {
            case ConfirmationResult.Save:
                // TODO: ここでセーブする
                return true;

            case ConfirmationResult.Discard:
                return true;

            case ConfirmationResult.Cancel:
                return false;
        }
    }
}

ポイントは、変更がなければ確認せずに true を返すというところです。
この場合、await を通っていないため、まだ処理は同期的に進行しています。
つまり、この場合は、Closed イベントの処理中に Close が呼ばれるということになります。

で、こういう場合、Close メソッドの呼び出しは無視されます。

WinUI の実装を見てみる

Close メソッドの実装はこんな感じになっています。
なお、WinUI3 のフレームワークは、Win32 API と WinRT API の上に構築されています。
Win32 API や WinRT API は Windows OS のネイティブ API なので、通常、ソースコードを見ることはできませんが、その上に構築された WinUI レイヤーに関しては、部分的にコードを見ることができます。

_Check_return_ HRESULT DesktopWindowImpl::CloseImpl()
{
    if (!m_bIsClosed && !m_bIsClosing)
    {
        auto guard = wil::scope_exit([&, this]()
        {
            // incase of any failure, closing and closed states
            // will reset so that closing operation can be attempted again
            m_bIsClosing = false;
            m_bIsClosed = false;
        });

        m_bIsClosing = true;

        // Create and populate the window closed event args
        ctl::ComPtr<WindowEventArgs> windowClosedEventArgs;
        IFC_RETURN(ctl::make(&windowClosedEventArgs));
        IFC_RETURN(windowClosedEventArgs->put_Handled(FALSE));

        // Raise the window closed event
        IFC_RETURN(m_closedEventSource.Raise(m_dxamlWindowInstance, windowClosedEventArgs.Get()));

        BOOLEAN handled = FALSE;
        IFC_RETURN(windowClosedEventArgs->get_Handled(&handled));
        if (handled)
        {
            // don't proceed to close if closing event has been handled
            // m_bIsClosing will get reset to false
            return S_OK;
        }

        m_desktopWindowXamlSource->PrepareToClose();

        // set these to null before marking window as closed as they fail if called after m_bIsClosed is set
        // because they check if window is closed already
        IFC_RETURN(m_dxamlWindowInstance->SetTitleBar(nullptr));
        IFC_RETURN(m_dxamlWindowInstance->put_Content(nullptr));

        m_windowChrome->SetDesktopWindow(nullptr);

        // Mark Desktop Window instance as 'closed'
        m_bIsClosed = true;
        guard.release(); // success, no need to reset closing and closed states

        if (!m_bMinimizedOrHidden)
        {
            // if this call fails, we don't want to reset shutdown status
            // better to crash
            VERIFYHR(RaiseWindowVisibilityChangedEvent(FALSE));
        }

        // Close win32 window, cleanup, and unregister from hwnd mapping from DXamlCore
        Shutdown();
    }

    return S_OK;
}

github.com

注目すべきは m_bIsClosing という変数です。これは CloseImpl 関数が呼ばれた直後に true になり、関数を抜けるときに wil::scope_exit の効果によって false にリセットされます。
wil::scope_exit は C# でいう finally みたいなもんです(標準 C++ には finally がありません)。
そして、CloseImpl 関数が呼ばれた時点で、既に m_bIsClosingtrue になっていたら、この関数は何も処理を行いません。

つまり、こうなるわけです。

  1. ウィンドウの×ボタンが押されるなどして、一度目の CloseImpl 関数が呼ばれる。
  2. m_bIsClosing フラグが立つ。
  3. Closed イベントが呼ばれる。
  4. Handled プロパティに true をセットする。
  5. 変更がなかったので await せずに制御を返す。
  6. 終了してよいのでもう一度 Close メソッドを呼ぶ。
  7. 二度目の CloseImpl 関数が呼ばれるが、一度目の CloseImpl 関数の処理がまだ終わっておらず、m_bIsClosing フラグが立ったままなので、再度実行されても無視される。
  8. ウィンドウが閉じない。

C# 側で(Handled プロパティに true をセットしてから)await を跨いでいる場合、その時点でここを通って CloseImpl 関数を抜け、m_bIsClosing フラグがリセットされるので、その後ならもう一度 Close メソッドを呼んでもちゃんと処理されるのです。

        if (handled)
        {
            // don't proceed to close if closing event has been handled
            // m_bIsClosing will get reset to false
            return S_OK;
        }

ふたたび完成品

冒頭の完成品にコメントを付けたものを再掲します。

partial class MainWindow
{
    private bool _closing;

    private async void OnClosed(object sender, WindowEventArgs args)
    {
        // _closing フラグが立っていたら終了してよい
        if (this._closing)
        {
            this.Closed -= this.OnClosed;
            return;
        }

        var task = this.ViewModel.CloseAsync();
        if (task.IsCompleted)
        {
            // CloseAsync メソッドが同期的に完了していたら
            // まだ CloseImpl 関数の処理が終わっていない(m_bIsClosing フラグが立っている)ので
            // ここで Close メソッドを呼んでも無効になる
            // そのため、終了してよい場合は Handled プロパティを true にしない
            args.Handled = !task.Result;
            return;
        }

        // await する前に Handled プロパティをセットする
        args.Handled = true;

        if (await task)
        {
            // 終了してよいので改めて Close メソッドを呼ぶ
            // この時点では m_bIsClosing フラグはリセットされているので受け付けられ、Closed イベントが再度発生する
            this._closing = true;
            this.Close();
        }
    }
}

どうでしょうか。
やろうとしていることは単純に見えて、思いのほか大変な実装になりました。

なお、私の無知ゆえに変なことをしている可能性はあります。その際はコメントでご指摘いただければ幸いです。

おわりに

え、マジでみんなこれやってんの?

追記

Window.Closed イベントは名前からしてすでに「閉じた後」であり、確認は「閉じる前」に行うべきなので、Window.Closed イベントで行うべきではないのではないか、というご指摘がありました。
ただ、Window クラスには Closing とかいうイベントはないんですよね。WPF にはあるみたいですが。

AppWindow.Closing というイベントはあるので、こっちで保存確認を出すこともできます。

partial class MainWindow
{
    public MainWindow()
    {
        this.InitializeComponent();

        this.AppWindow.Closing += this.OnAppWindowClosing;
    }

    private void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args)
    {
        // これでもウィンドウが閉じるのをキャンセルできる
        args.Cancel = true;
    }
}

この場合でもイベント ハンドラの戻り値が void なので、非同期メソッドを呼ぶ前に Cancel プロパティをセットするといった配慮は必要です。

この方法の欠点は、AppWindow.Closing イベントは、ユーザーが×ボタンでウィンドウを閉じる場合にしか発火しないということです。
たとえば、ウィンドウにメニューがあって、その「終了」を選択した場合の処理を以下のように書いているとすると、この場合、AppWindow.Closing イベントは発火しません。
おそらく、AppWindow.Closing イベントは WM_CLOSE メッセージに反応して呼ばれるのですが、Window.Close メソッドを呼んだ場合は WM_CLOSE メッセージが飛んでいないためと思われます。

そのため、二系統の終了確認処理が必要になるので、Window.Closed イベントに一本化したかったという理由もあり、前掲のようなコードになったのです。

partial class MainWindow
{
    public MainWindow()
    {
        this.InitializeComponent();

        this.AppWindow.Closing += this.OnAppWindowClosing;
        this.MenuFileExit.Click += this.OnMenuFileExitClicked;
    }

    private void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args)
    {
        // ここで終了確認をしていると、↓の this.Close() の場合には呼ばれない
    }

    private void OnMenuFileExitClicked(object sender, RoutedEventArgs args)
    {
        // そのため、こっちでも終了確認処理をする必要がある
        this.Close();
    }
}