如何在事件触发前阻塞代码流?
你的方法是错误的。事件驱动并不意味着阻塞和等待事件。你永远不要等待,至少你总是尽力避免等待。等待浪费资源,阻塞线程,可能会引入死锁或僵尸线程的风险(如果释放信号从未被提出)。很明显,阻塞线程以等待事件是一种反模式,因为它与事件的想法相矛盾。
通常有两个(现代)选项:实现异步API或事件驱动API。由于您不想将API实现异步,因此只能使用事件驱动API。
事件驱动API的关键是,不是强制调用者同步等待结果或轮询结果,而是让调用者继续,并在结果准备好或操作完成时向其发送通知。同时,调用者可以继续执行其他操作。
从线程角度看问题,事件驱动API允许调用线程(例如UI线程,执行按钮的事件处理程序)自由继续处理其他UI相关操作,例如渲染UI元素或处理用户输入,例如鼠标移动和按键按下。事件驱动API具有与异步API相同的效果或目标,尽管它要不方便得多。
由于您没有提供有关您实际要执行的操作,Utility.PickPoint()实际上在做什么以及任务的结果是什么或者为什么用户必须单击Grid的足够详细的信息,我不能为您提供更好的解决方案。我只能提供一个实现您要求的一般模式。
显然,您的流程或目标至少分为两个步骤,以使其成为操作序列:
1. 当用户单击按钮时执行操作1
2. 当用户单击Grid时执行操作2(继续/完成操作1)
具有至少两个约束条件:
1. 可选:必须在允许API客户端重复之前完成序列。序列完成后,操作2已运行到完成。
2. 操作1始终在操作2之前执行。操作1启动序列。
3. 在允许API客户端执行操作2之前,操作1必须完成。
这需要向API的客户端发送两个通知(事件)以允许非阻塞交互:
1. 操作1完成(或需要交互)
2. 操作2(或目标)完成
您应该通过公开两种公共方法和两种公共事件来让您的API实现此行为和约束。
由于此实现仅允许对API进行单个(非并发)调用,因此建议公开一个IsBusy
属性以指示运行序列。这允许在启动新序列之前轮询当前状态,尽管建议等待完成事件以执行后续调用。
实现/重构实用程序API
Utility.cs
class Utility
{
public event EventHandler InitializePickPointCompleted;
public event EventHandler<PickPointCompletedEventArgs> PickPointCompleted;
public bool IsBusy { get; set; }
private bool IsPickPointInitialized { get; set; }
public void BeginPickPoint(param)
{
if (this.IsBusy)
{
throw new InvalidOperationException("BeginPickPoint is already executing. Call EndPickPoint before starting another sequence.");
}
this.IsBusy = true;
Task.Run(() => StartOperationNonBlocking(param));
}
public void EndPickPoint(param)
{
if (!this.IsPickPointInitialized)
{
throw new InvalidOperationException("BeginPickPoint must have completed execution before calling EndPickPoint.");
}
Task.Run(() => CompleteOperationNonBlocking(param));
}
private void StartOperationNonBlocking(param)
{
...
this.IsPickPointInitialized = true;
OnInitializePickPointCompleted();
}
private void CompleteOperationNonBlocking(param)
{
Point result = ExecuteGoal();
this.IsBusy = false;
this.IsPickPointInitialized = false;
OnPickPointCompleted(result);
}
private void OnInitializePickPointCompleted()
{
this.InitializePickPointCompleted?.Invoke(this, EventArgs.Empty);
}
private void OnPickPointCompleted(Point result)
{
this.PickPointCompleted?.Invoke(this, new PickPointCompletedEventArgs(result));
}
}
PickPointCompletedEventArgs.cs
class PickPointCompletedEventArgs : AsyncCompletedEventArgs
{
public Point Result { get; }
public PickPointCompletedEventArgs(Point result)
{
this.Result = result;
}
}
使用API
MainWindow.xaml.cs
partial class MainWindow : Window
{
private Utility Api { get; set; }
public MainWindow()
{
InitializeComponent();
this.Api = new Utility();
}
private void StartPickPoint_OnButtonClick(object sender, RoutedEventArgs e)
{
this.Api.InitializePickPointCompleted += RequestUserInput_OnInitializePickPointCompleted;
this.Api.BeginPickPoint();
}
private void RequestUserInput_OnInitializePickPointCompleted(object sender, EventArgs e)
{
this.Api.InitializePickPointCompleted -= RequestUserInput_OnInitializePickPointCompleted;
MessageBox.Show("Please click the screen");
}
private void FinishPickPoint_OnGridMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
this.Api.PickPointCompleted += ShowPoint_OnPickPointCompleted;
this.Api.EndPickPoint();
}
private void ShowPoint_OnPickPointCompleted(object sender, PickPointCompletedEventArgs e)
{
this.Api.PickPointCompleted -= ShowPoint_OnPickPointCompleted;
Point point = e.Result;
MessageBox.Show(point.ToString());
}
}
MainWindow.xaml
<Window>
<Grid MouseLeftButtonUp="FinishPickPoint_OnGridMouseLeftButtonUp">
<Button Click="StartPickPoint_OnButtonClick" />
</Grid>
</Window>
备注
在后台线程上引发的事件将在同一线程上执行其处理程序。从在后台线程上执行的处理程序中访问像UI元素这样的DispatcherObject
,需要将关键操作使用Dispatcher.Invoke
或Dispatcher.InvokeAsync
排队到Dispatcher
中,以避免跨线程异常。
阅读有关DispatcherObject
的备注,了解称为调度程序亲和性或线程亲和性的现象的更多信息。
为了方便使用API,建议通过捕获和使用调用者的SynchronizationContext
或使用AsyncOperation
(或AsyncOperationManager
)将所有事件调度到调用者的原始上下文中。
可以通过提供取消(推荐)例如通过公开Cancel()
方法例如PickPointCancel()
和进度报告(最好使用Progress<T>
)来轻松增强上述示例。
一些想法 - 回应你的评论
因为你向我寻求“更好”的阻止解决方案,给出了控制台应用程序的例子,我感到需要说服你,你的观念或观点是完全错误的。
"Consider a Console application with these two lines of code in it.
var str = Console.ReadLine()
Console.WriteLine(str)
What happens when you execute the application in debug mode. It will
stop at the first line of code and force you to enter a value in
Console UI and then after you enter something and press Enter, it will
execute the next line and actually print what you entered. I was
thinking about exactly the same behavior but in WPF application."
一个控制台应用程序是完全不同的东西。线程概念也有点不同。控制台应用程序没有 GUI。只有输入/输出/错误流。你不能将控制台应用程序的架构与丰富的 GUI 应用程序进行比较。这是行不通的。你真的必须理解并接受这一点。
此外,不要被外表所欺骗。你知道
Console.ReadLine
内部发生了什么吗?它是如何实现的?它是否会阻塞主线程并同时读取输入?还是只是轮询?以下是
Console.ReadLine
的原始实现:
public virtual String ReadLine()
{
StringBuilder sb = new StringBuilder();
while (true)
{
int ch = Read();
if (ch == -1)
break;
if (ch == '\r' || ch == '\n')
{
if (ch == '\r' && Peek() == '\n')
Read();
return sb.ToString();
}
sb.Append((char)ch);
}
if (sb.Length > 0)
return sb.ToString();
return null;
}
如您所见,这是一个简单的同步操作。它在“无限”循环中轮询用户输入。没有神奇的块和继续。
WPF围绕渲染线程和UI线程构建。这些线程始终保持旋转,以便与操作系统通信,如处理用户输入-保持应用程序响应。您永远不希望暂停/阻止此线程,因为这将阻止框架执行必要的后台工作,例如响应鼠标事件-您不希望鼠标冻结:
等待=线程阻塞=不响应=糟糕的UX=恼怒的用户/客户=办公室麻烦。
有时,应用程序流需要等待输入或程序例行完成。但是我们不想阻止主线程。这就是人们发明复杂的异步编程模型的原因,以允许等待而不会阻止主线程,并且不强制开发人员编写复杂和错误的多线程代码。
每个现代应用程序框架都提供异步操作或异步编程模型,以允许开发简单高效的代码。
您努力抵制异步编程模型的事实,对我来说显示出一些理解上的欠缺。每个现代开发人员都喜欢异步API而不是同步API。没有认真的开发人员会关心使用await关键字或声明其方法为async。你是我遇到的第一个抱怨异步API并发现它们不方便使用的人。
如果我检查您的框架,该框架旨在解决UI相关问题或使UI相关任务更容易,我会期望它是异步的-一路上都是这样。非异步的UI相关API是浪费,因为它会使我的编程风格变得更加复杂,从而使我的代码变得更容易出错且难以维护。
不同的角度:当您承认等待会阻塞UI线程时,会创建非常糟糕和不可取的用户体验,因为UI将在等待结束之前冻结,现在您意识到这一点,为什么要提供一个API或插件模型,鼓励开发人员正好这样做-实现等待?您不知道第三方插件将做什么以及例行程序需要多长时间才能完成。这只是一种糟糕的API设计。当您的API在UI线程上运行时,则调用者必须能够对其进行非阻塞调用。
如果您拒绝唯一的便宜或优雅的解决方案,请像我的示例中所示使用事件驱动方法。它做你想要的事情:启动例行程序-等待用户输入-继续执行-完成目标。
我真的尝试了几次解释为什么等待/阻止是一种糟糕的应用程序设计。再次强调,您无法将控制台UI与丰富的图形化UI进行比较,例如仅监听输入流的输入处理要复杂得多。我真的不知道您的经验水平和起点在哪里,但是您应该开始接受异步编程模型。我不知道您避免使用它的原因。但这一点都不明智。
今天,异步编程模型已经在各个平台、编译器、环境、浏览器、服务器、桌面和数据库等各个领域得到了广泛的应用。事件驱动模型也可以实现相同的目标,但它使用起来不太方便(需要订阅/取消订阅事件、阅读文档(如果有)以了解事件),并且依赖于后台线程。事件驱动是老式的,只有在没有可用或不适用的异步库时才应使用。
顺便提一下:.NET Framework (.NET Standard) 提供了
TaskCompletionSource
(以及其他用途),以提供将现有的事件驱动 API 转换为异步 API 的简单方法。
“我已经在 Autodesk Revit 中看到了完全相同的行为。”
行为(你所体验或观察到的)与实现方式有很大的不同。这是两回事。你的 Autodesk 很可能正在使用异步库、语言特性或其他线程机制。这也与上下文相关。当你心中的方法在后台线程上执行时,开发人员可以选择阻塞该线程。他要么有非常好的理由这样做,要么只是做了一个糟糕的设计选择。你完全走错了方向;阻塞不好。(Autodesk 的源代码开源吗?或者你是怎么知道它的实现方式的?)
我不想冒犯你,请相信我。但请重新考虑使用异步实现你的 API。只有在你的头脑中,开发人员才不喜欢使用 async/await。你显然有错误的心态。而且忘记那个控制台应用程序的论点——那是无稽之谈;)
与 UI 相关的 API 必须尽可能使用 async/await。否则,你会将所有编写非阻塞代码的工作留给你的 API 的客户端。你会迫使我将对你的 API 的每次调用都包装到后台线程中。或者使用不太方便的事件处理。相信我——每个开发人员都更愿意使用
async
来修饰他的成员,而不是进行事件处理。每当使用事件时,您可能会冒险出现潜在的内存泄漏风险——这取决于某些情况,但风险是真实存在的,并不罕见。
我真的希望你理解为什么阻塞是不好的。我真的希望你决定使用 async/await 来编写现代异步 API。尽管如此,我向你展示了一种非常常见的等待非阻塞的方法,使用事件,尽管我敦促你使用 async/await。
“该 API 将允许程序员访问 UI 等。现在假设程序员想要开发一个插件,当单击按钮时,最终用户被要求在 UI 中选择一个点。”
如果您不希望插件直接访问UI元素,您应该提供一个接口来委托事件或通过抽象对象公开内部组件。
API内部将代表Add-in订阅UI事件,然后通过向API客户端公开相应的"包装器"事件来委托事件。您的API必须提供一些钩子,Add-in可以连接以访问特定的应用程序组件。插件API像适配器或外观一样,为外部使用者提供访问内部的方式。
为了实现一定程度的隔离,请看看Visual Studio如何管理插件或允许我们实现它们。假设您想为Visual Studio编写一个插件,并查找如何实现此操作。您会发现Visual Studio通过接口或API公开其内部。例如:您可以在没有真正访问它的情况下操纵代码编辑器或获取有关编辑器内容的信息。
Aync/Await
,但如果要执行操作A并保存该操作的状态,现在您希望用户单击网格,那么如果用户单击网格,则检查状态是否为真,然后执行您的操作,否则只需执行您想要的任何操作即可。 - Rao Armanvar point = Utility.PickPoint(Grid grid);
放在 Grid Click 方法中吗?执行一些操作并返回响应? - Rao Arman