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

如何在 C# 中(使用或不使用平台调用)检查用户不活动情况

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (66投票s)

2004 年 12 月 19 日

14分钟阅读

viewsIcon

310032

downloadIcon

7468

在一个月内,有两位程序员朋友询问如何实现一段不活动时间后的超时。本文介绍了四种半实现方法。

引言

不时地,有人会提出“如何实现像 Windows Messenger 中的‘当我处于非活动状态时显示为“离开”’那样的超时功能?”这个问题。可能会想到其他类似功能的用例,但无论如何,这是一个有趣的问题。因此,我尝试找出如何正确地实现它。结果就是本文以及这组小类,它们应该能够提供开箱即用的长期期望的功能。

基础知识

基本上有两种不同的方法。第一种是使用具有适当间隔的计时器,并在每次事件发生时重置它。然而,如何获取事件有点棘手,并且有几种可能性。因此,我创建了一个接口和一个抽象基类,以便以后能够轻松地实现处理鼠标和键盘事件的不同方法。第二种是仍然可以使用计时器,但这次使用一个短间隔,并在每次计时器引发其 Elapsed 事件时检查控制台输入。正是由于计时器的这种不同用法,使用轮询的类才不会继承自基类。它直接实现 IInactivityMonitor 接口。

界面

接口 IInactivityMonitor 由一个方法、五个属性和两个事件组成。方法 public void Reset() 用于重置我之前提到的内部计时器以及一些内部状态信息。属性应该基本不言自明。 public bool Enabled 允许确定一个实例是否应引发事件。 public double Interval 指定等待交互的时间间隔。该属性声明为 double 的唯一原因是,这是 Timer.Interval 属性所使用的类型,而该属性属于 Timer 类。属性 public bool MonitorMouseEventspublic bool MonitorKeyboardEvents 允许指定实例是仅监视鼠标事件、键盘事件还是两者都监视。也可以将两者都设置为 false,这与将 Enabled 设置为 false 效果相同。最后一个属性是 public ISynchronizeInvoke SynchronizingObject,它允许提供一个用于同步的控件。如果此属性为 null,则事件不同步。如果提供了同步对象,则事件处理程序的执行将被封送到拥有该对象(或控件)的线程。最后,还有两个事件。 event ElapsedEventHandler Elapsed 在不活动一段时间后引发。此外,还有 event EventHandler Reactivated,在 Elapsed 事件已引发后,当用户继续与被监视的 UI 元素交互时会引发此事件。

使用接口

由于接口看起来很像计时器的接口,因此用法几乎相似。下面的代码片段只是一个示例。当然,构造函数的正确调用取决于具体的实现。就我自己的基类而言,MonitorKeyboardEventsMonitorMouseEvents 的默认值均为 true,而 Enabled 的默认值是 false。因此,与大多数计时器类一样,在将 Enabled 设置为 true 之前,不会发生任何事情。

private IInactivityMonitor inactivityMonitor = null;
inactivityMonitor = new ControlMonitor(this);
inactivityMonitor.SynchronizingObject = this;
inactivityMonitor.MonitorKeyboardEvents = false;
inactivityMonitor.MonitorMouseEvents = true;
inactivityMonitor.Interval = 600000;
inactivityMonitor.Elapsed += new ElapsedEventHandler(TimeElapsed);
inactivityMonitor.Reactivated += new EventHandler(Reactivated);
inactivityMonitor.Enabled = true;

基类

抽象基类 MonitorBase 实现整个计时器处理,以便派生类可以专注于处理鼠标和键盘事件。总而言之,创建计时器实例就是基类构造函数所做的所有事情。

protected MonitorBase()
{
  monitorTimer = new Timer();
  monitorTimer.AutoReset = false;
  monitorTimer.Elapsed += new ElapsedEventHandler(TimerElapsed);
}

由于派生类可能处理许多引用以及非托管句柄,因此实现了 IDisposable 接口。基类析构函数负责处理所有先前注册的事件处理程序,并处置计时器对象。

protected virtual void Dispose(bool disposing)
{
  if (!disposed)
  {
    disposed = true;
    if (disposing)
    {
      Delegate[] delegateBuffer = null;

      monitorTimer.Elapsed -= new ElapsedEventHandler(TimerElapsed);
      monitorTimer.Dispose();

      delegateBuffer = Elapsed.GetInvocationList();
      foreach (ElapsedEventHandler item in delegateBuffer)
        Elapsed -= item;
      Elapsed = null;

      delegateBuffer = Reactivated.GetInvocationList();
      foreach (EventHandler item in delegateBuffer)
        Reactivated -= item;
      Reactivated = null;
    }
  }
}

属性的实现并不真正有趣。TimeElapsedReactivatedRaised 被实现为 protected 只读属性,因为它们仅供派生类内部使用。其他属性被声明为 public virtual 以允许重写。

如果启用了该对象,Reset() 会通过将其 Interval 属性设置为其当前值来重置计时器对象。我承认,这看起来有点奇怪,但它确实能做到它应该做的事情。

public virtual void Reset()
{
  if (disposed)
    throw new ObjectDisposedException("Object has already been disposed");

  if (enabled)
  {
    monitorTimer.Interval = monitorTimer.Interval;
    timeElapsed = false;
    reactivated = false;
  }
}

由于派生类无法调用在基类中声明的事件,因此有两个 protected 方法来封装事件。这也是因为需要执行一些额外的检查以防止意外引发事件。为什么会这样?嗯,基类中使用的 System.Timers.Timer 是一个多线程计时器。因此,计时器线程有可能在 Enabled 被设置为 false 的情况下调用事件处理程序(幸运的是,这种行为有很好的文档说明)。

protected void OnElapsed(ElapsedEventArgs e)
{
  timeElapsed = true;
  if (Elapsed != null && enabled && (monitorKeyboard || monitorMouse))
    Elapsed(this, e);
}

1. 仅使用托管代码监视鼠标和键盘事件

既然我们有了一个良好的基础,就可以转向第一个派生类:ControlMonitor。该类是为了希望使用 100% 托管代码、不使用平台调用,甚至不使用可能使类过于平台相关的托管方法而产生的。让我们看看它是如何工作的以及你可以用它做什么。

public ControlMonitor(Control target) : base()
{
  if (target == null)
    throw new ArgumentException("Parameter target must not be null");
  targetControl = target;
  ControlAdded(this, new ControlEventArgs(targetControl));
  if (MonitorKeyboardEvents)
    RegisterKeyboardEvents(targetControl);
  if (MonitorMouseEvents)
    RegisterMouseEvents(targetControl);
}

该类只有一个构造函数,它接受一个 Control 对象作为参数(因此得名 ControlMonitor)。它调用多达三个注册方法,这些方法都基于相同的原理。它们首先为控件注册一个事件处理程序,然后尝试递归地注册所有子控件。例如,让我们看看 RegisterKeyboardEvents()

private void RegisterKeyboardEvents(Control c)
{
  c.KeyDown += new KeyEventHandler(KeyboardEventOccured);
  c.KeyUp   += new KeyEventHandler(KeyboardEventOccured);
  foreach (Control item in c.Controls)
    RegisterKeyboardEvents(item);
}

首先,它为 KeyDownKeyUp 事件注册事件处理程序。之后,它使用 foreach 循环,并为 Control.Controls 集合中的每个控件调用自身。这确保了我们的监视器对象将由传递给构造函数的对象下方的每个对象通知。现在的问题是,这是否真的能解决我的问题?

优点

首先,这种方法有一个很大的优点。它不使用任何特定于平台的函数或定义。虽然我没有测试过,但我认为这个类很有可能在 Mono 上运行而无需进行任何修改。然而,这可能是唯一的优点。

缺点

如上所示,此类完全依赖于 Control 类。这意味着,一旦你的应用程序有多个窗体(这很可能是正常情况),你都需要为每个窗体创建一个监视器对象。此外,还有一个关于消息框的问题。由于显示消息框只涉及调用一个同步执行的静态方法,因此你完全不知道消息框显示时会发生什么。一个相当弱的解决方法是,一般将“消息框时间”视为不活动时间(或活动时间)。

2. 使用 Application.AddMessageFilter 拦截消息

Application 类提供了一个方便的封装,可以简化子类化。可以使用 AddMessageFilter() 方法将具有 IMessageFilter 接口的任何对象注册为消息过滤器。因此,ApplicationMonitor 类不仅继承自 MonitorBase,还实现了 IMessageFilter。这允许它自己注册,而不是使用辅助对象。由于过滤器将安装在调用 AddMessageFilter() 的线程的消息队列上,因此构造函数不需要任何参数。

public ApplicationMonitor() : base()
{
  Application.AddMessageFilter(this);
}

方法 PreFilterMessage()IMessageFilter 接口中定义的唯一方法,我们可以在其中检查鼠标和键盘活动。如果传入的消息指示用户输入,我们会调用 ResetBase() 来重置基类中的计时器。虽然我们仍然只使用托管代码,但此实现已包含特定于平台的信息,因为它需要对具有特定 ID 的 Windows 消息做出反应。

public bool PreFilterMessage(ref Message m)
{
  if (MonitorKeyboardEvents)
    if (m.Msg == (int)Win32Message.WM_KEYDOWN ||
      m.Msg == (int)Win32Message.WM_KEYUP)
      ResetBase();
  if (MonitorMouseEvents)
    if (m.Msg == (int)Win32Message.WM_LBUTTONDOWN ||
      m.Msg == (int)Win32Message.WM_LBUTTONUP     ||
      m.Msg == (int)Win32Message.WM_MBUTTONDOWN   ||
      m.Msg == (int)Win32Message.WM_MBUTTONUP     ||
      m.Msg == (int)Win32Message.WM_RBUTTONDOWN   ||
      m.Msg == (int)Win32Message.WM_RBUTTONUP     ||
      m.Msg == (int)Win32Message.WM_MOUSEMOVE     ||
      m.Msg == (int)Win32Message.WM_MOUSEWHEEL)
      ResetBase();
  return false;
}

正如你所看到的,PreFilterMessage() 检查的事件与 ControlMonitor 类相同。然而,如果你期望现在拥有一个完全有效的应用程序级别解决方案,那我不得不让你失望。我曾假设,每当新消息分派到线程的消息队列时,PreFilterMessage() 都会在任何其他处理发生之前被调用。但是,出于某些(似乎是未记录的)原因,存在两个例外。第一个是消息框。在显示消息框时,不会调用消息过滤器。第二,当你通过调用 Form.ShowDialog() 显示模态窗体时,你将不会收到任何与后台窗体相关的消息(尽管后台窗体无法获得焦点,但仍会分派消息,例如,当你将鼠标光标移到窗体上时)。

优点

此解决方案仍不完善,但已包含一些改进。除了消息框和模态窗体的问题外,此类还能够跟踪整个线程的每一次用户输入。此外,代码要短得多,因为它不需要注册任何事件处理程序。当然,它仍然不需要调用任何非托管代码。

缺点

其主要缺点显然是此类无法处理线程的所有消息,因为它根本收不到所有消息。与 ControlMonitor 类相比,我们放弃了很多平台独立性,因为消息过滤器处理的消息是 100% Windows 特有的。

3. 使用挂钩监视鼠标和键盘事件

在讨论了两种不调用非托管代码的解决方案后,我们现在将研究 Win32 API。如果考虑使用平台调用,那么人们肯定迟早会想到挂钩。根据 MSDN 库,挂钩是“系统消息处理机制中的一些点,应用程序可以在其中安装一个子程序来监视系统中的消息流,并在某些类型的消息到达目标窗口过程之前对其进行处理”。使用 SetWindowsHookEx()UnhookWindowsHookEx()CallNextHookEx() 函数,我们拥有拦截鼠标和键盘事件所需的一切(还有一篇关于挂钩和 .NET Framework 的详细 MSDN 杂志文章,标题为 Windows Hooks in the .NET Framework)。

让我们看看 HookMonitor 类的构造函数

public HookMonitor(bool global) : base()
{
  globalHooks = global;
  if (MonitorKeyboardEvents)
    RegisterKeyboardHook(globalHooks);
  if (MonitorMouseEvents)
    RegisterMouseHook(globalHooks);
}

此构造函数似乎与 ControlMonitor 类中使用的构造函数没有太大区别。然而,如果你仔细查看 private 方法 RegisterKeyboardHookRegisterMouseHook,你会发现它们使用了上面提到的 SetWindowsHookEx() 函数。因此,以下行注册了鼠标事件的挂钩

mouseHookHandle = User32.SetWindowsHookEx(
                         (int)Win32HookCodesEnum.WH_MOUSE, mouseHandler,
                         (IntPtr)0, AppDomain.GetCurrentThreadId());

mouseHookHandleSetWindowsHookEx() 返回的句柄,如果调用失败则为零。WH_MOUSE 是我们要安装的句柄类型,mouseHandler 是回调函数的委托(从 Windows 的角度来看是函数指针)。最后一个参数是当前线程的 ID。这意味着,一个拥有多个带自己的消息循环的线程的应用程序需要为所有这些线程安装一个挂钩。键盘挂钩的设置看起来完全一样。唯一的区别是第一个参数是 WH_KEYBOARD,并且键盘挂钩也使用自己的回调函数。回调函数本身相当精简。由于我们只想知道线程上是否有任何活动(但我们不关心活动类型),该函数会检查 nCode 是否大于或等于零。在这种情况下,消息应该由挂钩函数处理,因此它调用 ResetBase()。最后,它调用 CallNextHookEx() 将消息传递给链中的下一个函数。

private int MouseHook(int nCode, IntPtr wParam, IntPtr lParam)
{
  if (nCode >= 0)
    ResetBase();
  return User32.CallNextHookEx(mouseHookHandle, nCode, wParam, lParam);
}

优点

为了仅用几行额外的代码达到我们的目标,这种方法非常棒。我们不仅不必担心应用程序中的控件,实际上我们是独立的,并且能接收到分发到我们线程的所有消息。构造该类的线程需要一个消息循环,仅此而已。

缺点

缺点几乎是 ControlMonitor 类所有优点的反面,因为此解决方案大量使用了平台调用。这意味着即使是移植到 64 位 Windows 也可能需要进行一些更改。

4. 使用 GetLastInputInfo() 监视活动

Windows 挂钩允许我们查看线程的所有传入消息,无论哪个窗口拥有焦点,无论它是否是模态窗体。但是,如果我们想监视整个系统的活动怎么办?这个问题最简单的解决方案是 Win32 API 函数 GetLastInputInfo()。但是,有一个主要的缺点。虽然前面的三种方法通常允许分析用户活动,但此函数仅告知我们最后一次用户输入的发生时间。调用它之后,我们可能知道发生了一些事情,但我们不知道具体是什么。尽管这仍然足以普遍解决我们的问题,但它使鼠标和键盘事件之间的区别变得无关紧要。因此,LastInputMonitor 类将监视任何活动,只要 MonitorMouseEventsMonitorKeyboardEvents 中至少有一个属性设置为 true。由于 GetLastInputInfo() 是一个同步函数,LastInputMonitor 类使用轮询来定期检查活动。这就是为什么它不继承自基类 MonitorBase 的原因。但是,它使用相同计时器类的一个实例,默认间隔为 100 毫秒。

public LastInputMonitor()
{
  lastInputBuffer.cbSize = (uint)Win32LastInputInfo.SizeOf;
  pollingTimer = new Timer();
  pollingTimer.AutoReset = true;
  pollingTimer.Elapsed += new ElapsedEventHandler(TimerElapsed);
}

没有基类的帮助,LastInputMonitor 在事件评估和事件引发方面拥有所有逻辑。该类的核心是处理计时器对象的 Elapsed 事件的 TimerElapsed() 方法。

private void TimerElapsed(object sender, ElapsedEventArgs e)
{
  if (pollingTimer.SynchronizingObject != null)
    if (pollingTimer.SynchronizingObject.InvokeRequired)
    {
      pollingTimer.SynchronizingObject.BeginInvoke(
        new ElapsedEventHandler(TimerElapsed),
        new object[] {sender, e});
      return;
    }
  if (User32.GetLastInputInfo(out lastInputBuffer))
  {
    if (lastInputBuffer.dwTime != lastTickCount)
    {
      if (timeElapsed && !reactivated)
      {
        OnReactivated(new EventArgs());
        Reset();
      }
      lastTickCount = lastInputBuffer.dwTime;
      lastInputDate = DateTime.Now;
    }
    else if (!timeElapsed && (monitorMouse || monitorKeyboard))
      if (DateTime.Now.Subtract(lastInputDate).TotalMilliseconds > interval)
        OnElapsed(e);
  }
}

在检查是否需要跨线程封送后,该方法会调用 GetLastInputInfo()。如果它在 IInactivityMonitor.Elapsed 引发后检测到第一次活动,它会引发 Reactivated 事件。否则,它会更新滴答计数信息和时间戳 lastInputDate。如果经过 interval 指定的时间段仍无活动,它会引发 Elapsed 事件。

优点

此类允许在系统范围内检测不活动。我们的问题已完全解决(至少在你不需要仅监视鼠标或键盘事件的情况下)。

缺点

好吧,我已经写了两次了:你无法区分鼠标和键盘事件。但是,我猜大多数人不会介意。除此之外,这种方法使用轮询,我认为应尽可能避免,尽管文档明确提到了使用 GetLastInputInfo() 进行输入空闲检测的可能性。最后,此函数仅在 Windows 2000 及更高版本上可用。

4 1/2. 监视全局事件

我承诺展示四种半实现超时类的方法。最后一种方法解决了我们的所有问题,但无法帮助我们区分鼠标和键盘事件。使用 WH_MOUSEWH_KEYBOARD 挂钩解决了应用程序级别的问题,但有没有类似全局挂钩的东西可以拦截所有系统上的消息?简短的答案是肯定的。然而,.NET 中的全局挂钩是一个问题,因为它们通常使用代码注入,而将托管代码注入非托管进程不是一个好主意。流行的解决方法是使用原生的 Win32 DLL 来实现此目的(有关详细信息,请参阅 Michael Kennedy 的文章:Global System Hooks in .NET)。然而,George Mamaladze 发现有两个低级挂钩不需要代码注入,因此可以在托管环境中使用(请参阅 Processing Global Mouse and Keyboard Hooks in C#)。这也是我的最后一个建议只是一种半解决方案的原因。

正如你可能已经注意到的,HookMonitor 类的构造函数需要一个 bool 作为参数。如果它是 false,则该类安装线程挂钩,如上所述。然而,如果它是 trueRegisterMouseHook()RegisterKeyboardHook() 将安装全局挂钩 WH_MOUSE_LLWH_KEYBOARD_LL。这会导致 SetWindowsHookEx() 的不同调用

keyboardHookHandle = User32.SetWindowsHookEx(
  (int)Win32HookCodesEnum.WH_KEYBOARD_LL,
  keyboardHandler,
  Marshal.GetHINSTANCE(Assembly.GetExecutingAssembly().GetModules()[0]),
  (int)0);

不再需要线程 ID。相反,该函数需要一个包含回调函数的库的句柄。在我们的例子中,这是当前程序集。

结论

尽管我不得不承认我不太喜欢这个想法,但使用挂钩似乎是实现超时功能的最佳方法(至少目前是这样)。当未来版本的 Windows 和 .NET Framework 允许更方便的解决方案时,派生一个新类继承自 MonitorBase 并使用它而不是 HookMonitor 应该会很容易。我希望你觉得本文提供的类很有用,并且如果你发现任何错误或有任何改进代码的想法,请告诉我。

更改历史

  • 2004-12-22:修正了 ControlMonitor 类中的一个错误(未处理 Control.MouseWheel 事件),重命名了带有挂钩定义的枚举(非功能性更改),并包含类和段落来演示 IMessageFilter 接口和 Win32 API 函数 GetLastInputInfo() 的使用。
  • 2004-12-19:初始发布。
© . All rights reserved.