65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF Bug 及其解决方法:Button.Click 双重调用

2014年1月20日

CPOL

10分钟阅读

viewsIcon

43647

downloadIcon

343

如果通过访问字符抛出、捕获并处理异常,按钮点击事件会被调用两次

目录

  1. 引言
  2. 问题
  3. 重现步骤
  4. 解决方法
  5. 测试和兼容性
  6. 一些结论。我们没问题吗?
  7. 致谢

1. 引言

我在这么晚才发现这个 bug 真是太棒了。到目前为止,我还没有在网上找到对这个问题的描述,这也真是太棒了。很可能某个地方存在这样的描述,因为这个问题很明显,所以如果有人指出,我将不胜感激。

考虑到 bug 出现时情况的实际重要性,这似乎更令人惊讶。事实上,在有最后机会捕获所有异常时处理它们确实很重要:在每个线程堆栈的最顶层堆栈帧中,以及在 UI 的最外层事件循环中;在 WPF 中,这是通过使用事件 Application.DispatcherUnhandledException 处理所有未处理的异常来完成的。在这种情况下,每个未处理的异常都将在事件循环中被捕获、处理,然后如果它在给定的异常条件下有意义,则可以继续循环。

使用控件上的访问字符也很重要,这允许用户通过一个键盘组合键(Alt+Key)激活或聚焦每个控件。实际上,所有设计良好的应用程序都应允许仅使用键盘快速访问所有控件,因此访问字符非常有用。

不幸的是,当使用 Button 控件并且实际抛出并捕获异常时,这些最基本技术的组合会给 WPF 带来问题。

事实上,所有编程社区都有一些成员倾向于给出“这不是 bug,而是功能”的论点,坚持认为“如果你做的一切都正确,系统就会表现正确”。特别是对于这类人,我想强调的是,控件的 Click 事件旨在显示完全相同的行为,无论它们是通过鼠标还是键盘激活,也无论应用程序如何使用该事件。在某些条件下,WPF 并非如此,我将在下一节中描述这些条件。

无论如何,本文的主题是可以讨论的。我建议的解决方法有助于确保在每个特定应用程序中出现异常时所需的行为,但它几乎不可能以一种完全通用的方式完成,因此由于其本质,这个 bug 可能会无处不在。这就是我决定以文章的形式撰写此事的原因,这使我能够展示重现问题的确切代码。此外,我认为将问题提请尽可能多的 WPF 开发人员注意非常重要。

如果一些读者能指出解决这个问题的更好方法,我将不胜感激。所有正确的批评都将受到热烈欢迎。

2. 问题

最好从显示问题未出现的案例的注释开始。

值得注意的是,我只在 WPF 按钮上发现了这个问题,没有其他。我不可能尝试所有可用的控件,但我对其他最广泛使用的控件的测试没有显示任何异常;我尝试了复选框和单选按钮(它们是 Button 类的最接近的亲戚)以及菜单项。

只有当按钮使用其文本中存在的访问键“点击”时,问题才会出现。此文本在 XAML 中由下划线 ('_') 字符定义;如果它作为按钮文本中某个字符的前缀,则该字符用作访问键,当用户按下 Ctrl+Key 键盘组合键时会调用 Click 事件。如果 Click 事件以任何其他方式通过鼠标或键盘触发,则不会发生任何错误。

只有当 Click 事件处理程序中抛出了一些异常,并且只有当它使用事件 Application.DispatcherUnhandledException 处理时,问题才会出现。在这种情况下,Click 事件被处理,异常被抛出并处理,然后 Click 事件再次被调用,这导致异常的第二次处理。

有关更多详细信息,请参阅下一节中显示的重现问题的确切步骤。

3. 重现步骤

重现问题的最简单方法是加载本文顶部第一个代码下载链接引用的项目。

我还将展示从头开始重现它的最短方法,从 Visual Studio 模板“WPF 应用程序”开始

  1. 使用 Visual Studio 从头开始创建此类应用程序。
  2. 将一些按钮添加到主窗口 XAML 并为其命名。按钮文本应包含下划线字符,以表示某个访问键,以便通过 Alt+Key 键盘点击调用 Click 事件。
  3. Application 类添加构造函数;在此构造函数中,使用 '+=' 运算符向事件 System.Windows.Application.DispatcherUnhandledException 的调用列表添加事件处理程序。此事件处理程序应将 System.Windows.Threading.DispatcherUnhandledExceptionEventArgs.Cancel 的值赋值为 true 并显示一些异常信息。
  4. 向按钮的 Click 事件添加一些事件处理程序。此处理程序应抛出一些异常。

请注意,上面提到的“显示一些异常信息”步骤不是必需的,可以省略,它不会影响 bug 的表现。Click 事件的双重调用无论如何都会发生。这确实是事件的双重调用,而不是同一异常的双重处理或类似的东西。通过向 Click 事件处理程序添加一些代码行,在此行上设置断点并使用调试器,可以轻松检查。但是,在处理程序代码执行结束时抛出一些异常至关重要:没有它,将不会观察到双重事件调用的效果。

例如,我使用以下 XAML 用于演示窗口

<Window x:Class="HowToReproduce.ReproduceProblemWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    WindowStartupLocation="CenterScreen"
    Title="To Reproduce the Problem..." SizeToContent="WidthAndHeight">
    <StackPanel Margin="8">
        <TextBlock>Activate the button using Alt+B or blank space or mouse
        and see the difference<LineBreak/></TextBlock>
        <Button x:Name="button" Width="200">
            Test _Button Click</Button>
    </StackPanel>
</Window> 

我是这样抛出异常的

using System;
using System.Windows;

//...

public partial class ReproduceProblemWindow : Window {

    public ReproduceProblemWindow() {
        InitializeComponent();
        this.button.Focus();
        this.button.Click += (sender, evenArgs) => {
            throw new ApplicationException("Button click");
        };
    } //ReproduceProblemWindow

} //class ReproduceProblemWindow

我是这样在我的 Application 类中处理异常的

using System.Windows;

//...

public partial class App : Application {

    static class DefinitionSet {
        internal const string FormatException = "{0}:\n\n{1}";
        internal const string FormatTitle = "{0} Exception";
    } //class DefinitionSet

    internal App() {
        DispatcherUnhandledException += (sender, eventArgs) => {
            eventArgs.Handled = true;
            MessageBox.Show(
                string.Format(
                    DefinitionSet.FormatException,
                    eventArgs.Exception.GetType().FullName,
                    eventArgs.Exception.Message),
                string.Format(
                    DefinitionSet.FormatTitle,
                    App.Current.MainWindow.Title),
                MessageBoxButton.OK,
                MessageBoxImage.Error);
        }; //DispatcherUnhandledException
    } //App

} //class App

要查看问题,请点击 Alt+C。异常对话框将显示带消息“Button click”的 ApplicationException 异常。关闭对话框后,异常将再次显示一次;调试显示事件再次被调用,只有一次。至少还有两种方法可以使事件被调用:通过鼠标点击按钮,或选择它并敲击空格键——它们不会显示任何异常行为。

实际上,根据按钮点击方式的不同行为是这是 WPF bug 的主要证据。显然,控件和使用访问键激活控件是设计的。

4. 解决方法

一种解决方法很明显:我们可以通过 Dispatcher.Invoke 方法序列化事件调用。这将调用(连同调用所需的参数和局部变量)放入应用程序 UI 线程的事件队列中;这样委托实例将只从队列中取出并调用一次。

有关此技术和相关技术的一些背景知识,请参阅我在 CodeProject 问答论坛中的过去回答

此外,我的文章解释了线程间委托的思想及其工作原理

现在,我想要一个不需要更改已编写代码,并且只需要更改 XAML 的解决方法,仅适用于所有按钮控件都只在 XAML 中声明的情况。因此,我称之为 FixedButton 的新控件声明了具有相同名称的事件

namespace WpfFix {
    using System;
    using System.Windows.Controls;

    internal class FixedButton : Button {

        protected override void OnClick() {
            if (Click != null)
                Dispatcher.Invoke(new Action(() => {
                    Click.Invoke(this, new EventArgs());
                }));
        } //OnClick

        new internal event EventHandler Click;

    } //class FixedButton

} //namespace WpfFix

注意关键字 new;它允许避免关于派生类中 Button.Click 成员被“隐藏”的编译警告。通常,这种隐藏是不好的,但它帮助我最大程度地减少了以前使用 System.Windows.Controls.Button 类的代码中的更改。

现在,问题是如何将所有 XAML 文件中的所有按钮替换为新类。为此,应添加与 WpfFix 对应的 XML 命名空间;Visual Studio Intellisense 将帮助找到适当的命名空间声明

xmlns:Fix="clr-namespace:WpfFix"

因此,标签 Button 应替换为标签 Fix:FixedButton,属性 Name 替换为属性 x:Name。这是我的 Workaround Demo window 的完整 XAML

<Window x:Class="Workaround.WorkaroundDemoWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:Fix="clr-namespace:WpfFix"
    WindowStartupLocation="CenterScreen"
    Title="Workaround Demo" SizeToContent="WidthAndHeight">
    <StackPanel Margin="8">
        <TextBlock>
            Activate the button using Alt+B or blank space or mouse and see the difference:
            <LineBreak/>
            the click should be invoked only once,
            <LineBreak/>
            and the exception should be thrown and handled only once.
            <LineBreak/>
        </TextBlock>
        <Fix:FixedButton x:Name="button" Width="200">
            Test _Button Click</Fix:FixedButton>
    </StackPanel>
</Window>

还有一个问题:上面显示的异常处理方法中简单的 MessageBox 文本不会非常有用:它只会显示带消息“异常已被调用的目标抛出”的 TargetInvocationException 异常,从而隐藏了异常的根本原因。

但这确实是一个无论如何都应该解决的普遍问题,因为这种异常是初学者调试使用任何类型调用的代码时常见的问题。这里的关键是:原始异常对象仍保留在属性 System.Exception.InnerException 中。一种方法是使用此属性并递归显示所有内部异常。这可以通过不同的方式实现。例如,这是我改进的异常处理代码

using System;
using System.Windows;
using StringBuilder = System.Text.StringBuilder;
using StringList = System.Collections.Generic.List<string>;
using IStringList = System.Collections.Generic.IList<string>;

//...

public partial class App : Application {

    static class DefinitionSet {
        internal const string FormatException = "{0}:\n{1}";
        internal const string FormatTitle = "{0} Exception";
        internal const string ExceptionDelimiter = "\n\n";
    } //class DefinitionSet

    internal App() {
        DispatcherUnhandledException += (sender, eventArgs) => {
            Func<Exception, string> exceptionTextFinder = (ex) => {
                Action<Exception, IStringList> exceptionTextCollector
                    = null; // for recursiveness
                exceptionTextCollector = (exc, aList) => {
                    aList.Add(string.Format(
                        DefinitionSet.FormatException,
                        exc.GetType().Name,
                        exc.Message));
                    if (exc.InnerException != null)
                        exceptionTextCollector(exc.InnerException, aList);
                }; //exceptionTextCollector
                IStringList list = new StringList();
                exceptionTextCollector(ex, list);
                StringBuilder sb = new StringBuilder();
                bool first = true;
                foreach (string item in list)
                    if (first) {
                        sb.Append(item);
                        first = false;
                    } else
                        sb.Append(DefinitionSet.ExceptionDelimiter + item);
                return sb.ToString();
            };
            MessageBox.Show(
                exceptionTextFinder(eventArgs.Exception),
                string.Format(
                    DefinitionSet.FormatTitle,
                    App.Current.MainWindow.Title),
                MessageBoxButton.OK,
                MessageBoxImage.Error);
            eventArgs.Handled = true;
        }; //DispatcherUnhandledException
    } //App

} //class App 

仅此而已。问题非常令人不快,但解决方法并不太难应用。

5. 测试和兼容性

该问题仅在 Windows 7 上的 .NET Framework v. 3.5 和 v. 4.0 上进行了测试。我将非常感谢对其他版本、平台和组合进行问题演示测试

6. 一些结论。我们没问题吗?

当然不是。这只是一个权宜之计,它不能消除问题。开发人员应该意识到这个问题并小心谨慎。问题仍然可能以多种方式潜入代码中。一个明显的方法是访问 Button.Click 事件——它被 FixedButton.Click 隐藏,但不能使其无法访问。以下是访问它的方法

FixedButton button = new FixedButton();
//...
Button btn = button;
btn.Click += (sender, eventArgs) => {
    // will handle System.Windows.Controls.Button.Click
    // reproducing the same problem
}; 

此外,开发人员可能会不小心直接创建和使用 System.Windows.Controls.Button 实例。

因此,问题仍然存在,需要开发人员的注意。问题只能通过修复 WPF 本身来完全消除。

如果有人能分享一些更好的想法,以及一些批评,我将不胜感激。

7. 鸣谢

当本文首次发表时,CodeProject 成员 Nicolas Séveno 提供了一个指向 Microsoft Connect 反馈的链接:按钮的 Click 事件被触发两次

非常感谢,Nicolas!

这个问题截然不同,但可能有些相关。2011年12月1日,微软代表承认了这个bug,但以“不予修复”的状态关闭了它,并说明了做出这个决定的动机。我不认为这个动机足以成为保留这个bug的借口,因为没有完美的解决方法,但这则消息提供了一个关于正在发生什么的线索

微软写道
原因是调用按钮有两种方式:AccessKeyManager.OnKeyDown 和 AccessKeyManager.OnText。一种是响应 KeyDown(Key.Return) 事件引发的,另一种是响应 TextInput("\r") 事件引发的。KeyDown 事件来自 WM_KEYDOWN,通常被标记为已处理。但当从 Click 中抛出异常时,事件未被标记为已处理。由于您捕获了异常,我们返回到消息泵,消息泵认为 WM_KEYDOWN 未被处理,因此调用 TranslateMessage,这会生成 WM_CHAR。这会转换为 TextInput 事件,该事件被路由回按钮,AccessKeyManager 再次调用 click。

唯一的“修复”是如果事件处理程序引发异常,则将事件视为已处理。不幸的是,这是一个策略更改,目前很难做到。因此将此 bug 标记为不予修复。

不幸的是,微软的这个解释并没有提供一个解决方法。尝试通过在我的“重现步骤”代码中将事件标记为已处理,即 eventArgs.Handled = true;(请参阅我的此处第二个代码片段),将无济于事。我尚未检查 Nicolas 报告的 bug,但它看起来也需要一些不同的解决方法,请参阅他对本文的评论。

© . All rights reserved.