连续打字时不要引发 TextChanged

Don't raise TextChanged while continuous typing

我有一个文本框,它有一个相当庞大的 _TextChanged 事件处理程序。在正常打字条件下,性能还可以,但当用户执行长时间连续操作时,性能会明显滞后,例如按住退格键一次删除大量文本。

例如,事件用了 0.2 秒完成,但用户每 0.1 秒执行一次删除。因此,它无法赶上,并且需要处理的事件积压,导致 UI 滞后。

但是,对于这些中间状态,事件不需要运行,因为它只关心最终结果。有什么方法可以让事件处理程序知道它应该只处理最新的事件,而忽略所有以前的陈旧更改?

您可以将您的事件处理程序标记为 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 try-finally 子句是强制性的,以确保 isBusyProcessing 在某些时候保证设置为 false,这样您就不会陷入无限循环。

我不知道如何剔除事件队列,但我可以想到两种您可以处理此问题的方法。

如果你想要快速的东西(有些人的标准有点脏),你可以引入一个等待计时器 - 当验证函数 运行s 时,设置一个标志(函数内的静态变量应该足够)与当前时间。如果该函数在上次 运行 的 0.5 秒内再次调用并完成,则立即退出该函数(显着减少该函数的 运行 时间)。这将解决事件的积压,前提是导致它变慢的是函数的内容,而不是事件本身的触发。这样做的缺点是您必须引入某种备份检查以确保当前状态已经过验证——即,如果最后一次更改发生在 0.5 秒的块发生时。

或者,如果您唯一的问题是您不希望在用户进行连续操作时进行验证,您可以尝试修改您的事件处理程序,使其在按下按键时不执行验证就退出正在进行中,甚至可能将验证操作绑定到 KeyUp 而不是 TextChanged。

您可以通过多种方式实现这一目标。例如,如果在特定键上执行 KeyDown 事件(例如退格键,但理论上您应该将其扩展到任何可以键入字符的键),验证函数将退出而不执行任何操作,直到 KeyUp 事件相同的键被解雇。这样它就不会 运行 直到最后一次修改完成...希望如此。

这可能不是实现预期效果的最佳方式(它可能根本不起作用!_TextChanged 事件有可能在用户完成按键之前触发),但理论是合理的.如果不花一些时间玩耍,我就不能完全确定按键的行为——你能只检查按键是否被按下并退出,还是你必须手动举起一个在 KeyDown 和 KeyUp 之间为真的标志?稍微尝试一下您的选择应该会很清楚什么是适合您的特定情况的最佳方法。

希望对您有所帮助!

这个问题我遇到过好几次,根据我自己的经验,到目前为止我发现这个解决方案简单明了。它基于 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();
    }
}

优点:

  • 简单!
  • WPFWindows Form
  • 工作
  • 使用 .Net Framework 3.5+

缺点:

  • 再运行一个线程
  • 需要调用而不是直接操作表单

Reactive Extensions 很好地处理了这种情况。

所以您想通过限制 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 package.

如果您喜欢这种方法,可以查看 this blog post that implements auto complete control making use of Rx Linq. I would also recommend great talk of Bart De Smet 上的 Reactive Extensions。

你不能按照下面的思路做点什么吗?

Stopwatch stopWatch;

TextBoxEnterHandler(...)
{
    stopwatch.ReStart();
}

TextBoxExitHandler(...)
{
    stopwatch.Stop();
}

TextChangedHandler(...)
{
    if (stopWatch.ElapsedMiliseconds < threshHold)
    {
        stopwatch.Restart();
        return;
    }

    {
       //Update code
    }

    stopwatch.ReStart()
}

我也认为 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);

现在这完全符合您的预期。

FromEventPatternTextChanged 转换为 return 发送者和事件参数的可观察对象。 Select 然后将它们更改为 TextBox 中的实际文本。如果在 300 毫秒内发生新的击键,Throttle 基本上会忽略以前的击键 - 因此只有在滚动 300 毫秒 window 内按下的最后一次击键被传递。 Select 然后调用处理。

现在,这就是魔法。 Switch 做了一些特别的事情。由于 select return 我们有一个可观察的,在 Switch 之前,一个 IObservable<IObservable<string>>Switch 仅获取最新生成的可观察对象并从中生成值。这是至关重要的。这意味着如果用户在现有处理 运行ning 时键入击键,它将在结果出现时忽略该结果,并且只会报告最新的 运行 处理的结果。

最后有一个 ObserveOn 到 return 执行到 UI 线程,然后是 Subscribe 实际处理结果 - 在我的例子中更新第二个 TextBox.

上的文字

我认为这段代码非常简洁而且非常强大。您可以使用 Nuget for "Rx-WinForms".

获取 Rx

结合使用带有焦点检查的 TextChanged 和 TextLeave。

private void txt_TextChanged(object sender, EventArgs e)
{
    if (!((TextBox)sender).Focused)
        DoWork();
}

private void txt_Leave(object sender, EventArgs e)
{
    DoWork();
}

一种简单的方法是在内部方法或委托上使用 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;   
}
    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.       
    }

我玩了一会儿。对我来说,这是我能想到的最优雅(简单)的解决方案:

    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);
        }
    }

跳出@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();
}

这是我想出的解决办法。它类似于当前接受的答案,但我觉得它稍微优雅一些​​,原因有两个:

  1. 它使用异步方法,无需使用 invoke
  2. 进行手动线程编组
  3. 无需创建单独的事件处理程序。

一起来看看吧。

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 关键字。不要遗漏它。

说明

staticDebouncerClass声明了一个扩展方法DelayProcessing扩展了string类型,所以可以标记到.Text属性的一个 TextBox 组件。 DelayProcessing 方法采用一个 labmda 方法,该方法在去抖动延迟结束后立即被调用。在上面的示例中,我使用它来设置 label 控件的文本,但是您可以在这里做各种其他事情...

这是 PowerShell 的解决方案:

  1. 初始化你的秒表和计时器。秒表将测量自您最后一个字符输入到文本框以来经过的总时间,计时器将在每个时间间隔(以毫秒为单位)异步触发秒表检查。哈希是您的 GUI 和其他所有内容的全局同步哈希。
$hash.Stopwatch = New-Object System.Diagnostics.Stopwatch
$hash.Timer = New-Object System.Windows.Forms.Timer
$hash.Timer.Enabled = $true
$hash.Timer.Interval = 100
  1. 在每个计时器滴答时验证是否已超过所需的时间阈值。
$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
        }     
})
  1. 向您的事件添加文本更改处理程序,这将触发或重置文本框的每个字符输入的秒表。
$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()
    }
})
  1. 不要忘记在应用程序关闭时处理计时器。将其添加到程序代码的末尾。
$hash.Timer.Dispose()