十几年前我第一次翻到《Windows程序设计》第4章,看到SysMets3这个示例时,心里想的其实是:这不就是把系统度量信息打印到窗口里再挂个滚动条吗?有什么好讲的。后来自己动手写Win32小工具,真到了“内容超出客户区、滚动条不跟手、拖拽时画面乱闪”这一步,才意识到SysMets3几乎是学习Win32滚动机制最短的一条路径。它把窗口客户区、文本度量、滚动条状态、局部重绘这几件最容易绕晕的事全部串在了一起,而且代码量刚刚好——多一行嫌啰嗦,少一行又讲不清。
简单说,SysMets3是一个通过GetSystemMetrics读取屏幕尺寸、滚动条大小、图标尺寸、光标尺寸等系统度量值,并把它们以三列表格形式显示在可滚动窗口里的Win32示例程序。它适合两类人:一是刚开始学Win32,想在消息循环和窗口过程之外再往前迈一步的开发者;二是写过几年界面但始终没深究过滚动条内部逻辑的人。这篇文章我会按数据来源、滚动条设计、消息换算和绘制四个层面把程序拆开讲,最后聊几个我实测中踩过的坑。这篇先讲前两层:数据怎么来,滚动条的范围和页面大小为什么要那样算。
1. 为什么是“系统度量”:先搞清楚SysMets3这串数据从哪来
SysMets系列程序的核心数据全部来自一个函数:GetSystemMetrics。这个函数是Win32里非常特别的一个接口,它不接收窗口句柄,不接收设备上下文,只接收一个索引值,然后返回一个整数。这个整数代表的是什么,完全取决于你传进来的索引。
1.1 GetSystemMetrics索引表:一行数据背后是一次系统查询
SysMets3的代码里维护了一个静态表,表的每一项包含三个字段:索引进来的整数值、标签名、描述文本。伪结构大概是这样的:
struct { int iIndex; TCHAR *szLabel; TCHAR *szDesc; } sysmetrics[] = { SM_CXSCREEN, TEXT("SM_CXSCREEN"), TEXT("Screen width in pixels"), SM_CYSCREEN, TEXT("SM_CYSCREEN"), TEXT("Screen height in pixels"), SM_CXVSCROLL, TEXT("SM_CXVSCROLL"), TEXT("Vertical scroll width"), SM_CYHSCROLL, TEXT("SM_CYHSCROLL"), TEXT("Horizontal scroll height"), SM_CYCAPTION, TEXT("SM_CYCAPTION"), TEXT("Caption bar height"), SM_CXBORDER, TEXT("SM_CXBORDER"), TEXT("Window border width"), SM_CYBORDER, TEXT("SM_CYBORDER"), TEXT("Window border height"), // ... 后面还有几十项 };程序在绘制时遍历这张表,对每一项调用GetSystemMetrics(sysmetrics[i].iIndex)拿到实际的像素值,再把标签、描述、数值三列文本画到窗口里。
这里有一个值得注意的点:这张表里的大部分索引值,在现代Windows上依然有效,但返回值的含义可能比你想象的更动态。比如多显示器环境下,SM_CXSCREEN只返回主屏幕的宽度,而不是所有显示器的总宽度;在Per-Monitor DPI感知模式下,返回值还会随所在显示器的DPI变化。SysMets3是几十年前的教学程序,设计时根本没有考虑这些,但不妨碍我们现在拿它来理解Win32的基础机制。
1.2 行、列和单位:显示层的信息组织
SysMets3显示的是一个二维信息块:每一行是一个系统度量条目,每一列是这条度量的一个属性。垂直方向按行组织,水平方向按列组织。这个设计的妙处在于,它天然把你引向滚动条的两根轴:垂直滚动条管行方向,水平滚动条管列方向。
理解这一点很重要,因为很多人在看SysMets3时只盯着垂直滚动条,觉得水平滚动条是凑数的。但实际上,水平滚动条在这个程序里同样有完整的处理逻辑,而且它的滚动单位不是像素,而是“字符宽度”。这个我们到第5章细说。
单位问题也想提前强调一下:GetSystemMetrics返回的值全部以像素为单位。SM_CXSCREEN返回的是屏幕宽度像素,SM_CXVSCROLL返回的是垂直滚动条宽度像素。这些值本身没有“逻辑单位”或者“DIP”的概念。在传统GDI程序中,这点无所谓,因为窗口坐标本身也是像素;但如果你的程序开启了DPI感知,或者在高DPI显示器上运行,就需要额外换算。SysMets3没做这些,所以我后面会单独讲它在高DPI下的表现。
2. SysMets3 之前的两次进化:键盘滚动怎么就不够用了
SysMets在Petzold的书里是一步步演进的。你不是一上来就看到带滚动条的版本,而是先看到一个什么都显示不出来、再看到一个能用键盘翻页但没有任何视觉反馈的版本。理解这条演进路线,才能理解SysMets3的滚动条设计为什么是长成那个样子的。
2.1 SysMets1的静态展示:窗口盖不住数据
SysMets1干的事情很简单:在WM_PAINT里循环遍历sysmetrics数组,把每一行用TextOut画出来。窗口有多大,就画多少行;超出窗口底部的行,直接被裁剪掉,用户永远看不到。
这种做法的根本问题是:它把“数据总量”和“可视区域”混为一谈。数据有几十行,可视区域只有几十个像素高,没有中间层来协调两者。任何需要展示超过一屏数据的Win32程序,迟早都会撞上这个问题。
当时我的第一个反应是:那我把窗口拉大不就行了?问题是,用户屏幕就那么大,而且数据量是不固定的,你不可能让窗口高度永远匹配数据长度。SysMets1教会我们的其实是:客户区是一个有限的视口,数据流必须通过某种机制在这个视口里滑动。
2.2 SysMets2的键盘滚动:能用但用户看不到“进度”
SysMets2的改进思路很朴素:既然窗口不够大,那就给一个“当前显示到第几行”的游标,让用户用上下方向键和PageUp/PageDown键去移动这个游标,然后重绘整个窗口。
代码核心是在窗口过程里拦截WM_KEYDOWN,根据按键类型修改一个静态变量iVscrollPos,然后调用InvalidateRect触发全量重绘。这种方式在逻辑上是通的,而且你不需要碰任何滚动条API,只要维护一个整数就行了。
但它有两个硬伤:
- 用户看不到任何关于“当前位置”和“总长度”的视觉指示。你不知道下面还有多少行,也不知道自己是不是已经滚到末尾了。光标在文本里移动还有个光标位置,窗口滚动却什么提示都没有。
- 键盘操作路径太单一。你不能用鼠标直接拖动到任意位置,只能一下一下地翻,遇到数据量大、要跳到中间位置时,效率非常低。
SysMets3引入滚动条,本质上不是要解决“如何滚动”,而是要解决“如何让用户直观地控制滚动、感知位置”。滚动条在这里扮演的是一个双向交互控件:它展示当前视口在整个数据范围中的位置,同时又把用户的拖动、点击动作转换成滚动请求。
2.3 滚动条带来的“状态坐标”:iVscrollPos 与 iHscrollPos
SysMets3在窗口过程里新增了两个静态变量:
static int iVscrollPos, iHscrollPos;这两个变量分别保存垂直和水平滚动的“当前位置”,本质上是滚动单位的计数值。注意,它不是像素坐标,而是“滚动单位”。垂直方向一个单位对应一行文本,水平方向一个单位对应一个大写字母的宽度。这个概念很多人会在这个位置混一下,后面我会专门展开。
有了这两个坐标,WM_PAINT里就能算出每一行此刻应该画在客户区的哪个位置。滚动条的作用,就是通过消息循环不断修改这两个坐标值,从而让整个数据流在视口里滑动。
3. 滚动条控制的核心:范围、页面、位置三件套
Windows的滚动条不是“拉一下动一下”那么简单,它内部维护了一组状态,这组状态被封装在SCROLLINFO结构体里。SysMets3的滚动条代码,几乎就是在跟这个结构体打交道。
3.1 SCROLLINFO 与 SetScrollInfo/GetScrollInfo
SCROLLINFO结构是这样的:
typedef struct tagSCROLLINFO { UINT cbSize; UINT fMask; int nMin; int nMax; UINT nPage; int nPos; int nTrackPos; } SCROLLINFO;每个字段的含义:
- cbSize:结构体大小,必须初始化为sizeof(SCROLLINFO)
- fMask:指定你要读写哪些字段
- nMin/nMax:滚动范围的最小值和最大值
- nPage:页面大小,以滚动单位计
- nPos:当前滑块位置
- nTrackPos:拖动滑块过程中的临时位置,由系统填充
SysMets3里最常出现的操作模式是:
SCROLLINFO si; si.cbSize = sizeof(si); si.fMask = SIF_ALL; GetScrollInfo(hwnd, SB_VERT, &si);接着根据用户操作修改si.nPos,再SetScrollInfo写回去。这套“先Get、再改、再Set”的循环,就是滚动条编程的基本盘。
3.2 WM_SIZE里算页面大小:cyClient / cyChar 的含义
窗口大小变化时,滚动条的状态必须同步更新。SysMets3在WM_SIZE中重新计算了两个滚动条的范围和页面大小:
si.fMask = SIF_RANGE | SIF_PAGE; si.nMin = 0; si.nMax = NUMLINES - 1; si.nPage = cyClient / cyChar; SetScrollInfo(hwnd, SB_VERT, &si, TRUE);垂直方向:nMin固定为0,nMax是NUMLINES - 1,代表从第0行到最后一行。nPage是cyClient除以cyChar的结果。这里的cyClient是窗口客户区当前高度,cyChar是当前字体的行高。相除得到的就是“当前客户区一屏能显示多少行文本”。
举个例子:假设客户区高度是660像素,当前字体行高是22像素,那么nPage就是30。如果总共有50行数据,用户看到的效果就是:滚动条滑块高度大约占整个滚动轨道长度的30/50,也就是五分之三左右。这个比例正好反映了一屏内容占总内容的份额,用户一眼就能看出还有多少数据。
水平方向的算法类似,但用的是cxCaps(大写字母宽度)而不是cxChar,因为水平滚动的单位是“列宽”而不是“字符行高”。这个细节到第5章再展开。
3.3 fMask不是随便填的:SIF_ALL、SIF_POS的坑
很多初学SysMets3的人会在fMask上栽跟头。比如在WM_VSCROLL里,你GetScrollInfo的时候用了SIF_ALL,拿到了全部字段;但SetScrollInfo的时候,如果你把fMask也设为SIF_ALL,Windows会同时写入nMin、nMax、nPage、nPos。问题在于,你从GetScrollInfo拿回来的nMin和nMax是当前的range,写回去没关系;但如果你在考虑滚动范围时动态调整了nMax,就必须记得把SIF_RANGE也加进fMask,否则SetScrollInfo根本不会更新range。
SysMets3在WM_SIZE里设置范围时fMask用的是SIF_RANGE | SIF_PAGE,在WM_VSCROLL里回写位置时fMask用的是SIF_POS。这是两种不同的模式:前者告诉Windows“我要改范围和页面大小”,后者告诉Windows“我只改当前位置”。混用或漏用,滚动条不会报错,但行为会很诡异——比如滑块明明在中间,一拖动就跳回顶部。
我见过有人为了省事,在所有SetScrollInfo调用里都用SIF_ALL,结果窗口大小调整后,滚动范围被错误地重置,或者nPage被覆盖成0。SysMets3的写法虽然看起来啰嗦,但每一步都只改该改的东西,这是值得学习的严谨态度。
4. WM_VSCROLL 消息:从滚动框动作到像素位移的完整换算
滚动条最终要生效,必须通过WM_VSCROLL和WM_HSCROLL消息告诉窗口过程“用户想要做什么”。SysMets3对这两个消息的处理,是整个程序最精彩的部分。
4.1 各种滚动请求的语义:LINE、PAGE、THUMB
wParam的低16位携带的是滚动请求类型。SysMets3需要处理的包括:
- SB_LINEUP / SB_LINEDOWN:点击滚动条两端的箭头,一次滚动一行
- SB_PAGEUP / SB_PAGEDOWN:点击滚动条空白的轨道区域,一次滚动一页
- SB_THUMBPOSITION / SB_THUMBTRACK:用户拖动滑块,nTrackPos字段给出目标位置
代码的处理逻辑是:先从GetScrollInfo拿到当前状态,保存一份旧位置,然后根据请求类型修改si.nPos:
switch (LOWORD(wParam)) { case SB_LINEUP: si.nPos -= 1; break; case SB_LINEDOWN: si.nPos += 1; break; case SB_PAGEUP: si.nPos -= si.nPage; break; case SB_PAGEDOWN: si.nPos += si.nPage; break; case SB_THUMBPOSITION: si.nPos = si.nTrackPos; break; }这里有个细节:SB_THUMBPOSITION和SB_THUMBTRACK时,直接使用si.nTrackPos,而不是自己计算偏移量。因为拖动的目标位置是任意的,你不可能用“加减几行”来推算,系统已经告诉你滑块现在在哪了,你直接采纳就行。
4.2 为什么改完位置后还要再GetScrollInfo一次
改完si.nPos之后,SysMets3会做一次SetScrollInfo,然后立刻再GetScrollInfo一次:
si.fMask = SIF_POS; SetScrollInfo(hwnd, SB_VERT, &si, TRUE); GetScrollInfo(hwnd, SB_VERT, &si);为什么多此一举?因为Windows会在SetScrollInfo内部对nPos做一次边界钳制。如果你把位置设成了超出nMin/nMax范围的值,Windows并不会报错,而是悄悄把位置修正回合法范围。所以Set后再Get一次,拿回来的才是Windows真正接受的最终位置。
这个“Set后再Get”的模式,在几乎所有需要精确同步滚动状态的代码里都能看到。你可以把它理解成:不要相信你提交给系统的值,要相信系统最终采纳的值。
拿到最终位置后,和之前保存的旧位置比较:
if (si.nPos != iVscrollPos) { ScrollWindow(hwnd, 0, cyChar * (iVscrollPos - si.nPos), NULL, NULL); UpdateWindow(hwnd); }如果位置没变,就什么都不做;位置变了,才需要移动窗口内容并触发重绘。这个判断能省去大量无意义的绘制调用,尤其是你拖动滑块时,系统会连续发来很多次WM_VSCROLL消息,没有这个判断,窗口会疯狂重绘。
4.3 ScrollWindow与UpdateWindow:只重绘该重绘的
SysMets3移动窗口内容用的是ScrollWindow函数,而不是InvalidateRect全量重绘。两者的区别非常关键。
ScrollWindow做的事情是:把客户区中已经存在的像素内容,整体平移一段距离。它接受四个参数,其中dx和dy分别表示水平和垂直方向的移动量。在我们的场景里,dy的计算公式是:
cyChar * (iVscrollPos - si.nPos)当用户向下滚动时,新位置si.nPos比旧位置iVscrollPos大,差值为正,iVscrollPos - si.nPos为负,乘上cyChar后dy为负。负的dy意味着窗口内容向上移动,这正好符合“向下滚动,看到下面内容”的直觉。
ScrollWindow执行完之后,窗口里有一部分区域是“空的”——就是刚才内容移走后露出来的那块区域。这块区域会被系统标记为无效,随后调用UpdateWindow会立刻派发WM_PAINT,BeginPaint拿到的无效区域就是这一小块。相比之下,InvalidateRect会标记整个客户区无效,导致所有行重新绘制一遍。
文本行数少的时候,全量重绘的性能差异感受不出来;但如果数据量上千行,或者每行都有复杂背景色,ScrollWindow的局部重绘优势就非常明显。SysMets3在这里选ScrollWindow,不是炫技,而是告诉你正确做法就应该这样。
4.4 边界条件:滑块拉过头怎么办
滚动位置不是无限增大的。用户可以把滑块拖到很激进的位置,但SetScrollInfo的边界钳制会在最终位置生效。这就是为什么前面强调Set后再Get。
另外,WM_VSCROLL里对SB_THUMBTRACK的处理也值得多说一句。有些教程代码会忽略SB_THUMBTRACK,只处理SB_THUMBPOSITION,结果用户拖动滑块时,内容要等松手才更新,体验很差。SysMets3的写法是把这两种消息都归到同一套处理逻辑里,这样拖动过程中内容会实时跟随,手感顺滑。如果你想让滑块在拖动时内容持续滚动,就必须处理SB_THUMBTRACK;如果你只关心最终落点,SB_THUMBPOSITION就够了。这个取舍取决于产品需求,不是万能的。
5. WM_PAINT里如何把滚动位置变成画布坐标
滚动条状态维护得再好,最后也要落到WM_PAINT里把内容画出来。SysMets3的绘制逻辑其实很简单,但里面有几个换算关系必须理解清楚。
5.1 字符宽度/高度与行索引的换算公式
WM_CREATE里,程序通过GetTextMetrics拿到了三个关键值:
cxChar = tm.tmAveCharWidth; // 平均字符宽度 cxCaps = (tm.tmPitchAndFamily & 1 ? 3 * cxChar / 2 : cxChar); // 大写字符宽度估算 cyChar = tm.tmHeight + tm.tmExternalLeading; // 完整行高cyChar不是字体的高度,而是行高——包括字符本身高度加上外部行距。这个值决定了相邻两行文本之间的垂直间距,滚动条页面大小和垂直滚动位移都基于它。
绘制循环里,每一行文本的纵坐标是:
y = cyChar * (i - iVscrollPos);i是数据行索引,iVscrollPos是滚动位置。iVscrollPos等于0时,第0行画在最顶端;iVscrollPos等于1时,第0行在顶端的上一行,相当于被滚出了客户区,从第1行开始显示。
横坐标是:
x = cxCaps * (1 - iHscrollPos);这里用cxCaps而不是cxChar,因为水平滚动的单位是“大写字母宽度”。你可能会问:为什么是cxCaps?因为表格里的标签和描述大多是英文,英文大写字母宽度接近等宽,用它作为列宽单位比平均字符宽度更稳定。SysMets3的这个细节,很多人压根没注意到,但它恰恰体现了文本度量的灵活性。
5.2 三列布局:label、desc、value的对齐逻辑
SysMets3的输出是典型的三列结构。第一列是标签名,比如SM_CXSCREEN;第二列是描述文本,比如“Screen width in pixels”;第三列是数值,右对齐显示。
对应代码大致是这样:
TextOut(hdc, x, y, sysmetrics[i].szLabel, lstrlen(sysmetrics[i].szLabel)); TextOut(hdc, x + 22 * cxCaps, y, sysmetrics[i].szDesc, lstrlen(sysmetrics[i].szDesc)); SetTextAlign(hdc, TA_RIGHT | TA_TOP); TextOut(hdc, x + 62 * cxCaps, y, szBuffer, wsprintf(szBuffer, TEXT("%5d"), value)); SetTextAlign(hdc, TA_LEFT | TA_TOP);注意SetTextAlign的用法。画数值前把对齐方式改成右对齐,TextOut的x坐标就是右边界,这样不管数值是几位数,都整齐地靠右对齐。画完再恢复左对齐,以免影响下一行绘制。
22个cxCaps和40个cxCaps的间隔是经验值:22个字符宽度是给标签列留的,40个字符宽度是给描述列留的。这不是精确计算出来的,而是作者测出来的最佳目测值。如果你自己写表格类程序,这组数字可以根据实际内容调整。
5.3 一个隐藏的简化:绘制循环并不精确裁剪
细看WM_PAINT会发现,SysMets3的for循环遍历了所有NUMLINES行,不管这一行是否真的落在客户区可见范围内。它没有判断“首行可见的行号”和“末行可见的行号”,而是把所有行都画一遍,超出窗口的部分交给GDI裁剪掉。
这种做法的正确性毋庸置疑,但对极大数据量的程序来说,效率会出问题。SysMets3作为教学程序,数据量只有几十行,全量绘制毫无压力。了解这一点之后,你在实际项目中最好不要照搬——数据量上千行时,应该根据iVscrollPos和客户区高度算出可见范围,只绘制这一范围内的行,也就是俗称的“视口裁剪”。
一个简单的示意:
int firstLine = iVscrollPos; int lastLine = firstLine + cyClient / cyChar + 1; for (i = firstLine; i < lastLine && i < NUMLINES; i++) { // 只绘制可见行 }这段代码不会出现在SysMets3里,但它是我从SysMets3理解滚动机制后,在真实项目中应用的改进。同样的思路,往后延伸到ListView、TreeView的自绘逻辑时,会非常有用。
6. 跑通SysMets3之后:我实测中碰到的几个坑
代码逻辑讲完了,最后分享几个我自己在复现和改造SysMets3时踩过的坑。这些问题在原书示例代码里不一定触发,但在真实环境里几乎一定会遇到。
6.1 滚动条闪烁:SetScrollInfo的fRedraw参数
SysMets3里SetScrollInfo的最后一个参数传的是TRUE,意思是要Windows立刻重画滚动条。在多数场景下这没有问题,但如果你在WM_VSCROLL里高频更新滚动条状态,同时窗口内容也在滚动,滚动条区域可能会出现闪烁。
我的经验是:如果内容滚动是连续动画式的(比如按住滑块持续拖动),可以先把fRedraw设为FALSE,等内容滚动完成后再统一调用一次SetScrollInfo并传入TRUE。这样能把多次重绘合并成一次,减少闪烁。SysMets3没有这一步是因为它的滚动是离散的,每次只滚一行或一页,闪烁不明显。
6.2 DPI缩放:nPage计算在高分屏上翻车
SysMets3在100%缩放下一切正常,但到了150%或200%缩放的显示器上,问题就来了。原因是GetSystemMetrics返回的是物理像素值,而GetTextMetrics返回的是逻辑坐标下的字体尺寸。如果你的程序没有声明DPI感知,Windows会默认做DPI虚拟化,给你一套拉伸后的坐标系;如果声明了Per-Monitor DPI Aware,字体渲染尺寸和屏幕度量值就可能出现错位,导致nPage算出来的“一屏行数”和实际能显示的行数不一致。
我当时在2K分辨率、150%缩放下调试,发现滚动条滚到最底部时,最后几行死活显示不出来。排查了半天,发现就是nMax和nPage的计算方式没有考虑DPI差异。解决方案不是改SysMets3,而是在创建窗口前调用SetProcessDpiAwarenessContext,然后按实际的物理像素重新计算字符尺寸。这个坑现在越来越常见,建议所有写Win32界面的人都留意。
6.3 水平滚动条不是装饰:长标签会让布局露馅
SysMets3的标签和描述文本长度是固定的,最长的也就是“SM_CXICONSPACING”这种级别,22个字符宽度基本能放下。但如果你把文本换成中文,或者加了更长的描述,水平滚动条会突然变得非常有用。
问题在于,SysMets3的水平滚动范围nMax用的是NUMLINES / 2 - 1,这是一个拍脑袋的估算值,不是根据实际内容宽度算出来的。真正规范的做法是根据最长文本的像素宽度、加上所有列间隔,换算出总共需要的列宽,再减去客户区宽度,得到水平滚动的最大位置。SysMets3没有这么做,因为它的内容长度是固定的;你在实际项目中如果直接把它的水平滚动逻辑抄过去,很容易出现滚动条到头了内容还没显示完的尴尬情况。
6.4 窗口高度特别小时:nPage可能变成0
最后一个坑比较隐蔽。当用户把窗口拖得很矮,矮到cyClient比cyChar还小的时候,cyClient / cyChar的整除结果是0。此时如果直接设SetScrollInfo,Windows会忽略nPage为0的请求,或者产生未定义行为。
我遇到的情况是:窗口极矮时,滚动条滑块消失,滚动行为变得非常诡异。解决办法是给nPage加一个下限保护:
si.nPage = max(1, cyClient / cyChar);同样,nMax也不建议直接用NUMLINES - 1。更精确的做法是让nMax等于NUMLINES - nPage,保证最后一页能稳稳地落在客户区底部。SysMets3为了简化概念,没有做这一步,但真实代码里边界情况是不可回避的。
写到这里,SysMets3的数据来源、滚动条状态管理和绘制换算基本讲清楚了。这套滚动机制从Win16时代一路传到今天,UI框架换了一轮又一轮,内核的逻辑依然是“范围、页面、位置”三件套加“消息换算、窗口平移、局部重绘”三步走。你把这一个程序吃透,后面再看任何自绘控件和滚动优化,都会觉得似曾相识。下一篇如果继续,我会重点聊SysMets4加入鼠标滚轮之后,消息处理和滚动体验又发生了什么变化。