我有一个文本框,它有一个相当庞大的_TextChanged
事件处理程序。在正常输入条件下,性能尚可,但当用户执行长时间连续操作时,例如按住退格按钮以一次删除大量文本时,会明显出现滞后。
例如,事件需要0.2秒才能完成,但用户每0.1秒执行一次删除操作。因此,它无法赶上并且将存在需要处理的事件积压,导致UI滞后。
然而,该事件不需要处理这些中间状态,因为它只关心最终结果。是否有任何方法让事件处理程序知道它应该仅处理最新的事件,并忽略所有先前过时的更改?
我有一个文本框,它有一个相当庞大的_TextChanged
事件处理程序。在正常输入条件下,性能尚可,但当用户执行长时间连续操作时,例如按住退格按钮以一次删除大量文本时,会明显出现滞后。
例如,事件需要0.2秒才能完成,但用户每0.1秒执行一次删除操作。因此,它无法赶上并且将存在需要处理的事件积压,导致UI滞后。
然而,该事件不需要处理这些中间状态,因为它只关心最终结果。是否有任何方法让事件处理程序知道它应该仅处理最新的事件,并忽略所有先前过时的更改?
我已经遇到这个问题多次了,根据我的经验,我发现这个解决方案简单而整洁。它基于Windows Form
,但可以轻松转换为WPF
。
原理:
当TypeAssistant
检测到文本更改
时,它会启动定时器。在WaitingMilliSeconds
后,定时器将引发Idle
事件。通过处理此事件,您可以执行任何您想要的任务(例如处理输入的文本)。如果在计时器开始计时并且WaitingMilliSeconds
后的时间范围内发生另一个文本更改
,则计时器将被重置。
public class TypeAssistant
{
public event EventHandler Idled = delegate { };
public int WaitingMilliSeconds { get; set; }
System.Threading.Timer waitingTimer;
public TypeAssistant(int waitingMilliSeconds = 600)
{
WaitingMilliSeconds = waitingMilliSeconds;
waitingTimer = new Timer(p =>
{
Idled(this, EventArgs.Empty);
});
}
public void TextChanged()
{
waitingTimer.Change(WaitingMilliSeconds, System.Threading.Timeout.Infinite);
}
}
使用方法:
public partial class Form1 : Form
{
TypeAssistant assistant;
public Form1()
{
InitializeComponent();
assistant = new TypeAssistant();
assistant.Idled += assistant_Idled;
}
void assistant_Idled(object sender, EventArgs e)
{
this.Invoke(
new MethodInvoker(() =>
{
// do your job here
}));
}
private void yourFastReactingTextBox_TextChanged(object sender, EventArgs e)
{
assistant.TextChanged();
}
}
优点:
WPF
和 Windows Form
缺点:
waitingTimer = new Timer(p =>
这一行,具体是在p
处。感谢您的帮助。 - anandausing System.Threading;
? - AlirezaDispatcher.Invoke
。请参见:https://learn.microsoft.com/en-us/dotnet/api/system.windows.threading.dispatcher.invoke?redirectedfrom=MSDN&view=netframework-4.8#remarks - Alireza一个简单的方法是在内部方法或委托上使用async/await:
private async void textBox1_TextChanged(object sender, EventArgs e) {
// this inner method checks if user is still typing
async Task<bool> UserKeepsTyping() {
string txt = textBox1.Text; // remember text
await Task.Delay(500); // wait some
return txt != textBox1.Text; // return that text chaged or not
}
if (await UserKeepsTyping()) return;
// user is done typing, do your stuff
}
这里没有涉及到线程。对于C#版本低于7.0的情况,你可以声明一个委托:
Func<Task<bool>> UserKeepsTyping = async delegate () {...}
请注意,这种方法不能确保您不会偶尔处理相同的“最终结果”两次。例如,当用户输入“ab”,然后立即删除“b”时,您可能会重复处理“a”。但这种情况应该很少发生。为了避免这种情况,代码可以像这样编写:// last processed text
string lastProcessed;
private async void textBox1_TextChanged(object sender, EventArgs e) {
// clear last processed text if user deleted all text
if (string.IsNullOrEmpty(textBox1.Text)) lastProcessed = null;
// this inner method checks if user is still typing
async Task<bool> UserKeepsTyping() {
string txt = textBox1.Text; // remember text
await Task.Delay(500); // wait some
return txt != textBox1.Text; // return that text chaged or not
}
if (await UserKeepsTyping() || textBox1.Text == lastProcessed) return;
// save the text you process, and do your stuff
lastProcessed = textBox1.Text;
}
我也认为Reactive Extensions是这里的最佳选择。不过,我的查询略有不同。
我的代码看起来像这样:
IDisposable subscription =
Observable
.FromEventPattern(
h => textBox1.TextChanged += h,
h => textBox1.TextChanged -= h)
.Select(x => textBox1.Text)
.Throttle(TimeSpan.FromMilliseconds(300))
.Select(x => Observable.Start(() => /* Do processing */))
.Switch()
.ObserveOn(this)
.Subscribe(x => textBox2.Text = x);
现在代码按照你的预期精确地工作。
FromEventPattern
将 TextChanged
转换为一个可观察对象,该对象返回发送者和事件参数。然后,Select
将它们更改为 TextBox
中实际的文本。如果在 300
毫秒内出现新键入,则Throttle
基本上会忽略先前的按键 - 所以只有在连续的 300
毫秒窗口内按下的最后一个按键才会传递下去。然后,Select
调用处理过程。
现在,这里有一些神奇的东西。 Switch
做了一些特别的事情。由于 select 返回了一个可观察对象,在 Switch
之前,我们拥有一个 IObservable<IObservable<string>>
。 Switch
只获取最新产生的可观察对象并从中生成值。这非常重要。这意味着,如果用户在现有处理正在运行时键入按键,它将在到达时忽略该结果,并且将始终报告最新运行处理的结果。
最后,有一个 ObserveOn
让执行返回到 UI 线程,然后有一个 Subscribe
实际处理结果 - 在我的案例中,更新第二个 TextBox
上的文本。
我认为这段代码非常整洁和强大。你可以使用 Nuget 获取 Rx-WinForms 来获得 Rx。
async
并执行以下操作:bool isBusyProcessing = false;
private async void textBox1_TextChanged(object sender, EventArgs e)
{
while (isBusyProcessing)
await Task.Delay(50);
try
{
isBusyProcessing = true;
await Task.Run(() =>
{
// Do your intensive work in a Task so your UI doesn't hang
});
}
finally
{
isBusyProcessing = false;
}
}
尝试使用try-finally
语句块是必要的,以确保isBusyProcessing
在某个时间点被设置为false
,以避免无限循环。
Microsoft.Bcl.Async
可以在.NET 4.0上使用。请注意:它只能在VS2012及更高版本中工作,因为VS2010和更早版本的编译器无法识别async
和await
关键字。 - user743382这是我想出的一个解决方案。它类似于目前被接受的答案,但我认为它更加优雅,因为有以下两个原因:
invoke
进行手动线程调度的需求。让我们来看一下。
using System;
using System.Threading.Tasks;
using System.Diagnostics;
public static class Debouncer
{
private static Stopwatch _sw = new Stopwatch();
private static int _debounceTime;
private static int _callCount;
/// <summary>
/// The <paramref name="callback"/> action gets called after the debounce delay has expired.
/// </summary>
/// <param name="input">this input value is passed to the callback when it's called</param>
/// <param name="callback">the method to be called when debounce delay elapses</param>
/// <param name="delay">optionally provide a custom debounce delay</param>
/// <returns></returns>
public static async Task DelayProcessing(this string input, Action<string> callback, int delay = 300)
{
_debounceTime = delay;
_callCount++;
int currentCount = _callCount;
_sw.Restart();
while (_sw.ElapsedMilliseconds < _debounceTime) await Task.Delay(10).ConfigureAwait(true);
if (currentCount == _callCount)
{
callback(input);
// prevent _callCount from overflowing at int.MaxValue
_callCount = 0;
}
}
}
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private async void textBox1_TextChanged(object sender, EventArgs e)
{
// set the text of label1 to the content of the
// calling textbox after a 300 msecs input delay.
await ((TextBox)sender).Text
.DelayProcessing(x => label1.Text = x);
}
}
async
关键字。不要省略它。
说明
静态的Debouncer
类声明了一个扩展方法DelayProcessing
,该方法扩展了字符串类型,因此可以标记到TextBox
组件的.Text
属性上。 DelayProcessing
方法接受一个lambda方法,该方法在防抖延迟结束时立即调用。在上面的示例中,我使用它来设置label
控件的文本,但您也可以在此处执行各种其他操作...我玩了一会儿这个东西。对我来说,这是我能想出的最优雅(简单)的解决方案:
string mostRecentText = "";
async void entry_textChanged(object sender, EventArgs e)
{
//get the entered text
string enteredText = (sender as Entry).Text;
//set the instance variable for entered text
mostRecentText = enteredText;
//wait 1 second in case they keep typing
await Task.Delay(1000);
//if they didn't keep typing
if (enteredText == mostRecentText)
{
//do what you were going to do
doSomething(mostRecentText);
}
}
doSomething(mostRecentText);
之后重置 mostRecentText = "";
。 - MX313private void txt_TextChanged(object sender, EventArgs e)
{
if (!((TextBox)sender).Focused)
DoWork();
}
private void txt_Leave(object sender, EventArgs e)
{
DoWork();
}
响应式扩展非常好地处理了这种情况。
因此,您希望通过将TextChanged
事件限制为0.1
秒并处理输入来捕获它。 您可以将TextChanged
事件转换为IObservable<string>
并订阅它。
像这样
(from evt in Observable.FromEventPattern(textBox1, "TextChanged")
select ((TextBox)evt.Sender).Text)
.Throttle(TimeSpan.FromMilliSeconds(90))
.DistinctUntilChanged()
.Subscribe(result => // process input);
这段代码订阅了TextChanged
事件,进行了节流处理,确保只获取不同的值,然后从事件参数中提取Text
值。
请注意,此代码更像伪代码,我没有测试过。
为了使用Rx Linq
,您需要安装Rx-Linq Nuget package。
如果您喜欢这种方法,可以查看this blog post,该博客实现了自动完成控件并利用了Rx Linq。我还建议观看Bart De Smet的精彩演讲,介绍了响应式扩展。
private async Task ValidateText()
{
if (m_isBusyProcessing)
return;
// Don't validate on each keychange
m_isBusyProcessing = true;
await Task.Delay(200);
m_isBusyProcessing = false;
// Do your work here.
}
你不能沿着以下的方向做些什么吗?
Stopwatch stopWatch;
TextBoxEnterHandler(...)
{
stopwatch.ReStart();
}
TextBoxExitHandler(...)
{
stopwatch.Stop();
}
TextChangedHandler(...)
{
if (stopWatch.ElapsedMiliseconds < threshHold)
{
stopwatch.Restart();
return;
}
{
//Update code
}
stopwatch.ReStart()
}