提高 ListBox 和 ListView 的响应能力
检测并纠正无响应的表单
引言
许多文章都讨论过将 CPU 密集型任务从用户界面 (UI) 线程移到后台线程的优势。但是,如果后台线程通过高消息速率造成了问题呢?UI 的高消息显示速率会产生响应问题,这与 UI 线程中运行的 CPU 密集型任务类似。甚至可能导致窗体上的所有交互都被阻止,直到大量数据减少或停止。GuiMonitor
类通过动态监视窗体的响应能力来解决此问题。
GuiMonitor
类适用于 ListBox
和 ListView
控件,并提供一个选项,允许您自定义任何控件的使用。您的代码必须进行修改才能使用 GuiMonitor
类。
背景
窗口窗体通过消息队列(也称为消息泵)接收事件。鼠标单击和键盘输入是事件的示例,计时器事件和来自非 UI 线程的 Invoke
调用也是如此。如果用户界面线程过于繁忙,无论出于何种原因,消息队列中的事件都会排队,从而延迟事件。如果事件处理延迟过长,则窗体将被视为无响应。
.NET 框架支持三种计时器类:System.Windows.Forms.Timer
、System.Timers.Timer
和 System.Threading.Timer
。后两种计时器事件在从公共语言运行时线程池获取的工作线程中执行。在工作线程中运行的代码不能操作 UI 组件。System.Windows.Forms.Timer
在应用程序的 UI 线程中执行,允许修改 UI 控件。此外,System.Windows.Forms.Timer
引发的事件仅在 GUI 线程不繁忙时进行处理。这些特性使得 System.Windows.Forms.Timer
几乎完美地用于确定窗体的响应能力。
您可以在以下位置了解有关计时器之间差异的更多信息:
监视窗体响应能力
GuiMonitor
类利用通过消息队列传递的计时器事件。通过大约每秒调度十次计时器事件来衡量窗体的响应能力。只要 GuiMonitor
类调度的计时器事件在预期时间范围内发生,UI 就被认为是响应式的。如果连续两次计时器事件都错过了,则认为响应能力不可接受,并采取纠正措施。纠正措施要么更改 UI 控件的操作模式,要么请求写入 UI 的例程暂停一小段时间。GuiMonitor
有四种操作模式:正常、加速、延迟和扩展延迟。
正常模式仅监视 GuiMonitor
调度的计时器事件。当错过连续两次计时器事件时,GuiMonitor
类将切换到加速模式。
加速模式能够处理短暂的高数据量。在加速模式下,会为 ListBox
或 ListView
控件发出 BeginUpdate()
函数。此 BeginUpdate()
函数会暂时停止显示消息,从而能够每秒处理比正常显示模式下更多的消息。如果满足计划的计时器事件,则发出 EndUpdate()
函数并恢复正常模式。但是,如果计时器事件继续被错过,则需要进一步的纠正措施,因此会向控件发出 EndUpdate()
命令,并且 GuiMonitor
类进入延迟模式。
在延迟模式下,GuiMonitor
类将发出请求,要求在生成数据的例程中短暂暂停。如果满足计划的事件,ListBox
或 ListView
控件将返回正常模式,不请求延迟。否则,GuiMonitor
类将进入扩展延迟模式。
在扩展延迟模式下,延迟时间将定期增加,直到满足计划的计时器事件,此时 ListBox
或 ListView
控件将返回正常显示模式,并且不请求延迟时间。
示例代码
下载源代码,了解 GUI 监视器类实际工作示例。该示例允许您尝试使用和不使用监视和/或自动滚动的 ListBox
和 ListView
控件。
没有 GuiMonitor
,窗体在大量消息和多个线程的情况下会“冻结”。通过将鼠标移到各种按钮上,您可以轻松地看到冻结或未冻结。对于响应式的屏幕,按钮会迅速改变颜色。按下任一“测试响应”按钮将在按钮正下方显示一条消息。这些操作在屏幕无响应时会延迟。
“最大随机延迟”允许您模拟消息的随机传递时间。正数将在每条消息之间导致指定毫秒数的延迟。负数将在零和指定毫秒数的正值之间生成一个随机延迟。
“关闭消息显示”按钮在显示消息和不显示消息之间切换。它说明了加速模式的速度有多快。
“自动滚动”、“最大随机延迟”和“关闭消息显示”可以在测试运行时更改。
您在示例中启动的写入器线程越多,就越有可能创建无响应的窗体,尤其是在多核计算机上。当激活 GUI 监视器时,您的窗体永远不应该是无响应的。
使用代码
GuiMonitor
类通过两个构造函数完全支持 ListBox
或 ListView
控件。第三个构造函数仅执行监视。这允许程序员为任何其他类型的窗体控件提供支持,或者为 ListBox
或 ListView
控件提供自定义处理。
我将首先介绍如何使用 ListBox
和 ListView
构造函数,然后解释更通用的构造函数。
ListBox 和 ListView 构造函数
此示例使用 ListBox
,但几乎相同的代码同样适用于 ListView
。首先将 GuiMonitor
类复制到您的测试程序中。
现在,在您的测试程序中,定义对象并启动监视器。
using RAMSystems;
GuiMonitor guiMonitor = new GuiMonitor(listBox1);
guiMonitor.Enabled = true; //start responsiveness monitoring
如果正在使用自动滚动,请在滚动开启或关闭时通知 guiMonitor 对象。例如:
guiMonitor.AutoScroll = AutoScrollCheckBox.Checked;
在 GUI 控件更新时通知 guiMonitor 对象。GuiMonitor.TotalMessages
属性必须在 UI 更新时更新,否则无法检查 UI 响应时间。任何值都可以用于更新。
listBox1.Items.Add(text);
guiMonitor.TotalMessages = listBox1.Items.Count;
延迟和扩展延迟模式需要与写入控件的线程协作。有两种技术。这两种技术都假定已设置并初始化了委托。
private delegate int DelegateSendText(string textLine);
private DelegateSendText m_DelegateSendText;
m_DelegateSendText = this.SendText;
第一个示例演示了一个响应暂停请求的调用方。
private int SendText(string textLine)
{ //use this version if the caller will honor the returned pause value
if (this.InvokeRequired)
{ //this code block runs in a work thread
try { this.Invoke(m_DelegateSendText, textLine); }
catch (Exception) { }
}
else
{ //this code block runs in the GUI thread
listBox1.Items.Add(textLine);
if (guiMonitor.AutoScroll)
listBox1.TopIndex = listBox1.Items.Count - 1;
guiMonitor.TotalMessages = listBox1.Items.Count;
}
return guiMonitor.PauseValue;
} //ends private void SendText(...
// your code running in a worker thread
private void WriterThread(string myTextMessage)
{
int pause = SendText(myTextMessage);
if(0 != pause) //was a delay requested?
Thread.Sleep(pause); // yes
}
第二个示例演示了一个不响应暂停请求的调用方。在 SendText 函数中响应了暂停请求。
private delegate void DelegateSendText(string textLine);
private DelegateSendText m_DelegateSendText;
m_DelegateSendText = this.SendText;
private void SendText(string textLine)
{ //use this version if the caller does not honor the returned pause value
if (this.InvokeRequired)
{ //this code block runs in a work thread
try { this.Invoke(m_DelegateSendText, textLine); }
catch (Exception) { }
if (guiMonitor.PauseValue > 0)
Thread.Sleep(guiMonitor.PauseValue);
}
else
{ //this code block runs in the GUI thread
listBox1.Items.Add(textLine);
if (guiMonitor.AutoScroll)
listBox1.TopIndex = listBox1.Items.Count - 1;
guiMonitor.TotalMessages = listBox1.Items.Count;
}
} //ends private void SendText(...
// your code running in a worker thread
private void WriterThread(string myTextMessage)
{
SendText(myTextMessage);
}
不要在 GUI 线程中发出 Thread.Sleep
!
对 TotalMessages
的更新非常重要。GuiMonitor
使用此调用来检查是否错过了计时器事件。您可以在 GUI 更新完成后停止监视器。让监视器继续运行不是错误,但 100 毫秒的计时器会继续运行,导致一些轻微的开销。使用以下命令停止监视。
guiMonitor.Enabled = false;
您可以通过将 GuiMonitor.DelegateNotifyModePauseValueChange
委托添加到构造函数的参数列表中来监视或自定义延迟值。当模式和/或暂停值更改时,会调用该委托。这允许您协调或影响窗体上的其他控件。您可以在 GuiMonitor.DelegateNotifyModePauseValueChange
函数中更改 PauseValue 属性的值,但不能更改模式。
您可以通过将 GuiMonitor.DelegateNotifyModePauseValueChange
委托添加到构造函数的参数列表中来监视或自定义延迟值。当模式和/或暂停值更改时,会调用该委托。这允许您协调或影响窗体上的其他控件。您可以在 GuiMonitor.DelegateNotifyModePauseValueChange
函数中更改 PauseValue 属性的值,但不能更改模式。
int myPauseValue = 0; //calculated delay time
guiMonitor = new GuiMonitor(listBox1, this.HandlePauseValueChanged );
// your code …
private void HandlePauseValueChanged(GuiMonitor.TrafficMode newMode,
int newValue)
{
switch (newMode)
{
case GuiMonitor.TrafficMode.Normal:
case GuiMonitor.TrafficMode.Accelerated:
myPauseValue = newValue;
break;
case GuiMonitor.TrafficMode.Delayed:
myPauseValue = guiMonitor.PauseValueMilliseconds + (workerThreads.Count * 2);
break;
case GuiMonitor.TrafficMode.ExtendedDelay:
myPauseValue += guiMonitor.IncreasedDelaySinceExtended);
break;
}
}
通用构造函数
GuiMonitor myGuiMonitor = new GuiMonitor(ModeSwitchRoutine);
如果高 CPU 使用率来自 GuiMonitor
类不支持的控件,则通用构造函数很有用。GuiMonitor
类将处理计时器事件的调度和监视,并在需要更改操作模式时调用您通过构造函数提供的函数。您的例程应解释建议的操作模式以缓解延迟。
DataGridView
、GridView
和 DataTable
类是常用的,并且是 GuiMonitor
的完美候选者,除了 BeginLoadData()
/ EndLoadData()
的问题;在加载操作期间发出对数据表中已存在数据的相同性检查会失败。Microsoft 声明 BeginLoadData()
函数“在加载数据时关闭通知、索引维护和约束”。我建议在使用具有数据表的“LoadData”操作时,忽略“加速”模式,直接进入“延迟”模式。
示例代码使用状态工具栏来演示通用构造函数。实际上,状态行的更新非常快,我从未能够造成延迟问题。
其他 ListBox / ListView 函数
当已知将在控件上进行大量更改时,请使用 GuiMonitor
版本的 BeginUpdate / EndUpdate
和 SuspendLayout / ResumeLayout
。在开始和结束调用之间,监视器将暂时暂停。
其他类属性
可能感兴趣的性能/统计属性有:
EnteredAcceleratedMode number of times accelerated mode was entered
EnteredDelayedMode number of times delays were requested
IncreasedDelaySinceExtended number of times a delay increase was requested
关注点
每秒检查十次响应时间是我猜测的;间隔太短会减慢用户程序的运行速度,而间隔太长则无效。十分之一秒似乎是一个合理的折衷。
我的原始项目仅支持 ListBox
。ListBox
能够容纳和显示超过 64K 个项目,但手动滚动到列表末尾存在问题。在 ListBox 模式下运行演示程序,关闭消息显示并开启自动滚动。生成超过 64K 条消息,然后打开消息显示。显示将按预期显示最后一个条目,直到您使用滚动条定位到第一条消息,然后尝试重新定位到最后一条消息。ListView
在列表项数量达到 1 亿之前没有问题。我认为这更多的是一个滚动条问题,但它确实影响了您的显示设计。
历史
2015 年 10 月 初始发布