web-dev-qa-db-ja.com

WinForms C#のエレガントなログウィンドウ

Windowsフォームアプリケーションにログウィンドウを実装するための効率的な方法についてのアイデアを探しています。過去にTextBoxとRichTextBoxを使用していくつか実装しましたが、機能にまだ完全に満足していません。

このログは、さまざまなイベントの最近の履歴をユーザーに提供することを目的としています。主に、特定のトランザクションがどのように完了したかを知りたいデータ収集アプリケーションで使用されます。この場合、ログは永続的である必要も、ファイルに保存される必要もありません。

まず、いくつかの提案された要件:

  • 効率的かつ高速。数百行が連続してログに書き込まれる場合、最小限のリソースと時間を消費する必要があります。
  • 最大2000行程度の可変スクロールバックを提供できます。長いものは不要です。
  • 強調表示と色が推奨されます。フォント効果は必要ありません。
  • スクロールバックの制限に達すると、自動的に行をトリミングします。
  • 新しいデータが追加されると自動的にスクロールします。
  • ボーナスですが必須ではありません:ユーザーが履歴を閲覧している場合など、手動操作中に自動スクロールを一時停止します。

私がこれまでログの作成とトリムに使用してきたもの:

次のコードを使用します(他のスレッドから呼び出します)。

// rtbLog is a RichTextBox
// _MaxLines is an int
public void AppendLog(string s, Color c, bool bNewLine)
{
    if (rtbLog.InvokeRequired)
    {
        object[] args = { s, c, bNewLine };
        rtbLog.Invoke(new AppendLogDel(AppendLog), args);
        return;
    }
    try
    {
        rtbLog.SelectionColor = c;
        rtbLog.AppendText(s);
        if (bNewLine) rtbLog.AppendText(Environment.NewLine);
        TrimLog();
        rtbLog.SelectionStart = rtbLog.TextLength;
        rtbLog.ScrollToCaret();
        rtbLog.Update();
    }
    catch (Exception exc)
    {
        // exception handling
    }
}

private void TrimLog()
{
    try
    {
        // Extra lines as buffer to save time
        if (rtbLog.Lines.Length < _MaxLines + 10)
        {
            return;
        }
        else
        {
            string[] sTemp = rtxtLog.Lines;
            string[] sNew= new string[_MaxLines];
            int iLineOffset = sTemp.Length - _MaxLines;
            for (int n = 0; n < _MaxLines; n++)
            {
                sNew[n] = sTemp[iLineOffset];
                iLineOffset++;
            }
            rtbLog.Lines = sNew;
        }
    }
    catch (Exception exc)
    {
        // exception handling
    }
}

このアプローチの問題は、TrimLogが呼び出されるたびに、色の書式設定が失われることです。通常のTextBoxを使用すると、これは正常に機能します(もちろん少し変更します)。

これに対する解決策の検索は、本当に満足のいくものではありませんでした。 RichTextBoxで行数の代わりに文字数で余分な部分を削除することをお勧めします。 ListBoxが使用されているのを見たこともありますが、試してみませんでした。

54
JYelton

ログとしてコントロールを使用しないことをお勧めします。代わりに、希望するプロパティ(表示プロパティを含まない)を持つログコレクションクラスを記述します。

次に、そのコレクションをさまざまなユーザーインターフェイス要素にダンプするために必要なコードを少し記述します。個人的には、SendToEditControlメソッドとSendToListBoxメソッドをロギングオブジェクトに入れます。おそらく、これらのメソッドにフィルタリング機能を追加します。

UIログは、可能な限り頻繁に更新でき、可能な限り最高のパフォーマンスが得られます。さらに重要なことは、ログが急速に変化するときのUIオーバーヘッドを削減できることです。

重要なことは、ロギングをUIに結び付けないことです。これは間違いです。いつかあなたは頭なしで走りたいかもしれません。

長期的には、ロガーに適したUIはおそらくカスタムコントロールです。しかし、短期的には、任意のspecific UIからロギングを切断したいだけです。

25
John Knoeller

これは、私が少し前に書いたもっと洗練されたロガーに基づいてまとめたものです。

これは、ログレベルに基づいてリストボックスの色をサポートし、RTFとしてコピーするためのCtrl + Vおよび右クリックをサポートし、他のスレッドからListBoxへのロギングを処理します。

コンストラクターのオーバーロードのいずれかを使用して、ListBox(デフォルトでは2000)に保持されている行数とメッセージ形式をオーバーライドできます。

using System;
using System.Drawing;
using System.Windows.Forms;
using System.Threading;
using System.Text;

namespace StackOverflow
{
    public partial class Main : Form
    {
        public static ListBoxLog listBoxLog;
        public Main()
        {
            InitializeComponent();

            listBoxLog = new ListBoxLog(listBox1);

            Thread thread = new Thread(LogStuffThread);
            thread.IsBackground = true;
            thread.Start();
        }

        private void LogStuffThread()
        {
            int number = 0;
            while (true)
            {
                listBoxLog.Log(Level.Info, "A info level message from thread # {0,0000}", number++);
                Thread.Sleep(2000);
            }
        }

        private void button1_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Debug, "A debug level message");
        }
        private void button2_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Verbose, "A verbose level message");
        }
        private void button3_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Info, "A info level message");
        }
        private void button4_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Warning, "A warning level message");
        }
        private void button5_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Error, "A error level message");
        }
        private void button6_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Critical, "A critical level message");
        }
        private void button7_Click(object sender, EventArgs e)
        {
            listBoxLog.Paused = !listBoxLog.Paused;
        }
    }

    public enum Level : int
    {
        Critical = 0,
        Error = 1,
        Warning = 2,
        Info = 3,
        Verbose = 4,
        Debug = 5
    };
    public sealed class ListBoxLog : IDisposable
    {
        private const string DEFAULT_MESSAGE_FORMAT = "{0} [{5}] : {8}";
        private const int DEFAULT_MAX_LINES_IN_LISTBOX = 2000;

        private bool _disposed;
        private ListBox _listBox;
        private string _messageFormat;
        private int _maxEntriesInListBox;
        private bool _canAdd;
        private bool _paused;

        private void OnHandleCreated(object sender, EventArgs e)
        {
            _canAdd = true;
        }
        private void OnHandleDestroyed(object sender, EventArgs e)
        {
            _canAdd = false;
        }
        private void DrawItemHandler(object sender, DrawItemEventArgs e)
        {
            if (e.Index >= 0)
            {
                e.DrawBackground();
                e.DrawFocusRectangle();

                LogEvent logEvent = ((ListBox)sender).Items[e.Index] as LogEvent;

                // SafeGuard against wrong configuration of list box
                if (logEvent == null)
                {
                    logEvent = new LogEvent(Level.Critical, ((ListBox)sender).Items[e.Index].ToString());
                }

                Color color;
                switch (logEvent.Level)
                {
                    case Level.Critical:
                        color = Color.White;
                        break;
                    case Level.Error:
                        color = Color.Red;
                        break;
                    case Level.Warning:
                        color = Color.Goldenrod;
                        break;
                    case Level.Info:
                        color = Color.Green;
                        break;
                    case Level.Verbose:
                        color = Color.Blue;
                        break;
                    default:
                        color = Color.Black;
                        break;
                }

                if (logEvent.Level == Level.Critical)
                {
                    e.Graphics.FillRectangle(new SolidBrush(Color.Red), e.Bounds);
                }
                e.Graphics.DrawString(FormatALogEventMessage(logEvent, _messageFormat), new Font("Lucida Console", 8.25f, FontStyle.Regular), new SolidBrush(color), e.Bounds);
            }
        }
        private void KeyDownHandler(object sender, KeyEventArgs e)
        {
            if ((e.Modifiers == Keys.Control) && (e.KeyCode == Keys.C))
            {
                CopyToClipboard();
            }
        }
        private void CopyMenuOnClickHandler(object sender, EventArgs e)
        {
            CopyToClipboard();
        }
        private void CopyMenuPopupHandler(object sender, EventArgs e)
        {
            ContextMenu menu = sender as ContextMenu;
            if (menu != null)
            {
                menu.MenuItems[0].Enabled = (_listBox.SelectedItems.Count > 0);
            }
        }

        private class LogEvent
        {
            public LogEvent(Level level, string message)
            {
                EventTime = DateTime.Now;
                Level = level;
                Message = message;
            }

            public readonly DateTime EventTime;

            public readonly Level Level;
            public readonly string Message;
        }
        private void WriteEvent(LogEvent logEvent)
        {
            if ((logEvent != null) && (_canAdd))
            {
                _listBox.BeginInvoke(new AddALogEntryDelegate(AddALogEntry), logEvent);
            }
        }
        private delegate void AddALogEntryDelegate(object item);
        private void AddALogEntry(object item)
        {
            _listBox.Items.Add(item);

            if (_listBox.Items.Count > _maxEntriesInListBox)
            {
                _listBox.Items.RemoveAt(0);
            }

            if (!_paused) _listBox.TopIndex = _listBox.Items.Count - 1;
        }
        private string LevelName(Level level)
        {
            switch (level)
            {
                case Level.Critical: return "Critical";
                case Level.Error: return "Error";
                case Level.Warning: return "Warning";
                case Level.Info: return "Info";
                case Level.Verbose: return "Verbose";
                case Level.Debug: return "Debug";
                default: return string.Format("<value={0}>", (int)level);
            }
        }
        private string FormatALogEventMessage(LogEvent logEvent, string messageFormat)
        {
            string message = logEvent.Message;
            if (message == null) { message = "<NULL>"; }
            return string.Format(messageFormat,
                /* {0} */ logEvent.EventTime.ToString("yyyy-MM-dd HH:mm:ss.fff"),
                /* {1} */ logEvent.EventTime.ToString("yyyy-MM-dd HH:mm:ss"),
                /* {2} */ logEvent.EventTime.ToString("yyyy-MM-dd"),
                /* {3} */ logEvent.EventTime.ToString("HH:mm:ss.fff"),
                /* {4} */ logEvent.EventTime.ToString("HH:mm:ss"),

                /* {5} */ LevelName(logEvent.Level)[0],
                /* {6} */ LevelName(logEvent.Level),
                /* {7} */ (int)logEvent.Level,

                /* {8} */ message);
        }
        private void CopyToClipboard()
        {
            if (_listBox.SelectedItems.Count > 0)
            {
                StringBuilder selectedItemsAsRTFText = new StringBuilder();
                selectedItemsAsRTFText.AppendLine(@"{\rtf1\ansi\deff0{\fonttbl{\f0\fcharset0 Courier;}}");
                selectedItemsAsRTFText.AppendLine(@"{\colortbl;\red255\green255\blue255;\red255\green0\blue0;\red218\green165\blue32;\red0\green128\blue0;\red0\green0\blue255;\red0\green0\blue0}");
                foreach (LogEvent logEvent in _listBox.SelectedItems)
                {
                    selectedItemsAsRTFText.AppendFormat(@"{{\f0\fs16\chshdng0\chcbpat{0}\cb{0}\cf{1} ", (logEvent.Level == Level.Critical) ? 2 : 1, (logEvent.Level == Level.Critical) ? 1 : ((int)logEvent.Level > 5) ? 6 : ((int)logEvent.Level) + 1);
                    selectedItemsAsRTFText.Append(FormatALogEventMessage(logEvent, _messageFormat));
                    selectedItemsAsRTFText.AppendLine(@"\par}");
                }
                selectedItemsAsRTFText.AppendLine(@"}");
                System.Diagnostics.Debug.WriteLine(selectedItemsAsRTFText.ToString());
                Clipboard.SetData(DataFormats.Rtf, selectedItemsAsRTFText.ToString());
            }

        }

        public ListBoxLog(ListBox listBox) : this(listBox, DEFAULT_MESSAGE_FORMAT, DEFAULT_MAX_LINES_IN_LISTBOX) { }
        public ListBoxLog(ListBox listBox, string messageFormat) : this(listBox, messageFormat, DEFAULT_MAX_LINES_IN_LISTBOX) { }
        public ListBoxLog(ListBox listBox, string messageFormat, int maxLinesInListbox)
        {
            _disposed = false;

            _listBox = listBox;
            _messageFormat = messageFormat;
            _maxEntriesInListBox = maxLinesInListbox;

            _paused = false;

            _canAdd = listBox.IsHandleCreated;

            _listBox.SelectionMode = SelectionMode.MultiExtended;

            _listBox.HandleCreated += OnHandleCreated;
            _listBox.HandleDestroyed += OnHandleDestroyed;
            _listBox.DrawItem += DrawItemHandler;
            _listBox.KeyDown += KeyDownHandler;

            MenuItem[] menuItems = new MenuItem[] { new MenuItem("Copy", new EventHandler(CopyMenuOnClickHandler)) };
            _listBox.ContextMenu = new ContextMenu(menuItems);
            _listBox.ContextMenu.Popup += new EventHandler(CopyMenuPopupHandler);

            _listBox.DrawMode = DrawMode.OwnerDrawFixed;
        }

        public void Log(string message) { Log(Level.Debug, message); }
        public void Log(string format, params object[] args) { Log(Level.Debug, (format == null) ? null : string.Format(format, args)); }
        public void Log(Level level, string format, params object[] args) { Log(level, (format == null) ? null : string.Format(format, args)); }
        public void Log(Level level, string message)
        {
            WriteEvent(new LogEvent(level, message));
        }

        public bool Paused
        {
            get { return _paused; }
            set { _paused = value; }
        }

        ~ListBoxLog()
        {
            if (!_disposed)
            {
                Dispose(false);
                _disposed = true;
            }
        }
        public void Dispose()
        {
            if (!_disposed)
            {
                Dispose(true);
                GC.SuppressFinalize(this);
                _disposed = true;
            }
        }
        private void Dispose(bool disposing)
        {
            if (_listBox != null)
            {
                _canAdd = false;

                _listBox.HandleCreated -= OnHandleCreated;
                _listBox.HandleCreated -= OnHandleDestroyed;
                _listBox.DrawItem -= DrawItemHandler;
                _listBox.KeyDown -= KeyDownHandler;

                _listBox.ContextMenu.MenuItems.Clear();
                _listBox.ContextMenu.Popup -= CopyMenuPopupHandler;
                _listBox.ContextMenu = null;

                _listBox.Items.Clear();
                _listBox.DrawMode = DrawMode.Normal;
                _listBox = null;
            }
        }
    }
}
25
Stefan

RichTextBoxを使用して色付きの行を再度ログに記録する場合、これをFuture Meのヘルプとしてここに保存します。次のコードは、RichTextBoxの最初の行を削除します。

if ( logTextBox.Lines.Length > MAX_LINES )
{
  logTextBox.Select(0, logTextBox.Text.IndexOf('\n')+1);
  logTextBox.SelectedRtf = "{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1053\\uc1 }";
}

SelectedRtfを ""に設定するだけでは機能しないことを理解するのに時間がかかりすぎましたが、 "proper" RTFにテキストコンテンツなしで設定することは問題ありません。

13
m_eiman

最近、似たようなものを実装しました。私たちのアプローチは、スクロールバックレコードのリングバッファを保持し、手動で(Graphics.DrawStringを使用して)ログテキストをペイントすることでした。次に、ユーザーがスクロールバック、テキストのコピーなどを行う場合、通常のTextBoxコントロールに戻る「一時停止」ボタンがあります。

5
Daniel Pryden

ListViewはこれに最適(詳細表示モード)であり、いくつかの内部アプリで使用しているものとまったく同じです。

役立つヒント:一度に多くのアイテムを追加/削除することがわかっている場合は、BeginUpdate()とEndUpdate()を使用します。

4
Neil N

強調表示と色の書式設定が必要な場合は、RichTextBoxをお勧めします。

自動スクロールが必要な場合は、ListBoxを使用します。

いずれの場合も、行の循環バッファーにバインドします。

2
Cheeso

基本的なログウィンドウを作成する私の解決策は、彼の答えで John Knoeller が提案したとおりでした。ログ情報を直接TextBoxまたはRichTextBoxコントロールに保存するのを避け、代わりにpopulateコントロールに使用できるロギングクラスを作成するか、ファイルに書き込むなど.

このソリューション例にはいくつかの要素があります。

  1. ロギングクラス自体、Logger
  2. 更新後にスクロールして下部に機能を追加するためのRichTextBoxコントロールの変更。 ScrollingRichTextBox
  3. LoggerExampleの使用を示すメインフォーム。

まず、ロギングクラス:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;

namespace Logger
{
    /// <summary>
    /// A circular buffer style logging class which stores N items for display in a Rich Text Box.
    /// </summary>
    public class Logger
    {
        private readonly Queue<LogEntry> _log;
        private uint _entryNumber;
        private readonly uint _maxEntries;
        private readonly object _logLock = new object();
        private readonly Color _defaultColor = Color.White;

        private class LogEntry
        {
            public uint EntryId;
            public DateTime EntryTimeStamp;
            public string EntryText;
            public Color EntryColor;
        }

        private struct ColorTableItem
        {
            public uint Index;
            public string RichColor;
        }

        /// <summary>
        /// Create an instance of the Logger class which stores <paramref name="maximumEntries"/> log entries.
        /// </summary>
        public Logger(uint maximumEntries)
        {
            _log = new Queue<LogEntry>();
            _maxEntries = maximumEntries;
        }

        /// <summary>
        /// Retrieve the contents of the log as rich text, suitable for populating a <see cref="System.Windows.Forms.RichTextBox.Rtf"/> property.
        /// </summary>
        /// <param name="includeEntryNumbers">Option to prepend line numbers to each entry.</param>
        public string GetLogAsRichText(bool includeEntryNumbers)
        {
            lock (_logLock)
            {
                var sb = new StringBuilder();

                var uniqueColors = BuildRichTextColorTable();
                sb.AppendLine($@"{{\rtf1{{\colortbl;{ string.Join("", uniqueColors.Select(d => d.Value.RichColor)) }}}");

                foreach (var entry in _log)
                {
                    if (includeEntryNumbers)
                        sb.Append($"\\cf1 { entry.EntryId }. ");

                    sb.Append($"\\cf1 { entry.EntryTimeStamp.ToShortDateString() } { entry.EntryTimeStamp.ToShortTimeString() }: ");

                    var richColor = $"\\cf{ uniqueColors[entry.EntryColor].Index + 1 }";
                    sb.Append($"{ richColor } { entry.EntryText }\\par").AppendLine();
                }
                return sb.ToString();
            }
        }

        /// <summary>
        /// Adds <paramref name="text"/> as a log entry.
        /// </summary>
        public void AddToLog(string text)
        {
            AddToLog(text, _defaultColor);
        }

        /// <summary>
        /// Adds <paramref name="text"/> as a log entry, and specifies a color to display it in.
        /// </summary>
        public void AddToLog(string text, Color entryColor)
        {
            lock (_log)
            {
                if (_entryNumber >= uint.MaxValue)
                    _entryNumber = 0;
                _entryNumber++;
                var logEntry = new LogEntry { EntryId = _entryNumber, EntryTimeStamp = DateTime.Now, EntryText = text, EntryColor = entryColor };
                _log.Enqueue(logEntry);

                while (_log.Count > _maxEntries)
                    _log.Dequeue();
            }
        }

        /// <summary>
        /// Clears the entire log.
        /// </summary>
        public void Clear()
        {
            lock (_logLock)
            {
                _log.Clear();
            }
        }

        private Dictionary<Color, ColorTableItem> BuildRichTextColorTable()
        {
            var uniqueColors = new Dictionary<Color, ColorTableItem>();
            var index = 0u;

            uniqueColors.Add(_defaultColor, new ColorTableItem() { Index = index++, RichColor = ColorToRichColorString(_defaultColor) });

            foreach (var c in _log.Select(l => l.EntryColor).Distinct().Where(c => c != _defaultColor))
                uniqueColors.Add(c, new ColorTableItem() { Index = index++, RichColor = ColorToRichColorString(c) });

            return uniqueColors;
        }

        private string ColorToRichColorString(Color c)
        {
            return $"\\red{c.R}\\green{c.G}\\blue{c.B};";
        }
    }
}

Loggerクラスには別のクラスLogEntryが組み込まれています。このクラスは、行番号、タイムスタンプ、および目的の色を追跡します。構造体は、リッチテキストカラーテーブルを作成するために使用されます。

次に、変更されたRichTextBoxを次に示します。

using System;
using System.Runtime.InteropServices;

namespace Logger
{
    public class ScrollingRichTextBox : System.Windows.Forms.RichTextBox
    {
        [DllImport("user32.dll", CharSet = CharSet.Auto)]
        private static extern IntPtr SendMessage(
            IntPtr hWnd,
            uint Msg,
            IntPtr wParam,
            IntPtr LParam);

        private const int _WM_VSCROLL = 277;
        private const int _SB_BOTTOM = 7;

        /// <summary>
        /// Scrolls to the bottom of the RichTextBox.
        /// </summary>
        public void ScrollToBottom()
        {
            SendMessage(Handle, _WM_VSCROLL, new IntPtr(_SB_BOTTOM), new IntPtr(0));
        }
    }
}

ここで行っているのは、RichTextBoxを継承し、「一番下までスクロール」メソッドを追加することだけです。 StackOverflowでこれを行う方法については、他にもさまざまな質問がありますが、そこからこのアプローチを導き出しました。

最後に、フォームからこのクラスを使用する例:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;

namespace Logger
{
    public partial class LoggerExample : Form
    {
        private Logger _log = new Logger(100u);
        private List<Color> _randomColors = new List<Color> { Color.Red, Color.SkyBlue, Color.Green };
        private Random _r = new Random((int)DateTime.Now.Ticks);

        public LoggerExample()
        {
            InitializeComponent();
        }

        private void timerGenerateText_Tick(object sender, EventArgs e)
        {
            if (_r.Next(10) > 5)
                _log.AddToLog("Some event to log.", _randomColors[_r.Next(3)]);
        }

        private void timeUpdateLogWindow_Tick(object sender, EventArgs e)
        {
            richTextBox1.Rtf = _log.GetLogAsRichText(true);
            richTextBox1.ScrollToBottom();
        }
    }
}

このフォームは、2つのタイマーで作成されます。1つはログエントリを擬似ランダムに生成し、もう1つはRichTextBox自体にデータを入力します。この例では、ログクラスは100行のスクロールバックでインスタンス化されます。 RichTextBoxコントロールの色は、白の背景とさまざまな色の前景を持つ黒の背景を持つように設定されます。テキストを生成するタイマーは100ミリ秒間隔で、ログウィンドウを更新するタイマーは1000ミリ秒間隔です。

サンプル出力:

Logger Example Output

完璧とはほど遠いものや完成したものにはほど遠いですが、ここに追加または改善できるいくつかの注意事項と事柄を示します(そのうちのいくつかは後のプロジェクトで行いました)。

  1. maximumEntriesの値が大きいと、パフォーマンスが低下します。このロギングクラスは、数百行のスクロールバック用にのみ設計されました。
  2. RichTextBoxのテキストを置き換えると、ちらつきが生じる可能性があります。私は常に比較的遅い間隔で更新タイマーを保持します。 (この例では1秒。)
  3. 上記の#2に加えて、RichTextBoxのコンテンツを再描画する前に、不必要に更新されないように、いくつかのプロジェクトでログに新しいエントリがあるかどうかを確認します。
  4. 各ログエントリのタイムスタンプはオプションにでき、さまざまな形式を使用できます。
  5. この例ではログを一時停止する方法はありませんが、私のプロジェクトの多くは、ユーザーがログウィンドウからテキストを手動でスクロール、選択、コピーできるように、スクロール動作を一時停止するメカニズムを提供します。

この例を修正および改善してください。フィードバックは大歓迎です。

2
JYelton