不要在连续键入时引发文本更改
本文关键字:文本 连续 | 更新日期: 2023-09-27 18:22:24
我有一个文本框,它有一个相当大的_TextChanged
事件处理程序。在正常打字条件下,性能还可以,但是当用户执行长时间的连续操作(例如按住退格键以一次删除大量文本(时,性能可能会明显滞后。
例如,事件需要 0.2 秒才能完成,但用户每 0.1 秒执行一次删除。因此,它无法赶上,并且需要处理的事件积压,从而导致 UI 滞后。
但是,事件不需要针对这些中间状态运行,因为它只关心最终结果。有没有办法让事件处理程序知道它应该只处理最新的事件,而忽略所有以前的过时更改?
我已经多次遇到这个问题,根据我自己的经验,到目前为止,我发现这个解决方案简单而整洁。它基于Windows Form
,但可以轻松转换为WPF
。
工作原理:
当TypeAssistant
得知发生了text change
时,它会运行计时器。WaitingMilliSeconds
后,计时器将引发Idle
事件。通过处理此事件,您可以执行任何所需的工作(例如处理输入的tex(。如果在从计时器启动的时间开始到WaitingMilliSeconds
的时间范围内发生另一个text change
,计时器将重置。
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
工作 - 使用 .Net Framework 3.5+
弊:
- 再运行一个线程
- 需要调用而不是直接操作表单
一种简单的方法是在内部方法或委托上使用 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
}
这里不涉及线程。对于早于 7.0 的 C# 版本,可以声明委托:
Func<Task<bool>> UserKeepsTyping = async delegate () {...}
请注意,此方法不会确保您偶尔处理相同的"end reslut"两次。 例如,当用户键入"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;
}
我也认为反应式扩展是这里要走的路。不过,我有一个稍微不同的查询。
我的代码如下所示:
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
做了一些特别的事情。由于选择返回了一个可观察量,因此在Switch
之前,我们有一个IObservable<IObservable<string>>
。Switch
仅获取最新生成的可观察对象,并从中生成值。这一点至关重要。这意味着,如果用户在现有处理正在运行时键入击键,它将忽略该结果,并且只会报告最新运行处理的结果。
ObserveOn
将执行返回到 UI 线程,然后是实际处理结果的Subscribe
- 在我的情况下,在第二个TextBox
更新文本。
我认为这段代码非常整洁且非常强大。您可以通过使用 Nuget for "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
,这样您就不会陷入无限循环。
我玩了一会儿这个。 对我来说,这是我能想到的最优雅(简单(的解决方案:
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);
}
}
这是我想出的解决方案。它类似于目前接受的答案,但我觉得它稍微优雅一些,原因有两个:
- 它使用异步方法,无需手动进行线程编组
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
方法采用 labmda 方法,该方法在去抖延迟过后立即调用。在上面的例子中,我用它来设置label
控件的文本,但你可以在这里做各种其他事情......
反应式扩展可以很好地处理这种场景。
因此,您希望通过限制事件 0.1
秒并处理输入来捕获TextChanged
事件。您可以将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 包。
如果您喜欢这种方法,可以查看这篇使用 Rx Linq 实现自动完全控制的博客文章。我还推荐Bart De Smet关于反应式扩展的精彩演讲。
结合使用 TextChanged 和 TextLeave。
private void txt_TextChanged(object sender, EventArgs e)
{
if (!((TextBox)sender).Focused)
DoWork();
}
private void txt_Leave(object sender, EventArgs e)
{
DoWork();
}
如何剔除事件队列,但我可以想到两种方法来处理这个问题。
如果你想要一些快速的东西(按照某些人的标准有点脏(,你可以引入一个等待计时器 - 当验证函数运行时,设置一个标志(函数中的静态变量应该足够(与当前时间。 如果函数在上次运行和完成后的 0.5 秒内再次调用, 立即退出函数(显著缩短函数的运行时间(。这将解决事件的积压问题,前提是是函数的内容导致事件变慢,而不是事件本身的触发。这样做的缺点是,您必须引入某种备份检查以确保当前状态已得到验证 - 即,如果最后一次更改发生在0.5s块发生时。
或者,如果您唯一的问题是您不希望在用户执行连续操作时进行验证,则可以尝试修改事件处理程序,使其在按键正在进行时退出而不执行验证,或者甚至可以将验证操作绑定到 KeyUp 而不是 TextChanged。
您可以通过多种方式实现这一目标。例如,如果对特定键执行 KeyDown 事件(例如示例的退格键,但理论上应将其扩展到将键入字符的任何内容(,则验证函数将退出而不执行任何操作,直到触发同一键的 KeyUp 事件。这样,在进行最后一次修改之前,它不会运行...希望。
这可能不是达到预期效果的最佳方法(它可能根本不起作用!_TextChanged事件有可能在用户完成按键之前触发(,但理论是合理的。如果不花一些时间玩,我就不能绝对确定按键的行为 - 您能否检查按键是否已按下并退出,或者您是否必须手动举起一个在 KeyDown 和 KeyUp 之间是正确的标志?稍微玩一下你的选择应该很清楚,对于你的特定情况,最好的方法是什么。
我希望这有所帮助!
你不能做以下事情吗?
Stopwatch stopWatch;
TextBoxEnterHandler(...)
{
stopwatch.ReStart();
}
TextBoxExitHandler(...)
{
stopwatch.Stop();
}
TextChangedHandler(...)
{
if (stopWatch.ElapsedMiliseconds < threshHold)
{
stopwatch.Restart();
return;
}
{
//Update code
}
stopwatch.ReStart()
}
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.
}
从@lisz的工作中跳出来,它在所有边缘情况下都不太适合我,但它很接近。当用户确实未完成键入时,UI 有时会触发误报。
以下是对用户来说工作更流畅的更新代码。
private List<Task<bool>> taskTypeQueue = new List<Task<bool>>();
private async void textBox_TextChanged(object sender, EventArgs e)
{
async Task<bool> isStillTyping()
{
Application.DoEvents();
int taskCount = taskTypeQueue.Count;
string oldStr = textBox.Text;
await Task.Delay(1500);
if ((oldStr != textBox.Text) || (taskCount != taskTypeQueue.Count - 1))
{
return true;
}
return false;
}
taskTypeQueue.Add(isStillTyping());
if (await taskTypeQueue[taskTypeQueue.Count - 1])
return;
// typing appears to have stopped, continue
taskTypeQueue.Clear();
}
以下是PowerShell的解决方案:
- 初始化秒表和计时器。秒表将测量自上次字符输入文本框以来经过的总时间,计时器将每隔间隔(以毫秒为单位(异步触发秒表检查。哈希是 GUI 和其他所有内容的全局同步哈希。
$hash.Stopwatch = New-Object System.Diagnostics.Stopwatch
$hash.Timer = New-Object System.Windows.Forms.Timer
$hash.Timer.Enabled = $true
$hash.Timer.Interval = 100
- 在每个计时器时钟周期中,验证是否已超过所需的时间阈值。
$hash.Timer.Add_Tick({
# Write-Host "Elapsed time: $($hash.Stopwatch.Elapsed.Minutes.ToString("00")):$($hash.Stopwatch.Elapsed.Seconds.ToString("00")):$($hash.Stopwatch.Elapsed.Milliseconds.ToString("000"))"
# Get total time elapsed
$elapsedMs = $hash.Stopwatch.Elapsed.TotalMilliseconds
# Set threshold
$thresholdMs = 1000
# Check if elapsed time reach threshold
if ($elapsedMs -ge $thresholdMs) {
Write-Host "Time has Elapsed. Do Text Validation."
# Stop stopwatch
$hash.Stopwatch.Stop()
# Stop timer
$hash.Timer.Stop()
# Check if textbox value is valid
# .. Your code goes here, for example:
# Check if textbox value is valid
$isValid = Test-IfTextboxContentIsValid
if ($isValid) {
$hash.YourTextBox.Background = Get-ValidBackgroundColor
} else {
$hash.YourTextBox.Background = Get-InvalidBackgroundColor
}
})
- 将文本更改处理程序添加到事件,这将在 TextBox 的每个字符输入上触发或重置秒表。
$hash.YourTextBox.Add_TextChanged({
Write-Host "Text has changed"
# Reset background color to default
$this.Background = Get-DefaultBackgroundColor
# Restart stopwatch (reset time elapsed since previous character is entered)
$hash.Stopwatch.Restart()
# Check if timer is not running
if (-not $hash.Timer.Enabled) {
# Start timer
$hash.Timer.Start()
}
})
- 不要忘记在应用程序关闭时处理计时器。将其添加到程序代码的末尾。
$hash.Timer.Dispose()
可以创建继承 TextBox 的可重用用户控件,如下所示:
public partial class TypeAheadTextBox : TextBox
{
private bool _typeAheadEnabled = true;
private int _debounceTimeInMilleconds = 500;
private readonly Timer _timer;
private EventArgs _recentTextChangedEventArgs;
public bool TypeAheadEnabled
{
get
{
return _typeAheadEnabled;
}
set
{
_typeAheadEnabled = value;
}
}
public int DebounceTimeInMillSeconds
{
get
{
return _debounceTimeInMilleconds;
}
set
{
_debounceTimeInMilleconds = value;
if(_timer != null)
{
_timer.Interval = _debounceTimeInMilleconds;
}
}
}
public TypeAheadTextBox()
{
InitializeComponent();
_timer = new Timer();
_timer.Tick += OnTimerTick;
}
protected override void OnTextChanged(EventArgs e)
{
_recentTextChangedEventArgs = e;
if (_typeAheadEnabled)
{
_timer.Stop();
_timer.Start();
return;
}
base.OnTextChanged(e);
}
private void OnTimerTick(object sender, EventArgs e)
{
_timer.Stop();
base.OnTextChanged(_recentTextChangedEventArgs);
}
}