DiagnosticsTextBox:WinForms的日志窗口
目录
介绍
您是否需要为WinForm应用程序添加一个日志窗口?我们创建了一个易于使用的用户控件,以从具有线程支持的应用程序中捕获跟踪。
最新的源代码可从GitHub获得。
背景
当与应用程序开发的WinForms工作,大部分的时间,我们希望在开发期间以及发布之后捕获System.Diagnostics.Debug和System.Diagnostics.Trace的输出,其中的日志对于理解用户报告的问题非常有用。需求随着应用程序的复杂性而增加。对于执行复杂处理的应用程序,日志窗口是一种快速而有用的指示符,它可以告诉用户该应用程序正在运行,而不是冻结,因此它们不仅会杀死它。
因此,我们决定创建一个自定义控件,每次我们开始一个新项目时都可以将其拖放到窗体中以立即开始记录,而无需重写或复制一行代码。这些控件名为DiagnosticsTextBox和DiagnosticsRichTextBox,它们是CodeArtEng.Diagnostics NuGet包的一部分。
使用控件
该控件在GitHub中发布,并在NuGet中发布。
- 在您的WinForm项目中包括NuGet包CodeArtEng.Diagnostics。
- 拖动DiagnosticsTextBox或DiagnosticsRichTextBox到您的项目。
架构
DiagnosticsTextBox和DiagnosticsRichTextBox以相同的设计创建。我们将基于DiagnosticsTextBox显示纯文本的方式描述实现,并描述DiagnosticsRichTextBox支持颜色格式化的其他方法和功能。
类概述
- System.Diagnostcis.TraceListener:为监视跟踪和调试输出的侦听器提供abstract基类。
- CodeArtEng.Diagnostics.TraceLogger:从TraceListener派生,提供Write和WriteLine方法的实现。提供DiagnosticsTextBox的回调函数。
- CodeArtEng.Diagnostics.TraceFileWritter:从TraceListener派生,实现日志记录到文件。此类也可以单独使用。
- CodeArtEng.Diagnostics.DiagnosticsTextBox:TextBox控制显示DEBUG和TRACE消息。
幕后花絮
捕获和解析消息
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将从Main或Worker线程调用,因此我们无法将消息直接写入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事件中已经处理过的行。此外,当FormatText方法中更新文本框内容时,使用Updating标志来防止递归调用。
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的所有其他开发人员受益,以便更快地进行开发和调试。