.NET10+Avalonia跨平台截屏工具解析

一、技术栈与项目结构

整个工具是 .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)。注解模型------PenAnnotation、ArrowAnnotation、RectAnnotation、EllipseAnnotation、TextAnnotation、MosaicAnnotation------也都放在这里,因为它们是平台无关的纯数据 record。UI 层负责把 Core 渲染出来,包括全屏选区窗口、工具栏、标注画布、暗部遮罩、保存服务、剪贴板服务。LumScreenShot 是发布出去的 exe,它的全部职责就是"启动 → 弹出截屏 → 退出",连 MainWindow 都不需要------ShutdownMode 设成 OnExplicitShutdown,让进程的生命周期严格绑在用户的截屏行为上。ScreenShot.Test 用 Avalonia 的 Headless 后端跑无窗口渲染,专门验证 BitmapCropper.Crop 不会把全屏图错切成选区。

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


二、屏幕抓取:选 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 的 WriteableBitmap 在 PixelFormat.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.DpiScale。MDT_EFFECTIVE_DPI 与 MDT_ANGULAR_DPI、MDT_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.Normal、WindowDecorations.None、ShowInTaskbar=false、Topmost=true、CanResize=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 来跑这个渲染。


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

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

暗部遮罩 解决的是"框选时屏幕上其他内容是否清晰可见"。IdleDimBrush 是 Alpha=88 的纯黑,覆盖整屏但不喧宾夺主------它告诉你"截屏模式已启动",但不会遮挡你要截的内容。SelectionDimBrush 是 Alpha=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 是抽象基类,只带 Kind、Stroke、StrokeWidth 三个公共字段,具体的几何信息由子类承载:

复制代码
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 掉就能回退一步,不需要在每个工具类里维护 PreviousState。AnnotationEditor 是一个 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 渲染接口未初始化。

这一套测试在最初的几次重构中证明是有效的:把 BitmapCropper 从 Image.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 串联,没有循环依赖,没有共享私有代码段,也没有强制的配置注入点。要把它单独发布出去很简单:把 Core 与 UI 拷到一个新仓库,加一个 LumScreenShot 启动项目(App.axaml.cs 与 Program.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。

相关推荐
Omics Pro3 天前
~30,000+引用!理论2005,R包2008,多组学集成AI增强
开发语言·数据库·人工智能·算法·机器学习·自然语言处理·r语言
天桥下的卖艺者5 天前
chrals.CTI函数升级版发布,增加了CTI衍生的脂肪指数提取
数据库·r语言
小师兄吃牛肉6 天前
什么是R语言?如何快速学习R语言
开发语言·学习·r语言
喆星时瑜7 天前
Win10/11 安装 R 与 RStudio 详细图文教程
开发语言·r语言·安装
喆星时瑜7 天前
R语言基础语法入门
开发语言·r语言
喆星时瑜7 天前
R语言向量语法的详细教程
开发语言·r语言
临床数据科学和人工智能兴趣组9 天前
在R语言中, 使用 as.factor() 函数转换数值型变量
开发语言·r语言
babe小鑫19 天前
信息与计算科学专业应届生面试 怎么证明自己能解决业务问题
学习·r语言·excel
深兰科技20 天前
深兰科技受邀参与第二届中国(南宁)—东盟人工智能场景应用对接会,深化AI国际合作
人工智能·qt·r语言·scala·symfony·智能机器人·深兰科技
赵钰老师21 天前
基于ArcGIS Pro、R、INVEST等多技术融合下生态系统服务权衡与协同动态分析
python·arcgis·数据分析·r语言