不要在连续输入时触发TextChanged事件

22

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

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

然而,该事件不需要处理这些中间状态,因为它只关心最终结果。是否有任何方法让事件处理程序知道它应该仅处理最新的事件,并忽略所有先前过时的更改?


不,事件并不需要在用户输入时被处理。我只希望如果更改比系统处理速度更快,它可以跳过旧的事件。 - ananda
也许这可以帮助你:https://dev59.com/Rmsz5IYBdhLWcg3wR1zC - Shaharyar
14个回答

27

我已经遇到这个问题多次了,根据我的经验,我发现这个解决方案简单而整洁。它基于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();
    }
}

优点:

  • 简单!
  • 适用于 WPFWindows Form
  • 与 .Net Framework 3.5+ 兼容

缺点:

  • 运行一个额外的线程
  • 需要使用调用(Invocation)而非直接操作窗体

似乎已经修复了除我遇到问题的那个错误之外的所有错误。错误出现在waitingTimer = new Timer(p =>这一行,具体是在p处。感谢您的帮助。 - ananda
什么错误?你是否包含了 using System.Threading; - Alireza
啊,我明白了,它自动引用了 System.Windows.Forms.Timer。让我再试一次。 - ananda
@ananda 根据我的个人经验,即使是速度很快的人,在按键之间的时间间隔少于400毫秒时也无法打字;因此50毫秒有点奇怪。但是,如果键盘上的某个键被按下并保持按下状态很长时间,它仍然应该起作用。我的意思是,在释放按钮后,应该触发“空闲”事件。 - Alireza
显示剩余3条评论

18

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

13

我也认为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 转换为一个可观察对象,该对象返回发送者和事件参数。然后,Select 将它们更改为 TextBox 中实际的文本。如果在 300 毫秒内出现新键入,则Throttle 基本上会忽略先前的按键 - 所以只有在连续的 300 毫秒窗口内按下的最后一个按键才会传递下去。然后,Select 调用处理过程。

现在,这里有一些神奇的东西。 Switch 做了一些特别的事情。由于 select 返回了一个可观察对象,在 Switch 之前,我们拥有一个 IObservable<IObservable<string>>Switch 只获取最新产生的可观察对象并从中生成值。这非常重要。这意味着,如果用户在现有处理正在运行时键入按键,它将在到达时忽略该结果,并且将始终报告最新运行处理的结果。

最后,有一个 ObserveOn 让执行返回到 UI 线程,然后有一个 Subscribe 实际处理结果 - 在我的案例中,更新第二个 TextBox 上的文本。

我认为这段代码非常整洁和强大。你可以使用 Nuget 获取 Rx-WinForms 来获得 Rx。


4
您可以将事件处理程序标记为 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,以避免无限循环。


我刚试了一下,但不幸的是我正在使用 .Net 4.0 这个版本,而 async 关键字似乎不被支持。虽然这对于其他项目很有用,但你有任何在这个旧版 .Net 中能够工作的替代方法吗? - ananda
啊,真遗憾。很抱歉我不知道没有async/await的替代方案。你可以看一下http://blogs.msdn.com/b/bclteam/archive/2012/10/22/using-async-await-without-net-framework-4-5.aspx。 - kkyr
是的,在那篇博客文章中提到的Microsoft.Bcl.Async可以在.NET 4.0上使用。请注意:它只能在VS2012及更高版本中工作,因为VS2010和更早版本的编译器无法识别asyncawait关键字。 - user743382
如果你不能使用async/await,这里还有一些其他的答案,我相信它们会很有用。 - kkyr
优雅的解决方案。在.NET 4.5上运行良好。我猜一个后台工作者可以在旧版本的.NET上工作,但可能会有点混乱。 - Damien
在我看来,你可能错过了最后一个事件。对于我的情况,我可以限制所有事件,但最后一个事件需要进行一些验证。也许你只需要在 finally 中调用你的工作函数。 - RJ Thompson

2

这是我想出的一个解决方案。它类似于目前被接受的答案,但我认为它更加优雅,因为有以下两个原因:

  1. 它使用异步方法,消除了需要使用 invoke 进行手动线程调度的需求。
  2. 不需要创建单独的事件处理程序。

让我们来看一下。

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控件的文本,但您也可以在此处执行各种其他操作...

2

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

    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 = ""; - MX313
@MX313 - 在我的情况下,这不是我想要的,因为我会将输入的文本保留在屏幕上。例如,如果他们输入了“A”,然后3秒钟后按下退格键,如果我将mostRecentText重置为空,则无法检测到更改。 - osoblanco

1
使用TextChanged与焦点检查和TextLeave的组合。
private void txt_TextChanged(object sender, EventArgs e)
{
    if (!((TextBox)sender).Focused)
        DoWork();
}

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

1

响应式扩展非常好地处理了这种情况。

因此,您希望通过将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的精彩演讲,介绍了响应式扩展。


0
    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.       
    }

0

你不能沿着以下的方向做些什么吗?

Stopwatch stopWatch;

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

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

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

    {
       //Update code
    }

    stopwatch.ReStart()
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接