记录 WinForm 按钮单击 *之前* 触发由设计器生成的事件

Log WinForm button clicks *before* firing events generated by the Designer

我正在尝试找出一种方法让我的 WinForms 应用程序将每次按钮单击记录到文件中。到目前为止,我找到的最佳解决方案(来自 Log all button clicks in Win Forms app)类似于:

public class ButtonLogger
{
    public static void AttachButtonLogging(ControlCollection controls)
    {
        foreach (var control in controls.Cast<Control>())
        {
            if (control is Button)
            {
                Button button = (Button)control;
                button.Click += LogButtonClick;
            }
            else
            {
                AttachButtonLogging(control.Controls);
            }
        }
    }

    private static void LogButtonClick(object sender, EventArgs eventArgs)
    {
        Button button = sender as Button;
        WriteLog("Click: " + button.Parent.Name.ToString() + "." + button.Name.ToString() + " (\"" + button.Text + "\")");
    }

    private static void WriteLog(string message)
    {
        //...
    }
}

然后在每个表单构造函数的末尾:

ButtonLogger.AttachButtonLogging(this.Controls);

虽然这是一个很好的方法,但它有 3 个主要缺点,所有这些都是由于日志事件总是在 "real" 事件(由设计者创建)之后发生:

1) 如果单击按钮打开模式对话框,则在父级单击之前将记录该对话框内的单击(也称为日志显示乱序)。这是因为只有在模式对话框关闭后才会记录父级的点击,因此在该对话框内点击之后。

2) 同样,如果该模式对话框中的某些操作导致应用程序崩溃,打开它的点击将永远不会被记录,使其看起来好像崩溃发生在父级中。

3) 如果单击按钮关闭当前对话框,button.Parent 将为空(因为记录它的事件将在 对话框已经被触发由 "first" 事件关闭)。因此,我们无法记录 "close" 点击来自的表单的名称。

我一直在绞尽脑汁想办法颠倒顺序(所以日志事件在 "real" 事件之前被触发),但想不出任何 不排除使用 Designer。问题是设计器在 InitializeComponents() 中创建和分配事件,我们无法编辑。

关于可能的(不成功的)解决方案的想法:

看起来像这样的事情应该有一些开箱即用的想法,但我没有想法。注意:目标是记录点击 以一种在构建新对话框时不麻烦的方式 - 类似于上面的解决方案,但没有提到的三个陷阱。

你在这里有很多选择,有些比其他的更令人发指。 :) 不幸的是,一般来说,越方便的解决方案越令人发指。

选项 1:(最不令人发指)

不要使用设计器(这就是我们在 Visual Studio 中所说的 "GUI builder")来附加事件处理程序。相反,自己编写代码为每个按钮附加处理程序,使用点击记录器中的辅助方法自动插入所需的处理程序:

class LogClickEventArgs : EventArgs
{
    public string Name { get; private set; }
    public DateTime DateTime { get; private set; }

    public LogClickEventArgs(string name)
    {
        Name = name;
        DateTime = DateTime.UtcNow;
    }
}

class ClickLogger
{
    public static event EventHandler<LogClickEventArgs> LogClick;

    public static void SubscribeClick(Control control, EventHandler handler)
    {
        control.Click += (_ClickHandler + handler);
    }

    private static void _ClickHandler(object sender, EventArgs e)
    {
        LogClick.Raise(null, new LogClickEventArgs(((Control)sender).Name));
    }
}

static class Extensions
{
    public static void Raise<T>(this EventHandler<T> handler, object sender, T e) where T : EventArgs
    {
        if (handler != null)
        {
            handler(sender, e);
        }
    }
}

在你的 Form subclass:

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();

        ClickLogger.SubscribeClick(button1, button_Click);
        ClickLogger.SubscribeClick(button2, button_Click);
        ClickLogger.SubscribeClick(button3, button_Click);

        ClickLogger.LogClick += (sender, e) =>
        {
            textBox1.AppendText(string.Format("{0} -- {1}\r\n", e.DateTime, e.Name));
        };
    }

    private void button_Click(object sender, EventArgs e)
    {
        textBox1.AppendText(string.Format("==> clicked {0}\r\n", ((Control)sender).Name));
    }
}

恕我直言,Designer 事件订阅 UI(双击属性中的事件 window 以创建和订阅处理程序)并不是全部 方便。以上意味着无需在设计器中双击,只需将适当的代码行添加到 Form 构造函数即可。

选项 2:

使用自定义 Button 子 class,在其中覆盖 OnClick() 方法并记录点击:

class ClickLogger
{
    public static event EventHandler<LogClickEventArgs> LogClick;

    public static void NotifyClick(Control control)
    {
        LogClick.Raise(null, new LogClickEventArgs(control.Name));
    }
}

自定义Button分class:

public partial class LoggingButton : Button
{
    public LoggingButton()
    {
        InitializeComponent();
    }

    protected override void OnClick(EventArgs e)
    {
        ClickLogger.NotifyClick(this);
        base.OnClick(e);
    }
}

恕我直言,这相当方便,但当然也有缺点,即您必须子 class 任何 Control class 要记录的 Click 事件。您的 LoggingButton class 可以像任何其他 Control 一样在设计器中进行操作,并且用自定义替换 *.Designer.cs 文件中现有的 Button 实例是微不足道的一个(轻松搜索和替换)。

选项3:(最可恶)

作弊,并使用反射访问 Control class 的私有实现细节,让您自己直接操作底层数据结构以插入您的处理程序。这是您现在使用的技术的变体,但解决了您自己的日志记录处理程序在调用列表的哪一端结束的问题:

class ClickLogger
{
    public static event EventHandler<LogClickEventArgs> LogClick;

    public static void AttachLogging<T>(ControlCollection controls) where T : Control
    {
        foreach (Control control in controls)
        {
            if (control is T)
            {
                _AttachLoggingToControl(control);
            }

            AttachLogging<T>(control.Controls);
        }
    }

    private static void _AttachLoggingToControl(Control control)
    {
        FieldInfo fi = typeof(Control).GetField("EventClick", BindingFlags.Static | BindingFlags.NonPublic);
        PropertyInfo pi = typeof(Control).GetProperty("Events", BindingFlags.Instance | BindingFlags.NonPublic);

        object eventClickObject = fi.GetValue(null);
        EventHandlerList handlerList = (EventHandlerList)pi.GetValue(control);
        EventHandler clickHandlers = (EventHandler)handlerList[eventClickObject];

        handlerList[eventClickObject] = _ClickHandler + clickHandlers;
    }

    private static void _ClickHandler(object sender, EventArgs e)
    {
        LogClick.Raise(null, new LogClickEventArgs(((Control)sender).Name));
    }
}

在你的Form分class:

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();

        ClickLogger.AttachLogging<Button>(Controls);

        ClickLogger.LogClick += (sender, e) =>
        {
            textBox1.AppendText(string.Format("{0} -- {1}\r\n", e.DateTime, e.Name));
        };
    }
}

这与您似乎要求的最接近。但是,它在理论上非常脆弱,因为它依赖于未记录且不一定固定的实现细节。

一个主要的缓解因素是 System.Windows.Forms 命名空间正式处于 "bug-fix only" 模式。此外,微软已经开源了代码,虽然不能保证实现不会改变,但这将意味着更多的人将开始做这样令人发指的事情,从而增加微软改变实现的意愿。在任何情况下,如果他们确实更改了实现,因为它是开源的,修复您的代码以适应它不会是一件令人头疼的事情。


甚至还有其他选择。但我认为以上是平衡便利与危险的最佳选择。 :)


编辑:

我想到了另一个选择。我认为你有足够的想法,所以我不会完全充实这一点,但如果你对此有具体的想法,我会提到它......

选项 4:

与其关注记录客户端代码,不如考虑查看记录器实现本身。例如,您可以设置消息依赖关系,其中某些消息被视为 "dependent" 其他消息或 categories/levels 消息,并且只有在其中一条消息本身被记录后才会被记录。

更具体地说:您可以 class 将处理 Click 事件期间记录的消息确定为依赖于记录 Click 事件本身的消息。前者称为 "level 1" 消息,后者称为 "level 0" 消息。实现日志记录代码,以便它简单地批处理 "level 1" 消息,直到它收到 "level 0" 消息,此时它首先记录 "level 0" 消息,然后是排队的 "level 1" ] 消息。


终于……(我保证,关于这个主题的最后一句话)

我真的应该在一开始就提到这一点,但当然你的选择之一是根本不担心这个。特别是,我不太清楚为什么 Click 事件本身确实值得记录,以及为什么在 Click 事件的实际处理过程中记录的消息本身不存在足以通知日志的 reader Click 事件发生了。

换句话说,另一种选择是不记录 Click 事件。这显然是最简单、最容易实现的,而且将来也最不需要维护。 :)

我想我(终于)找到了解决方案(解释如下)。首先,记录器变为:

public class ButtonLogger
{
    public static void AttachButtonLogging(Form form)
    {
        foreach (var field in form.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
        {
            if (field.GetValue(form) is Button)
            {
                System.Windows.Forms.Button button = (Button)field.GetValue(form);
                button.Click += LogButtonClick;
            }
        }
    }

    private static void LogButtonClick(object sender, EventArgs eventArgs)
    {
        Button button = sender as Button;
        WriteLog("Click: " + button.Parent.Name.ToString() + "." + button.Name.ToString() + " (\"" + button.Text + "\")");
    }

    private static void WriteLog(string message)
    {
        //...
    }
}

然后我们在每个表格中做如下操作:

public new void SuspendLayout()
{
    base.SuspendLayout();
    ButtonLogger.AttachButtonLogging(this);
}

逻辑是设计器在创建每个按钮之后插入对 SuspendLayout() 的调用,但在向按钮添加事件之前 - 因此,如果我们可以在此时添加我们的日志记录事件,它们将在设计器的事件之前被触发事件。但是,此时按钮尚未添加到窗体的控件列表中。因此,我们使用反射来检查表单的所有字段,并为每个按钮添加我们的事件。

需要进行更多测试,但就目前而言,它似乎运行良好!

只需使用 IMessageFilter() with Application.AddMessageFilter() 即可在 将消息发送到您的应用程序之前 获取消息。这将适用于所有形式的应用程序中的所有按钮(无论它们嵌套多深)。它甚至适用于在 运行 时间动态添加的按钮。 最好的部分是,您根本不需要对现有代码和控件进行任何更改。您所要做的就是添加消息过滤器 一次 来自启动表单的 Load() 事件:

public partial class Form1 : Form
{

    public Form1()
    {
        InitializeComponent();
    }

    private void Form1_Load(object sender, EventArgs e)
    {
        Application.AddMessageFilter(new ButtonLogger());
    }

}

public class ButtonLogger : IMessageFilter
{

    private const int WM_KEYUP = 0x101;
    private const int WM_LBUTTONUP = 0x202;

    public bool PreFilterMessage(ref Message m)
    {
        if (m.Msg == WM_LBUTTONUP || (m.Msg == WM_KEYUP && ((int)m.WParam == 32 || (int)m.WParam == 13)))
        {
            Control ctl = Control.FromHandle(m.HWnd);
            if (ctl is Button)
            {
                LogButtonClick((Button)ctl);
            }
        }
        return false; // allow normal processing of all messages
    }

    private void LogButtonClick(Button btn)
    {
        WriteLog("Click: " + btn.Parent.Name.ToString() + "." + btn.Name.ToString() + " (\"" + btn.Text + "\")");
    }

    private void WriteLog(string message)
    {
        Console.WriteLine(message);
    }

}