wpf 如何在C#中阻止代码流直到激发事件

u1ehiz5o  于 2022-12-14  发布在  C#
关注(0)|答案(7)|浏览(193)

我在WPF应用程序中有一个带有按钮的网格。当用户单击该按钮时,将执行实用程序类中的一个方法,该方法强制应用程序接收网格上的单击操作。代码流必须在此处停止,并且在用户单击网格之前不能继续。
我以前也有过类似的问题,我使用async/await得到了答案,但由于我将此方法作为API的一部分使用,因此我不想使用async/await,因为它将要求API的使用者将其方法标记为async,这是我不希望的。
Wait till user click C# WPF
如何在不使用async/await的情况下编写Utility.PickPoint(Grid grid)方法来实现这一目标?我看到了这个答案,它可能会有帮助,但我不完全理解如何将它应用到我的情况中。
Blocking until an event completes
将其视为控制台应用程序中的Console.ReadKey()方法。当我们调用此方法时,代码流将停止,直到我们输入某个值。调试器不会继续,直到我们输入某个值。我需要PickPoint()方法的确切行为。代码流将停止,直到用户单击网格。

<Window x:Class="WpfApp1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp1"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="3*"/>
            <RowDefinition Height="1*"/>
        </Grid.RowDefinitions>

        <Grid x:Name="View" Background="Green"/>
        <Button Grid.Row="1" Content="Pick" Click="ButtonBase_OnClick"/>
    </Grid>
</Window>

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
    {
        // do not continue the code flow until the user has clicked on the grid. 
        // so when we debug, the code flow will literally stop here.
        var point = Utility.PickPoint(View);

        MessageBox.Show(point.ToString());
    }
}

public static class Utility
{
    public static Point PickPoint(Grid grid)
    {

    }
}
mmvthczy

mmvthczy1#

“如何阻止代码流直到事件被激发?”
你的方法是错误的。事件驱动并不意味着阻塞和等待一个事件。你从不等待,至少你总是努力避免它。等待是浪费资源,阻塞线程,并可能引入死锁或僵尸线程的风险(在释放信号从未被引发的情况下)。
应该清楚的是,阻止线程 * 等待 * 事件是一种反模式,因为它与事件的概念相矛盾。
您通常有两个(现代)选项:实现异步API或事件驱动的API。由于您不希望实现异步API,因此您只能使用事件驱动的API。
事件驱动API的关键是,不是强制调用者同步等待结果或轮询结果,而是让调用者继续,并在结果准备就绪或操作完成时向他发送通知。同时,调用者可以继续执行其他操作。
当从线程的Angular 来看问题时,事件驱动的API允许执行按钮的事件处理程序的调用线程(例如UI线程)自由地继续处理例如其他UI相关操作,如呈现UI元素或处理用户输入(如鼠标移动和按键)。事件驱动的API具有与异步API相同的效果或目标。尽管它远不方便。
由于您没有提供足够的细节来说明您真正要做什么、Utility.PickPoint()实际在做什么、任务的结果是什么或者为什么用户必须单击'Grid,因此我无法提供更好的解决方案,只能提供一个如何实现您的需求的一般模式。
你的流或目标显然被分成至少两个步骤来使它成为一个操作序列:
1.用户单击按钮时执行操作1
1.当用户单击Grid时,执行操作2(继续/完成操作1)
具有至少两个约束:
1.可选:必须先完成序列,然后才允许API客户机重复序列。工序2运行完成后,序列即告完成。
1.工序1始终在工序2之前执行。工序1启动工序。
1.必须先完成操作1,然后才允许API客户端执行操作2
这需要两个通知(事件),以便API的客户端允许非阻塞交互:
1.操作1已完成(或需要交互)
1.操作2(或目标)已完成
您应该通过公开两个公共方法和两个公共事件来让API实现此行为和约束。
由于此实现只允许对API进行一次(非并发)调用,因此建议公开一个IsBusy属性来指示正在运行的序列。这允许在启动新序列之前轮询当前状态,尽管建议等待已完成的事件来执行后续调用。

实作/重构公用程式API

实用程序.cs

class Utility
{
  public event EventHandler InitializePickPointCompleted;
  public event EventHandler<PickPointCompletedEventArgs> PickPointCompleted;
  public bool IsBusy { get; set; }
  private bool IsPickPointInitialized { get; set; }

  // The prefix 'Begin' signals the caller or client of the API, 
  // that he also has to end the sequence explicitly
  public void BeginPickPoint(param)
  {
    // Implement constraint 1
    if (this.IsBusy)
    {
      // Alternatively just return or use Try-do pattern
      throw new InvalidOperationException("BeginPickPoint is already executing. Call EndPickPoint before starting another sequence.");
    }

    // Set the flag that a current sequence is in progress
    this.IsBusy = true;

    // Execute operation until caller interaction is required.
    // Execute in background thread to allow API caller to proceed with execution.
    Task.Run(() => StartOperationNonBlocking(param));
  }

  public void EndPickPoint(param)
  {
    // Implement constraint 2 and 3
    if (!this.IsPickPointInitialized)
    {
      // Alternatively just return or use Try-do pattern
      throw new InvalidOperationException("BeginPickPoint must have completed execution before calling EndPickPoint.");
    }

    // Execute operation until caller interaction is required.
    // Execute in background thread to allow API caller to proceed with execution.
    Task.Run(() => CompleteOperationNonBlocking(param));
  }

  private void StartOperationNonBlocking(param)
  {
    ... // Do something

    // Flag the completion of the first step of the sequence (to guarantee constraint 2)
    this.IsPickPointInitialized = true;

    // Request caller interaction to kick off EndPickPoint() execution
    OnInitializePickPointCompleted();
  }

  private void CompleteOperationNonBlocking(param)
  {
    // Execute goal and get the result of the completed task
    Point result = ExecuteGoal();

    // Reset API sequence (allow next client invocation)
    this.IsBusy = false;
    this.IsPickPointInitialized = false;

    // Notify caller that execution has completed and the result is available
    OnPickPointCompleted(result);
  }

  private void OnInitializePickPointCompleted()
  {
    // Set the result of the task
    this.InitializePickPointCompleted?.Invoke(this, EventArgs.Empty);
  }

  private void OnPickPointCompleted(Point result)
  {
    // Set the result of the task
    this.PickPointCompleted?.Invoke(this, new PickPointCompletedEventArgs(result));
  }
}

选取点完成事件参数.cs

class PickPointCompletedEventArgs : AsyncCompletedEventArgs 
{
  public Point Result { get; }

  public PickPointCompletedEventArgs(Point result)
  {
    this.Result = result;
  }
}

使用API

主窗口.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;

    // Invoke API and continue to do something until the first step has completed.
    // This is possible because the API will execute the operation on a background thread.
    this.Api.BeginPickPoint();
  }

  private void RequestUserInput_OnInitializePickPointCompleted(object sender, EventArgs e)
  {
    // Cleanup
    this.Api.InitializePickPointCompleted -= RequestUserInput_OnInitializePickPointCompleted;

    // Communicate to the UI user that you are waiting for him to click on the screen
    // e.g. by showing a Popup, dimming the screen or showing a dialog.
    // Once the input is received the input event handler will invoke the API to complete the goal   
    MessageBox.Show("Please click the screen");  
  }

  private void FinishPickPoint_OnGridMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
  {
    this.Api.PickPointCompleted += ShowPoint_OnPickPointCompleted;

    // Invoke API to complete the goal
    // and continue to do something until the last step has completed
    this.Api.EndPickPoint();
  }

  private void ShowPoint_OnPickPointCompleted(object sender, PickPointCompletedEventArgs e)
  {
    // Cleanup
    this.Api.PickPointCompleted -= ShowPoint_OnPickPointCompleted;

    // Get the result from the PickPointCompletedEventArgs instance
    Point point = e.Result;

    // Handle the result
    MessageBox.Show(point.ToString());
  }
}

主窗口.xaml

<Window>
  <Grid MouseLeftButtonUp="FinishPickPoint_OnGridMouseLeftButtonUp">
    <Button Click="StartPickPoint_OnButtonClick" />
  </Grid>
</Window>

备注

在背景执行绪上引发的事件会在相同的执行绪上执行其行程常式。从行程常式存取DispatcherObject(例如UI项目)(在背景执行绪上执行),需要使用Dispatcher.InvokeDispatcher.InvokeAsync将关键作业排入Dispatcher队列,以避免跨执行绪例外状况。
阅读关于DispatcherObject的评论,了解更多关于这种称为调度程序关联或线程关联的现象。
为了方便地使用API,我建议通过捕获和使用调用者的SynchronizationContext或通过使用AsyncOperation(或AsyncOperationManager),将所有事件编组到调用者的原始上下文。
通过提供取消(推荐的),例如通过展示Cancel()方法(例如PickPointCancel())和进度报告(优选地使用Progress<T>),可以容易地增强上述示例。

一些想法-回复您的评论

因为你找我是为了找到一个“更好的”阻止解决方案,给我一个控制台应用程序的例子,我觉得要说服你,你的看法或观点是完全错误的。
“考虑一个包含这两行代码的控制台应用程序。

var str = Console.ReadLine(); 
Console.WriteLine(str);

当你在调试模式下执行应用程序时会发生什么。它会在第一行代码处停止,并强制你在控制台UI中输入一个值,然后在你输入一些内容并按Enter键后,它会执行下一行,并实际打印你输入的内容。我正在考虑完全相同的行为,但在WPF应用程序中。”
控制台应用程序是完全不同的东西。线程概念有点不同。控制台应用程序没有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;
}

正如你所看到的,这是一个简单的 synchronous 操作。它在一个“无限”循环中轮询用户输入。没有魔术块,继续。
WPF是围绕一个渲染线程和一个UI线程构建的。这些线程 * 总是 * 旋转,以便与操作系统进行通信,比如处理用户输入-保持应用程序 * 响应 *。您永远不想暂停/阻止这个线程,因为它会阻止框架进行必要的后台工作,比如响应鼠标事件-您不希望鼠标冻结:

等待=线程阻塞=无响应=糟糕的UX =惹恼用户/客户=办公室的麻烦。

有时,应用程序流需要等待输入或例程完成,但我们不想阻塞主线程。
这就是为什么人们发明了复杂的异步编程模型,以允许在不阻塞主线程的情况下进行等待,并且不迫使开发人员编写复杂且错误的多线程代码。
每个现代应用程序框架都提供异步操作或异步编程模型,以允许开发简单而高效的代码。
事实上,你正在努力抵制异步编程模型,这对我来说是缺乏理解的。每个现代的开发者都喜欢异步API而不是同步API。没有一个严肃的开发者关心使用await关键字或者声明他的方法async。没有人。你是我遇到的第一个抱怨异步API并且发现它们不方便使用的人。
如果我检查您的框架,它的目标是解决UI相关的问题或使UI相关的任务更容易,我会 * 期望 * 它是异步的-所有的方式。
与UI相关的非异步API是浪费,因为它会使我的编程风格复杂化,因此我的代码变得更容易出错,难以维护。
不同的视角:当你意识到等待会阻塞UI线程时,会产生一种非常糟糕和不理想的用户体验,因为UI会冻结,直到等待结束,既然你意识到了这一点,为什么还要提供一个API或插件模型来鼓励开发人员这样做-实现等待呢?
你不知道第三方插件会做什么,也不知道例程要花多长时间才能完成。这是一个糟糕的API设计。当你的API在UI线程上运行时,你的API调用者必须能够对它进行非阻塞调用。
如果您否定唯一廉价或优雅的解决方案,那么请使用事件驱动的方法,如我的示例所示。
它可以满足您的需求:启动例程-等待用户输入-继续执行-完成目标。
我真的试过好几次解释为什么等待/阻塞是一个糟糕的应用程序设计。同样,你不能把一个控制台用户界面和一个丰富的图形用户界面相比,比如说,单独的输入处理比仅仅听输入流要复杂得多。我真的不知道你的经验水平和你是从哪里开始的,但是你应该开始接受异步编程模型。我不知道你为什么要避免它。但是这一点都不明智。
今天,异步编程模型在任何地方,在任何平台、编译器、任何环境、浏览器、服务器、桌面、数据库--任何地方都被实现。(订阅/取消订阅事件,阅读文档(当存在文档时)以了解事件),依赖于后台线程。事件驱动是老式的,只应在异步库不可用或不适用时使用。
作为旁注:NET框架(.NET标准)提供了TaskCompletionSource(以及其他用途)来提供一种将现有的事件驱动API转换为异步API的简单方法。
“我已经在Autodesk Revit中看到了确切的行为。”
行为(您所体验或观察到的)与如何实现这种体验有很大的不同。两件不同的事情。您的Autodesk很可能使用异步库或语言功能或其他线程机制。而且它还与上下文相关。当您脑海中的方法在后台线程上执行时,开发人员可能会选择阻止此线程。他要么有很好的理由这样做,要么只是做了一个糟糕的设计选择。你完全走错了路;)堵塞不好。
(Is Autodesk源代码开源?或者你怎么知道它是如何实现的?)
我不想冒犯你,请相信我。但是请重新考虑异步实现你的API。只是在你的头脑中,开发人员不喜欢使用async/await。你显然有错误的心态。忘记控制台应用程序的论点--这是胡说八道;)

UI相关的API * 必须 * 尽可能使用async/await。否则,你会把所有的工作都留给你API的客户端去写非阻塞代码。你会迫使我把对你API的每个调用都 Package 到一个后台线程中。或者使用不太舒服的事件处理。相信我-每个开发人员都宁愿用async来装饰他的成员,每次使用事件时,都可能会有潜在的内存泄漏风险-这取决于某些情况,但当编程粗心时,这种风险是真实的的,并不罕见。
我真的希望你能理解为什么阻塞是不好的。我真的希望你决定使用async/await来编写一个现代的异步API。尽管如此,我还是向你展示了一个非常常见的非阻塞等待方法,使用事件,尽管我强烈建议你使用async/await。
API将允许程序员访问UI等。现在假设程序员希望开发一个加载项,当单击按钮时,最终用户将被要求在UI中选择一个点。
如果您不想让插件直接访问UI元素,您应该提供一个接口来委托事件或通过抽象对象公开内部组件。
API会在内部代表增益集订阅UI事件,然后透过将Map的“ Package 函数”事件公开给API客户端来委派事件。您的API必须提供一些拦截,增益集可以在这些拦截处连接以存取特定的应用程序元件。插件API的作用就像配接器或外观,给予外部存取内部。
允许一定程度的隔离。
看看Visual Studio是如何管理插件或允许我们实现它们的。假设你想为Visual Studio编写一个插件,并研究一下如何做到这一点。你会发现Visual Studio通过一个接口或API公开它的内部。例如,你可以操纵代码编辑器或获得有关编辑器内容的信息,而不必 * 真正 * 访问它。

bgtovc5b

bgtovc5b2#

我个人认为这是过于复杂的每个人,但也许我不完全理解的原因,为什么这需要这样做的某种方式,但它似乎像一个简单的布尔检查可以在这里使用。
首先也是最重要的,通过设置BackgroundIsHitTestVisible属性使网格可命中测试,否则它甚至无法捕获鼠标点击。

<grid MouseLeftButtonUp="Grid_MouseLeftButtonUp" IsHitTestVisible="True" Background="Transparent">

接下来创建一个bool值,用于存储是否应该发生“GridClick”事件。当网格被单击时,检查该值,如果它正在等待单击,则从网格单击事件执行。
示例:

bool awaitingClick = false;

private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
   awaitingClick=true;
}

private void Grid_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{     
     //Stop here if the program shouldn't do anything when grid is clicked
     if (!awaitingClick) { return; } 

     //Run event
     var point = Utility.PickPoint(View);
     MessageBox.Show(point.ToString());

     awaitingClick=false;//Reset
}
j5fpnvbx

j5fpnvbx3#

我尝试了一些东西,但我无法使它没有async/await。因为如果我们不使用它,它会导致DeadLock或UI被阻止,然后我们能够采取Grid_Click输入。

private async void ToolBtn_OnClick(object sender, RoutedEventArgs e)
{
    var senderBtn = sender as Button;
    senderBtn.IsEnabled = false;

    var response = await Utility.PickPoint(myGrid);
    MessageBox.Show(response.ToString());
    senderBtn.IsEnabled = true;
}  

public static class Utility
{
    private static TaskCompletionSource<bool> tcs;
    private static Point _point = new Point();

    public static async Task<Point> PickPoint(Grid grid)
    {
        tcs = new TaskCompletionSource<bool>();
        _point = new Point();

        grid.MouseLeftButtonUp += GridOnMouseLeftButtonUp;

        await tcs.Task;

        grid.MouseLeftButtonUp -= GridOnMouseLeftButtonUp;
        return _point;
    }

    private static void GridOnMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {

        // do something here ....
        _point = new Point { X = 23, Y = 34 };
        // do something here ....

        tcs.SetResult(true); // as soon its set it will go back

    }
}
ycl3bljg

ycl3bljg4#

您可以使用SemaphoreSlim

public partial class MainWindow : Window, IDisposable
{
    private readonly SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(0, 1);

    public MainWindow()
    {
        InitializeComponent();
    }

    private async void ButtonBase_OnClick(object sender, RoutedEventArgs e)
    {
        var point = Utility.PickPoint(View);

        // do not continue the code flow until the user has clicked on the grid. 
        // so when we debug, the code flow will literally stop here.
        await _semaphoreSlim.WaitAsync();

        MessageBox.Show(point.ToString());
    }

    private void View_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        //click on grid detected....
        _semaphoreSlim.Release();
    }

    protected override void OnClosed(EventArgs e)
    {
        base.OnClosed(e);
        Dispose();
    }

    public void Dispose() => _semaphoreSlim.Dispose();
}

您不能,也不想同步阻塞调度程序线程,因为这样它将永远无法处理Grid上的点击,也就是说,它不能同时被阻塞和处理事件。

iqjalb3h

iqjalb3h5#

从技术上讲,使用AutoResetEvent和不使用async/await都是可行的,但有一个明显的缺点:

public static Point PickPoint(Grid grid)
{
    var pointPicked = new AutoResetEvent(false);
    grid.MouseLeftButtonUp += (s, e) => 
    {
        // do whatever after the grid is clicked

        // signal the end of waiting
        pointPicked.Set();
    };

    // code flow will stop here and wait until the grid is clicked
    pointPicked.WaitOne();
    // return something...
}

缺点:如果您像范例程式码一样,直接在按钮事件行程常式中呼叫这个方法,就会发生死锁,而且您会看到应用程序停止回应。因为您是使用唯一的UI执行绪来等候使用者的按一下,所以它无法回应任何使用者的动作,包括使用者在方格上的按一下。
方法的消费者应该在另一个线程中调用它以防止死锁。如果可以保证,那就好。否则,你需要像这样调用方法:

private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
    // here I used ThreadPool, but you may use other means to run on another thread
    ThreadPool.QueueUserWorkItem(new WaitCallback(Capture));
}

private void Capture(object state)
{
    // do not continue the code flow until the user has clicked on the grid. 
    // so when we debug, the code flow will literally stop here.
    var point = Utility.PickPoint(View);

    MessageBox.Show(point.ToString());
}

这可能会给你的API的消费者带来更多的麻烦,除非他们习惯于管理自己的线程。

ss2ws0br

ss2ws0br6#

我认为问题出在设计本身。如果你的API在一个特定的元素上工作,那么它应该用在这个元素的事件处理程序中,而不是用在另一个元素上。
例如,在这里,我们要获取单击事件在Grid上的位置,需要在与Grid元素上的事件关联的事件处理程序中使用API,而不是在button元素上。
现在,如果要求仅在我们单击Button后处理Grid上的单击,则Button的职责将是在Grid上添加事件处理程序,Grid上的单击事件将显示消息框并删除按钮添加的此事件处理程序,以便在此单击后不再触发...(无需阻塞UI Thread)
我只想说,如果在单击按钮时阻塞UI线程,我认为UI线程将无法在之后触发Grid上的单击事件。

km0tfn4u

km0tfn4u7#

首先,UI线程不能被阻塞,就像你从前面的问题中得到的答案一样。
如果你能同意这一点,那么避免async/await以使你的客户做更少的修改是可行的,甚至不需要任何多线程。

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
    {
        Utility.PickPoint(View, (x) => MessageBox.Show(x.ToString()));
    }
}

public static class Utility
{
    private static Action<Point> work;

    public static void PickPoint(Grid grid, Action<Point> work)
    {
        if (Utility.work == null)
        {
            grid.PreviewMouseLeftButtonUp += Grid_PreviewMouseLeftButtonUp;
            Utility.work = work;
        }
    }

    private static void Grid_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        var grid = (Grid)sender;
        work.Invoke(e.GetPosition(grid));
        grid.PreviewMouseLeftButtonUp -= Grid_PreviewMouseLeftButtonUp;
        Utility.work = null;
    }
}

但如果你想阻塞UI线程或“代码流”,答案将是不可能的。因为如果UI线程被阻塞,就没有进一步的输入可以接收。
既然你提到了控制台应用程序,我只是做一些简单的解释。
当您运行控制台应用程序或从未附加到任何控制台(窗口)的进程调用AllocConsole时,将执行可提供控制台(窗口)的conhost.exe,并且控制台应用程序或调用方进程将附加到控制台(窗口)。
因此,您编写的任何可能阻塞调用者线程(如Console.ReadKey)的代码都不会阻塞控制台窗口的UI线程,这就是为什么当控制台应用程序等待您的输入时,仍然可以响应其他输入(如鼠标单击)的原因。

相关问题