首页 > 基础资料 博客日记

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

2026-06-11 15:00:02基础资料围观1

文章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 协议。本文 § 五 会专门讨论这一点。

废话不多说,直接看效果
VSSNAP

DSWEBSNAP

FILESSNAP

GITSNAP

一、Windows 滚动截屏的基本原理

直觉上,"滚动截屏"可以做得很简单:开一个循环,每一轮做三件事——"滚动目标区域"、"等渲染完成"、"截屏一次"——把每一轮的截图存下来,最后拼接成一张长图。这三件事单独看都不复杂,但组合起来有三个绕不开的问题:

问题 1:滚动高度不可知。 用户在按下快捷键的那一刻,并不知道要截的内容到底有多长。可能是 5 屏,可能是 50 屏,可能是 500 屏。这意味着截屏工具既不能"先决定要截多少张再开始",也不能假设"截到屏幕里没有新东西了"——很多应用在内容底部还会有空白、广告位、加载更多按钮,这些都会让"检测到没有新内容"这个朴素条件失效。

问题 2:怎么命令一个窗口"滚一下"。 屏幕上的内容是被某个应用程序"画"出来的,截屏工具本身不能直接修改应用程序的内部状态。它能做的只有两件事——向窗口发"请滚一下"的消息,或者模拟鼠标滚轮。Windows 上前者的对应物是 WM_MOUSEWHEEL 消息(注意是 SendMessage 而不是 PostMessage),后者是 SendInput / mouse_event 模拟真实滚轮。两条路在用户感知上有明显差别,下面会单独展开。

问题 3:怎么把多张局部截图拼成一张完整长图。 每一帧截图都包含与上一帧重复的部分(具体多少取决于"这次滚动了多少行"),把这些帧沿垂直方向对齐、去重、合并,得到一张高度等于"所有帧拼接后总长度"的位图——这件事听起来只是"沿垂直方向找到重复区间并裁掉",但"找到重复区间"本身是个非平凡的问题:肉眼看上去的"两行字完全一样"对机器来说并不显然,要用算法估计两个图像区域在垂直方向上的像素级偏移量。

这三个问题对应到代码里就是三个组件:滚动控制器(ScrollCaptureController)、屏幕抓取服务(Win32CaptureService,上一篇文章里讲过)、以及图片拼接器(ScrollStitcher + ScrollPhaseCorrelator)。整个流程可以概括为下面这张图:

┌────────────────────┐
│ 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_event API。三者在用户体验上有非常明显的差异。

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 已知——WindowFromPoint + GetAncestor + ChildWindowFromPoint 把消息明确送给最深子窗口,调试时能在日志里直接看到 target=0xNNNN pt=(x,y)。至于"绕过光标位置"这个常被宣传成 SendMessage 优势的特性——它在多数目标应用上并不成立,所以实际使用时还是必须把光标放在选区里

本项目的最终选择是 SendMessage 同步投递 + WindowFromPoint + ChildWindowFromPoint 解析目标窗口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 相位相关估计 dh

ScrollPhaseCorrelator 做的就是这件事。给定 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) 项。把这个相位差"抵消掉"(也就是对 F{prev} · conj(F{next}) 做归一化后求逆变换),会在 (0, dh) 这个位置得到一个尖锐的脉冲峰——峰的横坐标就是 dh。

代码里对应的几行是:

for (int i = 0; i < n; i++)
{
    var c = fa[i] * fb[i].Conjugate();
    float mag = c.Magnitude;
    cross[i] = mag > 1e-6f ? c / mag : Complex32.Zero;
}
FourierTransform2D(cross, fftRows, fftCols, inverse: true);

cross 数组里值最大的那一格就是 dh。这里有几个细节值得讲一下。

第一,输的是"水平边缘强度"而不是"灰度"。 如果直接把灰度图送进 FFT,渐变区域(背景色、空白大段、图片)会产生"低频主峰",把真正的滚动峰淹没。代码里 FillHorizontalEdge 这一步先对每张图做一次"水平方向的亮度差分"——把每个像素替换成它和右边像素的亮度差。这种处理让"水平方向有变化"的文字行 / 图标边界 / 表格线被突出,而"整片空白"或"整片同色背景"被压成接近 0;FFT 算出来的相关性就只反映"两图的文字行结构是否对齐",对纯背景不敏感。

第二,零均值归一化 + Hann 窗。 边缘差分后的图仍然有"平均亮度差"和"边缘集中在图像中段"这两个偏差。前者用零均值 + 单位方差归一化抵消(NormalizeZeroMeanInPlace),后者用 Hann 窗(ApplyHannInPlace)让图像边缘的权重平滑下降到 0。两者都是为了减少 FFT 的"边缘效应"——图像边界如果不加窗,FFT 会把边界当作"突变"信号去拟合,产生一圈伪相关峰。Hann 窗是窗函数里最经典的一种,形状像一个平滑的钟形。

第三,相对朴素模板匹配的优势。 朴素做法是枚举 dh、逐像素算 SAD,时间复杂度是 O(H²);相位相关只需要两次 2D FFT + 一次逆 FFT,时间复杂度是 O(H² log H),且 dh 是直接从频域峰位置读出来的。

第四,FFT 在 AOT 场景下的依赖问题。 原生做法是调 Intel MKL 或 FFTW——AOT 编译时这两个库都需要 PInvoke 加载动态库,要么与 exe 一起打包、要么目标机器预装,都和"单文件、零依赖"冲突。本项目选 MathNet.Numerics——纯 C# 托管的 FFT 实现,发布时连同代码一起 AOT 进 exe,没有外部 dll 依赖。

第五,dh 的分辨率。 dh = peakRow * stepstep 始终是 1 或 2——也就是说相位相关给的是整数像素对齐,没有再做亚像素插值。这对文字 / 表格 / 图标场景已经够用;如果未来要做"光栅扫描图像"这种需要亚像素对齐的场景,可以在 FindPeak 之后对峰的相邻三格做抛物线拟合,把 dh 精度从 step 压到小数。

3.3 相位相关不够,多指标融合才稳

相位相关不是万能的——它在"图像内容有明显重复结构"(文字、表格、图标)时表现很好,在"图像内容是大段相似色块"(聊天头像流、商品瀑布流、地图)时容易算错。这种"弱纹理"场景里,相位相关的峰可能出现在错误位置——因为大段相似色块的 FFT 本身就有很多"伪峰"。

所以 ScrollStitcher 不是只信相位相关,而是把多种"两图相似度"度量混在一起打分:

double baseScore = EdgeText * 2200.0 + TextEdge * 600.0
                   + EdgeSad * 150.0 + Sad * 80.0
                   + EdgeRaw * 400.0 + Raw * 120.0;

EdgeText / TextEdge 是把"水平边缘差分"二值化后做"1-异或比"——衡量"两图的文字行结构有多像"。EdgeSad / Sad 是带边缘权重的 SAD / 普通 SAD——衡量"两图在像素级有多接近"。EdgeRaw / Raw 是边缘图 / 原图上的归一化相关——衡量"两图的整体亮度分布有多一致"。

每种指标各有自己的擅长场景:

  • 强文字(VS Code、长文 PDF)EdgeText 主导,因为它正好刻画"文字行的水平结构"。
  • 图标 + 文字(资源管理器、聊天记录)EdgeSad 主导,因为边缘是图标轮廓。
  • 纯图片流(瀑布流、相册)Raw 主导,因为没有明显文字结构只能靠整体像素相似度。
  • 极弱纹理(深色背景下的浅色文字、对比度极低的页面)SoftPartialMatchRatio = 0.72 这个软阈值兜底——把"必须达到 0.82 才能接受"放松到 0.72,宁可接受一个弱匹配也不直接丢弃 tile。

最后用加权求和把所有指标折成一个分数,取分数最高的那个 dh 作为对齐结果。这种"多指标融合"的设计思路是任何一个指标算错时,其他指标能在分数上形成对比、把 dh 拉回更接近真实值的范围——但具体"拉回"的强度取决于指标出错的类型,没有给出量化保证。

3.4 滚动步长 hint:让后续帧越来越快

相位相关 + 多指标融合在第一帧(没有 hint)时要扫"所有可能的 dh"——从 4 像素到 tile 高度,候选数可能是几百个。每个候选都要算一遍所有指标,耗时是 O(候选数 × tile面积)。第一帧慢一点可以接受,但每帧都这么扫就太慢了。

观察到一件事:相邻两次滚动的步长通常很接近。用户点开 VS Code 滚到第一屏,第二次滚动大概率和第一次差不多(除非刻意飞轮或按住 Shift)。所以可以从历史 dh 序列里取中位数作为下一帧的 hint,hint 窗口的候选数从几百个压到 30-50 个。这一步在 ScrollStitcher 里是这样的:

if (relation == TileRelation.PartialOverlap && contributed >= MinHintContribPx)
{
    recentDh.Add(contributed);
    if (recentDh.Count > 6) recentDh.RemoveAt(0);
    scrollHintPx = Median(recentDh);
}

最近 6 帧的 dh 进入滑动窗口,median 作为下一帧的 hint。hint 窗口的 slack 是 max(12, scrollHintPx * 0.35)——也就是实际 dh 偏离 hint 超过 35% 时 hint 失效,重新走全 dh 扫描。

为什么用中位数而不是均值? 滚动中偶尔会出现"飞轮"(用户不小心滚得快一点)或者"瞬时跳变"(某些应用的滚动有减速过程)。这两类异常值会把均值拉偏,但中位数对异常值免疫——6 个样本里有 1 个异常,中位数仍然是正常那个。

为什么不是 hint 时直接信 FFT? 因为 hint 路径在窗口内只跑 SAD / EdgeSad,比 FFT 快得多;FFT 是冷启动路径——只在"完全没有 hint"或者"hint 路径算出不可信"时才走。MinHintContribPx = 16 这个阈值是经验值:tile 贡献的有效行数 < 16 说明对齐质量太差(很可能是滚动步长发生了跳变),这种情况下写进 hint 反而会污染后续帧。

3.5 几个让结果更稳的工程细节

粗搜 + 精修(coarse-to-fine)。 候选 dh 数量大的时候(hint 失效、全 dh 扫描),先 step=2 采样:横竖都每隔 2 像素采一个像素点,候选 dh 范围不变但每张图的有效像素数降到 1/4。粗搜之后取 top 5 的 dh,对每个 dh ±2 像素的窗口内再用 step=1 全分辨率精修。CoarseCandidateThreshold = 36CoarseTopK = 5RefineRadiusPx = 2 是手调出来的参数——目的是让"两个 dh 分数接近"时(比如 dh=120 和 dh=240 分数分别是 0.91 和 0.89)不致选错,但没有做过系统化的对照实验来定"为什么是 36、为什么是 5、为什么是 2"。

Hann 窗 + 零均值归一化:减少误峰。 如果不加窗直接 FFT,"图像边缘"会产生一圈"环状伪相关峰"——这种峰经常比真实滚动峰还强。Hann 窗让图像边缘的权重降到 0,环状伪峰被压下去。零均值归一化消除"两图整体亮度差"的影响——比如聊天记录里某些位置比另一些位置白一点,这种纯亮度差在 FFT 里也会产生相关峰。它们的代价只是 O(N) 一次循环,可忽略不计;收益是降低错峰率。但实际效果在多大程度上降低、没有 Hann 窗会失败到何种程度,本文没有给出量化数据——只是工程上"加了好于不加"的定性观察。

候选评估走 Parallel.For。 候选 dh 互相独立、每个候选都要扫整张 tile,是典型的"数据并行"。ScoreCandidates 里直接 Parallel.For(0, candidates.Count, i => ...),把候选评估并行化。这一步不引入新算法,只是把"原本要在单核上串行做的事"扔到多核上。具体加速比取决于 CPU 核心数与候选数,没有做基准测试。


五、不同应用的行为差异:为什么 VS / 微信 / 网页不一样

滚动截屏工具一旦在多个真实应用上跑过,会观察到"同一个工具,在不同应用上的行为差异"。这些差异不是 bug,而是不同应用对 WM_MOUSEWHEEL 消息的响应方式不同。本节把观察到的一些差异列出来——但以下每条都是经验性的、未在本项目里做严格的对照实验。建议读者把这一节当作"已知踩过的点",不要当作普适结论。

5.1 Visual Studio:光标必须放在选区内

这一节描述的现象部分来自 docking 容器应用的通用行为(推测),部分来自 §2 提到的"wheel 实际由 GetCursorPos 决定"这一更基础的事实——后者在所有复杂应用上都成立,不限于 VS。

VS 的窗口是 docking 容器(主窗口由多个可停靠子窗口组成,编辑器、Output、Solution Explorer、Error List 等各自有独立的滚动条)。不论 SendMessage 把 lParam 设成什么坐标,VS 的 WM_MOUSEWHEEL 处理器都会调 GetCursorPos() 取真实光标位置来路由滚动——光标在编辑器区就滚编辑器,光标在 Output 面板就滚 Output,光标在桌面其它位置就什么也不滚。这是 §2 里说"必须把光标放在选区内"那条原则的具体体现,VS 只是把它放大得很明显。

用户层能做的:把鼠标光标放在选区内(且停在目标滚动容器的可视区域上)再开始自动滚动截屏。这一条对 VS、JetBrains、复杂 WPF 应用都成立,本质上是同一个原因。其他"光标在哪儿就滚哪儿"的应用(资源管理器、ListView 表格类应用)也都遵循这个规律。

5.2 微信:聊天区偶尔自动滚动无法触发

观察到的现象是:自动滚着滚着,突然有几帧聊天区不再响应 WM_MOUSEWHEEL——截屏工具在派发滚轮,聊天区的滚动条不动,截到的连续几帧都和上一帧几乎一样。这一段 tile 在拼接时会被识别为"全等帧"丢弃,下一段又开始滚,长图在中断处缺一段。

实际可用的应对方法:手动滚动一下让微信重新进入响应状态,或者直接重开微信再继续截。根因目前不明确,本项目没有做诊断数据采集——SendMessage 同步阻塞调用本身能确认消息送到了目标窗口的 WndProc,问题大概率出在微信内部 wheel 事件被消化之后到滚动条响应的某个环节,工程上还没有稳定复现的路径。本文不打算替这条路径下结论,只记录"现象 + 应对方法"。

5.3 网页(Chrome / Edge):相对顺滑

网页是观察下来摩擦最小的场景之一。Chrome / Edge 的滚动监听挂载在 windowoverflow:auto 的容器上,wheel 事件触发后浏览器做命中测试、把滚动动作派发给鼠标位置下的滚动容器——这条路径是浏览器内核实现的,对所有标准 HTML 页面都成立。所以对网页来说,只要光标停在页面的"可滚动区域"内(不要停在 input / 视频控件 / 链接这种"会吞掉 wheel 默认行为"的元素上),滚轮就能派到正确的容器。

但需要注意:现代浏览器对 wheel 事件有不少额外处理——惯性滚动、滚动吸附(scroll-snap)、overscroll bounce、touchpad 的平滑滚动因子——这些都可能让 dh 在帧间不是严格的整数倍。拼接算法对此有一定容忍(hint 窗口有 35% slack),但不能保证所有网页都完美。

5.4 其他场景的"个性"

  • 资源管理器(文件列表):相对顺滑。文件列表是标准 ListView 控件,响应 wheel 是默认行为。
  • Word / Excel:只要鼠标在文档区就能滚;与窗口是否在前台、是否最小化没有强约束。
  • PDF 阅读器(Adobe / SumatraPDF / Edge PDF):本项目没有测过,按推测与网页类似。
  • 长命令行输出(PowerShell、cmd):通常需要窗口在前台且不被遮挡;光标位置不限。

5.5 操作干扰:哪些动作会让拼接异常

滚动截屏运行期间,用户对目标窗口的额外操作会让拼接算法读到"异常相邻帧",结果是长图出现错位、错缝、整段重复。常见的有三类:

  • 用户在拖动窗口。拖动过程中窗口的可见区域每一帧都在变,BitBlt 抓到的不是"滚动后的内容"而是"被移动的窗口在不同位置的快照"——两帧之间没有"重叠区"的概念,相位相关算不出 dh,整段被丢弃。
  • 目标窗口在播动画。CSS 动画、JS 平滑滚动、视频播放器进度条滚动、Markdown 渲染时的逐字显示——这些动画让相邻两帧的内容都在变,重叠区不再是"同一段内容的两次截图"而是"同一段内容 + 一点动画增量"的差异。dh 算出来是模糊的或者落在错误位置。

反向滚动是支持的——ScrollCaptureController 构造参数 bool scrollUp,默认 false 即向下滚;设为 true 即反向。反向滚动时两帧之间 dh 是负的,算法同样工作,长图从下往上增长。本项目在反向上做过自测,正反向拼接结果在视觉上是一致的。

把这些行为差异汇总起来,仅作为经验性的弱规律记录:观察下来,应用越接近"标准 Win32 控件(ListView、TreeView、WebView)",滚动截屏越稳定;越接近"自定义焦点路由的复杂框架",行为越不可预测。但这是若干次跑下来的整体印象,不是统计结论。


六、单文件 AOT 打包

把整个工程打成"一个 exe、双击即用、不需要装运行时"是产品形态的最后一公里。前一篇文章(LumScreenShot)已经讲过 TrimMode=fullSelfContained=true 等配置的作用,本节聚焦 AOT 启动壳——其余的 csproj / publish 命令沿用前一篇的范式即可。

6.1 启动壳 Program.cs

Avalonia 11 + .NET 10 的 AOT 启动壳需要写成显式 Main + [STAThread]——STAThread 是 GDI / OLE / 剪贴板 API 的硬性要求,截屏工具同时用了这三者。Avalonia 的 StartWithClassicDesktopLifetime 在 AOT 模式下比 Start(AppMain, args) 更稳——后者依赖某些反射初始化路径,AOT 下会被 trim 掉。

场景 1:单文件 exe 独立发布。 用户双击 exe → 唤起截屏 → 完成 → 进程退出。这种场景下 ShutdownMode 走默认 OnLastWindowClose 即可——截屏窗口关闭时整个进程自然退出:

using Avalonia;

namespace LumScreenShot;

internal static class Program
{
    [System.STAThread]
    public static void Main(string[] args)
    {
        BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
    }

    public static AppBuilder BuildAvaloniaApp()
        => AppBuilder.Configure<App>()
            .UsePlatformDetect()
            .LogToTrace();
}

场景 2:把截屏模块集成进其他宿主程序(例如 tds 项目里把截屏作为主窗口的一个功能被调用)。截屏窗口是宿主主窗口的子窗口,关闭截屏窗口不能让整个进程跟着退出——必须由宿主显式调用 ApplicationLifetime.Exit() 才退出。这种场景下需要把 ShutdownMode 改为 OnExplicitShutdown

BuildAvaloniaApp().StartWithClassicDesktopLifetime(args, Avalonia.Controls.ShutdownMode.OnExplicitShutdown);

两种用法的区别只在 ShutdownMode 参数上;[STAThread]AppBuilder 链都保持不变。本文对应的"单文件 exe"用场景 1 的版本。


七、总结

滚动截屏看起来是"按一个键、等一会儿、出来一张长图"的简单操作,工程上要做的事情其实有相当多:滚动控制要走 SendMessage 同步投递(SendInput 也可以,但 SendMessage 能同步阻塞、等目标窗口处理完再截图,不需要额外的"等渲染"延迟),overlay 自身的遮挡要通过 SetWindowRgn 挖洞规避,截图间隔要在"渲染完成"和"重叠区足够"之间找平衡。还有一条容易被忽略的前提:用户必须把鼠标光标放在选区内——因为多数现代应用的 WM_MOUSEWHEEL 处理器会忽略 SendMessage 携带的 lParam 坐标,调用 GetCursorPos() 取真实光标位置来决定滚哪个容器。

拼接算法是这件事的主要难点。朴素拼接错位、缺漏、重复的根因是"不知道滚动距离"——这件事截屏工具既不能问应用、也不能问用户,只能从相邻两帧的像素内容里反推。FFT 相位相关是经典答案,但它在弱纹理场景会算错;多指标融合(EdgeText + TextEdge + EdgeSad + Sad + EdgeRaw + Raw)是工程上的稳健性补丁,每种指标倾向覆盖不同的纹理类型,相互之间形成一定程度的"投票"——任何一个指标算错时其他指标能形成对比,但具体能纠正到什么程度没有量化数据。滑动中位数 hint 让后续帧的拼接从"全 dh 扫描"变成"hint 窗口精搜",时间复杂度从 O(H²) 降到 O(H),代价是 hint 失效时(步长跳变)会自动 fallback 回全扫描。

不同应用对 WM_MOUSEWHEEL 的响应方式不同:标准 Win32 控件(ListView、TreeView、WebView)相对稳定;微信聊天区偶尔自动滚动无法触发(手动滚或者重开微信可解决);复杂 docking 容器(如 VS)要求光标必须在选区内才稳定。这些不是工具的 bug,是目标应用对 UI 事件处理方式的多样性——但本文对每条结论的稳健性不做绝对保证。

整个工程的代码、所有 demo 视频(VS / 网页 / 文件列表 / Git)以及发布产物的 sample 都放在仓库里,新朋友关注萤火初芒回复 tds 可以获取仓库地址。截屏只是 tds 项目的多个能力之一——tds 本体是一个文件搜索工具,截屏是其中一个独立可剥离的子模块。如果只想用截屏这一块,把 § 六 的 Program.cs 拷过去、配上 PublishAot=true 的 csproj,五分钟就能拉出一个独立工程并跑通首次 publish。

在Release里,TDS.exe 是主程序;ScreenShot.zip 是从TDS项目里把截屏打包单独剥离出来的aot编译后的exe文件,双击执行立即触发截屏,建议生成桌面快捷方式+快捷键使用。


文章来源:https://www.cnblogs.com/luojin765/p/20447314
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云