連續輸入時不要引發 TextChanged (Don't raise TextChanged while continuous typing)


問題描述

連續輸入時不要引發 TextChanged (Don't raise TextChanged while continuous typing)

我有一個文本框,它有一個相當大的 _TextChanged 事件處理程序。在正常打字情況下,性能還可以,但當用戶執行長時間的連續操作時,性能會明顯滯後,例如按住退格鍵一次刪除大量文本。

例如,事件需要 0.2 秒才能完成,但用戶每 0.1 秒執行一次刪除。因此,它無法趕上,需要處理的事件會積壓,導致 UI 滯後。

但是,事件不需要為這些中間狀態運行,因為它只關心最終結果。有沒有辦法讓事件處理程序知道它應該只處理最新的事件,而忽略所有以前的過時更改?


參考解法

方法 1:

I've come across this problem several times, and based on my own experience I found this solution simple and neat so far. It is based on Windows Form but can be converted to WPF easily.

How it works:

When TypeAssistant learns that a text change has happened, it runs a timer. After WaitingMilliSeconds the timer raises Idle event. By handling this event, you can do whatever job you wish (such as processing the entered tex). If another text change occurs in the time frame starting from the time that the timer starts and WaitingMilliSeconds later, the timer resets.

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

Usage:

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

Advantages:

  • Simple!
  • Working in WPF and Windows Form
  • Working with .Net Framework 3.5+

Disadvantages:

  • Runs one more thread
  • Needs Invocation instead of direct manipulation of form

方法 2:

One easy way is to use async/await on an inner method or delegate:

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    
}

No threading involved here. For C# version older than 7.0, you can declare a delegate:

Func<Task<bool>> UserKeepsTyping = async delegate () {...}

Please note, that this method will not secure you from occasionally processing the same "end reslut" twice. E.g. when user types "ab", and then immediately deletes "b", you might end up processing "a" twice. But these occasions shoud be rare enough. To avoid them, the code could be like this:

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

方法 3:

I also think that the Reactive Extensions are the way to go here. I have a slightly different query though.

My code looks like this:

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

Now this works precisely the way you were anticipating.

The FromEventPattern translates the TextChanged into an observable that returns the sender and event args. Select then changes them to the actual text in the TextBox. Throttle basically ignores previous keystrokes if a new one occurs within the 300 milliseconds ‑ so that only the last keystroke pressed within the rolling 300 millisecond window are passed on. The Select then calls the processing.

Now, here's the magic. The Switch does something special. Since the select returned an observable we have, before the Switch, an IObservable<IObservable<string>>. The Switch takes only the latest produced observable and produces the values from it. This is crucially important. It means that if the user types a keystroke while existing processing is running it will ignore that result when it comes and will only ever report the result of the latest run processing.

Finally there's a ObserveOn to return the execution to the UI thread, and then there's the Subscribe to actually handle the result ‑ and in my case update the text on a second TextBox.

I think that this code is incredibly neat and very powerful. You can get Rx by using Nuget for "Rx‑WinForms".

方法 4:

You can mark your event handler as async and do the following:

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 clause is mandatory to ensure that isBusyProcessing is guaranted to be set to false at some point, so that you don't end up in an infinite loop.

方法 5:

This is a solution I came up with. It resembles the currently accepted answer, but I find it slightly more elegant, because of two reasons:

  1. It uses an async method that eliminates the need for manual thread marshalling with invoke
  2. There's no need to create a separate event handler.

Lets take a look.

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

In your form code you can use it as follows:

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

Note the use of the async keyword on the event handler here. Dont't leave it out.

Explanation

The static Debouncer Class declares an extension method DelayProcessing that extends the string type, so it can be tagged onto the .Text property of a TextBox component. The DelayProcessing method takes a labmda method that get's called as soon as the debounce delay elapses. In the example above I use it to set the text of label control, but you could do all sorts of other things here...

(by anandaAlirezaliszEnigmativitykkyrgvdvenis)

參考文件

  1. Don't raise TextChanged while continuous typing (CC BY‑SA 2.5/3.0/4.0)

#.net-4.0 #winforms #C#






相關問題

SendKeys.Send NullReferenceException (SendKeys.Send NullReferenceException)

訪問 MarkupExtension.ProvideValue 中的構造函數參數 (Access to a constructor parameter within MarkupExtension.ProvideValue)

調試時使用 WorkflowInvoker 發生瘋狂的內存洩漏 (Crazy memory leak with WorkflowInvoker while debugging)

為跨域的 .NET Framework 靜默安裝創建部署應用程序? (Creating a deployment application for .NET Framework silent install across domain?)

Windows 窗體關閉後刪除的窗體數據 (Form data erased after Windows form is closed)

如何從 php wordpress 服務器呈現 aspx 頁面 (How to render an aspx page from a php wordpress server)

嘗試通過方法“System.Web.Helpers.Json.Decode(System.String)”訪問字段“System.Web.Helpers.Json._serializer”失敗 (Attempt by method 'System.Web.Helpers.Json.Decode(System.String)' to access field 'System.Web.Helpers.Json._serializer' failed)

如何使用 Windows 資源管理器顯示項目和詳細信息展開/折疊 (How to Display Items and Details with Windows Explorer Expand/Collapse)

在 C# 中通過反射訪問屬性 (Accessing attribute via reflection in C#)

連續輸入時不要引發 TextChanged (Don't raise TextChanged while continuous typing)

MSMQ 異步異常行為 - .NET 4.0 與 .NET 2.0 (MSMQ asynchronous exception behavior - .NET 4.0 vs .NET 2.0)

多線程 WMI 調用 - 如何最好地處理這個? (Multithreading WMI calls - how best to handle this?)







留言討論