简介面向C# WinForms初学者的一份日志输出例程源码聚焦系统事件与操作行为的窗口展示和文件留痕可用于学习日志模块的基本结构也可直接嵌入小型桌面程序。压缩包为rar格式共36个文件、约745KB其中9个cs源文件承载主要逻辑config、settings与manifest等配置清单文件配合程序运行sln与csproj便于直接打开解决方案另有txt说明及编译产物等辅助文件整体轻量、结构完整。已有2123人学习说明该例程对入门者具有一定参考价值。代码演示了程序启动、关闭等系统事件的记录以及用户操作在界面实时输出并同步写入后台文件日志按日期生成独立文件程序反复打开时以追加方式保存不会覆盖历史记录。读者可据此理解日志显示与持久化的衔接方式快速复用现成实现减少从零搭建日志功能的试错成本。1. 一个日志面板把主线程拖垮之后做 C# Winform 工具软件的开发者多半遇到过这种场景工装测试台跑了一下午点按钮要卡两三秒才有反应日志面板还在疯狂刷行关掉程序翻文件却发现关键的几十秒一条没记下来。这份 C# Winform 日志输出例程源码对准的就是这类问题——把系统日志、操作日志的级别划分、记录格式、文件落盘、界面展示、线程安全串成一条能直接照抄的链路而不是甩给你一句File.AppendAllText就完事。它适合三类人刚接手上位机项目的新手、要给老旧工具补日志的维护者、以及被「日志写了却查不出问题」折腾过的从业者。2. 日志分级与落盘先定接口再写实现2.1 先抽 ILog 再谈写文件为什么例程第一步是定契约很多人的第一版日志是这么写的在按钮事件里File.AppendAllText(log.txt, 点了保存);。跑得通但撑不过两个月。第一个问题出在 IO 上——每次调用都打开、写入、关闭句柄工装程序一秒刷几十条日志时文件系统调用会成为隐形瓶颈还容易出现两个线程抢同一个文件抛IOException。第二个问题是耦合调用点直接绑死了「写文件」这个动作哪天要在界面上同步显示就得回头改几十处业务代码。第三个问题是格式不统一有人写时间有人不写有人写级别有人全靠感叹号事后用文本工具筛的时候根本无从下手。所以这份例程的第一件事不是写落盘代码而是先定义一份契约让「产生日志的人」和「处理日志的人」互相不认识。常见做法是抽一个ILog接口加一个级别枚举业务侧只依赖这两个东西。这样后面换文件、换数据库、换界面输出业务代码一行都不用动——这是整个日志模块能不能长大的分水岭。// 日志级别数值大小用于比较过滤不要随意改动已有数值 public enum LogLevel { Trace 0, // 最细粒度的流程跟踪只在排障时开 Debug 1, // 开发期调试信息 Info 2, // 正常的系统日志 / 操作日志 Warn 3, // 可恢复的异常、降级行为 Error 4, // 单次操作失败但程序还能跑 Fatal 5 // 致命错误即将退出 } // 契约只暴露写和是否启用两个能力避免调用方关心落地方式 public interface ILog { void Write(LogLevel level, string message, Exception ex null); // 关键先判断再拼字符串避免关闭 Debug 后仍然付出格式化的开销 bool IsEnabled(LogLevel level); }LogLevel用连续整数而不是[Flags]是因为它只做「大于等于阈值才输出」这一种比较用整数最省事也最好序列化到配置文件里。IsEnabled这个看似多余的成员实际是性能关键点调用方写成if (log.IsEnabled(LogLevel.Debug)) log.Write(..., 拼装复杂字符串)在发布版把阈值调到 Info 之后那段字符串拼接根本不会执行。反过来如果只有Write一个方法参数先在调用点求值完毕才进函数关掉日志也照样付出反射、序列化、ToString()的代价。接口定完顺手补一组扩展方法让调用点读起来短一点。这不是为了好看是为了降低「懒得写日志」的概率。public static class LogExtensions { public static void Info(this ILog log, string msg) { if (log.IsEnabled(LogLevel.Info)) log.Write(LogLevel.Info, msg); } public static void Error(this ILog log, string msg, Exception ex) { // Error 以上级别不做开关判断的省略异常必须落地 log.Write(LogLevel.Error, msg, ex); } }2.2 分级、格式化与文件命名三个决定排障效率的细节接口有了下一个问题是「一条日志长什么样」。格式看起来是审美问题实际上是检索问题。例程里用一条固定模板时间精确到毫秒、级别定宽、线程号、消息体。毫秒不能省——多线程场景下同一秒里几条日志的先后顺序全靠毫秒和线程号区分少了它排查时序问题基本靠猜这块属于玄学区经验比文档管用。级别定宽用一个-5对齐是为了用文本编辑器正则筛的时候行首结构一致\[\w\s*\]这种模式能稳定命中。public sealed class LogEntry { public DateTime Time { get; set; } public LogLevel Level { get; set; } public int ThreadId { get; set; } public string Message { get; set; } public Exception Exception { get; set; } } public static class LogFormatter { // {1,-5} 让级别左对齐占 5 字符方便文本工具按列切 private const string Template {0:yyyy-MM-dd HH:mm:ss.fff} [{1,-5}] [T{2:D2}] {3}; public static string Format(LogEntry e) { var body e.Exception null ? e.Message : e.Message Environment.NewLine e.Exception; // 异常堆栈直接跟在消息后 return string.Format(Template, e.Time, e.Level, e.ThreadId, body); } }参数上还有两个容易忽略的点。ThreadId用Environment.CurrentManagedThreadId而不是Thread.CurrentThread.ManagedThreadId前者不分配对象高频调用下差别明显。异常不要只写ex.Message堆栈才是定位问题的本体但要放在消息之后单独成行否则一行日志太长很多查看器会截断。文件命名同样影响排障。例程按天滚动形如AppName-yyyyMMdd.log。有人喜欢按小时切觉得文件小好打开对于工装、测试台这类一天几百 KB 到几 MB 的程序按小时切会碎成一地事后要跨文件拼时间线反而增加工作量。真正需要按小时切的是服务端那种单日上 GB 的场景工具软件不必跟风。2.3 三种落地方式的取舍与文件落盘的实现日志最终要去一个地方例程里把「去哪里」做成可替换的 sink接收器。常见三种文件、界面面板、调试输出。它们的定位完全不同不能互相替代。落地方式适用场景优点边界与代价文件生产环境、事后追查进程被杀也留痕、可归档有 IO 开销需处理句柄与滚动UI 面板现场观察、演示即时可见操作员能看懂跨线程、高频下会拖慢界面Debug 输出开发期零配置IDE 直接看Release 下默认不输出不可依赖文件 sink 的实现要点是「持有句柄 自动刷新 按日切换」而不是每次打开关闭。下面的实现把StreamWriter缓存在字段里写入时判断日期是否变化变了就关旧开新。public sealed class FileLogSink : IDisposable { private readonly string _dir; private readonly string _appName; private StreamWriter _writer; private DateTime _currentDate; public FileLogSink(string dir, string appName) { _dir dir; _appName appName; Directory.CreateDirectory(_dir); // 目录不存在时自动建省掉启动失败 } public void Write(LogEntry entry) { // 跨天时切换文件避免单文件无限增长 if (_writer null || entry.Time.Date ! _currentDate) { _writer?.Flush(); _writer?.Dispose(); _currentDate entry.Time.Date; var path Path.Combine(_dir, ${_appName}-{_currentDate:yyyyMMdd}.log); // FileShare.ReadWrite允许其他进程只读打开便于边跑边看 var fs new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); _writer new StreamWriter(fs, new UTF8Encoding(false)) { AutoFlush true }; } _writer.WriteLine(LogFormatter.Format(entry)); } public void Dispose() { _writer?.Flush(); _writer?.Dispose(); _writer null; } }几个参数值得逐个说清。FileShare.ReadWrite决定了程序运行时你能不能双击打开这个日志文件看——不给共享读Windows 上会直接提示被占用这在现场排查时非常尴尬。UTF8Encoding(false)里的false是「不写 BOM」带 BOM 的文件在部分老版本查看器或日志采集工具里首行会显示成乱码。AutoFlush true意味着每条日志都刷进操作系统缓冲代价是吞吐略降但换来的是进程被强杀时日志基本不丢这个交换对工具软件完全值得。真正在意性能时才改成定时 Flush那属于极端场景。3. Winform 线程安全输出把日志从后台线程搬到控件上3.1 Invoke 与 BeginInvoke同步等待的代价日志面板卡顿的根因八成不是控件本身慢而是后台线程同步等待 UI 线程。Control.Invoke是同步的后台线程发出调用后阻塞直到 UI 线程处理完这个委托才返回。如果 UI 线程正在处理一个耗时操作比如加载几万行数据后台线程就跟着一起卡如果后台线程持着锁去 Invoke而 UI 线程恰好要拿那把锁直接死锁。BeginInvoke是异步入队后台线程投递完立刻返回主线程空闲时再取出来执行这才是不拖累业务线程的写法。还有一个经典翻车点InvokeRequired在控件句柄尚未创建时返回false。窗口还没Show出来、构造函数里就写日志时这句判断会误以为「已经在 UI 线程」直接操作控件抛InvalidOperationException或者更隐蔽地不报错但什么都不显示。所以封装时要把IsHandleCreated一起判断。public static void SafeAppend(RichTextBox box, string text) { // 窗口已销毁或句柄未创建直接丢弃避免 ObjectDisposedException if (box.IsDisposed) return; if (!box.IsHandleCreated) return; if (box.InvokeRequired) { // BeginInvoke投递即返回后台线程不阻塞 box.BeginInvoke(new Action(() Append(box, text))); } else { Append(box, text); } } private static void Append(RichTextBox box, string text) { box.AppendText(text Environment.NewLine); box.SelectionStart box.TextLength; box.ScrollToCaret(); }IsDisposed和IsHandleCreated两道判断不是保守是必需程序关闭过程中后台线程往往还在跑这时候往已经销毁的控件投递消息会抛异常而这个异常发生在日志系统内部等于日志把程序搞崩了——这是最不该犯的错。ScrollToCaret()放在每次追加后是为了让面板始终停在最新一行但如果每秒追加上百条滚动本身的开销也会累积第四章的批量刷新就是来解决这个的。3.2 高频日志的批量刷新队列加定时器一条日志触发一次BeginInvoke一千条就是一千次消息投递主线程的光标滚动、重绘、布局全部跟着跑一遍界面必卡。合格做法是解耦后台线程只往线程安全队列里塞数据UI 侧用一个短周期定时器批量取走一次AppendText写一批。这样投递次数从「日志条数」降到「定时器滴答次数」量级差几个数量级。public sealed class UiLogSink { private readonly ConcurrentQueuestring _queue new ConcurrentQueuestring(); private readonly RichTextBox _box; private readonly System.Windows.Forms.Timer _timer; public UiLogSink(RichTextBox box, int flushIntervalMs 150) { _box box; _timer new System.Windows.Forms.Timer { Interval flushIntervalMs }; _timer.Tick (s, e) Flush(); _timer.Start(); } // 任意线程调用只入队不碰控件 public void Enqueue(LogEntry entry) _queue.Enqueue(LogFormatter.Format(entry)); private void Flush() { if (_queue.IsEmpty || _box.IsDisposed) return; var sb new StringBuilder(4096); int count 0; // 单次最多取 300 条防止突发流量把一次 Tick 拖成卡顿 while (count 300 _queue.TryDequeue(out var line)) { sb.AppendLine(line); count; } if (count 0) return; _box.AppendText(sb.ToString()); _box.SelectionStart _box.TextLength; _box.ScrollToCaret(); } }flushIntervalMs 150是个折中值低于 100ms定时器本身开始占用可观的主线程时间高于 300ms操作员会觉得日志「半天没动」怀疑程序卡死。单次取 300 条的上限是背压阀门——如果日志产生速度长期超过消费速度队列会无限膨胀直到吃满内存限制单批数量能让单次刷新时间可控队列积压则通过丢旧数据或降级到只写文件来处理。另外要注意这里用的是System.Windows.Forms.Timer它的 Tick 在 UI 线程执行天然安全换成System.Timers.Timer就会在池线程里跑反而又多出一层跨线程问题。3.3 面板容量控制让日志窗口跑一整天也不崩RichTextBox是无上限增长的跑一天几万行之后追加和滚动都会明显变慢内存也水涨船高。做法是设一个行数或字符数阈值超过就从头部裁掉一段。裁剪要按行对齐别从字符中间切否则会出现半行乱码。private const int MaxChars 400_000; // 约合数千行视行长度浮动 private void TrimIfNeeded(RichTextBox box) { if (box.TextLength MaxChars) return; // 找到超出部分之后的第一行起点保证不从半行切断 int removeTo box.TextLength - MaxChars / 2; int lineEnd box.GetFirstCharIndexFromLine(box.GetLineFromCharIndex(removeTo)); if (lineEnd 0) return; box.Select(0, lineEnd); box.SelectedText string.Empty; box.Select(box.TextLength, 0); }阈值MaxChars之所以不设成精确行数是因为日志行长度差异大异常堆栈一行能顶几十行普通日志按字符算更贴近真实的内存占用。每次裁掉一半而不是刚好裁到阈值是为了避免「每加一行就裁一次」的抖动这是典型的均摊思路。裁剪之后一定要把光标挪回末尾否则下一次AppendText会插在中间日志顺序就乱了。4. 源码结构与关键类拆解例程里每个文件在干什么4.1 文件清单与职责边界拿到源码包先看结构比先看代码重要。这类例程通常按「契约—格式化—落地—宿主」四层拆分每层只依赖下一层的接口反向依赖是不允许的。文件/类所在层职责允许依赖LogLevel.cs/ILog.cs契约层级别定义与写日志契约无LogEntry.cs/LogFormatter.cs格式化层组织日志字段、拼装文本契约层FileLogSink.cs落地层按日滚动写文件、句柄管理格式化层UiLogSink.cs落地层队列缓冲、批量刷新控件格式化层Logger.cs门面层级别过滤、分发到多个 sink全部MainForm.cs宿主层初始化日志、按钮事件、面板展示门面层门面层的Logger是唯一被业务代码看到的东西它内部持有一个 sink 列表Write时遍历分发。分发过程要包 try-catch某个 sink 抛异常比如磁盘满了写文件失败不能让日志调用把业务流程带崩最差也要吞掉异常并继续走下一个 sink。这条规则看起来粗暴但在日志系统里是合理的取舍——日志是旁路不能成为主流程的故障点。4.2 启动时的初始化顺序与配置读取初始化顺序错了会出现「前几条日志没落地」「路径不存在直接抛异常」这类问题。正确顺序是读配置 → 建目录 → 实例化 sink → 赋值给全局 Logger → 注册未处理异常钩子 → 写第一条 Info 日志作为启动锚点。配置一般放App.config的appSettings几个键覆盖 90% 的需求。// App.config 中add keyLogLevel valueInfo/ // add keyLogDir valuelogs/ static ILog BuildLogger() { var level (LogLevel)Enum.Parse( typeof(LogLevel), ConfigurationManager.AppSettings[LogLevel] ?? Info, ignoreCase: true); // 相对路径转绝对路径避免工作目录变化导致日志写错地方 var dir Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ConfigurationManager.AppSettings[LogDir] ?? logs); var logger new Logger(level); logger.AddSink(new FileLogSink(dir, AppName)); return logger; } // 程序入口 [STAThread] static void Main() { var log BuildLogger(); GlobalLog.Current log; // 钩子必须在主窗体显示前挂上否则启动阶段异常没人接 Application.ThreadException (s, e) log.Write(LogLevel.Fatal, UI线程未处理异常, e.Exception); AppDomain.CurrentDomain.UnhandledException (s, e) log.Write(LogLevel.Fatal, 非UI线程未处理异常, e.ExceptionObject as Exception); log.Info(应用启动); Application.Run(new MainForm()); log.Info(应用退出); }AppDomain.CurrentDomain.BaseDirectory而不是Directory.GetCurrentDirectory()是因为后者的值取决于谁启动的进程——从桌面快捷方式启动和在 IDE 里启动可能不同日志会莫名其妙写到别处。这是很典型的一个坑用相对路径的版本在开发机上永远正常一到客户现场就找不到日志。Enum.Parse加ignoreCase: true是让配置文件里写info和Info都能用省掉一次现场电话支持。4.3 全局异常兜底让崩溃前的最后一行落盘Winform 程序崩溃时最常见的现象是弹一个系统错误框然后进程消失日志里干干净净。原因是异常发生在 UI 线程而日志代码没有机会执行。上面挂的两个钩子分别覆盖 UI 线程和后台线程但还有一个容易被漏掉的Task里抛出的异常如果没人await会挂在TaskScheduler.UnobservedTaskException上直到 GC 时才触发时机完全不可控。三处都挂上才算完整。钩子里写日志时要注意两点一是不要做重活弹窗、网络上报、复杂格式化崩溃现场做这些大概率二次崩溃二是Fatal级别要绕过级别过滤即使配置成只输出Error也不能把致命信息吞掉。这一点在Logger.Write里加一个例外判断即可if (level _minLevel level ! LogLevel.Fatal) return;。5. 避坑排查五条血泪经验5.1 第二次启动就抛 IOException文件被占用现象程序第一次运行正常关掉再开写日志时抛IOException: 文件正被另一进程使用或者干脆第一条日志之后就再没有输出。原因FileStream打开时没指定FileShare默认是FileShare.None独占句柄上一次进程如果是被任务管理器强杀的句柄释放有延迟或者上一次的Dispose因为异常没走到。解决构造FileStream时显式传FileShare.ReadWrite并且把Dispose放进try/finally或直接用using。如果程序有单实例限制还要在入口处用Mutex兜一下防止两个实例同时写同一文件。真正稳妥的做法是给日志加进程 ID 后缀多实例各写各的代价是事后要合并查看。5.2 界面越跑越卡BeginInvoke 也救不了高频日志现象换成BeginInvoke之后短暂流畅跑十几分钟后开始出现点击延迟任务管理器里进程的句柄数和内存都在涨。原因BeginInvoke是异步入队但队列本身没有上限。后台线程每秒投递几百条UI 线程消费不过来消息队列越堆越长主线程光是处理这些投递就够呛而且每条都触发一次重绘和滚动。解决改成 3.2 节的队列 定时器批量刷新把投递次数从「条数」降到「滴答数」。同时给队列设上限超过阈值时降级——UI 面板丢弃只保留文件 sink。这个降级策略要写死不要靠调用方自觉。5.3 Release 版日志全没了Debug.WriteLine 不进文件现象开发机上看日志一切正常打包发给现场之后日志文件是空的或者只有几行。原因Debug.WriteLine带[Conditional(DEBUG)]特性Release 编译时整个调用被编译器剔除连参数求值都不做。更隐蔽的一种是自定义的Debug级别方法内部调用了Debug.WriteLine接口看着在实际什么都没发生。解决落盘路径一律走FileLogSinkDebug.WriteLine只用于开发期临时打印。发布前用 Release 配置跑一遍冒烟测试确认日志文件有内容这一步别省它是唯一能提前暴露该问题的手段。5.4 程序被强杀时最后几十行日志消失现象现场反馈程序卡死后被强制结束日志文件最后一条比实际崩溃时间早了几十秒。原因StreamWriter有内部缓冲区不开AutoFlush时数据攒够 1KB 左右才真正写下去进程被 kill 时缓冲区里的内容直接蒸发。操作系统缓冲是另一层Flush只保证进了 OS没保证落盘。解决AutoFlush true解决进程被杀的问题如果连断电都不能丢得用FileStream.Flush(true)强制刷盘但那个开销很大工具软件一般不需要。折中方案是关键节点启动、操作前后、异常手动调一次强制刷盘其余靠自动刷新。5.5 中文变问号或乱码编码没显式指定现象日志文件用记事本打开正常用某些采集工具或老版本编辑器打开中文全是乱码首行还多出几个不可见字符。原因new StreamWriter(path)不传编码时使用UTF8Encoding(true)会写 BOM部分工具不认 BOM把它当正文内容。另一种情况是用了系统默认编码GBK跨机器读取时字符集不匹配。解决统一写成new StreamWriter(fs, new UTF8Encoding(false))明确「UTF-8 无 BOM」。如果下游系统只认 GBK就在配置里开一个编码开关但不要靠Encoding.Default——它的值随系统区域设置变化属于不可控因素。6. 进阶用一次压测量一下日志链路到底扛不扛代码抄完不等于能用得验证。我习惯在交付前跑一次最小压测开四个后台线程各写 5000 条日志同时观察主界面是否还能拖动、文件最终有多少行、内存增长多少。这段代码不长但能一次性暴露缓冲、锁竞争、UI 投递三类问题。private async void btnStress_Click(object sender, EventArgs e) { var log GlobalLog.Current; var sw Stopwatch.StartNew(); // 4 个任务并发写模拟多设备回调同时上报 var tasks Enumerable.Range(0, 4).Select(i Task.Run(() { for (int n 0; n 5000; n) { if (log.IsEnabled(LogLevel.Info)) log.Write(LogLevel.Info, $线程{i} 第{n}条 模拟操作日志); } })).ToArray(); await Task.WhenAll(tasks); sw.Stop(); log.Info($压测完成20000 条耗时 {sw.ElapsedMilliseconds} ms); }判读方式有三条。第一耗时在 13 秒内属正常超过 10 秒说明 IO 或锁有问题先查是不是每条都Flush(true)。第二压测期间拖动窗口如果能流畅拖动说明 UI 解耦到位卡住不动就是还在同步Invoke。第三压测结束后wc -l数一下文件行数20000 条加首尾两条锚点日志差得多就是丢日志了回头查AutoFlush和异常吞没。这套检查我一般跑三遍第二遍看有没有句柄泄漏第三遍看按日滚动是否正常触发。最后一个技巧是给日志加一个轻量的上下文标记。工装程序经常多工位并行日志混在一起根本分不清是谁的。做法是在Logger里放一个AsyncLocalstring存当前工位号格式化时拼进模板用完自动清空不需要层层传参。这个改动只有十几行但把「翻半天日志找对应工位」变成一次搜索收益比加一堆日志点大得多。早期我给一个测试台补日志图省事直接用File.AppendAllText加同步Invoke交付当天现场跑满负荷界面卡成幻灯片日志还丢了最后半分钟。从那以后我每次接这类活都先把「接口—落地—UI 解耦」三层跑通再用上面那段压测走一遍确认没问题才往业务里加点位。希望帮到你。本文还有配套的精品资源点击获取