简介:这份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]; CArray<MMA_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 分配的 ID,DDX_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_cast<CString*>(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_filterMask,DrawItem 判断当前行级别是否在掩码里,不在就把文字画成灰色。这样过滤只是绘制层面的选择,不复制数据、不动索引,日志量再大也不怕。
代码上只需要提供一个 SetLevelFilter 入口,内部存掩码并重绘。这个技巧比新建临时控件再往里 addString 轻量得多,也是我从一次惨痛现场调试里学到的教训。
5.3 验证改动是否可靠
控件改完之后,我会用一张小表做最终验收,跑一遍再决定是否接进主线:
| 检查项 | 操作 | 预期结果 |
|---|---|---|
| 字体生效 | 启动后查看 Debug 行 | Consolas 等宽显示,无裁剪 |
| 级别颜色 | 分别写入四类日志 | 每种级别颜色独立,无串色 |
| 高频刷新 | 循环 2000 条日志 | 界面无闪烁,CPU 不持续高占用 |
| 跨线程安全 | 工作线程 PostMessage 写日志 | 运行 24 小时无崩溃 |
| 行数上限 | 观察列表条目数 | 稳定在 MAX_LOG_LINES 左右 |
这套流程走完,自定义日志列表框才能从演示级别变成能上现场的控件。那次改完,我给自己定了个规矩:凡是给 UI 控件加日志功能,先问三个问题——字体谁持有、线程谁调用、条目有没有上限。这三个问题不问清楚,后面一定会在某个凌晨的现场版本里翻车。希望帮到你。
本文还有配套的精品资源,点击获取