技术栈与项目结构整个工具是 .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没有 WPFUI 层所有 XAML 都开启编译期生成发布产物体积和冷启动时间都控制在合理区间内。二、屏幕抓取选 BitBlt 而非 DXGIWindows 下截屏至少有三条路GDIBitBlt、DXGI Output Duplication、Windows.Graphics.CaptureWGC。第三条最现代、性能最好但要求 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 场景下为数不多的老派写法更可靠的地方。三、多显示器构造一个虚拟桌面EnumDisplayMonitorsMONITORINFOEX给到的是每个物理显示器的rcMonitor整个显示器范围、rcWork扣除任务栏的工作区、dwFlags是否为主屏、szDevice设备名。这套数据已经足够用来回答用户在哪个屏、屏幕有多大这些问题但额外构造了一个合成条目VirtualDesktopint 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 pixelsGDI、HWND、EnumDisplayMonitors全部使用1 个屏幕像素 1 个单位。DIPdevice-independent pixelsAvalonia UI 布局、WPF/WinUI 的世界1 DIP 1/96 英寸。Monitor DPI scale每个显示器独立常见 1.0100%、1.25125%、1.5150%、2.0200%。三者关系是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、ShowInTaskbarfalse、Topmosttrue、CanResizefalse全部为了把窗口变成跨满整屏的透明画布。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控件的StretchSourceRect组合在某些高 DPI 设备上有过不正确的变换 bug而DrawingContext.DrawImage走的是直接路径不会触发那些 layout 相关的副作用所以裁剪单独抽出一个最小化的Control来跑这个渲染。六、产品形态选区、暗部、工具栏技术问题解决之后剩下的就是做成什么样。产品形态的每个细节都是从用户行为倒推的。暗部遮罩解决的是框选时屏幕上其他内容是否清晰可见。IdleDimBrush是Alpha88的纯黑覆盖整屏但不喧宾夺主——它告诉你截屏模式已启动但不会遮挡你要截的内容。SelectionDimBrush是Alpha210覆盖选区外区域配合CombinedGeometry(Xor)把选区内挖空——这意味着选区内的原屏幕像素完全可见而选区外是接近全黑但仍能看出轮廓的暗罩。两档透明度分别对应未选和已选两个状态。选区拖拽用inputCatcher层一个全屏透明Border吸收初始按下事件因为它在选区外不会被选区内的 AnnotationCanvas 抢走事件而在选区内又会被更高 ZIndex 的标注层接管。_dragCreating标志区分创建新选区和修改现有选区宽度或高度小于 5 像素的选区会被视作误触而丢弃——5 像素是经验值足够大以至于肉眼能看清选区又不至于让手抖用户难以触发取消。8 个手柄放在SelectionAdorner里直接挂到 root CanvasZIndex 高于 AnnotationCanvas 但又不会被 canvas 内部的画布元素遮挡。HandleKind枚举覆盖 4 角、4 边鼠标光标按位置切换为TopLeftCorner/SizeNorthSouth/SizeWestEast等系统光标。手柄尺寸 10×10 像素半数落在选区外、数落在选区内——这样在选区边缘附近也能稳定抓取。ApplyHandle是一个纯函数传入开始时的选区 手柄类型 位移返回新选区使得 8 个方向的几何计算集中在一个方法里便于测试。尺寸标签实时显示当前选区的W x H位置在选区顶部上方若顶部空间不足则自动落到选区底部下方。它不是装饰品是用户做精确截取时唯一能依赖的当前框选区域尺寸的视觉反馈。工具栏是浮动的黑色半透明圆角条工具按钮按功能分组工具组选择 / 矩形 / 椭圆 / 箭头 / 画笔 / 文字 / 马赛克颜色组7 色红蓝绿橙黄白黑线宽组4 档2/3/5/8 DIP动作组撤销 / 保存 / 取消 / 完成每组有自己的CornerRadius8圆角容器组间用 1 像素分隔条区隔。视觉上像 macOS 截图工具的悬浮条但按键更大、间距更宽容——28×28 像素的按钮在 200% DPI 下也有 56 物理像素实际可点击区超过 Windows 默认 40 物理像素的最小推荐值。ToolbarButton内部维护_hover/_pressed/_active三个状态激活态用绿色描边、悬停态用半透明白底、按下态用更深的灰底——这种三态反馈在没有原生控件默认样式覆盖时尤其重要。工具栏位置自适应默认在选区正下方 12 像素下方空间不足时翻转到选区上方左右越界则向内收缩任何方向都不会落到屏幕外 8 像素的安全区外。这套位置策略写在PositionToolbar里每次选区变化后调用。键盘语义也经过明确分工Enter 完成confirmEsc 取消cancelCtrlZ 撤销。KeyDown用 Tunnel 路由注册避免子控件比如内联 TextBox吞掉事件。保存到磁盘则走CtrlS触发的另存为对话框——快捷保存是另一个独立的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 IReadOnlyListPoint 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; } }recordwith表达式的组合让修改某条标注等同于创建一条新 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_DIBWindows 桌面应用兼容性 CopyDibToClipboardWindows(bitmap);CopyDibToClipboardWindows把 bitmap 转成BITMAPINFOHEADER 原始 BGRA 字节通过OpenClipboard/EmptyClipboard/SetClipboardData(CF_DIB, hGlobal)推上去。biHeight必须写正数——与抓取时的 top-down 相反CF_DIB 期望 bottom-up。复制时行序翻转一次几十毫秒可接受。这种两路并存的策略让剪贴板内容在几乎所有 Windows 应用里都能正确粘贴。剪贴板写入是 best-efforttry/catch 静默吞掉异常因为剪贴板被其他进程占有时是常见情况用户刚刚 CtrlC 了一段文本不应该让截屏工具因此报错。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(TLTR 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