当前位置:首页|资讯

技术速递|调用异步功能 - WinForms 在 .NET 9 中的未来发展

作者:微软中国MSDN发布时间:2024-12-30

作者:Klaus Loeffelmann

排版:Alan Wang

随着 .NET 的不断发展,WinForms 开发者可用的工具也在不断进步,这使得开发更加高效且应用响应更迅速。在 .NET 9 中,我们很高兴引入了一系列新的异步 API,这些 API 大大简化了UI管理任务。从更新控件到显示窗体和对话框,这些新增功能以全新的方式将异步编程的强大功能引入到 WinForms 中。在本文中,我们将深入探讨四个关键 API,解释它们的工作原理、适用场景以及如何开始使用它们。

认识新的异步 API

.NET 9 专门为 WinForms 引入了几种异步 API,使得在异步场景中进行 UI 操作变得更加直观和高效。这些新增功能包括:

  • Control.InvokeAsync – 在 .NET 9 中全面发布的 API,有助于异步调用调用 UI 线程。

  • Form.ShowAsync 和 Form.ShowDialogAsync(实验性) – 这些 API 允许开发者以异步方式显示窗体,在复杂的 UI 场景中极大简化操作。

  • TaskDialog.ShowDialogAsync(实验性) – 该 API 提供了一种异步显示那些基于任务对话框的消息对话框控件的方法,特别适用于长时间运行的与 UI 绑定的操作。

接下来,我们将从 InvokeAsync 开始逐一解析这些 API。

Control.InvokeAsync:无缝异步 UI 线程调用

InvokeAsync 提供了一种强大的,可在不阻塞调用线程的情况下将调用传递给UI线程的方法。此方法允许在 UI 线程上执行同步和异步回调,提供了灵活性,并防止意外的“即发即弃”行为。它通过将操作排入 WinForms 主消息队列来实现,确保它们在 UI 线程上执行。这种行为类似于 Control.Invoke,后者也会将调用调度到 UI 线程,但两者之间有一个重要区别:InvokeAsync 不会阻塞调用线程,因为它是将委托发布到消息队列中,而不是直接发送。

Wait – 发送与发布?消息队列?

让我们分解这些概念,阐明它们的含义,以及为什么 InvokeAsync 的方法可以帮助改善应用程序的响应性。

在 WinForms 中,所有的 UI 操作都发生在主 UI 线程上。为了管理这些操作,UI 线程运行一个循环,称为消息循环(message loop),该循环会持续处理消息——例如按钮点击、屏幕重绘以及其他操作。这个循环是 WinForms 能够在处理指令的同时对用户操作保持响应的核心。当您使用现代 API 时,大多数应用程序代码并不是运行在这个 UI 线程上的。理想情况下,UI 线程应该仅用于那些必须更新UI的操作。然而,在某些情况下,代码不会自动运行在 UI 线程上。例如,当您启动一个独立的任务以并行执行计算密集型操作时,就会发生这种情况。在这些情况下,您需要将代码执行“调度”到 UI 线程,这样 UI 线程才能更新界面。否则就会出现以下情况:

假设我不被允许进入某个房间取一杯牛奶,而你可以。在这种情况下,只有一个选择:因为我不可能变成你,所以我只能请求你帮我取那杯牛奶。这与线程调度是一样的。工作线程不能变成 UI 线程,但代码的执行(取牛奶)可以被调度。换句话说,工作线程可以请求 UI 线程代表它执行某些代码。简单来说,这通过将一个方法的委托排入消息队列中来实现。

说到这里,让我们解决发送和发布的困惑:在消息循环中排队操作有两种主要方式:

发送消息(阻塞):Control.Invoke 使用这种方式。当调用 Control.Invoke 时,它会将指定的委托同步发送到 UI 线程的消息队列。这是一个阻塞操作,意味着调用线程会等待 UI 线程处理完该委托后才能继续。这在调用代码依赖于 UI 线程立即返回结果时非常有用,但如果过度使用,尤其是在处理长时间运行的操作时,可能导致 UI 卡顿。

发布消息(非阻塞):InvokeAsync 将委托发布到消息队列,这是一个非阻塞操作。这种方式告诉 UI 线程将操作排入队列,并尽快处理,但调用线程无需等待操作完成。方法会立即返回,使调用线程可以继续其工作。这种区别在异步场景中尤为重要,因为它允许应用程序同时处理其他任务而不产生延迟,从而最大限度地减少 UI 线程的瓶颈。

这里是一个简单比较:

操作

方法

阻塞

描述

发送

Control.Invoke

发送

在 UI 线程上调用委托,并等待其完成。

发布

Control.InvokeAsync

发布

将委托排入 UI 线程的队列,并立即返回。

为什么这很重要

通过使用 InvokeAsync 发布委托,您的代码现在可以将多个更新排队到控件上,执行后台操作,或等待其他异步任务,而无需阻塞主 UI 线程。这种方法不仅有助于防止“冻结的 UI”体验,还能保持应用程序的响应性,即使在处理大量与 UI 绑定的任务时也能保持流畅。

总结:Control.Invoke 会等待 UI 线程完成委托(阻塞),InvokeAsync 会将任务交给 UI 线程,并立即返回(非阻塞)。这种差异使得 InvokeAsync 非常适合异步场景,让开发者能够构建更流畅、更具响应性的 WinForms 应用程序。

以下是每个 InvokeAsync 重载的工作方式:

publicasyncTask InvokeAsync(Action callback, CancellationToken cancellationToken = default)publicasyncTask<T> InvokeAsync<T>(Func<T> callback, CancellationToken cancellationToken = default)publicasyncTask InvokeAsync(Func<CancellationToken, ValueTask> callback, CancellationToken cancellationToken = default)publicasyncTask<T> InvokeAsync<T>(Func<CancellationToken, ValueTask<T>> callback, CancellationToken cancellationToken = default)

每个重载都允许不同的同步和异步方法组合,可以选择是否带有返回值:InvokeAsync(Action callback, CancellationToken cancellationToken = default) 用于没有返回值的同步操作。如果您想在 UI 线程上更新控件的属性——例如设置 Label 的 Text 属性——这个重载允许您做到这一点,而无需等待返回值。回调会被发布到消息队列,并异步执行,返回一个 Task,如果需要,您可以等待该任务的完成。

awaitcontrol.InvokeAsync( => control.Text = "Updated Text");InvokeAsync(Funccallback, CancellationToken cancellationToken = default)

用于返回类型为 T 的同步操作。使用它可以在 UI 线程上计算并获取一个值,例如从 ComboBox 中获取 SelectedItem。InvokeAsync 将回调发布到 UI 线程,并返回一个 Task,允许您等待结果的完成。

intitemCount = awaitcontrol.InvokeAsync( => comboBox.Items.Count);InvokeAsync(Funccallback, CancellationToken cancellationToken = default):

这个重载用于不返回结果的异步操作。它非常适用于较长时间运行的异步操作,更新 UI 的场景,例如等待数据加载完成后再更新控件。回调接收一个 CancellationToken 以支持取消,并需要返回一个 ValueTask,InvokeAsync 会(内部)等待该任务完成,同时保持 UI 在操作异步执行时的响应性。因此,实际上有两个“等待”发生:InvokeAsync 被等待(或者说可以被等待),同时您传递的 ValueTask 也会被内部等待。

awaitcontrol.InvokeAsync(async(ct) =>{awaitTask.Delay(1000, ct); // Simulating a delaycontrol.Text = "Data Loaded";});InvokeAsync(Func<cancellationtoken, valuetask> callback, CancellationToken cancellationToken = default)

最后是用于返回类型为 T 的异步操作的重载版本。当一个异步操作必须在 UI 线程上完成并返回一个值时使用,例如在延迟后查询控件的状态或获取数据以更新 UI。回调接收一个 CancellationToken 并返回一个 ValueTask,InvokeAsync 会等待该任务完成并提供结果。

varitemCount = awaitcontrol.InvokeAsync(async(ct) =>{awaitTask.Delay(500, ct); // Simulating data fetching delayreturncomboBox.Items.Count;});

快速决策:选择正确的重载

  • 对于没有返回值的同步操作,使用 Action。

  • 对于有返回值的同步操作,使用 Func。

  • 对于没有结果的异步操作,使用 Func。

  • 对于有结果的异步操作,使用 Func<cancellationtoken, valuetask>。

使用正确的重载有助于在异步 WinForms 应用程序中平滑处理 UI 任务,避免主线程瓶颈,并提升应用程序的响应性。

以下是一个简单的例子:

varcontrol = newControl;// Sync actionawaitcontrol.InvokeAsync( => control.Text = "Hello, async world!");// Async function with return valuevarresult = awaitcontrol.InvokeAsync(async(ct) =>{control.Text = "Loading...";awaitTask.Delay(1000, ct);control.Text = "Done!";return42;});

混淆异步和同步重载——真的会发生吗?

由于有许多重载选项,可能会误将异步方法传递给同步重载,从而导致意外的“即发即弃”行为。为了防止这种情况,WinForms 在 .NET 9 中引入了一种专门的 WinForms 分析器,当将一个异步方法(例如返回 Task 的方法)传递给不带 CancellationToken 的 InvokeAsync 同步重载时,该分析器会检测到并触发警告。这有助于您在潜在问题引发运行时错误之前发现并纠正它们。

例如,传递一个不支持 CancellationToken 的异步方法可能会生成如下警告:

warning WFO2001: Task isbeing passed to InvokeAsync without a cancellation token.

此分析器确保异步操作被正确处理,从而在您的 WinForms 应用程序中保持可靠且响应迅速的行为。

实验性 API

除了 InvokeAsync,WinForms 在 .NET 9 中还引入了用于显示窗体和对话框的实验性异步选项。这些 API 仍处于实验阶段,但为开发者提供了更大的异步 UI 交互灵活性,例如文档管理和窗体生命周期控制。

Form.ShowAsync 和 Form.ShowDialogAsync 是新的方法,允许异步显示窗体。它们简化了多个窗体实例的处理,尤其适用于需要多个相同窗体类型实例的情况,例如在单独窗口中显示不同文档时。

以下是如何使用 ShowAsync 的基本示例:

varmyForm = newMyForm;awaitmyForm.ShowAsync;

并且对于模态对话框,您可以使用 ShowDialogAsync:

varresult = awaitmyForm.ShowDialogAsync;if(result == DialogResult.OK){// Perform actions based on dialog result}

这些方法简化了异步窗体显示的管理,并帮助您在等待用户交互时避免阻塞 UI 线程。

TaskDialog.ShowDialogAsync

TaskDialog.ShowDialogAsync 是 .NET 9 中的另一个实验性 API,旨在提升对话框交互的灵活性。它提供了一种异步显示任务对话框的方法,非常适合涉及耗时操作或多步骤流程的场景。

以下是异步显示任务对话框的方法示例:

vartaskDialogPage = newTaskDialogPage{Heading = "Processing...",Text = "Please wait while we complete the task."};varbuttonClicked = awaitTaskDialog.ShowDialogAsync(taskDialogPage);

此 API 允许开发者异步显示对话框,从而释放 UI 线程,提供更流畅的用户体验。

异步 API 的实际应用

这些异步 API 为 WinForms 应用程序解锁了新的功能,特别是在多表单应用程序、MVVM 设计模式和依赖注入场景中。通过利用异步操作处理表单和对话框,您可以:

  • 在异步场景中简化表单生命周期管理,特别是当处理同一表单的多个实例时。

  • 支持 MVVM 和 DI 工作流,在 ViewModel 驱动的架构中,异步表单处理是有益的。

  • 避免 UI 线程阻塞,即使在执行密集操作时也能实现更具响应性的界面。

如果您对如何通过 Invoke.Async 彻底改变 WinForms 应用程序的 AI 驱动现代化感到好奇,那么请观看 .NET Conf 2024 的演讲,看看这些功能在实际场景中的实现!

这还不是全部——不要错过我们在另一场精彩讲座中深入探讨 .NET 9 中 WinForms 的所有新特性。深入了解并获得灵感!

.NET Conf 2024 的演讲

https://www.youtube.com/watch?v=EBpJ99VriJk

另一场精彩讲座

https://youtu.be/1ZjCGdmQl_g?si=43PRkdjm41Y4XEwp

如何从同步操作启动异步操作

在 UI 场景中,从同步上下文触发异步操作是很常见的。当然,我们都知道,最好避免使用 async void 方法。

为什么要避免这种做法?当你使用 async void 时,调用者无法等待或观察方法的完成。这可能导致未处理的异常或意外行为。async void 方法实际上是“即发即弃”,它们不受Task提供的标准错误处理机制的约束。这使得在大多数场景中调试和维护更加困难。

但是!这里有一个例外,那就是事件处理方法或具有“事件处理方法特征”的方法。事件处理方法不能返回 Task 或 Task,因此 async void 允许它们触发异步操作,而不会阻塞 UI 线程。然而,由于 async void 方法不可等待,异常很难被捕获。为了解决这个问题,你可以在事件处理方法内部的异步操作周围使用错误处理结构,比如 try-catch。这样,即使在这些特殊情况下,也能确保异常得到适当处理。

例如:

privateasyncvoidButton_Click(objectsender, EventArgs e){try{awaitPerformLongRunningOperationAsync;}catch (Exception ex){MessageBox.Show($"An error occurred: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);}}

在这里,由于事件处理程序的签名,async void 是不可避免的,但通过将等待的代码包装在 try-catch 中,我们可以安全地处理异步操作过程中可能发生的任何异常。

以下示例使用一个名为 SevenSegmentTimer 的7段显示控件,以典型的7段式显示方式显示一个计时器,精度为十分之一秒。它有几个方法来更新和动画内容:

publicpartialclassTimerForm: Form{privateSevenSegmentTimer _sevenSegmentTimer;privatereadonlyCancellationTokenSource _formCloseCancellation = new;publicFrmMain(){InitializeComponent;SetupTimerDisplay;}[MemberNotNull(nameof(_sevenSegmentTimer))]privatevoidSetupTimerDisplay(){_sevenSegmentTimer = newSevenSegmentTimer{Dock = DockStyle.Fill};Controls.Add(_sevenSegmentTimer);}overrideasyncprotectedvoid(EventArgs e){base.(e);awaitRunDisplayLoopAsyncV1;}privateasyncTask RunDisplayLoopAsyncV1(){// When we update the time, the method will also wait 75 ms asynchronously._sevenSegmentTimer.UpdateDelay = 75;while(true){// We update and then wait for the delay.// In the meantime, the Windows message loop can process other messages,// so the app remains responsive.await_sevenSegmentTimer.UpdateTimeAndDelayAsync(time: TimeOnly.FromDateTime(DateTime.Now));}}}

当我们运行这个程序时,我们可以在屏幕上的窗体中看到这个计时器:

异步方法 UpdateTimeAndDelayAsync 完全按字面意思执行:它更新控件中显示的时间,然后等待我们在前一行通过 UpdateDelay 属性设置的时间。

如您所见,这个异步方法 RunDisplayLoopAsyncV1 在窗体的 中启动。这是典型的做法,即如何从同步 void 方法中启动异步操作。

对于典型的 WinForms 开发者来说,乍一看这可能会显得有些奇怪。毕竟,我们在 中调用了另一个方法,而那个方法永远不会返回,因为它最终会进入一个无限循环。那么,在这种情况下, 是否会完成呢?我们不是在这里阻塞应用程序吗?

这就是异步编程的亮点所在。尽管 RunDisplayLoopAsyncV1 包含一个无限循环,但它是以异步方式构造的。当在循环内部遇到 await 关键字时(例如 await _sevenSegmentTimer.UpdateTimeAndDelayAsync),方法会将控制权返回给调用者,直到等待的任务完成。

在 WinForms 应用程序的上下文中,这意味着 Windows 消息循环可以继续处理事件,比如重新绘制 UI、处理按钮点击或响应键盘输入。由于 await 暂停了 RunDisplayLoopAsyncV1 的执行而没有阻塞 UI 线程,应用程序保持响应。

当 被标记为async时,它会在遇到 RunDisplayLoopAsyncV1 中的第一个 await 时完成。待任务完成后,运行时会从上次暂停的地方恢复执行 RunDisplayLoopAsyncV1。这一切发生时不会阻塞 UI 线程,实际上允许 立即 return,即使异步操作在后台继续执行。

后台执行?您可以将其视为将方法拆分成几个部分,就像一个虚拟的 WaitAsync-Initiator,它在第一个 await 解决后被调用。接着它启动一个后台运行的 WaitAsync-Waiter,直到等待期结束。然后,触发 WaitAsync-Callback,实际上要求消息循环重新进入调用并完成所有跟随该异步调用的操作。

因此,实际的代码路径大致如下:

最好的理解方式是将其与连续处理的两个鼠标点击事件进行比较,第一个鼠标点击触发了 RunDisplayLoopAsyncV1,而第二个鼠标点击对应于 WaitAsync 回调,进入该方法的“第3部分”,当延迟正好在等待时。

这个过程随后会对每个异步方法中的 await 进行重复。这就是为什么即使存在无限循环,应用程序也不会卡住。实际上,技术上来说, 实际上是正常完成的,但每个 await 后的部分会被消息循环在稍后的时间回调。

现在,我们仍然基本上只在 UI 线程上工作。(严格来说,回调会在短暂的时间内运行在线程池线程上,但我们暂时忽略这一点。)是的,我们是异步的,但到目前为止,并没有真正发生并行操作。直到现在,这更像是一个巧妙组织的接力赛,接力棒被无缝地传递给下一个选手,以至于根本不会有卡顿或阻塞。

但是,异步方法随时可以从不同的线程调用。如果我们在当前示例中这样做……

privateasyncTask RunDisplayLoopAsyncV2(){// When we update the time, the method will also wait 75 ms asynchronously._sevenSegmentTimer.UpdateDelay = 75;// Let's kick-off a dedicated task for the loop.awaitTask.Run(ActualDisplayLoopAsync);// Local function, which represents the actual loop.asyncTask ActualDisplayLoopAsync(){while(true){// We update and then wait for the delay.// In the meantime, the Windows message loop can process other messages,// so the app remains responsive.await_sevenSegmentTimer.UpdateTimeAndDelayAsync(time: TimeOnly.FromDateTime(DateTime.Now));}}}

然后…

InvokeAsync 的重载解析的复杂性

如我们之前所学,这是一个很容易解决的问题,对吧?我们只是使用 InvokeAsync 来调用我们本地的函数 ActualDisplayLoopAsync,然后就完成了。那么,让我们这么做吧。我们获取 InvokeAsync 返回的 Task,然后将其传递给 Task.Run。轻松解决。

好吧——看起来并不太好。我们遇到了两个问题。首先,如前所述,我们正在尝试调用一个返回 Task 的方法,但没有传递取消令牌。InvokeAsync 正在警告我们,在这种情况下我们正在设置一个“即发即弃”操作,而这个操作无法被内部等待。第二个问题不仅仅是警告,它还是一个错误。InvokeAsync 返回的是一个 Task,我们当然不能将其传递给 Task.Run。我们只能传递一个 Action 或返回 Task 的 Func,但绝不能直接传递一个 Task。不过,我们可以做的是将这一行转换为另一个本地函数,所以从这里……

// Doesn't work. InvokeAsync wants a cancellation token, and we can't pass Task.Run a task.varinvokeTask = this.InvokeAsync(ActualDisplayLoopAsync);// Let's kick-off a dedicated task for the loop.awaitTask.Run(invokeTask);// Local function, which represents the actual loop.asyncTask ActualDisplayLoopAsync(CancellationToken cancellation)

改为:

// This is a local function now, calling the actual loop on the UI Thread.Task InvokeTask()=> this.InvokeAsync(ActualDisplayLoopAsync, CancellationToken.None);awaitTask.Run(InvokeTask);asyncValueTask ActualDisplayLoopAsync(CancellationToken cancellation=default)...

现在它工作得非常顺利了!

为性能或目标代码流程进行并行化

我们的7段控制器还有一个巧妙的功能:分隔列的渐变动画。我们可以按以下方式使用这个功能:

privateasyncTask RunDisplayLoopAsyncV4(){while(true){// We also have methods to fade the separators in and out!// Note: There is no need to invoke these methods on the UI thread,// because we can safely set the color for a label from any thread.await_sevenSegmentTimer.FadeSeparatorsInAsync.ConfigureAwait(false);await_sevenSegmentTimer.FadeSeparatorsOutAsync.ConfigureAwait(false);}}

当我们运行它时,结果看起来像这样:

然而,存在一个挑战:我们如何设置代码流程,使得运行时钟和渐变分隔符能够并行执行,并且都在一个连续的循环中?

为了实现这一目标,我们可以利用基于任务的并行性。

具体思路如下:

  • 同时运行时钟更新和分隔符渐变:我们异步执行这两个任务,并等待它们完成。

  • 妥善处理不同任务的时长:由于时钟更新和渐变动画可能需要不同的时间,我们使用 Task.WhenAny 来确保较快的任务不会延迟较慢的任务。

  • 重置已完成的任务:一旦某个任务完成,我们将其重置为 null,以便下一次迭代时重新启动该任务。

最终结果是:

privateasyncTask RunDisplayLoopAsyncV6(){Task? uiUpdateTask = null;Task? separatorFadingTask = null;while(true){asyncTask FadeInFadeOutAsync(CancellationToken cancellation){await_sevenSegmentTimer.FadeSeparatorsInAsync(cancellation).ConfigureAwait(false);await_sevenSegmentTimer.FadeSeparatorsOutAsync(cancellation).ConfigureAwait(false);}uiUpdateTask ??= _sevenSegmentTimer.UpdateTimeAndDelayAsync(time: TimeOnly.FromDateTime(DateTime.Now),cancellation: _formCloseCancellation.Token);separatorFadingTask ??= FadeInFadeOutAsync(_formCloseCancellation.Token);Task completedOrCancelledTask = awaitTask.WhenAny(separatorFadingTask, uiUpdateTask);if(completedOrCancelledTask.IsCanceled){break;}if(completedOrCancelledTask == uiUpdateTask){uiUpdateTask = null;}else{separatorFadingTask = null;}}}protectedoverridevoidOnFormClosing(FormClosingEventArgs e){base.OnFormClosing(e);_formCloseCancellation.Cancel;}

还有这个。在这个动画 GIF 中,您可以看到 UI 始终保持响应性,因为窗口可以通过鼠标平滑拖动。

总结

通过这些新的异步 API,.NET 9 为 WinForms 带来了先进的功能,使得处理异步 UI 操作变得更加容易。虽然一些 API,如 Control.InvokeAsync,已经准备好投入使用,但针对表单和对话框管理的实验性 API 为响应式 UI 开发提供了更多令人兴奋的可能性。

您可以在我们的 Extensibility-Repo中的相应示例子文件夹找到本博客文章的示例代码。

通过 .NET 9 探索 WinForms 中异步编程的潜力,并确保在非关键项目中测试这些实验性功能。像往常一样,您的反馈至关重要,我们期待听到这些新的异步功能如何提升您的开发过程!

最后,祝编码愉快!

Extensibility-Repo

https://github.com/microsoft/winforms-designer-extensibility

相应示例子文件夹

https://github.com/microsoft/winforms-designer-extensibility/tree/main/Samples/NET 9/Async in NET 9

微信


Copyright © 2025 aigcdaily.cn  北京智识时代科技有限公司  版权所有  京ICP备2023006237号-1