当前位置: 首页 > news >正文

C#从零开始:自己实现一个截屏工具

C#从零开始:自己实现一个截屏工具

一、技术栈与项目结构

整个工具是 .NET 10 + Avalonia 12.0.4 + Skia + Win32 PInvoke 的组合。技术选型上有几个明确的核心依据:UI 层选 Avalonia 而非 WPF 或 WinForms,关键在于其支持 Native AOT 发布——AOT 的目标是把发布产物体积压到单个可执行文件级别,并把冷启动时间控制在 200ms 以内,这与"按下快捷键立即进入选区"的产品目标直接对齐;Avalonia 同时跨平台、控件丰富、绑定体系现代,是满足这个目标的合理载体。屏幕抓取走 Win32 GDI 而非 DirectX 或 Windows.Graphics.Capture,是为了把系统依赖压到最小——GDI 在所有 Windows 版本上都能工作,不要求 D3D 设备,不要求会话 0 提权,也就不需要在目标机器上预装任何额外运行时。.NET 10 选 Preview 是因为它对 Avalonia 12 的 trim warning 处理最干净,CI 跑 dotnet publish -c Release -r win-x64 能直接产出单一可执行文件,复制到任意 Windows 机器上双击就能跑,不需要随附 .NET 运行时。

工程划分遵循"核心库 / UI 库 / 启动壳 / 测试"四层结构:

src/
├── ScreenShot.Core/      # 抓取 + 注解模型(AOT 友好,无 System.Drawing)
├── ScreenShot.UI/        # Avalonia 控件、窗口、服务
├── LumScreenShot/        # 启动 exe:双击/快捷键直接进入截屏,结束即退出
├── ScreenShot.Demo/      # WinForms 演示宿主
└── ScreenShot.Test/      # Headless 单元测试,验证裁剪与位图操作

Core 层只做两件事:定义抽象的抓取接口(ICaptureService),以及实现 Windows 平台的具体类(Win32CaptureService)。注解模型——PenAnnotationArrowAnnotationRectAnnotationEllipseAnnotationTextAnnotationMosaicAnnotation——也都放在这里,因为它们是平台无关的纯数据 record。UI 层负责把 Core 渲染出来,包括全屏选区窗口、工具栏、标注画布、暗部遮罩、保存服务、剪贴板服务。LumScreenShot 是发布出去的 exe,它的全部职责就是"启动 → 弹出截屏 → 退出",连 MainWindow 都不需要——ShutdownMode 设成 OnExplicitShutdown,让进程的生命周期严格绑在用户的截屏行为上。ScreenShot.Test 用 Avalonia 的 Headless 后端跑无窗口渲染,专门验证 BitmapCropper.Crop 不会把全屏图错切成选区。

这种划分最直接的好处是 Native AOT 编译路径畅通。Core 层除了 PInvoke 之外不依赖任何反射密集型库(没有 System.Drawing,没有 WPF),UI 层所有 XAML 都开启编译期生成,发布产物体积和冷启动时间都控制在合理区间内。


image

二、屏幕抓取:选 BitBlt 而非 DXGI

Windows 下截屏至少有三条路:GDI BitBlt、DXGI Output Duplication、Windows.Graphics.Capture(WGC)。第三条最现代、性能最好,但要求 Win10 1903+ 且对部分虚拟化/远程桌面场景兼容性差;DXGI 中间,需要 D3D 设备上下文,对纯截图而言过重;GDI 几乎在所有 Windows 版本、所有会话、所有虚拟化层上都能工作,是稳定性最高的兜底方案。代价是性能——BitBlt 是 GDI 软件路径,在 4K 屏上会有可感知的延迟。但截屏是低频操作,这点延迟可以接受。

具体的数据流走的是一条很经典的 GDI 套路:

IntPtr screenDc = Win32.GetDC(IntPtr.Zero);
IntPtr memDc    = Win32.CreateCompatibleDC(screenDc);
IntPtr bmp      = Win32.CreateCompatibleBitmap(screenDc, w, h);
IntPtr old      = Win32.SelectObject(memDc, bmp);Win32.BitBlt(memDc, 0, 0, w, h, screenDc, x, y,Win32.SRCCOPY | (int)Win32.CAPTUREBLT);

几个值得展开的细节。GetDC(IntPtr.Zero) 拿到的是整个屏幕的 DC,作用范围跨所有显示器;CreateCompatibleBitmap 必须以 screenDc 为基准,否则颜色深度会落到默认 1bpp。BitBlt 的最后一个参数除了 SRCCOPY 之外还要按位或上 CAPTUREBLT(值为 0x40000000),否则抓不到 layered window 的实际内容——很多应用的悬浮窗、置顶通知、某些视频播放器的字幕层都是 layered window,省了 CAPTUREBLT 会得到一块"看起来没东西"的区域。

光栅化步骤需要把 GDI bitmap 转成 Avalonia 期望的格式。GetDIBits 的关键技巧是把 biHeight 设为负值(-h)——GDI 默认输出 bottom-up(行从下往上排列),Avalonia 的 WriteableBitmapPixelFormat.Bgra8888 下要求 top-down。把 biHeight 写成负数后,GDI 就直接输出 top-down 排列的 BGRA 字节流,省掉一次翻转。WriteableBitmap 构造时把 DPI 显式写为 (96, 96)——这是有意为之,因为后续的所有尺寸换算都基于"像素 == 布局像素"的前提,bitmap 的元数据 DPI 字段不参与布局。

GDI 给的 buffer 行宽可能与 Avalonia 内部 RowBytes 不完全一致(前者是 w*4,后者可能因为对齐而略大),所以逐行用 Buffer.MemoryCopy 而不是整块拷贝。这一步在 4K 屏上也是大头之一,但相对于 BitBlt 本身的延迟可以忽略。

Win32 内部全部使用经典 DllImport 而非 LibraryImport 源生成器——后者对包含嵌入变长字符串的结构体(MONITORINFOEX.szDevice)支持不全,硬上会得到静默的乱码。这是 PInvoke 在 AOT 场景下为数不多的"老派写法更可靠"的地方。


三、多显示器:构造一个"虚拟桌面"

EnumDisplayMonitors + MONITORINFOEX 给到的是每个物理显示器的 rcMonitor(整个显示器范围)、rcWork(扣除任务栏的工作区)、dwFlags(是否为主屏)、szDevice(设备名)。这套数据已经足够用来回答"用户在哪个屏"、"屏幕有多大"这些问题,但额外构造了一个合成条目 VirtualDesktop

int left   = list.Min(s => s.Bounds.X);
int top    = list.Min(s => s.Bounds.Y);
int right  = list.Max(s => s.Bounds.Right);
int bottom = list.Max(s => s.Bounds.Bottom);
list.Add(new ScreenInfo("VirtualDesktop",new Rect(left, top, right - left, bottom - top), ...));

虚拟桌面的边界是所有物理显示器 Bounds 的并集,逻辑上等价于 GDI 的"整个屏幕 DC"——也就是 GetDC(IntPtr.Zero) 覆盖的范围。它的存在让上层调用者不必关心"当前是截一块屏还是所有屏":传单屏对象得到单屏 bitmap,传虚拟桌面对象得到跨屏的整张 bitmap。DeviceName == "VirtualDesktop" 这个字符串在调用方代码里反复出现作为"截取整张虚拟桌面"的标记。

鼠标光标当前所在屏由 GetScreenAtCursor 解析。流程是先用 GetCursorPos 拿到物理像素坐标,再遍历物理显示器列表做 Bounds.Contains 命中。命中失败时退回到主屏,再失败则取列表首项——这套 fallback 链确保即便驱动返回畸形数据也不会让截屏入口抛异常。启动截屏时只捕获光标所在屏而不是虚拟桌面,是一个产品决定:用户的工作区域通常集中在某个屏上,截全屏会带来不必要的处理负担(更大的内存、更慢的 GDI 调用、UI 加载更慢),而只截单屏已经能覆盖 90% 的用例。

ScreenInfo 记录用 Avalonia.Rect 而非自定义的 RECT 结构——这是个有意识的选择。Avalonia 的 Rect 跨平台、不可变、便于在 Core 与 UI 之间共享,避免了把 Win32 类型泄漏到平台无关层。


四、DPI 缩放:物理像素、DIP、Monitor DPI 的三角关系

DPI 是这套系统里最容易出错的地方。截屏工具面临的"坐标"有三种:

  • 物理像素(physical pixels):GDI、HWND、EnumDisplayMonitors 全部使用,1 个屏幕像素 = 1 个单位。
  • DIP(device-independent pixels):Avalonia UI 布局、WPF/WinUI 的世界,1 DIP = 1/96 英寸。
  • Monitor DPI scale:每个显示器独立,常见 1.0(100%)、1.25(125%)、1.5(150%)、2.0(200%)。

三者关系是 physical = DIP * scale。Windows 10 1703 之后 per-monitor DPI V2 普及,每个显示器可以独立缩放。这意味着用户可能在主屏 100%、副屏 200% 的混合环境下工作,鼠标从主屏滑到副屏的瞬间,光标坐标与窗口坐标的关系会发生跳变。

抓取层首先用 GetDpiForMonitor(MDT_EFFECTIVE_DPI) 取每个显示器的 effective DPI,存到 ScreenInfo.DpiScaleMDT_EFFECTIVE_DPIMDT_ANGULAR_DPIMDT_RAW_DPI 的区别在于,前者是 Windows 推荐应用使用的 DPI,会考虑用户手动覆盖与兼容性缩放。返回值是 0(成功)或非 0(错误码),失败时退回到 1.0——1.0 是不带任何缩放信息的"裸物理像素",是个安全的默认值。

把 DPI 信息带回到 ScreenInfo 是关键决定:调用方拿到的不仅是"屏有多大",还有"屏的物理-DIP 换算系数是多少"。后续的窗口布局、坐标转换全部基于这个系数,而不是依赖运行时再去查 Screens[i].Scaling——后者虽然是 Avalonia 自己的 API,但它的 Screens 列表在窗口创建之初可能尚未完全初始化,必须在 Opened 之后才能保证可用。


五、Avalonia 坐标:与 Win32 的衔接

ScreenShotWindow 启动时拿到的 physicalBounds 是物理像素矩形(PixelRect),但 Avalonia 的 Window.Width/Height 期望 DIP。所以构造函数先把窗口尺寸设为 physicalBounds / dpiScale,再调用 SetWindowPos 强制把原生 HWND 放到物理像素位置:

Position = new PixelPoint(physicalBounds.X, physicalBounds.Y);
Width  = physicalBounds.Width  / _dpiScale;
Height = physicalBounds.Height / _dpiScale;
// ...
Win32WindowPlacement.SetPhysicalBounds(hwnd, physicalBounds.X, physicalBounds.Y,physicalBounds.Width, physicalBounds.Height);

这套"逻辑尺寸 + 物理位置"的组合有几个微妙之处。第一,DPI 取值时机_dpiScale 的初值是 targetScreen.DpiScale(来自 Core 层),但 Avalonia 自己在 Opened 之后会重新计算 Screens.ScreenFromPoint(...).Scaling,两者理论上应该一致,实践中有概率不一致——AOT 场景下尤其如此。所以 OnScreenshotOpened 里会再调用一次 ApplyScreenLayout,并且在 DispatcherPriority.Loaded 上 post 一次重排,让第一帧布局完成之后再纠正一次尺寸。

第二,HWND 物理尺寸SetWindowPos 接受的单位是物理像素,所以传 physicalBounds.Width/Height 而非 Width/Height。Avalonia 的 Window.Position 虽然也能用 DPI 缩放,但它内部会做"逻辑位置 → 物理位置"的换算,与直接通过 SetWindowPos 设置的物理位置可能产生冲突。所以正确做法是只相信 Avalonia 的 Position 来表达"逻辑位置"(本项目场景下用不到),原生位置用 SetWindowPos 直接钉死。

第三,窗口模式WindowState.NormalWindowDecorations.NoneShowInTaskbar=falseTopmost=trueCanResize=false,全部为了把窗口变成"跨满整屏的透明画布"。Background = Brushes.Transparent 让没截到的内容(选区外)透出原屏幕,截到的内容(选区内)覆盖在原屏幕之上,组合出"截图编辑器"而不是"另一个窗口"。

鼠标坐标方面,Avalonia 的 PointerEventArgs.GetPosition(...) 给的是相对坐标,用它做选区矩形的几何运算时不需要关心物理/DIP 换算——所有几何都在 DIP 空间内进行,最后在裁剪位图时才转成物理像素。这种"全程 DIP,落地时才转物理"的做法把坐标混乱压到了边界上。

BitmapCropper.Crop 是另一处需要明确的坐标契约。selection 参数是相对于源 bitmap 的"逻辑坐标"——也就是说,它的单位与源 bitmap 的 PixelSize 对应的就是物理像素。裁剪时使用 DrawingContext.DrawImage(source, sourceRect, destRect),源矩形是 selection,目标矩形是 (0, 0, w, h),这样保证 1:1、无缩放、无插值。从历史上看,Image 控件的 Stretch + SourceRect 组合在某些高 DPI 设备上有过不正确的变换 bug,而 DrawingContext.DrawImage 走的是直接路径,不会触发那些 layout 相关的副作用,所以裁剪单独抽出一个最小化的 Control 来跑这个渲染。


六、产品形态:选区、暗部、工具栏

技术问题解决之后,剩下的就是"做成什么样"。产品形态的每个细节都是从用户行为倒推的。

暗部遮罩 解决的是"框选时屏幕上其他内容是否清晰可见"。IdleDimBrushAlpha=88 的纯黑,覆盖整屏但不喧宾夺主——它告诉你"截屏模式已启动",但不会遮挡你要截的内容。SelectionDimBrushAlpha=210,覆盖选区外区域,配合 CombinedGeometry(Xor) 把选区内"挖空"——这意味着选区内的原屏幕像素完全可见,而选区外是接近全黑但仍能看出轮廓的暗罩。两档透明度分别对应"未选"和"已选"两个状态。

选区拖拽inputCatcher 层(一个全屏透明 Border)吸收初始按下事件,因为它在选区外不会被选区内的 AnnotationCanvas 抢走事件,而在选区内又会被更高 ZIndex 的标注层接管。_dragCreating 标志区分"创建新选区"和"修改现有选区",宽度或高度小于 5 像素的"选区"会被视作误触而丢弃——5 像素是经验值,足够大以至于肉眼能看清选区,又不至于让手抖用户难以触发"取消"。

8 个手柄 放在 SelectionAdorner 里直接挂到 root Canvas,ZIndex 高于 AnnotationCanvas 但又不会被 canvas 内部的画布元素遮挡。HandleKind 枚举覆盖 4 角、4 边,鼠标光标按位置切换为 TopLeftCorner / SizeNorthSouth / SizeWestEast 等系统光标。手柄尺寸 10×10 像素,半数落在选区外、数落在选区内——这样在选区边缘附近也能稳定抓取。ApplyHandle 是一个纯函数,传入"开始时的选区 + 手柄类型 + 位移"返回新选区,使得 8 个方向的几何计算集中在一个方法里,便于测试。

尺寸标签 实时显示当前选区的 W x H,位置在选区顶部上方;若顶部空间不足则自动落到选区底部下方。它不是装饰品,是用户做精确截取时唯一能依赖的"当前框选区域尺寸"的视觉反馈。

工具栏 是浮动的黑色半透明圆角条,工具按钮按功能分组:

  • 工具组:选择 / 矩形 / 椭圆 / 箭头 / 画笔 / 文字 / 马赛克
  • 颜色组:7 色(红蓝绿橙黄白黑)
  • 线宽组:4 档(2/3/5/8 DIP)
  • 动作组:撤销 / 保存 / 取消 / 完成

每组有自己的 CornerRadius=8 圆角容器,组间用 1 像素分隔条区隔。视觉上像 macOS 截图工具的悬浮条,但按键更大、间距更宽容——28×28 像素的按钮在 200% DPI 下也有 56 物理像素,实际可点击区超过 Windows 默认 40 物理像素的最小推荐值。ToolbarButton 内部维护 _hover/_pressed/_active 三个状态,激活态用绿色描边、悬停态用半透明白底、按下态用更深的灰底——这种三态反馈在没有原生控件默认样式覆盖时尤其重要。

工具栏位置自适应:默认在选区正下方 12 像素;下方空间不足时翻转到选区上方;左右越界则向内收缩;任何方向都不会落到屏幕外 8 像素的安全区外。这套位置策略写在 PositionToolbar 里,每次选区变化后调用。

键盘语义 也经过明确分工:Enter = 完成(confirm),Esc = 取消(cancel),Ctrl+Z = 撤销。KeyDown 用 Tunnel 路由注册,避免子控件(比如内联 TextBox)吞掉事件。保存到磁盘则走 Ctrl+S 触发的"另存为"对话框——快捷保存是另一个独立的 TryQuickSaveAsync 路径,不带对话框。


七、标注系统:六种工具的统一模型

标注的核心是"不可变 record + 集中式编辑器"。

Annotation 是抽象基类,只带 KindStrokeStrokeWidth 三个公共字段,具体的几何信息由子类承载:

public sealed record RectAnnotation    : Annotation { public required Rect Rect { get; init; } }
public sealed record EllipseAnnotation : Annotation { public required Rect Rect { get; init; } }
public sealed record MosaicAnnotation  : Annotation { public required Rect Rect { get; init; } public required int BlockSize { get; init; } }
public sealed record ArrowAnnotation   : Annotation { public required Point Start { get; init; } public required Point End { get; init; } }
public sealed record PenAnnotation     : Annotation { public required IReadOnlyList<Point> Points { get; init; } }
public sealed record TextAnnotation    : Annotation { public required string Text { get; init; } public required Point Origin { get; init; } public required double FontSize { get; init; } }

record + with 表达式的组合让"修改某条标注"等同于"创建一条新 record"——这种不可变性在撤销/重做场景下特别顺手:把 Items[count-1] pop 掉就能回退一步,不需要在每个工具类里维护 PreviousStateAnnotationEditor 是一个 static class,集中处理所有标注的 hit-test、bounds、transform,是这套系统的"几何大脑"。

HitTest 的实现按标注类型分而治之:矩形/椭圆/马赛克都基于 Inflate(rect, HitTolerance) 的容差矩形;箭头/画笔的判定稍复杂,用点到线段的距离公式 DistanceToSegment,比直接 Bounds.Contains 精确得多(用 bounds 的话,斜线会过早被判定为不命中)。HandleKind 枚举包括 Body(整体移动)、8 个 resize 方向、以及箭头专属的 StartPoint/EndPoint——这种"按标注类型暴露不同 handle"的模型使得拖动箭头终点时整个箭头形状实时变化,而矩形手柄只动矩形边。

马赛克是其中最特别的一个。它的 Rect 描述的是被遮盖区域,渲染时按 BlockSize 把区域划分为网格,每格取其内部像素的平均色再贴回——视觉上就是模糊化处理。这种"标注 = 后期变换"的抽象让马赛克与矩形共享同一套 resize/移动逻辑,但渲染路径完全不同,是 record 模型带来的灵活性。

文字标注有内联编辑:用户点下文字工具 → 在画布上拖出位置 → 弹出内嵌 TextBox → 输入完成回车 → 创建 TextAnnotation。期间所有其他标注工具被临时屏蔽,避免误操作。文字缩放也走 handle 路径——ApplyTextHandle 计算新 bounds 与原始文字尺寸的比例,把 FontSize 按比例缩放,最小 8 像素、最大 3 倍。

撤销栈深度 50,由 AnnotationCanvas.UndoLimit 控制。每次"提交"一个标注(鼠标抬起时)会触发 Committed 事件,工具栏据此更新 Undo 按钮的可用状态。


八、双击自动保存与"快门闪光"

截屏工具的一个隐藏痛点是"截图成功了但用户不知道"。系统通知不够醒目,剪贴板变更在很多应用里没指示,保存到磁盘也无声无息。本项目用一个 250ms 的径向闪光模拟相机快门反馈:

// 白边 + 暗中心的径向 gradient overlay,渐变 250ms 后淡出
var flash = new Rectangle { Fill = FlashBrush, IsHitTestVisible = false };

PlaySaveFlashAndCloseAsync 在确认保存成功后插入这段动画再关闭窗口。视觉上像是"按了一下快门"——这种物理隐喻对截图这种"咔嚓一下"的体验是恰当的,比"已保存到 ..."的文字通知有记忆点。

"双击自动保存"对应的入口是 TryQuickSaveAsync(saveDirectory, fallbackDirectory):构造一个"已选区确认"的结果,按 snapshot_yyyyMMdd_HHmmss_fff.png 命名规则写到指定目录;若文件名已存在则追加 _1_2 序号;1000 次都重名则用 Guid.NewGuid() 兜底。目录不存在时尝试创建,权限不足或 IO 错误时返回结构化错误((false, null, "Access denied: ...")),调用方负责显示提示。这个接口是为外部宿主(命令行调用、Agent 工具调用)设计的——典型用法是 Agent 在检测到屏幕上出现错误信息时直接调用截屏 API,结果静默落到本地目录。

写文件路径用 BuildUniquePath 而非 GUID 主键,是有意为之。yyyyMMdd_HHmmss_fff 这种命名在资源管理器里是自然排序的,毫秒级精度足以避免同日内的秒级冲突;附加 _1 序号解决同毫秒内的极端并发(极少见,但理论上存在)。这套命名比 GUID 友好得多,因为用户通常需要看到"这是今天 14:23:05 那张图"。


九、剪贴板:CF_DIB 兼容

把 bitmap 复制到剪贴板看似一行代码,但跨应用粘贴是个雷区。Avalonia 的 Clipboard.SetBitmapAsync 在自家应用、浏览器、VS Code 内部粘贴都正常,但粘贴到 Paint、Word、Discord、Slack 等 Windows 桌面应用时经常失败——这些应用期望的是 GDI 标准的 CF_DIB 格式,而不是 Avalonia 内部的高层 representation。

解决方案是同时 push 两份数据:

// 高层 API(跨平台 fallback)
await clipboard.SetBitmapAsync(bitmap);
// 底层 CF_DIB(Windows 桌面应用兼容性)
CopyDibToClipboardWindows(bitmap);

CopyDibToClipboardWindows 把 bitmap 转成 BITMAPINFOHEADER + 原始 BGRA 字节,通过 OpenClipboard / EmptyClipboard / SetClipboardData(CF_DIB, hGlobal) 推上去。biHeight 必须写正数——与抓取时的 top-down 相反,CF_DIB 期望 bottom-up。复制时行序翻转一次,几十毫秒可接受。这种"两路并存"的策略让剪贴板内容在几乎所有 Windows 应用里都能正确粘贴。

剪贴板写入是 best-effort:try/catch 静默吞掉异常,因为剪贴板被其他进程占有时是常见情况(用户刚刚 Ctrl+C 了一段文本),不应该让截屏工具因此报错。SetClipboardData 返回的句柄在系统接管内存之后不能再 FreeHGlobal——这是 Win32 文档里反复强调但容易忽略的细节。


十、可测试性:Headless 验证裁剪

BitmapCropper 是最关键的纯函数——它把选区矩形从源 bitmap 中切出来,1:1,无缩放。这种"看起来简单但历史上 bug 多发"的函数必须有自动化测试覆盖。

ScreenShot.Test 用 Avalonia 的 Headless 后端跑无窗口渲染(AppBuilder.UseSkia().UseHeadless(...)),先构造一张 200×200 的四象限位图(红/绿/蓝/黄),然后对每个象限、每个跨象限区域做 Crop,并对结果 bitmap 的每个像素采样,验证颜色完全匹配期望:

// TL 象限必须全红
Check("TL red", source, new Rect(0, 0, 100, 100), Red, ref failures);
// 跨象限区域每个像素必须 = 红 or 绿
CheckCrossQuadrant("TL+TR cross", source, new Rect(80, 20, 60, 40), Red, Green, ref failures);

跨象限的"必须是红或绿"测试特别有价值——它能抓出"裁剪返回了全屏而不是选区"这一类历史 bug:如果代码不小心把 DrawImage(source, selectionRect, destRect) 写成 DrawImage(source, fullRect, destRect),跨象限测试会立刻在中间象限里发现不属于红/绿的颜色。失败时把错误的 bitmap 写到 %TEMP%\screenshot_test_FAIL_<name>.png,便于人工核查。

Headless 后端的价值在于把 Avalonia 强绑的 RenderTargetBitmap 渲染流程跑在没有显示器的 CI 机器上。它不需要 X Server,不需要 DirectX,甚至不需要 Avalon 资源;唯一的约束是必须在 SetupWithoutStarting() 之后才能创建 WriteableBitmap,否则 Skia 渲染接口未初始化。

这一套测试在最初的几次重构中证明是有效的:把 BitmapCropperImage.SourceRect 迁移到 DrawingContext.DrawImage 的时候,跨象限测试立刻报告了"边界附近的像素错位"的失败,指明 0.5 像素的 subpixel 取整 bug——这种问题在真机截屏里极难复现,但在 headless 测试里是确定性的。


十一、总结

从结果看,这个工具在工程上值得展开的,是一组清晰的坐标模型与产品形态决策。多显示器、per-monitor DPI、Avalonia 坐标模型这三者之间的关系是最容易出 bug 的地方,处理的核心思路是"全程 DIP、落地才转物理",所有几何在 DIP 空间内运算,最后在写入/裁剪时统一换算到物理像素——这一决策直接消除了 90% 的高 DPI 适配问题。产品形态上则要克制,不堆长截屏、滚动截屏、OCR 这些功能,让"打开 → 截图 → 关闭"这条主链路尽量短。

这套截屏模块的设计从一开始就是奔着"可剥离发布"去的。ScreenShot.Core 只做平台无关的接口与数据模型,ScreenShot.UI 把 Avalonia 控件与窗口封装成一个库,LumScreenShot 是一个极薄的启动壳——它们之间通过 ProjectReference 串联,没有循环依赖,没有共享私有代码段,也没有强制的配置注入点。要把它单独发布出去很简单:把 CoreUI 拷到一个新仓库,加一个 LumScreenShot 启动项目(App.axaml.csProgram.cs 加起来不到五十行),dotnet publish -c Release -r win-x64 就能得到一个独立的 AOT exe。发布产物体积在 Native AOT 模式下压到了单个可执行文件级别,冷启动到进入选区状态在 200ms 以内;运行时不需要 .NET 运行时,不需要任何 NuGet 依赖文件,复制到任意一台 Windows 机器上双击就能跑。

最自然的部署方式是把 exe 放到桌面或者 C:\Tools\ 之类固定位置,然后创建快捷方式并指定一个全局快捷键(Windows 自带的"快捷方式 → 快捷键"字段、或者 AutoHotkey 之类的小工具都行)。按下快捷键,调起截屏,框选,标注,保存到预设目录或复制到剪贴板,进程退出——整个过程不会留下任何托盘图标、后台服务、注册表项。如果已经习惯用其他全局快捷键工具(比如 PowerToys、uTools、Raycast 的 Windows 替代品),那直接把它当作一个可执行命令注册进去也很顺滑,不会和原有工作流冲突。

整个截屏模块已经作为子项目集成进了 tds 项目的 tds/screenShot/ 目录中,新朋友关注萤火初芒回复 tds 即可获取仓库地址。tds 是一个文件搜索软件,截屏只是其中一个能力;如果只想用截屏这一块,单独剥离也毫无负担,三五分钟就能拉出独立仓库并完成首次 publish。