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

提高 WPF DispatcherTimer 的精度

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2022 年 2 月 3 日

公共领域

4分钟阅读

viewsIcon

14450

downloadIcon

209

如何纠正 DispatcherTimer 触发 Tick 事件比 Interval 要求慢的问题

引言

我编写了一款开源 WPF 游戏 MasterGrab,它已经成功运行了 6 年。在这个游戏中,人类玩家与几个机器人对战。最近,我想扩展游戏,让机器人之间也可以互相比赛。用户可以选择机器人每秒应该移动多少次,每个移动都显示在屏幕上。

我以为使用 DispatcherTimer 很容易实现这一点,它会在 WPF 线程上每隔 x 秒触发一个 Tick 事件。此持续时间由 DispatcherTimer.Interval 控制。当我将其设置为 100 毫秒时,我注意到我每秒只看到大约 4 次移动,而且时间不规律。所以我开始调查 DispatcherTimer 的行为以及如何改进它。

WPF 两次 Tick 之间所需的最短时间

好吧,第一个问题是:DispatcherTimer 运行速度有多快?为了在我的电脑上测量这一点,我编写了一个 WPF 应用程序,除了运行一个 DispatcherTimer 之外什么都不做。XAML 窗口只包含一个名为 MainTextBlockTextBlock

using System;
using System.Text;
using System.Windows;
using System.Windows.Threading;

namespace WpfTimer {
  public partial class MainWindow: Window {
    DispatcherTimer timer;

    public MainWindow() {
      InitializeComponent();

      timer = new();
      timer.Interval = TimeSpan.FromMilliseconds(0);
      timer.Tick += Timer_Tick;
      timer.Start();
    }

    const int timesCount = 20;
    DateTime[] times = new DateTime[timesCount];
    int timesIndex;

    private void Timer_Tick(object? sender, EventArgs e) {
      times[timesIndex] = DateTime.Now;
      if (++timesIndex>=timesCount) {
        timer.Stop();
        var sb = new StringBuilder();
        var startTime = times[0];
        for (int i = 1; i < timesCount; i++) {
          var time = times[i];
          sb.AppendLine($"{(time - startTime):ss\\.fff} | 
                       {(int)(time - times[i-1]).TotalMilliseconds, 3:##0}");
        }
        MainTextBox.Text = sb.ToString();
      }
    }
  }
}

输出是

00.021 |  21
00.021 |   0
00.021 |   0
00.021 |   0
...

对于每个 tick,都有一行。第一列显示自第一个 tick 之后经过了多少 秒.毫秒。第二列显示这次 tick 和上次 tick 之间经过了多少时间。

由于我将 Interval 设置为 0,所以在 tick 之间没有时间损失,除了第一个 tick 和第二个 tick 之间。显然,将 Interval 设置为 0 没有任何意义,我本以为会发生异常。

这是 Interval = 1 毫秒 的结果

00.192 | 192
00.215 |  22
00.219 |   3
00.235 |  15
00.471 | 236
00.600 | 128
00.743 | 142
00.764 |  21
00.935 | 170
01.239 | 303
01.326 |  87
01.628 | 302
01.894 | 266
02.210 | 316
02.375 | 164
02.435 |  60
02.527 |  92
02.658 | 131
02.685 |  26

我没想到会每毫秒触发 Tick,但令我惊讶的是,在两次 tick 之间可能会经过 300 多毫秒,考虑到我的应用程序除了运行计时器之外什么都不做。

增加用于 DispatcherTimer 的 DispatcherPriority

然后我注意到 DispatcherTimer 构造函数可以接受一个 Dispatcher<wbr />Priority 参数,该参数似乎被设置为 Background,这意味着计时器将仅在_“所有其他非空闲操作完成”_后运行。

Name      Priority Description
Invalid         -1 This is an invalid priority.
Inactive         0 Operations are not processed.
SystemIdle       1 Operations are processed when the system is idle.
ApplicationIdle  2 Operations are processed when the application is idle.
ContextIdle      3 Operations are processed after background operations have completed.
Background       4 Operations are processed after all other non-idle operations are completed.
Input            5 Operations are processed at the same priority as input.
Loaded           6 Operations are processed when layout and render has finished but just 
                   before items at input priority are serviced. Specifically this is used 
                   when raising the Loaded event.
Render           7 Operations processed at the same priority as rendering.
DataBind         8 Operations are processed at the same priority as data binding.
Normal           9 Operations are processed at normal priority. This is the typical 
                   application priority.
Send            1o Operations are processed before other asynchronous operations. This is 
                   the highest priority.

我在我的游戏应用程序中尝试了可以使用什么最高的优先级。显然,在显示下一次移动之前,渲染必须完成。所以让我们看看使用 Dispatcher<wbr />Priority.Input 时,Tick 的触发速度有多快

00.014 |  14
00.024 |   9
00.091 |  67
00.202 | 111
00.221 |  19
00.226 |   4
00.242 |  16
00.272 |  30
00.307 |  34
00.369 |  61
00.460 |  91
00.493 |  33
00.524 |  30
00.555 |  31
00.586 |  30
00.712 | 125
00.745 |  33
00.761 |  15
00.788 |  27

为 Interval 选择一个实际的持续时间

我想说它现在可以运行将近 3 倍的速度。显然,Interval=1毫秒 确实没有意义。那么 Interval=100 毫秒 呢?

00.193 | 193
00.292 |  98
00.417 | 124
00.592 | 174
00.718 | 126
00.876 | 157
01.001 | 125
01.142 | 141
01.263 | 120
01.392 | 129
01.559 | 166
01.677 | 117
01.872 | 195
02.010 | 137
02.143 | 133
02.256 | 113
02.358 | 101
02.472 | 114
02.589 | 116

哎呀,现在两次 tick 之间所需的时间几乎总是明显超过 100 毫秒,而且我每秒只获得大约 7 次,而不是 10 次。 :-( 问题似乎是,在 x 毫秒的随机延迟之后,计时器再次等待 100 毫秒,而不是 100-x 毫秒。

使 Tick 能够可靠地每 100 毫秒触发一次

所以基本上,我们必须在每次 tick 事件期间说明 Interval 应该持续多久,直到下一次 tick

const int constantInterval = 100;//milliseconds

private void Timer_Tick(object? sender, EventArgs e) {
  var now = DateTime.Now;
  var nowMilliseconds = (int)now.TimeOfDay.TotalMilliseconds;
  var timerInterval = constantInterval - 
   nowMilliseconds%constantInterval + 5;//5: sometimes the tick comes few millisecs early
  timer.Interval = TimeSpan.FromMilliseconds(timerInterval);

代码是这样工作的。它尝试每 0.1 秒触发一次 tick。所以如果第一个 tick 在 142 毫秒后发生,则 Interval 被设置为 58 毫秒,而不是 100

00.093 |  93
00.216 | 122
00.311 |  95
00.408 |  96
00.515 | 106
00.611 |  96
00.730 | 119
00.859 | 128
00.929 |  70
00.995 |  65
01.147 | 152
01.209 |  62
01.314 | 104
01.402 |  87
01.496 |  94
01.621 | 125
01.731 | 109
01.794 |  63
01.936 | 141

终于!现在我每秒有 10 次 tick。当然,它并不完全是每 100 毫秒一次,因为有时,WPF 线程仍然需要太多时间来处理其他活动。但当这种情况发生时,计时器至少会尝试更快地触发下一次 tick。

关于我的游戏

我 6 年前编写了 MasterGrab,从那时起,我几乎每天都在开始编程之前玩它。击败 3 个试图在随机地图上占领所有 300 个国家的机器人大约需要 10 分钟。当一个玩家拥有所有国家时,游戏结束。这款游戏很有趣,而且每天都是新鲜的,因为地图看起来完全不同。机器人给游戏带来了一些活力,它们既与人类玩家竞争,也互相竞争。如果您愿意,甚至可以编写自己的机器人,该游戏是开源的。我大约在 2 周内编写了我的,但我很惊讶击败它们有多么困难。在与它们对战时,必须制定一项策略,以便机器人互相攻击而不是攻击你。我迟早会写一篇关于它的 CodeProject 文章,但您已经可以下载并玩它了,应用程序中有很好的帮助说明了如何玩

历史

  • 2022 年 2 月 3 日:初始版本
© . All rights reserved.