在FormClosing事件中等待异步函数

51

我遇到了一个问题,在FormClosing事件中无法等待异步函数完成,这个函数将决定窗体关闭是否应该继续。我创建了一个简单的示例,如果您在没有保存的情况下关闭(就像记事本或Microsoft Word一样),会提示您保存未保存的更改。我遇到的问题是,当我等待异步的保存函数时,它会在保存函数完成之前关闭窗体,然后在完成后返回到关闭函数并尝试继续。我的唯一解决方案是在调用SaveAsync之前取消关闭事件,然后如果保存成功,它将调用form.Close()函数。我希望有一种更干净的处理这种情况的方法。

要复制这种情况,请创建一个带有文本框(txtValue)、复选框(cbFail)和按钮(btnSave)的表单。以下是表单的代码:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace TestZ
{
public partial class Form1 : Form
{

    string cleanValue = "";

    public Form1()
    {
        InitializeComponent();
    }

    public bool HasChanges()
    {
        return (txtValue.Text != cleanValue);
    }

    public void ResetChangeState()
    {
        cleanValue = txtValue.Text;
    }

    private async void btnSave_Click(object sender, EventArgs e)
    {
        //Save without immediate concern of the result
        await SaveAsync();
    }

    private async Task<bool> SaveAsync()
    {
        this.Cursor = Cursors.WaitCursor; 
        btnSave.Enabled = false;
        txtValue.Enabled = false;
        cbFail.Enabled = false;

        Task<bool> work = Task<bool>.Factory.StartNew(() =>
        {
            //Work to do on a background thread
            System.Threading.Thread.Sleep(3000); //Pretend to work hard.

            if (cbFail.Checked)
            {
                MessageBox.Show("Save Failed.");
                return false;
            }
            else
            {
                //The value is saved into the database, mark current form state as "clean"
                MessageBox.Show("Save Succeeded.");
                ResetChangeState();
                return true;
            }
        });

        bool retval = await work;

        btnSave.Enabled = true;
        txtValue.Enabled = true;
        cbFail.Enabled = true;
        this.Cursor = Cursors.Default;

        return retval;            
    }


    private async void Form1_FormClosing(object sender, FormClosingEventArgs e)
    {
        if (HasChanges())
        {
            DialogResult result = MessageBox.Show("There are unsaved changes. Do you want to save before closing?", "Unsaved Changes", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question);
            if (result == System.Windows.Forms.DialogResult.Yes)
            {
                //This is how I want to handle it - But it closes the form while it should be waiting for the Save() to complete.
                //bool SaveSuccessful = await Save();
                //if (!SaveSuccessful)
                //{
                //    e.Cancel = true;
                //}

                //This is how I have to handle it:
                e.Cancel = true; 
                bool SaveSuccessful = await SaveAsync();                    
                if (SaveSuccessful)
                {
                    this.Close();
                }
            }
            else if (result == System.Windows.Forms.DialogResult.Cancel)
            {
                e.Cancel = true;
            }

            //If they hit "No", just close the form.
        }
    }

}
}

编辑于05/23/2013

人们问我为什么要这样做是可以理解的。我们库中的数据类经常会有Save、Load、New、Delete等函数,这些函数都设计成可以异步运行(例如SaveAsync)。我并不在意在FormClosing事件中特别地以异步方式运行函数。但是,如果用户想在关闭表单之前保存,我需要它等待并查看保存是否成功。如果保存失败,则我希望取消表单关闭事件。我只是在寻找最简洁的方法来处理这个问题。


8
在程序终止前一毫秒触发的事件中使用await不会很好地运行。你需要让程序保持活动状态。 - Hans Passant
1
我认为到现在为止你还没有得到答案,这似乎表明你目前正在做的可能是最好的方法,或者至少足够好。它看起来不是很漂亮,但我能想到的唯一问题就是当它正在保存更改时,有人可能会点击关闭按钮并选择保存;你需要处理这个问题,并确保只有在发生这种情况时才调用保存一次。 - Servy
你说得对,也许没有更好的方法。至于在保存时防止用户点击保存按钮,在我的实际应用程序中我已经处理了。当保持表单响应时,这是一个需要注意的好事情。 - Hagelt18
相关:.NET中的关闭方法异步?。一个重要的细节是在程序关闭窗体之前,使用await Task.Yield();,否则如果SaveAsync()同步完成,可能会出现异常。 - Theodor Zoulias
6个回答

67
在我看来,最好的答案是取消窗体的关闭。始终如此。取消它,以任何您想要的方式显示对话框,一旦用户完成对话框操作,通过编程方式关闭窗体。
以下是我的操作:
async void Window_Closing(object sender, CancelEventArgs args)
{
    var w = (Window)sender;
    var h = (ObjectViewModelHost)w.Content;
    var v = h.ViewModel;

    if (v != null &&
        v.IsDirty)
    {
        args.Cancel = true;
        w.IsEnabled = false;

        // caller returns and window stays open
        await Task.Yield();

        var c = await interaction.ConfirmAsync(
            "Close",
            "You have unsaved changes in this window. If you exit, they will be discarded.",
            w);
        if (c)
            w.Close();

        // doesn't matter if it's closed
        w.IsEnabled = true;
    }
}

需要注意调用await Task.Yield()。如果异步方法总是异步执行,则不必要。但是,如果该方法具有任何同步路径(即空值检查和返回等),则Window_Closing事件将永远无法完成执行,并且对w.Close()的调用将引发异常。


这是我最初解决问题的方法。我希望这不是真正的解决方案,但似乎这确实是处理它的唯一方式。我认为这个问题已经存在了足够长的时间,所以我将这个解决方案标记为答案。感谢您提供示例代码! - Hagelt18
在Windows Forms中,禁用所有顶级控件,以允许对窗口进行基本布局操作,同时禁用表单内的任何交互。 - tm1
6
这对我有用,但在调用“this.Close()”之前,我必须取消注册关闭事件,否则将再次调用Closing事件,并且确认窗口会再次显示。 - bN_
1
这在WPF中是可行的。但正如其他人所提到的,您需要在调用Close()之前注销Closing事件处理程序(例如上面的Window_Closing),否则您将会得到无限反馈。 - Jonathan Lidbeck
1
我进行了编辑,解释为什么需要调用Task.Yield()。 (即使在这个例子中没有它也可能工作正常) - Smolakian
显示剩余2条评论

1

对话框可以在保留当前方法的堆栈的同时处理消息。

您可以在FormClosing处理程序中显示“正在保存...”对话框,并在新任务中运行实际的保存操作,该操作在完成后以编程方式关闭对话框。

请记住,SaveAsync在非UI线程中运行,并且需要通过Control.Invoke(请参见下面对decoy.Hide的调用)进行任何访问UI元素的调度。最好预先从控件中提取任何数据,并仅在任务中使用变量。

protected override void OnFormClosing(FormClosingEventArgs e)
{
        Form decoy = new Form()
        {
                ControlBox = false,
                StartPosition = FormStartPosition.CenterParent,
                Size = new Size(300, 100),
                Text = Text, // current window caption
        };
        Label label = new Label()
        {
                Text = "Saving...",
                TextAlign = ContentAlignment.MiddleCenter,
                Dock = DockStyle.Fill,
        };
        decoy.Controls.Add(label);
        var t = Task.Run(async () =>
        {
                try
                {
                        //keep the form open if saving fails
                        e.Cancel = !await SaveAsync();
                }
                finally
                {
                        decoy.Invoke(new MethodInvoker(decoy.Hide));
                }
        });
        decoy.ShowDialog(this);
        t.Wait(); //TODO: handle Exceptions
}

0

使用async/await无法阻止窗体关闭,而且可能会导致奇怪的结果。

我的建议是创建一个Thread并将其IsBackground属性设置为false(默认值为false),以在窗体关闭时保持进程活动状态。

protected override void OnClosing(CancelEventArgs e)
{
    e.Cancel = false;
    new Thread(() => { 
        Thread.Sleep(5000); //replace this line to save some data.....
        MessageBox.Show("EXITED"); 
    }).Start();
    base.OnClosing(e);
}

@Downvoter,您能否评论一下我的代码有什么问题,这样我就可以学习了吗? - I4V
3
你将 e.Cancel 设为默认值 false。在线程有机会运行之前关闭了窗体——如果保存数据的进程失败,窗体已经关闭,用户将失去工作,除非你想编写代码重新显示和填充 Form 的新实例来避免这种情况。正是 OP 想要避免的;至少你间接地建议优先覆盖方法而不是注册事件处理程序。 - binki
2
@binki 无论窗体是否关闭,在线程完成其工作之前,应用程序都无法退出。(请注意,它不是一个后台线程,我是有意这样设置的)。 - I4V
1
因此,您的“Save()”方法变体始终成功。即使磁盘已满或网络连接不可用 - 当然,没有必要将数据重新显示给用户以便记忆、复制到纸上或以不同方式保存。很好知道这点。 - binki
@binki,好的,你看到自己错了并添加了额外的评论 :) 首先,用户与此无关(即使是你心中的代码),当磁盘已满时,如果有解决方案,也可以在该线程中实现。其次,显示一些信息甚至获取一些确认可以在另一个线程中完成。你可以在我的其他答案中学习如何创建另一个消息泵。 - I4V

-1

如果在异步方法执行期间引发异常,我需要中止关闭表单的操作。

实际上,我正在使用 Task.Run.Wait()

private void Example_FormClosing(object sender, FormClosingEventArgs e)
{
    try
    {
        Task.Run(async () => await CreateAsync(listDomains)).Wait();
    }
    catch (Exception ex)
    {
        MessageBox.Show($"{ex.Message}", "Attention", MessageBoxButtons.OK, MessageBoxIcon.Error);
        e.Cancel = true;
    }
}

-1

当我尝试异步处理所有关闭事件时,我遇到了类似的问题。我认为这是因为没有阻止主线程继续进行实际的FormClosingEvents。只需在等待后放置一些内联代码即可解决该问题。在我的情况下,我保存当前状态,无论响应如何(同时等待响应)。您可以轻松地使任务返回一个当前状态,以便在用户响应后适当保存。

这对我有用:分离任务,询问退出确认,等待任务,一些内联代码。

    Task myNewTask = SaveMyCurrentStateTask();  //This takes a little while so I want it async in the background

    DialogResult exitResponse = MessageBox.Show("Are you sure you want to Exit MYAPPNAME? ", "Exit Application?", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);

            await myNewTask;

            if (exitResponse == DialogResult.Yes)
            {
                e.Cancel = false;
            }
            else
            {
                e.Cancel = true;
            }

2
在这种情况下,当它遇到await并停止阻塞时,它将看到Cancel被设置为false。此时,窗体将被拆除,如果它是主窗体,则整个进程将在此时被拆除。 - Servy
@Servy 我认为你是对的。MessageBox每次都能给我的保存函数足够的时间来完成。所以我很想知道除了“我的唯一解决方案是取消关闭事件...,然后如果保存成功,它将调用form.Close()函数。我希望有一种更清晰的处理这种情况的方法。” - user2044810
我认为你可以显示一个无法关闭的模态“保存”状态对话框(你需要自己创建一个表单,然后使用ShowDialog()显示它,并通过Task.ContinueWith()关闭它)。这样,应用程序的事件泵将继续运行,而不必返回事件,从而防止“(未响应)”,并给你在主机表单中设置e.Cancel = true的机会。如果我有时间,我可能会尝试测试并发布另一种替代答案... - binki

-2
为什么需要涉及异步行为?这听起来像是必须按线性方式发生的事情...我发现最简单的解决方案通常是正确的。
除了下面我的代码,你可以让主线程睡眠一两秒钟,然后在异步线程中设置一个标志位。
void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
    if (HasChanges())
    {
        DialogResult result = MessageBox.Show("There are unsaved changes. Do you want to save before closing?", "Unsaved Changes", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question);
        if (result == DialogResult.Yes)
        {
            e.Cancel = true; 
            if(!Save())
            {
                MessageBox.Show("Your work could not be saved. Check your input/config and try again");
                e.Cancel = true;
            }
        }
        else if (result == DialogResult.Cancel)
        {
            e.Cancel = true;
        } } }

1
这会导致表单被标记为未响应,向用户指示出现了问题并且程序已经崩溃,即使它没有。然后他们可能会终止程序,如果在保存时发生这种情况,那将是非常糟糕的。 - Servy
我的SaveAsync函数也可以通过按钮点击调用,在这种情况下,我希望屏幕在工作时保持响应。我正在考虑另一种选择,即让SaveAsync函数调用一个单独的非异步函数,只称为Save()。然后,当在表单关闭时,我将完全按照您所说的方式调用非异步保存函数。但是我会先等待其他答案。 - Hagelt18
因为如果你将它变成非异步的,那么你就必须维护一个新的非异步版本的保存方法。 - rollsch

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接