提高 WPF DispatcherTimer 的精度





5.00/5 (6投票s)
如何纠正 DispatcherTimer 触发 Tick 事件比 Interval 要求慢的问题
引言
我编写了一款开源 WPF 游戏 MasterGrab,它已经成功运行了 6 年。在这个游戏中,人类玩家与几个机器人对战。最近,我想扩展游戏,让机器人之间也可以互相比赛。用户可以选择机器人每秒应该移动多少次,每个移动都显示在屏幕上。
我以为使用 DispatcherTimer
很容易实现这一点,它会在 WPF 线程上每隔 x 秒触发一个 Tick
事件。此持续时间由 DispatcherTimer.Interval
控制。当我将其设置为 100 毫秒时,我注意到我每秒只看到大约 4 次移动,而且时间不规律。所以我开始调查 DispatcherTimer
的行为以及如何改进它。
WPF 两次 Tick 之间所需的最短时间
好吧,第一个问题是:DispatcherTimer
运行速度有多快?为了在我的电脑上测量这一点,我编写了一个 WPF 应用程序,除了运行一个 DispatcherTimer
之外什么都不做。XAML 窗口只包含一个名为 MainTextBlock
的 TextBlock
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 日:初始版本