Modal Progress Form showing IProgress and supporti

2020-02-28 10:36发布

问题:

I have been attempting to have a re-usable modal progress window (I.e. progressForm.ShowDialog()) to show progress from a running async task, including enabling cancellation.
I have seen some implementations that launch start the async task by hooking the Activated event handler on the form, but I need to start the task first, then show the modal dialog that will show it's progress, and then have the modal dialog close when completed or cancellation is completed (note - I want the form closed when cancellation is completed - signalled to close from the task continuation).

I currently have the following - and although this working - are there issues with this - or could this be done in a better way?

I did read that I need to run this CTRL-F5, without debugging (to avoid the AggregateException stopping the debugger in the continuation - and let it be caught in the try catch as in production code)

ProgressForm.cs - Form with ProgressBar (progressBar1) and Button (btnCancel)

public partial class ProgressForm : Form
{
    public ProgressForm()
    {
        InitializeComponent();
    }

    public event Action Cancelled;
    private void btnCancel_Click(object sender, EventArgs e)
    {
        if (Cancelled != null) Cancelled();
    }

    public void UpdateProgress(int progressInfo)
    {
        this.progressBar1.Value = progressInfo;
    }
}

Services.cs - Class file containing logic consumed by WinForms app (as well as console app)

public class MyService
{
    public async Task<bool> DoSomethingWithResult(
        int arg, CancellationToken token, IProgress<int> progress)
    {
        // Note: arg value would normally be an 
        //  object with meaningful input args (Request)

        // un-quote this to test exception occuring.
        //throw new Exception("Something bad happened.");

        // Procressing would normally be several Async calls, such as ...
        //  reading a file (e.g. await ReadAsync)
        //  Then processing it (CPU instensive, await Task.Run), 
        //  and then updating a database (await UpdateAsync)
        //  Just using Delay here to provide sample, 
        //   using arg as delay, doing that 100 times.

        for (int i = 0; i < 100; i++)
        {
            token.ThrowIfCancellationRequested();
            await Task.Delay(arg);
            progress.Report(i + 1);
        }

        // return value would be an object with meaningful results (Response)
        return true;
    }
}

MainForm.cs - Form with Button (btnDo).

public partial class MainForm : Form
{
    public MainForm()
    {
        InitializeComponent();
    }

    private async void btnDo_Click(object sender, EventArgs e)
    {
        CancellationTokenSource cts = new CancellationTokenSource();
        CancellationToken token = cts.Token;

        // Create the ProgressForm, and hook up the cancellation to it.
        ProgressForm progressForm = new ProgressForm();
        progressForm.Cancelled += () => cts.Cancel();

        // Create the progress reporter - and have it update 
        //  the form directly (if form is valid (not disposed))
        Action<int> progressHandlerAction = (progressInfo) =>
        {
            if (!progressForm.IsDisposed) // don't attempt to use disposed form
                progressForm.UpdateProgress(progressInfo);
        };
        Progress<int> progress = new Progress<int>(progressHandlerAction);

        // start the task, and continue back on UI thread to close ProgressForm
        Task<bool> responseTask
            = MyService.DoSomethingWithResultAsync(100, token, progress)
            .ContinueWith(p =>
            {
                if (!progressForm.IsDisposed) // don't attempt to close disposed form
                    progressForm.Close();
                return p.Result;
            }, TaskScheduler.FromCurrentSynchronizationContext());

        Debug.WriteLine("Before ShowDialog");

        // only show progressForm if 
        if (!progressForm.IsDisposed) // don't attempt to use disposed form
            progressForm.ShowDialog();

        Debug.WriteLine("After ShowDialog");

        bool response = false;

        // await for the task to complete, get the response, 
        //  and check for cancellation and exceptions
        try
        {
            response = await responseTask;
            MessageBox.Show("Result = " + response.ToString());
        }
        catch (AggregateException ae)
        {
            if (ae.InnerException is OperationCanceledException)
                Debug.WriteLine("Cancelled");
            else
            {
                StringBuilder sb = new StringBuilder();
                foreach (var ie in ae.InnerExceptions)
                {
                    sb.AppendLine(ie.Message);
                }
                MessageBox.Show(sb.ToString());
            }
        }
        finally
        {
            // Do I need to double check the form is closed?
            if (!progressForm.IsDisposed) 
                progressForm.Close();
        }

    }
}

Modified code - using TaskCompletionSource as recommended...

    private async void btnDo_Click(object sender, EventArgs e)
    {
        bool? response = null;
        string errorMessage = null;
        using (CancellationTokenSource cts = new CancellationTokenSource())
        {
            using (ProgressForm2 progressForm = new ProgressForm2())
            {
                progressForm.Cancelled += 
                    () => cts.Cancel();
                var dialogReadyTcs = new TaskCompletionSource<object>();
                progressForm.Shown += 
                    (sX, eX) => dialogReadyTcs.TrySetResult(null);
                var dialogTask = Task.Factory.StartNew(
                    () =>progressForm.ShowDialog(this),
                    cts.Token,
                    TaskCreationOptions.None,
                    TaskScheduler.FromCurrentSynchronizationContext());
                await dialogReadyTcs.Task;
                Progress<int> progress = new Progress<int>(
                    (progressInfo) => progressForm.UpdateProgress(progressInfo));
                try
                {
                    response = await MyService.DoSomethingWithResultAsync(50, cts.Token, progress);
                }
                catch (OperationCanceledException) { } // Cancelled
                catch (Exception ex)
                {
                    errorMessage = ex.Message;
                }
                finally
                {
                    progressForm.Close();
                }
                await dialogTask;
            }
        }
        if (response != null) // Success - have valid response
            MessageBox.Show("MainForm: Result = " + response.ToString());
        else // Faulted
            if (errorMessage != null) MessageBox.Show(errorMessage);
    }

回答1:

I think the biggest issue I have, is that using await (instead of ContinueWith) means I can't use ShowDialog because both are blocking calls. If I call ShowDialog first the code is blocked at that point, and the progress form needs to actually start the async method (which is what I want to avoid). If I call await MyService.DoSomethingWithResultAsync first, then this blocks and I can't then show my progress form.

The ShowDialog is indeed a blocking API in the sense it doesn't return until the dialog has been closed. But it is non-blocking in the sense it continues to pump messages, albeit on a new nested message loop. We can utilize this behavior with async/await and TaskCompletionSource:

private async void btnDo_Click(object sender, EventArgs e)
{
    CancellationTokenSource cts = new CancellationTokenSource();
    CancellationToken token = cts.Token;

    // Create the ProgressForm, and hook up the cancellation to it.
    ProgressForm progressForm = new ProgressForm();
    progressForm.Cancelled += () => cts.Cancel();

    var dialogReadyTcs = new TaskCompletionSource<object>();
    progressForm.Load += (sX, eX) => dialogReadyTcs.TrySetResult(true);

    // show the dialog asynchronousy
    var dialogTask = Task.Factory.StartNew( 
        () => progressForm.ShowDialog(),
        token,
        TaskCreationOptions.None,
        TaskScheduler.FromCurrentSynchronizationContext());

    // await to make sure the dialog is ready
    await dialogReadyTcs.Task;

    // continue on a new nested message loop,
    // which has been started by progressForm.ShowDialog()

    // Create the progress reporter - and have it update 
    //  the form directly (if form is valid (not disposed))
    Action<int> progressHandlerAction = (progressInfo) =>
    {
        if (!progressForm.IsDisposed) // don't attempt to use disposed form
            progressForm.UpdateProgress(progressInfo);
    };
    Progress<int> progress = new Progress<int>(progressHandlerAction);

    try
    {
        // await the worker task
        var taskResult = await MyService.DoSomethingWithResultAsync(100, token, progress);
    }
    catch (Exception ex)
    {
        while (ex is AggregateException)
            ex = ex.InnerException;
        if (!(ex is OperationCanceledException))
            MessageBox.Show(ex.Message); // report the error
    }

    if (!progressForm.IsDisposed && progressForm.Visible)
        progressForm.Close();

    // this make sure showDialog returns and the nested message loop is over
    await dialogTask;
}