《多显示器与高 DPI 下滚动速度不一致:缩放、刷新率与方向的取舍》

举报
某不科学的高数 发表于 2026/11/08 08:12:47 2026/11/08
【摘要】 同一套全局滚动逻辑,换到副屏就变快、4K 屏上滚不动、高刷屏上发飘:问题不在滚动本身,而在缩放系数、刷新率与注入节奏没对齐。本文记录我在 FlowScroll 里处理多显示器、高 DPI 与高刷屏的几组取舍:按显示器取缩放、按刷新率定注入间隔、方向反转只做一层,附三处容易踩的坑。

做 FlowScroll 的头两年,我一直以为滚动是个已经解决的问题:监听中键,定时往目标窗口注入滚轮事件,完事。直到用户反馈集中爆发:同一份配置,在主屏上顺滑,在副屏上飞快;1080p 上正好,4K 屏上滚三下才动一格;60Hz 显示器上稳,144Hz 上发飘。问题不在滚动逻辑本身,而在三件事没对齐:缩放系数、刷新率、注入节奏。

第一组取舍:滚动距离按像素算,还是按“行”算

Windows 的滚轮事件本质上是“滚了几行”的抽象:一次 WHEEL_DELTA 是 120,系统再按“每次滚动行数”换算成像素。但这个换算发生在应用内部,不同应用、不同 DPI 下结果不同。

我最早的做法是固定注入量:每 16ms 注入一个固定 delta。1080p、100% 缩放下手感正好;一到 150% 缩放的 4K 屏,同样的 delta 在应用里换算出的像素变少,用户感觉“滚不动”,于是把速度调大,调大后回到 100% 的屏又飞快。

取舍是:滚动速度按“目标显示器”取缩放系数,而不是按全局取一份。每次注入前先判断鼠标当前在哪个显示器上,读该显示器的 DPI 缩放比例,再换算本次注入量。代价是每次注入都要多一次显示器查询——实测这点开销可以忽略,但代码里要处理显示器热插拔:显示器拔掉后缓存的缩放系数失效,必须重新枚举,否则注入量会按一张不存在的屏来算。

更深一层的坑是“按行”还是“按像素”。浏览器、IDE、PDF 阅读器对滚轮 delta 的解释并不一致:有的按行数累积,有的直接按像素。我的做法是只保证注入的 delta 序列本身均匀、节奏稳定,把“滚多远”的最终解释权留给应用。想在所有应用里滚得一样远是不可能的,强行拟合某一款应用只会让其他应用更怪。

第二组取舍:注入间隔跟刷新率走,还是固定 16ms

固定 16ms 间隔在 60Hz 屏上是合理的:约等于一帧一次。但在 144Hz 屏上,16ms 意味着每 2.3 帧才注入一次,滚动看起来一顿一顿;反过来如果改成 7ms,在 60Hz 屏上又会变成一帧注入两次,应用收到的 delta 序列出现毛刺。

取舍是:注入间隔按当前显示器的刷新率动态定。实现上不复杂:取鼠标所在显示器的刷新率,间隔 = 1000 / 刷新率,再留一点余量(我用的做法是间隔略小于一帧,避免和 vsync 撞车造成固定相位差)。这组取舍的代价是 timer 精度:Windows 下普通 timer 的精度约 15.6ms,不做处理根本跑不到 7ms。我的做法是只在滚动进行中使用 timeBeginPeriod(1) 把系统 timer 精度临时提到 1ms,滚动一停就恢复——一直开着 1ms 精度会让整机功耗上升,这是很多小工具悄悄吃电的原因。

这里有个反直觉的结论:注入频率越高不等于越顺滑。超过显示器刷新率的注入,应用要么合并、要么丢弃,多余的事件只增加 CPU 占用。我的上限就是“每帧一次”,再多没有意义。

第三组取舍:方向反转做几层

macOS 用户习惯了“自然滚动”(内容跟着手指走),Windows 用户习惯反方向。跨平台工具一定会遇到“我要反转方向”的需求。坑在于:系统级已经有一层反转,应用级可能还有一层,工具再加一层,三层叠加后方向完全不可预测。

我的取舍是:方向反转只做一层,而且只在工具这一层做。具体做法是读取系统当前的滚动方向设置,工具内部不再假设方向,只做“用户要的方向 = 系统方向取反(如果用户勾了反转)”这一次换算。绝不在注入时再乘一个 -1 去“修正”某个应用的表现——一旦为某个应用开特例,其他应用的方向就乱了。

还有一个细节:触摸板和鼠标滚轮的方向偏好可能是分开的。Windows 下这两者共用一套设置,macOS 下是分开的。跨平台代码里不要假设“反转”是一个全局布尔值,要按输入设备类型分别处理,否则会出现“鼠标方向对了,触摸板反了”的怪现象。

第四组取舍:多显示器切换时的速度跳变

用户把鼠标从主屏(100% 缩放、60Hz)移到副屏(150% 缩放、144Hz),滚动中的参数要不要立刻切换?立刻切换会出现速度跳变:上一帧还按旧参数注入,下一帧突然变快或变慢,手感上是一个明显的“顿一下”。不切换则会在新屏幕上用错参数滚几百毫秒。

我的取舍是:参数切换只发生在两次滚动手势之间,滚动进行中不切换。实现上就是滚动开始时锁定一套参数(显示器、缩放、刷新率),整个手势用同一套,哪怕中途鼠标跨屏。代价是跨屏滚动的前几百毫秒手感略有偏差,但换来的是手势内部绝对稳定——人对“中途变速”比对“整体偏快一点”敏感得多。

三个踩过的坑

第一,不要用鼠标位置缓存显示器。我早期为了省一次 API 调用,把“当前显示器”缓存起来,只在鼠标移动超过阈值时更新。结果是用户用键盘切换焦点后鼠标没动,滚动注入到了错误的显示器参数上。教训:显示器查询便宜,别缓存。

第二,高 DPI 下的坐标换算要用物理像素。SetCursorPos 一类 API 用的是逻辑坐标,而滚轮注入的目标判断有时需要物理像素。混用两者会在 150% 缩放下出现“鼠标在 A 屏,工具以为在 B 屏”的错位。我的做法是内部统一用物理像素,只在最后与系统交互时再换算。

第三,副屏拔掉时的 timer 泄漏。显示器热插拔时如果正在滚动,旧显示器的刷新率参数失效,timer 回调里读到无效值可能抛异常或死循环。我现在的做法是每次 timer 回调开头先校验显示器句柄是否仍有效,无效就地停止本次滚动——宁可断一次滚动,也不要让后台线程卡死。

结论

多显示器、高 DPI、高刷屏这三个变量单独看都不难,难的是它们同时变化。我的四条经验是:滚动距离按目标显示器取缩放;注入间隔按刷新率定、上限每帧一次;方向反转只做一层;参数切换只发生在手势之间。这些取舍没有一条是“最正确”的,都是在“手感稳定”和“实现简单”之间选了前者。

FlowScroll 是一个 Python 写的全局无级滚动工具,把浏览器中键自动滚动带到整个系统,Windows 体验最完整。如果你也在做系统级小工具,希望这几组取舍能帮你少踩几个我踩过的坑。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。