我采用的方法是在 BackgroundWorker 中运行格式化程序逻辑。我之所以选择这个,是因为该格式需要“很长”的时间,超过一两秒,所以我无法在 UI 线程上执行此操作。
只是为了重申这个问题:BackgroundWorker 对 RichTextBox.SelectionColor 上的设置器的每次调用都会再次触发 TextChanged 事件,这将重新启动 BG 线程。在 TextChanged 事件中,我无法区分“用户已键入内容”事件和“程序已格式化文本”事件。所以你可以看到这将是一个无限的变化过程。
简单的方法不起作用
一种常见的方法(如 Eric 所建议的)是在文本更改处理程序中运行时“禁用”文本更改事件处理。但这当然不适用于我的情况,因为文本更改(SelectionColor 更改)是由后台线程生成的。它们不在文本更改处理程序的范围内执行。因此,过滤用户启动事件的简单方法不适用于我的情况,因为后台线程正在进行更改。
检测用户发起的更改的其他尝试
我尝试使用 RichTextBox.Text.Length 作为一种方式来区分来自我的格式化程序线程的richtextbox 中的更改与用户在richtextbox 中所做的更改。如果长度没有改变,我推断,那么改变是我的代码完成的格式改变,而不是用户编辑。但是检索 RichTextBox.Text 属性的成本很高,而且对每个 TextChange 事件都这样做会使整个 UI 变得慢得无法接受。即使这足够快,它在一般情况下也不起作用,因为用户也会更改格式。而且,用户编辑可能会产生相同长度的文本,如果它是一种类型的操作的话。
我希望仅捕获和处理 TextChange 事件以检测源自用户的更改。由于我做不到,我将应用程序更改为使用 KeyPress 事件和 Paste 事件。因此,由于格式更改(如 RichTextBox.SelectionColor = Color.Blue),我现在不会收到虚假的 TextChange 事件。
向工作线程发出信号以完成其工作
好的,我正在运行一个可以更改格式的线程。从概念上讲,它这样做:
while (forever)
wait for the signal to start formatting
for each line in the richtextbox
format it
next
next
如何告诉 BG 线程开始格式化?
我使用了ManualResetEvent。当检测到 KeyPress 时,keypress 处理程序设置该事件(将其打开)。后台工作人员正在等待同一事件。当它打开时,BG 线程将其关闭,并开始格式化。
但是如果 BG worker已经在格式化呢?在这种情况下,新的按键可能已经改变了文本框的内容,并且到目前为止所做的任何格式化现在都可能无效,因此必须重新开始格式化。我真正想要的格式化程序线程是这样的:
while (forever)
wait for the signal to start formatting
for each line in the richtextbox
format it
check if we should stop and restart formatting
next
next
使用此逻辑,当 ManualResetEvent 设置(打开)时,格式化程序线程检测到它,并将其重置(将其关闭),并开始格式化。它遍历文本并决定如何格式化它。格式化程序线程会定期再次检查 ManualResetEvent。如果在格式化期间发生另一个按键事件,则该事件再次进入信号状态。当格式化程序看到它被重新发出信号时,格式化程序会退出并从文本的开头重新开始格式化,就像西西弗斯一样。更智能的机制将从文档中发生更改的位置重新开始格式化。
延迟开始格式化
另一个转折:我不希望格式化程序在每次按键时立即开始格式化工作。作为人类类型,击键之间的正常停顿小于 600-700 毫秒。如果格式化程序立即开始格式化,那么它将尝试在击键之间开始格式化。很没有意义。
因此,格式化程序逻辑只有在检测到超过 600 毫秒的击键暂停时才开始进行格式化工作。收到信号后,它等待 600 毫秒,如果没有中间的按键,则打字停止,开始格式化。如果中间发生了变化,那么格式化程序什么也不做,得出用户仍在打字的结论。在代码中:
private System.Threading.ManualResetEvent wantFormat = new System.Threading.ManualResetEvent(false);
按键事件:
private void richTextBox1_KeyPress(object sender, KeyPressEventArgs e)
{
_lastRtbKeyPress = System.DateTime.Now;
wantFormat.Set();
}
在后台线程中运行的 colorizer 方法中:
....
do
{
try
{
wantFormat.WaitOne();
wantFormat.Reset();
// We want a re-format, but let's make sure
// the user is no longer typing...
if (_lastRtbKeyPress != _originDateTime)
{
System.Threading.Thread.Sleep(DELAY_IN_MILLISECONDS);
System.DateTime now = System.DateTime.Now;
var _delta = now - _lastRtbKeyPress;
if (_delta < new System.TimeSpan(0, 0, 0, 0, DELAY_IN_MILLISECONDS))
continue;
}
...analyze document and apply updates...
// during analysis, periodically check for new keypress events:
if (wantFormat.WaitOne(0, false))
break;
用户体验是在他们键入时不会发生格式化。输入暂停后,格式化开始。如果再次开始输入,格式化将停止并再次等待。
在格式更改期间禁用滚动
最后一个问题:格式化 RichTextBox 中的文本需要调用RichTextBox.Select(),这会导致RichTextBox在 RichTextBox 获得焦点时自动滚动到选定的文本。因为格式化是在用户专注于控件、阅读和编辑文本的同时发生的,所以我需要一种抑制滚动的方法。我找不到使用 RTB 的公共界面阻止滚动的方法,尽管我确实发现很多人在 intertubes 中询问它。经过一番实验,我发现使用Win32 SendMessage()调用(来自user32.dll),在Select()之前和之后发送WM_SETREDRAW,可以防止调用Select()时在RichTextBox中滚动。
因为我使用 pinvoke 来防止滚动,所以我还在 SendMessage 上使用 pinvoke 来获取或设置文本框中的选择或插入符号(EM_GETSEL或EM_SETSEL),并设置选择的格式(EM_SETCHARFORMAT)。pinvoke 方法最终比使用托管接口要快一些。
批量更新以提高响应能力
而且因为防止滚动会产生一些计算开销,所以我决定批量处理对文档所做的更改。该逻辑不是突出显示一个连续的部分或单词,而是保留要进行的突出显示或格式更改的列表。每隔一段时间,它就会一次对文档应用 30 次更改。然后它清除列表并返回分析和排队需要进行哪些格式更改。在应用这些批次的更改时,输入文档不会中断,这已经足够快了。
结果是,当没有打字时,文档会自动格式化并以离散的块着色。如果用户按键之间经过足够的时间,整个文档最终将被格式化。对于 1k XML 文档,这不到 200 毫秒,对于 30k 文档可能需要 2 秒,对于 100k 文档可能需要 10 秒。如果用户编辑文档,则正在进行的任何格式化都会中止,并且重新开始格式化。
呸!
我很惊讶,在用户输入内容的同时格式化富文本框这样看似简单的事情却如此复杂。但我想不出任何更简单的方法,既不锁定文本框,又避免了奇怪的滚动行为。
您可以查看我上面描述的代码。