不要在连续键入时引发文本更改

本文关键字:文本 连续 | 更新日期: 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();
    }
}

优势:

  • 简单!
  • WPFWindows 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);

现在,这完全按照您预期的方式工作。

FromEventPatternTextChanged转换为返回发送方和事件参数的可观察量。 然后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);
        }
    }

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

  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方法采用 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的解决方案:

  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. 将文本更改处理程序添加到事件,这将在 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()
    }
})
  1. 不要忘记在应用程序关闭时处理计时器。将其添加到程序代码的末尾。
$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);
    }
}