C#从零开始:自己实现窗口滚动截屏Win

发布时间:2026/6/29 23:09:28

C#从零开始:自己实现窗口滚动截屏Win 把滚动截屏放在截屏工具的扩展项里本质上反映的是一件很普通的事现代应用几乎没有一屏装得下的内容。一个长 PDF 几十页一个 GitHub Issue 几百条评论一段没分页的 Word 文档一份产品需求的网页版说明一段微信里被反复刷出来的长文截图——这些内容统统需要滚动条才能看完但当你需要把它完整地交给别人时传统的单屏截屏只能截到你眼睛当前看到的那一块剩下的全部丢失。最朴素的办法是手动一段一段地截、然后到画图板里拼起来——这件事我自己也干过很多次每次都一边截一边数我滚到第几屏了截完才发现第 7 屏和第 8 屏之间少了一行没截到重来。滚动截屏工具要解决的正是这个反复重截与反复对齐的循环让程序自己滚、自己截、自己拼最后只把一张完整的长图交到你手里。这件事在日常里能用到的场景比很多人意识到的要多得多长网页、Wiki、文档站点很多时候目录与正文不在一个滚动条上文件资源管理器里长长的文件列表、压缩包预览VS Code / Visual Studio 里一份长文件、一长串报错、一长串终端输出PDF 阅读器、Word 长文档、Excel 长表微信 / Slack 等 IM 里很长的聊天记录或长文转发的图片任意带滚动条的应用——长 git log、长 commit message、长 stack trace、长邮件、长代码 diff一个滚动截屏工具应该能用在所有能滚的地方——这是设计目标但实际能不能用要看目标应用是否遵循标准的WM_MOUSEWHEEL协议。本文 § 五 会专门讨论这一点。废话不多说直接看效果一、Windows 滚动截屏的基本原理直觉上滚动截屏可以做得很简单开一个循环每一轮做三件事——滚动目标区域、等渲染完成、截屏一次——把每一轮的截图存下来最后拼接成一张长图。这三件事单独看都不复杂但组合起来有三个绕不开的问题问题 1滚动高度不可知。用户在按下快捷键的那一刻并不知道要截的内容到底有多长。可能是 5 屏可能是 50 屏可能是 500 屏。这意味着截屏工具既不能先决定要截多少张再开始也不能假设截到屏幕里没有新东西了——很多应用在内容底部还会有空白、广告位、加载更多按钮这些都会让检测到没有新内容这个朴素条件失效。问题 2怎么命令一个窗口滚一下。屏幕上的内容是被某个应用程序画出来的截屏工具本身不能直接修改应用程序的内部状态。它能做的只有两件事——向窗口发请滚一下的消息或者模拟鼠标滚轮。Windows 上前者的对应物是WM_MOUSEWHEEL消息注意是SendMessage而不是PostMessage后者是SendInput/mouse_event模拟真实滚轮。两条路在用户感知上有明显差别下面会单独展开。问题 3怎么把多张局部截图拼成一张完整长图。每一帧截图都包含与上一帧重复的部分具体多少取决于这次滚动了多少行把这些帧沿垂直方向对齐、去重、合并得到一张高度等于所有帧拼接后总长度的位图——这件事听起来只是沿垂直方向找到重复区间并裁掉但找到重复区间本身是个非平凡的问题肉眼看上去的两行字完全一样对机器来说并不显然要用算法估计两个图像区域在垂直方向上的像素级偏移量。这三个问题对应到代码里就是三个组件滚动控制器ScrollCaptureController、屏幕抓取服务Win32CaptureService上一篇文章里讲过、以及图片拼接器ScrollStitcherScrollPhaseCorrelator。整个流程可以概括为下面这张图┌────────────────────┐ │ Begin 滚动截屏 │ │ - 截图首帧 (tile#0)│ │ - 显示滚动提示 │ └────────┬───────────┘ │ loop: │ ┌── SimulateWheelAtCenter() (SendMessage WM_MOUSEWHEEL) │ ├── Task.Delay(200ms) (等目标窗口渲染) │ └── CaptureScreenRect() (抓 tile#i) ▼ ┌────────────────────┐ │ StopCapturing │ │ - 用户按 Esc/Enter│ │ - 一次性拼接所有tile│ │ ScrollStitcher.Stitch(tiles)│ └────────┬───────────┘ │ ▼ WriteableBitmap (长图)整个循环里最关键的一步是SimulateWheelAtCenter——往选区中心对应的窗口投递WM_MOUSEWHEEL让那个窗口自己处理滚动而不是我们去操作它的滚动条。但这里有一个常被忽略的限制wParam 携带滚轮 delta 是没问题的lParam 携带的x, y坐标在多数现代应用的WM_MOUSEWHEEL处理器里会被忽略——这些处理器通常会调GetCursorPos()取真实光标位置来决定滚动的目标滚动容器。所以代码里把选区中心塞进 lParam 实际上是个尽力而为的提示最终 wheel 事件落在哪里、滚动哪个容器仍然由当前光标位置决定。这一点的工程后果是用户必须把鼠标光标放在选区内否则 wheel 不会触发目标窗口的滚动。二、技术路线的几条岔路把上面的骨架搭起来之后工程上有几个绕不开的选择题。每一个选择都影响工具能不能用、容不容易用、是否能在所有应用上工作。2.1 滚动事件的投递方式SendMessage vs SendInput让一个窗口滚一下至少有三条路直接PostMessage(target, WM_MOUSEWHEEL, ...)、用SendInput模拟真实鼠标滚轮、或者用旧的mouse_eventAPI。三者在用户体验上有非常明显的差异。SendInput走的是全局输入合成路径它把滚轮事件塞进系统输入队列Windows 把它当成一次真实输入派发到当前光标所在窗口。这种方式对所有应用都开箱即用——因为它就是模拟了一只真的滚轮。但它的副作用是要求光标在那个窗口的可滚动区域上。如果用户开始截屏时光标放在屏幕左上角目标是右下角的 Chrome 窗口合成出来的滚轮事件根本不会派发到 Chrome——它会派发到左上角那个无关窗口结果就是 Chrome 不动截屏工具截到一堆没滚动的帧。SendMessage(target, WM_MOUSEWHEEL, ...)走的是同步阻塞投递到指定窗口路径消息绕过输入队列、直接塞进目标窗口的消息泵、调用返回时窗口已经处理完。真正可控的部分是消息送给谁和消息什么时候被处理完不是消息最终滚动哪里——后者由目标窗口的WM_MOUSEWHEEL处理器决定传统 Win32 控件ListView、TreeView通常信任 lParam 里的坐标但 Chromium / Electron / VS 这类现代容器普遍忽略 lParam、调GetCursorPos()取真实光标位置。本项目选SendMessage而不是SendInput的真实理由是前两件事1同步——下一帧截图拿到的就是滚动后的稳定画面不需要额外的等渲染开销2目标 HWND 已知——WindowFromPointGetAncestorChildWindowFromPoint把消息明确送给最深子窗口调试时能在日志里直接看到target0xNNNN pt(x,y)。至于绕过光标位置这个常被宣传成 SendMessage 优势的特性——它在多数目标应用上并不成立所以实际使用时还是必须把光标放在选区里。本项目的最终选择是SendMessage同步投递 WindowFromPointChildWindowFromPoint解析目标窗口。SendMessage是阻塞调用函数返回时目标窗口已经处理完这条消息——这意味着下一帧截图拿到的就是滚动后的稳定画面不需要额外的等渲染开销。代码层面是这样IntPtr target ResolveScrollTargetAt(pt, skipOverlayHwnd); IntPtr wParam unchecked((IntPtr)((delta 16) 0xFFFFFFFF)); IntPtr lParam (IntPtr)(((physicalY 0xFFFF) 16) | (physicalX 0xFFFF)); Win32.SendMessage(target, Win32.WM_MOUSEWHEEL, wParam, lParam);WindowFromPoint拿到屏幕坐标下的顶层窗口句柄再GetAncestor(GA_ROOT)走到顶层根窗口然后ChildWindowFromPoint循环 32 次走到最深子窗口——这一套解析出来的 target HWND 主要用于SendMessage 的派发目标和调试日志对实际滚动的是否触发 / 滚到哪里几乎不构成影响这两件事最终由光标位置和处理器实现决定。2.2 截屏和滚动之间的遮挡问题挖洞WM_MOUSEWHEEL已经发出去了但此时屏幕上还盖着我们自己的截屏 overlay——选区工具栏、滚动提示、调试信息。这个 overlay 是Window类型的DC 在最上层如果不处理BitBlt 抓到的就是 overlay 自己看到的是内容被截屏工具挡住的画面。解决办法是挖洞用SetWindowRgn把 overlay 的可见区域限制成选区外 工具栏——也就是把选区内部那块从 overlay 的可视区里挖掉让 BitBlt 在那一块直接读到下层窗口的活像素。这件事的巧妙之处在于 overlay 的逻辑位置和物理像素在 Win32 层是一致的overlay 的 HRGN 是一个物理像素矩形BitBlt 抓到的就是那块物理像素上的真实内容。挖洞的代码大致是这样// 选区 rect物理像素 IntPtr hole Win32.CreateRectRgn(selLeft, selTop, selRight, selBottom); // 整个 overlay 减掉选区洞 只剩选区外可见 IntPtr visible Win32.CreateRectRgn(0, 0, w, h); Win32.CombineRgn(visible, visible, hole, Win32.RGN_DIFF); Win32.SetWindowRgn(overlayHwnd, visible, true); Win32.DeleteObject(hole);挖洞之后还有一个细节选区内部对鼠标事件是穿透的不是挡住的。SetWindowRgn把 HWND 的可见区域和命中区域同时设成除了洞以外的部分——落在洞里的鼠标坐标在 OS 命中测试层面就属于没有命中 overlay命中的是洞后面的真实桌面窗口而不是 overlay 本身。但要注意OS 命中测试穿透只解决命中到谁不解决wheel 触发哪个滚动容器——后者仍然由光标位置下的目标窗口的WM_MOUSEWHEEL处理器决定挖洞本身不能把 wheel 派给特定区域。挖洞同时解决了BitBlt 抓到的内容是真实的和洞内鼠标事件能命中到下层真实窗口两件事本项目在 Avalonia 层还做了一层冗余——把_dimPath/_inputCatcher/_annoCanvas这些子控件的IsHitTestVisible设为false——这是为了防止某天 SetWindowRgn 因为 DPI 变化等原因失效时仍有兜底。2.3 滚动速率与截图间隔的取舍ScrollCaptureController的默认间隔是 200ms构造函数默认值可被调用方覆盖。太短会怎么样目标窗口的渲染管线还没把滚动后的内容提交到 GDI 表面BitBlt 抓到的是滚动中的半帧——这种帧会与上一帧产生半步滚动的偏移相位相关算出来的 dh 是个非整数倍的小数验收时被识别为不可信而丢弃结果就是 tile 数量看起来上去了但拼接时长图缺一大段。太长会怎么样滚动量累积目标窗口可能已经响应了多次加载更多或懒加载占位每一帧与上一帧的重叠区变小相位相关在重叠区太短时算不出可靠偏移——典型表现是 200ms 时一张长网页能拼出 30 屏、800ms 时同样的网页只能拼出 12 屏很多 tile 因为重叠太少被丢弃。200ms 是ScrollCaptureController构造函数的默认值在 Win32 / WPF / Chromium 内核的几个常见应用VS Code、资源管理器、Edge、Chrome上跑下来没遇到明显问题。少数渲染节奏特殊的应用高刷新率视频、懒加载型瀑布流可以由调用方把scrollIntervalMs调高或调低 50ms 一档本项目当前没有自适应逻辑所有截图都走固定间隔。三、图片拼接从相邻帧估算纵向偏移抓完所有 tile 之后列表里是一组高度等于屏幕高度、内容部分重叠的位图。要把它们拼成一张完整长图核心问题是对相邻两张 tile找到它们的纵向偏移量 dh使得 prev[0..H-dh) 与 next[dh..H) 像素级一致。这是一个经典的图像配准问题。3.1 为什么朴素拼接会失败最朴素的拼接是直接把每张 tile 堆叠起来——上一张 tile 的底部接到下一张 tile 的顶部中间不裁切。这种做法在每张 tile 之间有完全重复的内容比如两次截图之间什么都没变时会直接产生重复在两张 tile 之间没有重复时则会丢失中间内容。两种情况都不对。稍微聪明一点的朴素做法是取上一张 tile 的下半部分接到下一张 tile 的上半部分——固定裁掉 50% 顶部 50% 底部。这种做法在每次滚动距离恒定时凑合能用但只要目标应用的滚动步长变化很多应用是按行滚动或按页滚动不同行高 / 不同字号下步长都不一样拼接出来的图就会有错位或缺漏。根本问题在于滚动距离不是常量。它由目标应用一行多高 用户上次滚到哪儿 字体缩放设置 主题样式等很多因素共同决定。截屏工具既不能控制目标应用也不能预知这些设置——它只能从两张相邻 tile 的像素内容里反推出它们之间差了多少像素。3.2 用 FFT 相位相关估计 dhScrollPhaseCorrelator做的就是这件事。给定 prev 和 next 两张 tile输出它们在垂直方向上的像素偏移量 dh。核心算法是二维相位相关Phase Correlation——一种基于傅里叶变换的图像配准方法。直觉上相位相关利用的是傅里叶变换的一个性质两幅图像之间的平移在频域里表现为相位差而非幅度差。具体来说如果next(x, y) prev(x, y - dh)即 next 是 prev 向下平移 dh 像素那么根据傅里叶变换的平移定理F{next}(u, v) F{prev}(u, v) · exp(-2πi · v · dh / N)也就是两者的幅度谱相同只有相位差了一个exp(-2πi · v · dh / N)项。把这个相位差抵消掉

相关新闻