
简介这份zip压缩包面向需要在Windows界面开发中定制ListBox控件显示效果的C/MFC开发者针对日志展示场景给出字体与颜色自定义的完整方案。资源共3个文件、大小16KB包含类声明头文件与实现源码各一份另有1个txt说明文档补充背景与来源信息结构紧凑便于直接对照学习。通过解读源码可掌握SetFont、SetBkColor与SetTextColor等API的配合用法理解如何按日志级别动态切换前景与背景颜色从而提升日志条目的可读性与界面整体质感。已有88人学习下载适合初涉控件自绘或正在优化日志窗口显示逻辑的Windows程序员参考在消息映射与自绘控件细节上也能带来启发。1. 从一份 log_listbox_src 拆开看日志框字体与颜色不是设两个 API 那么简单排查一个老 MFC 项目时我常被日志窗口的字体和颜色搞到头疼默认 ListBox 字体太小错误和提示混成一片盯着屏幕找关键信息就像在扫雷。后来拿到一份 log_listbox_src.zip里面是 MMALogListBox.h、MMALogListBox.cpp 和一个从 www.pudn.com 顺带下载的说明 txt。别看包不大它把自定义 ListBox 字体、按日志级别分色的路子完整走了一遍。如果你也在用 CListBox 显示日志又不想为了改字体和颜色去换整个控件体系这份源码值得拆开看。2. 先把 MMALogListBox 的结构讲透这个控件到底动了哪些消息2.1 源码包里三个文件的职责把压缩包解开第一件事不是看代码而是把文件职责分清楚。这个包里只有三个文件彼此分工很明确文件内容在实现里的作用MMALogListBox.h类声明、枚举、消息映射声明定义对外接口和数据成员MMALogListBox.cpp自绘实现、字体/颜色设置、AddLog控件主体www.pudn.com.txt下载站存档说明通常只记录来源没有代码价值很多人会把下载站生成的 txt 当成文档读结果读半天没收获。我拆过不少这类包www.pudn.com.txt 大多是源页面备份可能带一段简介也可能只是空模板。真正值得读的是 cpp 文件里的注释和实现思路。这种单文件控件的好处是依赖少往工程里塞的时候不拖泥带水。坏处是它默认绑定了 MFC 的 CListBox所以你的项目必须是 MFC 工程或者至少能编译 MFC 类库。如果是纯 Win32 对话框项目需要先把消息循环和子类化机制理清楚再考虑搬过来。2.2 类声明与对外接口字体、颜色、日志级别把源码拆开看MMALogListBox 本质上是一个从 CListBox 派生的自绘控件。它要对外提供的不是一堆散落的函数而是三个清晰的入口设置字体、设置级别颜色、追加日志。我用它给日志框做过一轮重构重新整理后的类声明大致是这个样子// MMALogListBox.h #pragma once #include afxwin.h #include afxtempl.h enum MMA_LOG_LEVEL { MMA_LOG_DEBUG 0, MMA_LOG_INFO, MMA_LOG_WARN, MMA_LOG_ERROR, MMA_LOG_LEVEL_COUNT }; class MMALogListBox : public CListBox { public: MMALogListBox(); virtual ~MMALogListBox(); void SetLogFont(LOGFONT lf); void SetLevelColor(MMA_LOG_LEVEL level, COLORREF color); void AddLog(MMA_LOG_LEVEL level, LPCTSTR text); protected: afx_msg void OnDrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); afx_msg UINT OnMeasureItem(LPMEASUREITEMSTRUCT lpMeasureItemStruct); DECLARE_MESSAGE_MAP() private: CFont m_font; COLORREF m_colorTable[MMA_LOG_LEVEL_COUNT]; CArrayMMA_LOG_LEVEL, MMA_LOG_LEVEL m_levels; };这段骨架不一定是原包的逐行拷贝但拆开 MMALogListBox.cpp 后你会发现有几个绕不开的点一是类继承自 CListBox二是重写了 DrawItem 和 MeasureItem三是把字体对象作为成员变量持有。SetLogFont 接受 LOGFONT 而不是直接 HFONT是为了让调用方能决定字体名、高度、粗细而控件内部把 HFONT 包装成 CFont。参数说明MMA_LOG_LEVEL 枚举把日志分成四档Debug、Info、Warn、Error颜色表长度就是四索引和枚举对齐AddLog 的 text 参数是日志内容level 参数决定这一行用什么颜色画。m_levels 数组用来记录每一行对应的日志级别这样 DrawItem 里才能按行取颜色。2.3 自绘还是背景色通知两条实现路线怎么选很多人第一次改 ListBox 颜色会先撞上 WM_CTLCOLORLISTBOX。这是父窗口收到的一种通知消息处理它时可以用 SetTextColor 和 SetBkColor 临时改设备上下文让整个控件统一变色。听起来方便但它的粒度太粗顶多让所有行都是同一种颜色。日志场景按级别分色需要每一行各自不同所以必须走第二条路Owner Draw也就是让控件自己画每一行。路线适用场景优点缺点WM_CTLCOLORLISTBOX整体统一样式实现简单两三个分支就够不能按行区分Owner draw DrawItem按日志级别分色、混合字体每一行都能单独控制要处理 MeasureItem、选中态、重绘时机MMALogListBox 走的是第二条路因为它的目标很明确Warn 是琥珀色Error 是红色Debug 是灰色。如果只用 WM_CTLCOLORLISTBOX这套逻辑根本落不了地。DrawItem 的核心写作方式是把设备上下文从 LPDRAWITEMSTRUCT 里取出来然后自己填充背景、自己写文字。典型实现长这样void MMALogListBox::OnDrawItem(LPDRAWITEMSTRUCT lpDIS) { if ((int)lpDIS-itemID 0 || lpDIS-itemID m_levels.GetSize()) return; CDC* pDC CDC::FromHandle(lpDIS-hDC); CString text; GetLBText(lpDIS-itemID, text); if (lpDIS-itemState ODS_SELECTED) pDC-FillSolidRect(lpDIS-rcItem, GetSysColor(COLOR_HIGHLIGHT)); else pDC-FillSolidRect(lpDIS-rcItem, GetSysColor(COLOR_WINDOW)); COLORREF color m_colorTable[m_levels[lpDIS-itemID]]; pDC-SetTextColor(color); pDC-DrawText(text, lpDIS-rcItem, DT_LEFT | DT_VCENTER | DT_SINGLELINE); }逻辑说明先通过 itemID 从 m_levels 里取这一行的级别再查颜色表拿到颜色。FillSolidRect 保证每行背景都不受上一次绘制残留的影响然后 DrawText 在矩形内左对齐、垂直居中、单行显示。参数说明ODS_SELECTED 是选中状态标志如果不判断它点击某一行时看不到系统默认的高亮底色用户会以为控件没选中能力。DT_VCENTER 要求单行模式下才可靠列表项行高固定时用这一组标志很合适。颜色表 m_colorTable 需要在构造时初始化成默认值否则第一次 AddLog 时会读到未初始化内存画出乱码色。3. 接进对话框的完整步骤字体、颜色、条目刷新3.1 在资源文件里创建自绘 ListBox 并绑定变量理论弄清楚之后落地第一步是先在对话框资源里放一个 ListBox然后把它的 Owner Draw 属性改成 Fixed。这一步很关键如果选 Variable控件会认为每一行高度都可能不同每次添加都要重新测量日志高频写入时性能会很难看。放好控件后在对话框类头文件里声明一个 MMALogListBox 成员然后在 DoDataExchange 里用 DDX_Control 绑上。DDX_Control 会完成子类化让对话框上的原生 ListBox 行为切到 MMALogListBox 的消息映射里。// CLogDlg.h class CLogDlg : public CDialogEx { public: MMALogListBox m_wndLog; }; // CLogDlg.cpp void CLogDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_LOG_LIST, m_wndLog); }逻辑说明IDC_LOG_LIST 是资源编辑器里给 ListBox 分配的 IDDDX_Control 会根据这个 ID 找到子控件并把它子类化成 MMALogListBox 的实例。子类化成功之后WM_DRAWITEM 才有机会进入 OnDrawItem。参数说明DDX_Control 必须在 DoDataExchange 里调用不能在 OnInitDialog 里手动 SetWindowLong 代替。对话框框架会在创建时自动调用 DoDataExchange手动处理容易漏掉消息反射导致自绘代码不执行。3.2 初始化时设置 LOGFONT 和分级颜色进入 OnInitDialog 之后做两件事构造字体设置各日志级别的颜色。字体方面我一般用 LOGFONT 结构指定一个等宽字体比如 Consolas。日志窗口用等宽字体有个实际好处时间戳和关键字段按列对齐扫一眼能看出缩进和排版异常。BOOL CLogDlg::OnInitDialog() { CDialogEx::OnInitDialog(); CClientDC dc(this); LOGFONT lf {}; lf.lfHeight -MulDiv(12, dc.GetDeviceCaps(LOGPIXELSY), 72); lf.lfWeight FW_NORMAL; lf.lfCharSet DEFAULT_CHARSET; lstrcpy(lf.lfFaceName, _T(Consolas)); m_wndLog.SetLogFont(lf); m_wndLog.SetLevelColor(MMA_LOG_DEBUG, RGB(128, 128, 128)); m_wndLog.SetLevelColor(MMA_LOG_INFO, RGB(30, 30, 30)); m_wndLog.SetLevelColor(MMA_LOG_WARN, RGB(180, 120, 0)); m_wndLog.SetLevelColor(MMA_LOG_ERROR, RGB(200, 20, 20)); return TRUE; }逻辑说明lf.lfHeight 用负数表示字符高度正数表示字符全高两者相差可能造成一行文字要么偏上要么被裁。MulDiv 的作用是根据当前屏幕 DPI 把 12 磅字号换算成像素高度不同缩放比例的机器上显示大小保持一致。参数说明lf.lfFaceName 用宽字符字面量 _T(Consolas)如果项目用 ANSI 字符集也可以直接 Consolas。颜色方面Error 用高饱和红Warn 用偏暗的琥珀色Debug 用灰Info 保持近黑色这套配色在浅色背景下可读性最好。3.3 添加日志条目时触发重绘初始化完成接下来就是控件最常用的入口 AddLog。很多人误以为 AddString 会自己把新行画出来实际不是。AddString 只是把字符串放进控件内部缓冲自绘模式下如果当前没有触发重绘消息屏幕上的显示不会立刻变化。void MMALogListBox::AddLog(MMA_LOG_LEVEL level, LPCTSTR text) { int idx AddString(text); if (idx ! LB_ERR idx ! LB_ERRSPACE) { m_levels.SetAtGrow(idx, level); InvalidateRect(NULL, FALSE); } }逻辑说明AddString 返回新行的索引如果返回 LB_ERR 说明控件句柄无效LB_ERRSPACE 说明内存不够。索引拿到之后把对应的日志级别写进 m_levels这样 DrawItem 里才知道这一行该用什么颜色。InvalidateRect 发送的是异步重绘请求函数本身很快返回不会卡住调用线程。参数说明InvalidateRect 的第二个参数是 FALSE意思是背景不能擦除。自绘模式下背景本来就在 DrawItem 里逐行填充让系统再擦一次背景反而会闪烁。高频调用时这一个小参数对界面平滑度影响很大。4. 避坑日志列表框最容易翻车的五个细节4.1 字体设置了却不生效检查句柄和重绘顺序现象在 OnInitDialog 里调用了 SetFont运行时发现字体没变还是对话框默认的宋体或者系统字体。原因最常见的是字体对象被放在局部变量里函数结束 HFONT 被销毁控件拿到一个悬空句柄。第二个常见原因是 SetFont 之后没有触发重绘WM_SETFONT 虽然发出去了但控件某些状态下不会主动刷新文字。解决字体对象必须是类的成员变量不能是局部 CFont。设置完字体后强制走一遍 SetRedraw(TRUE) 和 InvalidateRect(NULL, TRUE)。另外要注意 CWnd::SetFont 接收的是 CFont*不是 HFONT写 m_wndLog.SetFont(m_font) 和 m_wndLog.SetFont(m_font) 效果完全不同别传错。4.2 颜色闪烁、串色自绘与 WM_CTLCOLORLISTBOX 两条线打架现象每行文字颜色是对的但整个控件背景偶尔闪一下滚动时出现残影有时候某一行颜色突然变成上一次画笔设置的状态。原因同时处理了 WM_CTLCOLORLISTBOX 和 DrawItem。父窗口在 WM_CTLCOLORLISTBOX 里返回了自定义画刷DrawItem 里又在自绘两套绘制路径都在改画刷和文本颜色设备上下文状态互相覆盖。解决如果选 Owner Draw就不要在 WM_CTLCOLORLISTBOX 里做任何颜色处理让父窗口返回空画刷或者默认画刷。所有颜色逻辑集中在 OnDrawItem 里。我拆这个包的时候也踩过同样的坑最后把 WM_CTLCOLORLISTBOX 整个分支删掉闪烁立刻消失。4.3 日志一多界面就卡AddString 每次全量重绘现象日志频率到每秒几百条时窗口开始一卡一卡CPU 占用很高滚动条拖不动。原因AddLog 每次调用 AddString 都会触发控件内部结构变化如果还带了 InvalidateRect相当于每一条日志都请求一次全量重绘。日志量上来后重绘成本变成 O(n)越滚越慢。解决高频写入时先 SetRedraw(FALSE)一批日志插入完再 SetRedraw(TRUE) 并只在最后 Invalidate 一次。还要给条目数设上限比如超过 5000 行就删掉头部最旧的行。常见做法是加一个上下限保护再配合攒批插入效果能差出一个量级。注意 DeleteString(0) 之后m_levels 数组的头部也要同步删掉否则后续 DrawItem 取级别时索引对不上画出错误颜色。4.4 多线程写日志直接崩溃控件不是线程安全的现象工作线程里直接调用 AddLog调试器偶尔弹访问冲突release 版本表现更随机有时运行几小时才崩一次。原因CListBox 内部依赖 HWND 和 Windows 消息机制这些状态都在创建它的 UI 线程上维护。工作线程直接操作控件相当于两个线程同时改一份没有加锁的缓冲区崩溃是迟早的事。解决工作线程不要直接调用 AddLog改成向主窗口 PostMessage把日志文本和级别通过消息参数传过去。主窗口收到消息后再调 AddLog。文本要 new 到堆上不能传栈上 CString 的指针因为消息是异步的栈对象在函数返回后就失效了。#define WM_APP_ADD_LOG (WM_APP 100) // 工作线程中 CString* pText new CString(buffer); ::PostMessage(m_hMainWnd, WM_APP_ADD_LOG, level, (LPARAM)pText); // 主窗口消息处理 LRESULT OnAddLog(WPARAM wp, LPARAM lp) { CString* pText reinterpret_castCString*(lp); m_wndLog.AddLog((MMA_LOG_LEVEL)wp, *pText); delete pText; return 0; }逻辑说明PostMessage 不等待处理就返回工作线程不会因为 UI 卡顿而被拖住。消息参数 WPARAM 放级别LPARAM 放堆上的 CString 指针主窗口处理完后负责 delete防止泄漏。参数说明m_hMainWnd 是主窗口句柄工作线程初始化时就要保存好不要在运行中通过 FindWindow 现找窗口标题一变就找不到了。如果日志量极大还可以把文本指针放进一个锁保护的队列里再 PostMessage 一个空消息主窗口批量取进一步减少 UI 线程压力。4.5 行高不对、文字被裁剪自绘控件没有处理 MeasureItem现象设置大号字体后文字只显示上半截或者每行上下贴得很紧像是被行高框住切掉了。原因Owner Draw 模式下控件行的默认高度是按系统字体算的。你换了一个更大的字体DrawText 在默认行高的矩形里画不下于是文字被压扁或者裁剪。解决重写 OnMeasureItem根据当前字体取得文字度量再上下各留 2 像素边距计算出每行高度。固定高度模式下每个条目的高度都由一次 MeasureItem 决定之后不再变化。void MMALogListBox::OnMeasureItem(LPMEASUREITEMSTRUCT lpMeasureItemStruct) { CDC* pDC GetDC(); TEXTMETRIC tm; pDC-GetTextMetrics(tm); lpMeasureItemStruct-itemHeight tm.tmHeight 4; ReleaseDC(pDC); }逻辑说明GetTextMetrics 拿到的是当前 DC 关联字体的高度、上间距、下间距tmHeight 是字符顶到底的整体高度。加 4 像素是为了让文字上下不顶框。这套方案在 Fixed 模式下简单可靠Variable 模式下还要根据每个条目的内容动态计算复杂度会高很多。参数说明GetDC 拿到的 DC 默认选中控件当前字体吗不一定。更稳妥的做法是先 SelectObject 把 m_font 选进 DC再 GetTextMetrics否则可能还是拿系统字体来测量。如果你发现改了字体后行高依然不对优先检查这里。5. 再往前走一步给日志列表框加上行数上限与过滤习惯5.1 给 AddLog 补一个上限保护控件能正常显示之后我第一件事是把行数上限加上。不加上限的日志框就是内存黑洞跑一个通宵几百 MB 就没了。我的做法是在 AddLog 里插入裁剪逻辑每次新增后检查总数超过上限就删掉最旧的。void MMALogListBox::AddLog(MMA_LOG_LEVEL level, LPCTSTR text) { SetRedraw(FALSE); int idx AddString(text); if (idx ! LB_ERR idx ! LB_ERRSPACE) { m_levels.SetAtGrow(idx, level); while (GetCount() MAX_LOG_LINES) { DeleteString(0); m_levels.RemoveAt(0); } } SetRedraw(TRUE); InvalidateRect(NULL, FALSE); }逻辑说明SetRedraw(FALSE) 把重绘暂停裁剪和插入都在内存操作里完成最后再恢复重绘。m_levels.RemoveAt(0) 这一句不能省DeleteString 让控件里的条目索引整体前移级别数组也必须前移否则 DrawItem 后面取到的级别全部错位。参数说明MAX_LOG_LINES 我一般设 5000删除操作是 O(n) 的5000 行时开销可以接受。如果日志量到几万行建议改用环形队列或者索引偏移不要靠 RemoveAt(0) 硬删。5.2 按级别过滤不要复制数据另一个值得养成的习惯是过滤功能不加搜索框而是加一个级别掩码。在类里加成员 m_filterMaskDrawItem 判断当前行级别是否在掩码里不在就把文字画成灰色。这样过滤只是绘制层面的选择不复制数据、不动索引日志量再大也不怕。代码上只需要提供一个 SetLevelFilter 入口内部存掩码并重绘。这个技巧比新建临时控件再往里 addString 轻量得多也是我从一次惨痛现场调试里学到的教训。5.3 验证改动是否可靠控件改完之后我会用一张小表做最终验收跑一遍再决定是否接进主线检查项操作预期结果字体生效启动后查看 Debug 行Consolas 等宽显示无裁剪级别颜色分别写入四类日志每种级别颜色独立无串色高频刷新循环 2000 条日志界面无闪烁CPU 不持续高占用跨线程安全工作线程 PostMessage 写日志运行 24 小时无崩溃行数上限观察列表条目数稳定在 MAX_LOG_LINES 左右这套流程走完自定义日志列表框才能从演示级别变成能上现场的控件。那次改完我给自己定了个规矩凡是给 UI 控件加日志功能先问三个问题——字体谁持有、线程谁调用、条目有没有上限。这三个问题不问清楚后面一定会在某个凌晨的现场版本里翻车。希望帮到你。本文还有配套的精品资源点击获取