DiagnosticsTextBox:WinForms 的日志窗口
一个可重用的 Windows 窗体文本框控件,用于捕获 DEBUG 和 TRACE 输出
引言
您是否需要为您的 WinForm 应用程序添加日志窗口?我们创建了一个易于使用的用户控件,可以捕获具有线程支持的应用程序的跟踪信息。
最新的源代码可在 GitHub 获取。
背景
在使用 WinForms 进行应用程序开发时,大多数时候我们都希望在开发过程中以及发布后捕获 System.Diagnostics.Debug
和 System.Diagnostics.Trace
的输出,日志在理解用户报告的问题时非常有用。随着应用程序的复杂性增加,这种需求也会增加。对于执行复杂处理的应用程序,日志窗口是一个快速有用的指示器,可以告知用户应用程序正在运行,没有冻结,这样他们就不会随意终止它。
因此,我们决定创建一个自定义控件,只需将其拖放到窗体上即可立即开始记录,而无需重写或复制任何代码行。这些控件名为 DiagnosticsTextBox
和 DiagnosticsRichTextBox
,它们是 CodeArtEng.Diagnostics
NuGet 包的一部分。
使用控件
该控件已在 GitHub 上发布,并已在 NuGet 上发布。
- 将 NuGet 包 CodeArtEng.Diagnostics 包含到您的 WinForm 项目中。
- 将
DiagnosticsTextBox
或DiagnosticsRichTextBox
拖到您的项目中。
架构
DiagnosticsTextBox
和 DiagnosticsRichTextBox
的设计相同。我们将基于 DiagnosticsTextBox
描述实现,它显示纯文本,并描述 DiagnosticsRichTextBox
中包含的其他方法和功能,该控件支持颜色格式化。
类概述
System.Diagnostcis.TraceListener
:为监视跟踪和调试输出的侦听器提供抽象基类。CodeArtEng.Diagnostics.TraceLogger
:派生自TraceListener
,实现了Write
和WriteLine
方法。为DiagnosticsTextBox
提供回调函数。CodeArtEng.Diagnostics.TraceFileWritter
:派生自TraceListener
,实现日志记录到文件。此类也可以独立使用。CodeArtEng.Diagnostics.DiagnosticsTextBox
:用于显示DEBUG
和TRACE
消息的TextBox
控件。
幕后
捕获和解析消息
TraceLogger 负责处理从 TraceListener
收到的所有消息,处理并将它们传递给 DiagnosticsTextBox
和 TraceFileWritter
。TraceLogger 中实现了三个回调函数:OnMessageReceived
、OnWrite
、OnFlush
。
类 TraceLogger
public override void Write(string message)
{
OnMessageReceived(ref message);
if (string.IsNullOrEmpty(message)) return;
message = ParseMessage(message);
OnWrite(message);
IsNewLine = false;
}
类 DiagnosticsTextBox
private void Tracer_OnMessageReceived(ref string message)
{
//Message filter implementation.
if(MessageReceived != null)
{
TextEventArgs eArg = new TextEventArgs() { Message = message };
MessageReceived.Invoke(this, eArg);
message = eArg.Message;
}
}
/// <summary>
/// Occur when message is received by Trace Listener.
/// </summary>
[DisplayName("MessageReceived")]
public event EventHandler<TextEventArgs> MessageReceived;
当从 Write
或 WriteLine
接收到消息时,原始消息通过 OnMessageReceived
转发给父类。在 DiagnosticsTextBox
类中,消息然后通过 MessageReceived
事件转发到下一个级别。开发人员可以利用此事件来根据需要响应或过滤消息。
然后,消息由 ParseMessage
处理,以在将消息写入文件或文本框之前对每条传入消息进行格式化,这在 Write
和 WriteLine
方法中完成。
private string ParseMessage(string message)
{
string dateTimeStr = ShowTimeStamp ? AppendDateTime() : string.Empty;
string result = IsNewLine ? dateTimeStr : string.Empty;
if (message.Contains("\r") || message.Contains("\n"))
{
//Unified CR, CRLF, LFCR, LF
message = message.Replace("\n", "\r");
message = message.Replace("\r\r", "\r");
string newLineFiller = new string(' ', dateTimeStr.Length);
string[] multiLineMessage = message.Split('\r');
result += multiLineMessage[0].Trim() + NewLineDelimiter;
foreach (string msg in multiLineMessage.Skip(1))
result += newLineFiller + msg.Trim() + NewLineDelimiter;
result = result.TrimEnd();
}
else result += message;
return result;
}
如果启用了 ShowTimeStamp
,则在调用 ParseMessage
时会将时间戳附加到消息。时间戳的格式使用 .NET 字符串格式在 TimeStampFormat
属性中定义。
private string AppendDateTime()
{
switch (TimeStampStyle)
{
case TraceTimeStampStyle.DateTimeString:
return "[" + DateTime.Now.ToString(TimeStampFormat) + "] ";
case TraceTimeStampStyle.TickCount: return "[" + DateTime.Now.Ticks.ToString() + "] ";
}
return "-";
}
此外,ParseMessage
还负责在添加时间戳时对多行消息进行对齐。通过计算时间戳占用的字符数插入前导空格来实现文本对齐。这种对齐方式在固定宽度字体(如 Courier New)下效果最佳。
在 DiagnosticsTextBox 中显示消息
我们希望文本框尽快用最新消息更新,但又不能阻塞主线程直到主窗体无响应。考虑到 Write
和 WriteLine
可以从主线程或工作线程调用,我们不能直接将消息写入 TextBox
,因为 TextBox
只能由 MainThread
更新。
DiagnosticsTextBox
中引入了一个 MessageBuffer
来捕获所有传入的消息,并以定义的计时器间隔更新到文本框控件,该值可通过 RefreshInterval
属性配置。每次计时器滴答时,文本框都会用 MessageBuffer
中的消息更新。添加了一个锁以防止 MessageBuffer
被同时修改。
private void refreshTimer_Tick(object sender, EventArgs e)
{
lock (LockObject)
{
//Transfer from Message Buffer to Diagnostics Text Box without locking main thread.
if (MessageBuffer.Length == 0) return;
this.AppendText(MessageBuffer);
MessageBuffer = "";
内存消耗
我们在部署 DiagnosticTextBox
的第一个版本时遇到的一个问题是内存消耗随时间增加。对于运行数小时甚至数天的应用程序,最终会触发内存溢出异常。然后我们注意到这是由文本框被所有日志消息填满引起的。
为了提高性能并最大限度地减少总内存消耗,我们添加了一个名为 DisplayBufferSize
的属性来定义文本框中显示的最大行数。这是 refreshTimer_Tick
方法的一部分。我们使用 Array.Copy
来最大化性能。用户仍然可以选择将 DisplayBufferSize
设置为 0 以始终显示所有消息。
if (DisplayBufferSize <= 0) return;
if (Lines.Length > DisplayBufferSize)
{
string[] data = new string[DisplayBufferSize];
Array.Copy(Lines, Lines.Length - DisplayBufferSize, data, 0, DisplayBufferSize);
Lines = data;
}
SelectionStart = Text.Length;
ScrollToCaret();
}
}
彩色格式化
DiagnosticsTextBox
和 DiagnosticsRichTextBox
之间的区别在于后者“丰富”在颜色方面。一个名为 AddFormattingRule
的附加方法,用于根据字典中匹配的字符串定义特定行的字体颜色。
public void AddFormattingRule(string containString, Color color)
{
if (SyntaxTable.ContainsKey(containString)) return;
SyntaxTable.Add(containString, color);
}
每当触发 TextChanged
事件时,都会扫描新添加的行,并根据格式规则进行格式化。对于每一行,这可能不是最高效的实现,但它简单易读,无需直接操作富文本框中的 RTF 格式。
请注意,我们使用 LastSelection
来跟踪最后更新的行,以便在下一次 TextChanged
事件发生时跳过已处理过的行。此外,还使用了一个 Updating
标志来防止在 FormatText
方法中更新文本框内容时发生递归调用。
private void DiagnosticsRichTextBox_TextChanged(object sender, EventArgs e)
{
FormatText();
}
private void FormatText()
{
if (Updating) return;
Updating = true;
try
{
int startLine = GetLineFromCharIndex(LastSelection);
for (int x = startLine; x < Lines.Length; x++)
{
int lineStart = GetFirstCharIndexFromLine(x);
int lineEnd = GetFirstCharIndexFromLine(x + 1) - 1;
SelectionStart = lineStart;
SelectionLength = lineEnd < 0 ? 0 : lineEnd - lineStart + 1;
if (AutoResetFormat)
SelectionColor = LastFontColor = ForeColor;
else
SelectionColor = LastFontColor;
foreach (KeyValuePair<string, Color> entry in SyntaxTable)
{
if (Lines[x].Contains(entry.Key))
{
SelectionColor = LastFontColor = entry.Value;
break;
}
}
}
SelectionStart = LastSelection = Text.Length;
ScrollToCaret();
}
finally { Updating = false; }
}
不幸的是,没有简单的方法可以从富文本框中删除行而不影响其之前的格式。当富文本框中的行数达到定义的 DisplayBufferSize
时,整个富文本框必须重新扫描并再次更新格式。
if (DisplayBufferSize <= 0) return;
if (Lines.Length > DisplayBufferSize)
{
string[] data = new string[DisplayBufferSize];
Array.Copy(Lines, Lines.Length - DisplayBufferSize, data, 0, DisplayBufferSize);
Lines = data;
LastSelection = 0;
FormatText();
}
关注点
我们在开发此控件时面临的一个挑战是,我们不能在任何类中调用 Write
和 WriteLine
,这会导致递归调用。我们只能利用 Visual Studio 中的断点来单步调试代码以识别错误。我们希望这个工具能使所有使用 WinForms 的开发人员受益,从而实现更快的开发和调试。
历史
- 2020年6月25日:初始版本