学 Win32 的人大概都有这么一段经历:前面几章还在折腾消息框、画线、点鼠标,个个都觉得自己会做窗口了;到了第 4 章,突然被一个 SysMets1 糊了一脸——原来前面那些都是热身,滚动条一进来,才算是真正开始做交互程序。
SysMets1 是《Windows 程序设计》里那个经典的"系统指标展示器",全称大概是 System Metrics Display 一号工程。它干的事很简单:把 Windows 的系统指标(屏幕宽高、滚动条宽度、标题栏高度、光标尺寸、图标尺寸这些)一条条列在窗口里,并且同时用垂直滚动条和水平滚动条让用户来回翻看。数据源不需要数据库,不需要网络,完全靠一个 GetSystemMetrics 函数就能拿到;界面不需要花哨控件,两个滚动条加若干 TextOut 就撑起了整个程序。
但越是简单的目标,越能暴露基本功。这个案例把消息处理、字体度量、滚动条三套机制缝在了一起,是典型的"看着不起眼,写起来全是细节"。这篇文章把我自己从读源码、手敲、编译到踩坑的完整过程捋一遍,重点讲清楚滚动条那套消息是怎么转起来的,以及哪些地方教材没明说但实战一定会遇到。
1. SysMets1 解决了什么问题:从"画窗口"跨到"做程序"的分水岭
1.1 为什么第 4 章要用"系统指标"当案例
如果你手边有 Windows SDK 文档,搜 GetSystemMetrics 会看到一个长长列表:SM_CXSCREEN 是屏幕宽度,SM_CYSCREEN 是屏幕高度,SM_CXVSCROLL 是垂直滚动条宽度,SM_CYHSCROLL 是水平滚动条高度,SM_CYCAPTION 是标题栏高度,SM_CXCURSOR 是鼠标光标宽度,SM_CYICON 是图标高度……这些都是操作系统在启动时根据当前显示模式、主题、字体平滑策略等计算好的"系统级参数"。
选择它当综合案例,我认为有三个很现实的原因。
第一,数据来源零依赖。GetSystemMetrics 是 Win32 API 里最"白送"的函数之一,不需要初始化设备、不需要打开文件、不需要网络请求,任何时候调用都会立刻返回结果。这让初学者可以把全部注意力放在界面逻辑上,不会被业务数据干扰。
第二,天然适合滚动展示。系统指标一个屏幕通常装不下,比如屏幕分辨率、滚动条尺寸、边框尺寸、光标尺寸、图标尺寸、窗口框架尺寸、菜单高度、消息框尺寸……Windows 2000 之后又加入了 DPI 相关、触摸相关的指标。数据行数超过窗口可视区域,正好逼着你必须处理滚动条。
第三,结果能即时验证。程序跑起来,上面显示的每一个值,你都可以用系统设置或者 Spy++ 之类的工具对照检查。比如显示"Screen width in pixels: 1920",你一看自己的分辨率确实是 1920,这种"程序里的数字和现实一一对应"的反馈,对建立信心特别有帮助。
SysMets1 把三列信息组织成一张表:第一列是指标常量名,比如 SM_CXSCREEN;第二列是英文描述,比如 "Screen width in pixels";第三列是实际数值,比如 1920。窗口底部没有状态栏,顶部没有工具栏,就是干干净净的滚动区域。
1.2 程序整体行为拆解:双滚动条加三列文本
这个程序最容易被忽略的地方是:它同时有垂直和水平两个滚动条。
垂直滚动条负责按"行"滚动,每滚动一行就是跳一个系统指标的记录。这样 20 多条数据,窗口哪怕只有巴掌大,也能全部翻到。水平滚动条负责按"字符"滚动,因为第三列说明文字很长,如果窗口宽度不够,水平拖动可以看到被截断的部分。
两个滚动条对应两套完全独立的滚动范围:垂直方向的范围是 0 到 NUMLINES - 1(NUMLINES 是数据行数减 1),水平方向的范围是 0 到某个最大字符跨度。这个"一个程序里出现两套滚动范围"的设计,其实是在告诉你:滚动条不是窗口的附属品,它就是一个独立控件,你想让它滚什么、滚多宽,完全由你定义。
我刚开始学的时候,想当然地以为滚动条只能滚"一整屏"或者"一整行"。实际上 Win32 的滚动条消息给了一套更细的粒度:SB_LINEUP / SB_LINEDOWN 是单步滚动,SB_PAGEUP / SB_PAGEDOWN 是整页翻滚,SB_THUMBTRACK 是拖动滚动框时连续触发。这四个维度组合起来,体验上已经非常接近现代 UI。
SysMets1 的窗口过程里,WM_VSCROLL 和 WM_HSCROLL 各写一段 switch,用 GetScrollPos 拿到当前位置,根据滚动请求计算出新位置,再用 SetScrollPos 写回去,最后 InvalidateRect 触发重绘。整个过程没有花哨的技巧,但它是后来所有滚动交互(键盘滚动、鼠标滚轮、触摸滚动)的地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 亲手过一遍核心代码:消息循环之外的三个关键函数
2.1 WM_CREATE 里的字体度量与滚动条初始化
SysMets1 在 WM_CREATE 里做的事情,决定了后面所有绘制坐标怎么算。
首先是取设备上下文,调用 GetTextMetrics 拿到当前字体的度量结构 TEXTMETRIC。这个结构里最关键的是三个字段:
tmAveCharWidth:字体小写字符的平均宽度,赋值给cxChar,用于计算水平坐标。tmHeight + tmExternalLeading:行高,赋值给cyChar,用于垂直方向每行的高度。tmPitchAndFamily & 1:判断字体是否比例字体。如果是比例字体,大写字母宽度和平均宽度不同,需要单独算一个cxCaps = 3 * cxChar / 2;如果是不等宽字体,cxCaps = cxChar。
这里 cxCaps 是个很容易被新手忽略的细节。SysMets1 的第二列是英文描述,第三列是数字,如果直接用 cxChar 去做三列之间的水平偏移,比例字体下大写字母串会跟后面的数字粘在一起。作者用 22 * cxCaps 和 40 * cxChar 这种粗略比例来分栏,目的就是让三列视觉上基本对齐。放到今天看,这个算法并不精致,但在 GDI 纯文本绘制的年代,已经是兼顾简洁和效果的实用方案。
拿到字体度量之后,程序创建了水平滚动条和垂直滚动条。教材里用的是老式 SetScrollRange:
cpp复制SetScrollRange(hwnd, SB_VERT, 0, NUMLINES - 1, FALSE);
SetScrollPos(hwnd, SB_VERT, 0, TRUE);
SetScrollRange(hwnd, SB_HORZ, 0, MAX_WIDTH / cxChar, FALSE);
SetScro
