最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

使用C#從零開始實(shí)現(xiàn)一個(gè)截屏工具

 更新時(shí)間:2026年06月09日 08:43:35   作者:LdotJdot  
這篇文章主要為大家詳細(xì)介紹了如何使用C#從零開始實(shí)現(xiàn)一個(gè)截屏工具,文中的示例代碼講解詳細(xì),具有一定的借鑒價(jià)值,感興趣的小伙伴可以了解下

一、技術(shù)棧與項(xiàng)目結(jié)構(gòu)

整個(gè)工具是 .NET 10 + Avalonia 12.0.4 + Skia + Win32 PInvoke 的組合。技術(shù)選型上有幾個(gè)明確的核心依據(jù):UI 層選 Avalonia 而非 WPF 或 WinForms,關(guān)鍵在于其支持 Native AOT 發(fā)布——AOT 的目標(biāo)是把發(fā)布產(chǎn)物體積壓到單個(gè)可執(zhí)行文件級別,并把冷啟動(dòng)時(shí)間控制在 200ms 以內(nèi),這與"按下快捷鍵立即進(jìn)入選區(qū)"的產(chǎn)品目標(biāo)直接對齊;Avalonia 同時(shí)跨平臺(tái)、控件豐富、綁定體系現(xiàn)代,是滿足這個(gè)目標(biāo)的合理載體。屏幕抓取走 Win32 GDI 而非 DirectX 或 Windows.Graphics.Capture,是為了把系統(tǒng)依賴壓到最小——GDI 在所有 Windows 版本上都能工作,不要求 D3D 設(shè)備,不要求會(huì)話 0 提權(quán),也就不需要在目標(biāo)機(jī)器上預(yù)裝任何額外運(yùn)行時(shí)。.NET 10 選 Preview 是因?yàn)樗鼘?Avalonia 12 的 trim warning 處理最干凈,CI 跑 dotnet publish -c Release -r win-x64 能直接產(chǎn)出單一可執(zhí)行文件,復(fù)制到任意 Windows 機(jī)器上雙擊就能跑,不需要隨附 .NET 運(yùn)行時(shí)。

工程劃分遵循"核心庫 / UI 庫 / 啟動(dòng)殼 / 測試"四層結(jié)構(gòu):

src/
├── ScreenShot.Core/      # 抓取 + 注解模型(AOT 友好,無 System.Drawing)
├── ScreenShot.UI/        # Avalonia 控件、窗口、服務(wù)
├── LumScreenShot/        # 啟動(dòng) exe:雙擊/快捷鍵直接進(jìn)入截屏,結(jié)束即退出
├── ScreenShot.Demo/      # WinForms 演示宿主
└── ScreenShot.Test/      # Headless 單元測試,驗(yàn)證裁剪與位圖操作

Core 層只做兩件事:定義抽象的抓取接口(ICaptureService),以及實(shí)現(xiàn) Windows 平臺(tái)的具體類(Win32CaptureService)。注解模型——PenAnnotation、ArrowAnnotation、RectAnnotationEllipseAnnotation、TextAnnotation、MosaicAnnotation——也都放在這里,因?yàn)樗鼈兪瞧脚_(tái)無關(guān)的純數(shù)據(jù) record。UI 層負(fù)責(zé)把 Core 渲染出來,包括全屏選區(qū)窗口、工具欄、標(biāo)注畫布、暗部遮罩、保存服務(wù)、剪貼板服務(wù)。LumScreenShot 是發(fā)布出去的 exe,它的全部職責(zé)就是"啟動(dòng) → 彈出截屏 → 退出",連 MainWindow 都不需要——ShutdownMode 設(shè)成 OnExplicitShutdown,讓進(jìn)程的生命周期嚴(yán)格綁在用戶的截屏行為上。ScreenShot.Test 用 Avalonia 的 Headless 后端跑無窗口渲染,專門驗(yàn)證 BitmapCropper.Crop 不會(huì)把全屏圖錯(cuò)切成選區(qū)。

這種劃分最直接的好處是 Native AOT 編譯路徑暢通。Core 層除了 PInvoke 之外不依賴任何反射密集型庫(沒有 System.Drawing,沒有 WPF),UI 層所有 XAML 都開啟編譯期生成,發(fā)布產(chǎn)物體積和冷啟動(dòng)時(shí)間都控制在合理區(qū)間內(nèi)。

二、屏幕抓?。哼x BitBlt 而非 DXGI

Windows 下截屏至少有三條路:GDI BitBlt、DXGI Output Duplication、Windows.Graphics.Capture(WGC)。第三條最現(xiàn)代、性能最好,但要求 Win10 1903+ 且對部分虛擬化/遠(yuǎn)程桌面場景兼容性差;DXGI 中間,需要 D3D 設(shè)備上下文,對純截圖而言過重;GDI 幾乎在所有 Windows 版本、所有會(huì)話、所有虛擬化層上都能工作,是穩(wěn)定性最高的兜底方案。代價(jià)是性能——BitBlt 是 GDI 軟件路徑,在 4K 屏上會(huì)有可感知的延遲。但截屏是低頻操作,這點(diǎn)延遲可以接受。

具體的數(shù)據(jù)流走的是一條很經(jīng)典的 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);

幾個(gè)值得展開的細(xì)節(jié)。GetDC(IntPtr.Zero) 拿到的是整個(gè)屏幕的 DC,作用范圍跨所有顯示器;CreateCompatibleBitmap 必須以 screenDc 為基準(zhǔn),否則顏色深度會(huì)落到默認(rèn) 1bpp。BitBlt 的最后一個(gè)參數(shù)除了 SRCCOPY 之外還要按位或上 CAPTUREBLT(值為 0x40000000),否則抓不到 layered window 的實(shí)際內(nèi)容——很多應(yīng)用的懸浮窗、置頂通知、某些視頻播放器的字幕層都是 layered window,省了 CAPTUREBLT 會(huì)得到一塊"看起來沒東西"的區(qū)域。

光柵化步驟需要把 GDI bitmap 轉(zhuǎn)成 Avalonia 期望的格式。GetDIBits 的關(guān)鍵技巧是把 biHeight 設(shè)為負(fù)值(-h)——GDI 默認(rèn)輸出 bottom-up(行從下往上排列),Avalonia 的 WriteableBitmapPixelFormat.Bgra8888 下要求 top-down。把 biHeight 寫成負(fù)數(shù)后,GDI 就直接輸出 top-down 排列的 BGRA 字節(jié)流,省掉一次翻轉(zhuǎn)。WriteableBitmap 構(gòu)造時(shí)把 DPI 顯式寫為 (96, 96)——這是有意為之,因?yàn)楹罄m(xù)的所有尺寸換算都基于"像素 == 布局像素"的前提,bitmap 的元數(shù)據(jù) DPI 字段不參與布局。

GDI 給的 buffer 行寬可能與 Avalonia 內(nèi)部 RowBytes 不完全一致(前者是 w*4,后者可能因?yàn)閷R而略大),所以逐行用 Buffer.MemoryCopy 而不是整塊拷貝。這一步在 4K 屏上也是大頭之一,但相對于 BitBlt 本身的延遲可以忽略。

Win32 內(nèi)部全部使用經(jīng)典 DllImport 而非 LibraryImport 源生成器——后者對包含嵌入變長字符串的結(jié)構(gòu)體(MONITORINFOEX.szDevice)支持不全,硬上會(huì)得到靜默的亂碼。這是 PInvoke 在 AOT 場景下為數(shù)不多的"老派寫法更可靠"的地方。

三、多顯示器:構(gòu)造一個(gè)"虛擬桌面"

EnumDisplayMonitors + MONITORINFOEX 給到的是每個(gè)物理顯示器的 rcMonitor(整個(gè)顯示器范圍)、rcWork(扣除任務(wù)欄的工作區(qū))、dwFlags(是否為主屏)、szDevice(設(shè)備名)。這套數(shù)據(jù)已經(jīng)足夠用來回答"用戶在哪個(gè)屏"、"屏幕有多大"這些問題,但額外構(gòu)造了一個(gè)合成條目 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 的并集,邏輯上等價(jià)于 GDI 的"整個(gè)屏幕 DC"——也就是 GetDC(IntPtr.Zero) 覆蓋的范圍。它的存在讓上層調(diào)用者不必關(guān)心"當(dāng)前是截一塊屏還是所有屏":傳單屏對象得到單屏 bitmap,傳虛擬桌面對象得到跨屏的整張 bitmap。DeviceName == "VirtualDesktop" 這個(gè)字符串在調(diào)用方代碼里反復(fù)出現(xiàn)作為"截取整張?zhí)摂M桌面"的標(biāo)記。

鼠標(biāo)光標(biāo)當(dāng)前所在屏由 GetScreenAtCursor 解析。流程是先用 GetCursorPos 拿到物理像素坐標(biāo),再遍歷物理顯示器列表做 Bounds.Contains 命中。命中失敗時(shí)退回到主屏,再失敗則取列表首項(xiàng)——這套 fallback 鏈確保即便驅(qū)動(dòng)返回畸形數(shù)據(jù)也不會(huì)讓截屏入口拋異常。啟動(dòng)截屏?xí)r只捕獲光標(biāo)所在屏而不是虛擬桌面,是一個(gè)產(chǎn)品決定:用戶的工作區(qū)域通常集中在某個(gè)屏上,截全屏?xí)聿槐匾奶幚碡?fù)擔(dān)(更大的內(nèi)存、更慢的 GDI 調(diào)用、UI 加載更慢),而只截單屏已經(jīng)能覆蓋 90% 的用例。

ScreenInfo 記錄用 Avalonia.Rect 而非自定義的 RECT 結(jié)構(gòu)——這是個(gè)有意識的選擇。Avalonia 的 Rect 跨平臺(tái)、不可變、便于在 Core 與 UI 之間共享,避免了把 Win32 類型泄漏到平臺(tái)無關(guān)層。

四、DPI 縮放:物理像素、DIP、Monitor DPI 的三角關(guān)系

DPI 是這套系統(tǒng)里最容易出錯(cuò)的地方。截屏工具面臨的"坐標(biāo)"有三種:

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

三者關(guān)系是 physical = DIP * scale。Windows 10 1703 之后 per-monitor DPI V2 普及,每個(gè)顯示器可以獨(dú)立縮放。這意味著用戶可能在主屏 100%、副屏 200% 的混合環(huán)境下工作,鼠標(biāo)從主屏滑到副屏的瞬間,光標(biāo)坐標(biāo)與窗口坐標(biāo)的關(guān)系會(huì)發(fā)生跳變。

抓取層首先用 GetDpiForMonitor(MDT_EFFECTIVE_DPI) 取每個(gè)顯示器的 effective DPI,存到 ScreenInfo.DpiScaleMDT_EFFECTIVE_DPIMDT_ANGULAR_DPI、MDT_RAW_DPI 的區(qū)別在于,前者是 Windows 推薦應(yīng)用使用的 DPI,會(huì)考慮用戶手動(dòng)覆蓋與兼容性縮放。返回值是 0(成功)或非 0(錯(cuò)誤碼),失敗時(shí)退回到 1.0——1.0 是不帶任何縮放信息的"裸物理像素",是個(gè)安全的默認(rèn)值。

把 DPI 信息帶回到 ScreenInfo 是關(guān)鍵決定:調(diào)用方拿到的不僅是"屏有多大",還有"屏的物理-DIP 換算系數(shù)是多少"。后續(xù)的窗口布局、坐標(biāo)轉(zhuǎn)換全部基于這個(gè)系數(shù),而不是依賴運(yùn)行時(shí)再去查 Screens[i].Scaling——后者雖然是 Avalonia 自己的 API,但它的 Screens 列表在窗口創(chuàng)建之初可能尚未完全初始化,必須在 Opened 之后才能保證可用。

五、Avalonia 坐標(biāo):與 Win32 的銜接

ScreenShotWindow 啟動(dòng)時(shí)拿到的 physicalBounds 是物理像素矩形(PixelRect),但 Avalonia 的 Window.Width/Height 期望 DIP。所以構(gòu)造函數(shù)先把窗口尺寸設(shè)為 physicalBounds / dpiScale,再調(diào)用 SetWindowPos 強(qiáng)制把原生 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);

這套"邏輯尺寸 + 物理位置"的組合有幾個(gè)微妙之處。第一,DPI 取值時(shí)機(jī)_dpiScale 的初值是 targetScreen.DpiScale(來自 Core 層),但 Avalonia 自己在 Opened 之后會(huì)重新計(jì)算 Screens.ScreenFromPoint(...).Scaling,兩者理論上應(yīng)該一致,實(shí)踐中有概率不一致——AOT 場景下尤其如此。所以 OnScreenshotOpened 里會(huì)再調(diào)用一次 ApplyScreenLayout,并且在 DispatcherPriority.Loaded 上 post 一次重排,讓第一幀布局完成之后再糾正一次尺寸。

第二,HWND 物理尺寸SetWindowPos 接受的單位是物理像素,所以傳 physicalBounds.Width/Height 而非 Width/Height。Avalonia 的 Window.Position 雖然也能用 DPI 縮放,但它內(nèi)部會(huì)做"邏輯位置 → 物理位置"的換算,與直接通過 SetWindowPos 設(shè)置的物理位置可能產(chǎn)生沖突。所以正確做法是只相信 Avalonia 的 Position 來表達(dá)"邏輯位置"(本項(xiàng)目場景下用不到),原生位置用 SetWindowPos 直接釘死。

第三,窗口模式WindowState.Normal、WindowDecorations.None、ShowInTaskbar=false、Topmost=true、CanResize=false,全部為了把窗口變成"跨滿整屏的透明畫布"。Background = Brushes.Transparent 讓沒截到的內(nèi)容(選區(qū)外)透出原屏幕,截到的內(nèi)容(選區(qū)內(nèi))覆蓋在原屏幕之上,組合出"截圖編輯器"而不是"另一個(gè)窗口"。

鼠標(biāo)坐標(biāo)方面,Avalonia 的 PointerEventArgs.GetPosition(...) 給的是相對坐標(biāo),用它做選區(qū)矩形的幾何運(yùn)算時(shí)不需要關(guān)心物理/DIP 換算——所有幾何都在 DIP 空間內(nèi)進(jìn)行,最后在裁剪位圖時(shí)才轉(zhuǎn)成物理像素。這種"全程 DIP,落地時(shí)才轉(zhuǎn)物理"的做法把坐標(biāo)混亂壓到了邊界上。

BitmapCropper.Crop 是另一處需要明確的坐標(biāo)契約。selection 參數(shù)是相對于源 bitmap 的"邏輯坐標(biāo)"——也就是說,它的單位與源 bitmap 的 PixelSize 對應(yīng)的就是物理像素。裁剪時(shí)使用 DrawingContext.DrawImage(source, sourceRect, destRect),源矩形是 selection,目標(biāo)矩形是 (0, 0, w, h),這樣保證 1:1、無縮放、無插值。從歷史上看,Image 控件的 Stretch + SourceRect 組合在某些高 DPI 設(shè)備上有過不正確的變換 bug,而 DrawingContext.DrawImage 走的是直接路徑,不會(huì)觸發(fā)那些 layout 相關(guān)的副作用,所以裁剪單獨(dú)抽出一個(gè)最小化的 Control 來跑這個(gè)渲染。

六、產(chǎn)品形態(tài):選區(qū)、暗部、工具欄

技術(shù)問題解決之后,剩下的就是"做成什么樣"。產(chǎn)品形態(tài)的每個(gè)細(xì)節(jié)都是從用戶行為倒推的。

暗部遮罩 解決的是"框選時(shí)屏幕上其他內(nèi)容是否清晰可見"。IdleDimBrushAlpha=88 的純黑,覆蓋整屏但不喧賓奪主——它告訴你"截屏模式已啟動(dòng)",但不會(huì)遮擋你要截的內(nèi)容。SelectionDimBrushAlpha=210,覆蓋選區(qū)外區(qū)域,配合 CombinedGeometry(Xor) 把選區(qū)內(nèi)"挖空"——這意味著選區(qū)內(nèi)的原屏幕像素完全可見,而選區(qū)外是接近全黑但仍能看出輪廓的暗罩。兩檔透明度分別對應(yīng)"未選"和"已選"兩個(gè)狀態(tài)。

選區(qū)拖拽inputCatcher 層(一個(gè)全屏透明 Border)吸收初始按下事件,因?yàn)樗谶x區(qū)外不會(huì)被選區(qū)內(nèi)的 AnnotationCanvas 搶走事件,而在選區(qū)內(nèi)又會(huì)被更高 ZIndex 的標(biāo)注層接管。_dragCreating 標(biāo)志區(qū)分"創(chuàng)建新選區(qū)"和"修改現(xiàn)有選區(qū)",寬度或高度小于 5 像素的"選區(qū)"會(huì)被視作誤觸而丟棄——5 像素是經(jīng)驗(yàn)值,足夠大以至于肉眼能看清選區(qū),又不至于讓手抖用戶難以觸發(fā)"取消"。

8 個(gè)手柄 放在 SelectionAdorner 里直接掛到 root Canvas,ZIndex 高于 AnnotationCanvas 但又不會(huì)被 canvas 內(nèi)部的畫布元素遮擋。HandleKind 枚舉覆蓋 4 角、4 邊,鼠標(biāo)光標(biāo)按位置切換為 TopLeftCorner / SizeNorthSouth / SizeWestEast 等系統(tǒng)光標(biāo)。手柄尺寸 10×10 像素,半數(shù)落在選區(qū)外、數(shù)落在選區(qū)內(nèi)——這樣在選區(qū)邊緣附近也能穩(wěn)定抓取。ApplyHandle 是一個(gè)純函數(shù),傳入"開始時(shí)的選區(qū) + 手柄類型 + 位移"返回新選區(qū),使得 8 個(gè)方向的幾何計(jì)算集中在一個(gè)方法里,便于測試。

尺寸標(biāo)簽 實(shí)時(shí)顯示當(dāng)前選區(qū)的 W x H,位置在選區(qū)頂部上方;若頂部空間不足則自動(dòng)落到選區(qū)底部下方。它不是裝飾品,是用戶做精確截取時(shí)唯一能依賴的"當(dāng)前框選區(qū)域尺寸"的視覺反饋。

工具欄 是浮動(dòng)的黑色半透明圓角條,工具按鈕按功能分組:

  • 工具組:選擇 / 矩形 / 橢圓 / 箭頭 / 畫筆 / 文字 / 馬賽克
  • 顏色組:7 色(紅藍(lán)綠橙黃白黑)
  • 線寬組:4 檔(2/3/5/8 DIP)
  • 動(dòng)作組:撤銷 / 保存 / 取消 / 完成

每組有自己的 CornerRadius=8 圓角容器,組間用 1 像素分隔條區(qū)隔。視覺上像 macOS 截圖工具的懸浮條,但按鍵更大、間距更寬容——28×28 像素的按鈕在 200% DPI 下也有 56 物理像素,實(shí)際可點(diǎn)擊區(qū)超過 Windows 默認(rèn) 40 物理像素的最小推薦值。ToolbarButton 內(nèi)部維護(hù) _hover/_pressed/_active 三個(gè)狀態(tài),激活態(tài)用綠色描邊、懸停態(tài)用半透明白底、按下態(tài)用更深的灰底——這種三態(tài)反饋在沒有原生控件默認(rèn)樣式覆蓋時(shí)尤其重要。

工具欄位置自適應(yīng):默認(rèn)在選區(qū)正下方 12 像素;下方空間不足時(shí)翻轉(zhuǎn)到選區(qū)上方;左右越界則向內(nèi)收縮;任何方向都不會(huì)落到屏幕外 8 像素的安全區(qū)外。這套位置策略寫在 PositionToolbar 里,每次選區(qū)變化后調(diào)用。

鍵盤語義 也經(jīng)過明確分工:Enter = 完成(confirm),Esc = 取消(cancel),Ctrl+Z = 撤銷。KeyDown 用 Tunnel 路由注冊,避免子控件(比如內(nèi)聯(lián) TextBox)吞掉事件。保存到磁盤則走 Ctrl+S 觸發(fā)的"另存為"對話框——快捷保存是另一個(gè)獨(dú)立的 TryQuickSaveAsync 路徑,不帶對話框。

七、標(biāo)注系統(tǒng):六種工具的統(tǒng)一模型

標(biāo)注的核心是"不可變 record + 集中式編輯器"。

Annotation 是抽象基類,只帶 Kind、Stroke、StrokeWidth 三個(gè)公共字段,具體的幾何信息由子類承載:

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 表達(dá)式的組合讓"修改某條標(biāo)注"等同于"創(chuàng)建一條新 record"——這種不可變性在撤銷/重做場景下特別順手:把 Items[count-1] pop 掉就能回退一步,不需要在每個(gè)工具類里維護(hù) PreviousState。AnnotationEditor 是一個(gè) static class,集中處理所有標(biāo)注的 hit-test、bounds、transform,是這套系統(tǒng)的"幾何大腦"。

HitTest 的實(shí)現(xiàn)按標(biāo)注類型分而治之:矩形/橢圓/馬賽克都基于 Inflate(rect, HitTolerance) 的容差矩形;箭頭/畫筆的判定稍復(fù)雜,用點(diǎn)到線段的距離公式 DistanceToSegment,比直接 Bounds.Contains 精確得多(用 bounds 的話,斜線會(huì)過早被判定為不命中)。HandleKind 枚舉包括 Body(整體移動(dòng))、8 個(gè) resize 方向、以及箭頭專屬的 StartPoint/EndPoint——這種"按標(biāo)注類型暴露不同 handle"的模型使得拖動(dòng)箭頭終點(diǎn)時(shí)整個(gè)箭頭形狀實(shí)時(shí)變化,而矩形手柄只動(dòng)矩形邊。

馬賽克是其中最特別的一個(gè)。它的 Rect 描述的是被遮蓋區(qū)域,渲染時(shí)按 BlockSize 把區(qū)域劃分為網(wǎng)格,每格取其內(nèi)部像素的平均色再貼回——視覺上就是模糊化處理。這種"標(biāo)注 = 后期變換"的抽象讓馬賽克與矩形共享同一套 resize/移動(dòng)邏輯,但渲染路徑完全不同,是 record 模型帶來的靈活性。

文字標(biāo)注有內(nèi)聯(lián)編輯:用戶點(diǎn)下文字工具 → 在畫布上拖出位置 → 彈出內(nèi)嵌 TextBox → 輸入完成回車 → 創(chuàng)建 TextAnnotation。期間所有其他標(biāo)注工具被臨時(shí)屏蔽,避免誤操作。文字縮放也走 handle 路徑——ApplyTextHandle 計(jì)算新 bounds 與原始文字尺寸的比例,把 FontSize 按比例縮放,最小 8 像素、最大 3 倍。

撤銷棧深度 50,由 AnnotationCanvas.UndoLimit 控制。每次"提交"一個(gè)標(biāo)注(鼠標(biāo)抬起時(shí))會(huì)觸發(fā) Committed 事件,工具欄據(jù)此更新 Undo 按鈕的可用狀態(tài)。

八、雙擊自動(dòng)保存與"快門閃光"

截屏工具的一個(gè)隱藏痛點(diǎn)是"截圖成功了但用戶不知道"。系統(tǒng)通知不夠醒目,剪貼板變更在很多應(yīng)用里沒指示,保存到磁盤也無聲無息。本項(xiàng)目用一個(gè) 250ms 的徑向閃光模擬相機(jī)快門反饋:

// 白邊 + 暗中心的徑向 gradient overlay,漸變 250ms 后淡出
var flash = new Rectangle { Fill = FlashBrush, IsHitTestVisible = false };

PlaySaveFlashAndCloseAsync 在確認(rèn)保存成功后插入這段動(dòng)畫再關(guān)閉窗口。視覺上像是"按了一下快門"——這種物理隱喻對截圖這種"咔嚓一下"的體驗(yàn)是恰當(dāng)?shù)?,?quot;已保存到 ..."的文字通知有記憶點(diǎn)。

"雙擊自動(dòng)保存"對應(yīng)的入口是 TryQuickSaveAsync(saveDirectory, fallbackDirectory):構(gòu)造一個(gè)"已選區(qū)確認(rèn)"的結(jié)果,按 snapshot_yyyyMMdd_HHmmss_fff.png 命名規(guī)則寫到指定目錄;若文件名已存在則追加 _1、_2 序號;1000 次都重名則用 Guid.NewGuid() 兜底。目錄不存在時(shí)嘗試創(chuàng)建,權(quán)限不足或 IO 錯(cuò)誤時(shí)返回結(jié)構(gòu)化錯(cuò)誤((false, null, "Access denied: ...")),調(diào)用方負(fù)責(zé)顯示提示。這個(gè)接口是為外部宿主(命令行調(diào)用、Agent 工具調(diào)用)設(shè)計(jì)的——典型用法是 Agent 在檢測到屏幕上出現(xiàn)錯(cuò)誤信息時(shí)直接調(diào)用截屏 API,結(jié)果靜默落到本地目錄。

寫文件路徑用 BuildUniquePath 而非 GUID 主鍵,是有意為之。yyyyMMdd_HHmmss_fff 這種命名在資源管理器里是自然排序的,毫秒級精度足以避免同日內(nèi)的秒級沖突;附加 _1 序號解決同毫秒內(nèi)的極端并發(fā)(極少見,但理論上存在)。這套命名比 GUID 友好得多,因?yàn)橛脩敉ǔP枰吹?quot;這是今天 14:23:05 那張圖"。

九、剪貼板:CF_DIB 兼容

把 bitmap 復(fù)制到剪貼板看似一行代碼,但跨應(yīng)用粘貼是個(gè)雷區(qū)。Avalonia 的 Clipboard.SetBitmapAsync 在自家應(yīng)用、瀏覽器、VS Code 內(nèi)部粘貼都正常,但粘貼到 Paint、Word、Discord、Slack 等 Windows 桌面應(yīng)用時(shí)經(jīng)常失敗——這些應(yīng)用期望的是 GDI 標(biāo)準(zhǔn)的 CF_DIB 格式,而不是 Avalonia 內(nèi)部的高層 representation。

解決方案是同時(shí) push 兩份數(shù)據(jù):

// 高層 API(跨平臺(tái) fallback)
await clipboard.SetBitmapAsync(bitmap);
// 底層 CF_DIB(Windows 桌面應(yīng)用兼容性)
CopyDibToClipboardWindows(bitmap);

CopyDibToClipboardWindows 把 bitmap 轉(zhuǎn)成 BITMAPINFOHEADER + 原始 BGRA 字節(jié),通過 OpenClipboard / EmptyClipboard / SetClipboardData(CF_DIB, hGlobal) 推上去。biHeight 必須寫正數(shù)——與抓取時(shí)的 top-down 相反,CF_DIB 期望 bottom-up。復(fù)制時(shí)行序翻轉(zhuǎn)一次,幾十毫秒可接受。這種"兩路并存"的策略讓剪貼板內(nèi)容在幾乎所有 Windows 應(yīng)用里都能正確粘貼。

剪貼板寫入是 best-effort:try/catch 靜默吞掉異常,因?yàn)榧糍N板被其他進(jìn)程占有時(shí)是常見情況(用戶剛剛 Ctrl+C 了一段文本),不應(yīng)該讓截屏工具因此報(bào)錯(cuò)。SetClipboardData 返回的句柄在系統(tǒng)接管內(nèi)存之后不能再 FreeHGlobal——這是 Win32 文檔里反復(fù)強(qiáng)調(diào)但容易忽略的細(xì)節(jié)。

十、可測試性:Headless 驗(yàn)證裁剪

BitmapCropper 是最關(guān)鍵的純函數(shù)——它把選區(qū)矩形從源 bitmap 中切出來,1:1,無縮放。這種"看起來簡單但歷史上 bug 多發(fā)"的函數(shù)必須有自動(dòng)化測試覆蓋。

ScreenShot.Test 用 Avalonia 的 Headless 后端跑無窗口渲染(AppBuilder.UseSkia().UseHeadless(...)),先構(gòu)造一張 200×200 的四象限位圖(紅/綠/藍(lán)/黃),然后對每個(gè)象限、每個(gè)跨象限區(qū)域做 Crop,并對結(jié)果 bitmap 的每個(gè)像素采樣,驗(yàn)證顏色完全匹配期望:

// TL 象限必須全紅
Check("TL red", source, new Rect(0, 0, 100, 100), Red, ref failures);
// 跨象限區(qū)域每個(gè)像素必須 = 紅 or 綠
CheckCrossQuadrant("TL+TR cross", source, new Rect(80, 20, 60, 40), Red, Green, ref failures);

跨象限的"必須是紅或綠"測試特別有價(jià)值——它能抓出"裁剪返回了全屏而不是選區(qū)"這一類歷史 bug:如果代碼不小心把 DrawImage(source, selectionRect, destRect) 寫成 DrawImage(source, fullRect, destRect),跨象限測試會(huì)立刻在中間象限里發(fā)現(xiàn)不屬于紅/綠的顏色。失敗時(shí)把錯(cuò)誤的 bitmap 寫到 %TEMP%\screenshot_test_FAIL_<name>.png,便于人工核查。

Headless 后端的價(jià)值在于把 Avalonia 強(qiáng)綁的 RenderTargetBitmap 渲染流程跑在沒有顯示器的 CI 機(jī)器上。它不需要 X Server,不需要 DirectX,甚至不需要 Avalon 資源;唯一的約束是必須在 SetupWithoutStarting() 之后才能創(chuàng)建 WriteableBitmap,否則 Skia 渲染接口未初始化。

這一套測試在最初的幾次重構(gòu)中證明是有效的:把 BitmapCropperImage.SourceRect 遷移到 DrawingContext.DrawImage 的時(shí)候,跨象限測試立刻報(bào)告了"邊界附近的像素錯(cuò)位"的失敗,指明 0.5 像素的 subpixel 取整 bug——這種問題在真機(jī)截屏里極難復(fù)現(xiàn),但在 headless 測試?yán)锸谴_定性的。

十一、知識擴(kuò)展

以下是一個(gè)完整的基于 C# 和 Windows Forms 的截圖工具實(shí)現(xiàn),支持全屏截圖、區(qū)域選擇截圖,并可將截圖保存為 PNG 或 JPEG 文件。具體代碼如下:

功能概述

  • 全屏截圖:一鍵截取整個(gè)屏幕。
  • 區(qū)域截圖:鼠標(biāo)拖拽選擇矩形區(qū)域,釋放后自動(dòng)截取所選區(qū)域。
  • 截圖后顯示預(yù)覽窗口,可選擇保存路徑和格式(PNG/JPG)。
  • 支持復(fù)制到剪貼板。
  • 任務(wù)欄托盤圖標(biāo),最小化到托盤。

核心代碼

FormMain.cs

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Windows.Forms;
namespace ScreenshotTool
{
    public partial class FormMain : Form
    {
        public FormMain()
        {
            InitializeComponent();
            this.ShowInTaskbar = true;
        }
        // 全屏截圖
        private void btnFullScreen_Click(object sender, EventArgs e)
        {
            // 獲取所有屏幕的總邊界
            Rectangle bounds = Screen.GetBounds(Point.Empty);
            using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height))
            {
                using (Graphics g = Graphics.FromImage(bitmap))
                {
                    g.CopyFromScreen(Point.Empty, Point.Empty, bounds.Size);
                }
                ShowPreview(bitmap);
            }
        }
        // 區(qū)域截圖
        private void btnRegion_Click(object sender, EventArgs e)
        {
            this.Hide(); // 隱藏主窗體
            using (SelectionForm selection = new SelectionForm())
            {
                if (selection.ShowDialog() == DialogResult.OK && selection.SelectedImage != null)
                {
                    ShowPreview(selection.SelectedImage);
                }
            }
            this.Show();
        }
        // 顯示預(yù)覽并保存
        private void ShowPreview(Bitmap bmp)
        {
            using (PreviewForm preview = new PreviewForm(bmp))
            {
                preview.ShowDialog();
            }
        }
    }
}

SelectionForm.cs(區(qū)域選擇窗體)

using System;
using System.Drawing;
using System.Windows.Forms;
namespace ScreenshotTool
{
    public partial class SelectionForm : Form
    {
        private Rectangle rect = Rectangle.Empty;
        private Point startPoint;
        private Bitmap screenShot;
        public Bitmap SelectedImage { get; private set; }
        public SelectionForm()
        {
            InitializeComponent();
            this.DoubleBuffered = true;
            this.FormBorderStyle = FormBorderStyle.None;
            this.WindowState = FormWindowState.Maximized;
            this.BackColor = Color.Black;
            this.Opacity = 0.3; // 半透明遮罩
            this.Cursor = Cursors.Cross;
            // 截取當(dāng)前完整屏幕作為背景
            Rectangle bounds = Screen.GetBounds(Point.Empty);
            using (Bitmap full = new Bitmap(bounds.Width, bounds.Height))
            using (Graphics g = Graphics.FromImage(full))
            {
                g.CopyFromScreen(Point.Empty, Point.Empty, bounds.Size);
                screenShot = new Bitmap(full);
            }
            this.BackgroundImage = screenShot;
            this.BackgroundImageLayout = ImageLayout.Stretch;
        }
        protected override void OnMouseDown(MouseEventArgs e)
        {
            if (e.Button == MouseButtons.Left)
            {
                startPoint = e.Location;
                rect = new Rectangle(e.Location, Size.Empty);
                this.Invalidate();
            }
            base.OnMouseDown(e);
        }
        protected override void OnMouseMove(MouseEventArgs e)
        {
            if (e.Button == MouseButtons.Left)
            {
                rect = new Rectangle(
                    Math.Min(startPoint.X, e.X),
                    Math.Min(startPoint.Y, e.Y),
                    Math.Abs(startPoint.X - e.X),
                    Math.Abs(startPoint.Y - e.Y));
                this.Invalidate();
            }
            base.OnMouseMove(e);
        }
        protected override void OnMouseUp(MouseEventArgs e)
        {
            if (e.Button == MouseButtons.Left && rect.Width > 0 && rect.Height > 0)
            {
                // 截取所選區(qū)域
                SelectedImage = new Bitmap(rect.Width, rect.Height);
                using (Graphics g = Graphics.FromImage(SelectedImage))
                {
                    g.DrawImage(screenShot, new Rectangle(0, 0, rect.Width, rect.Height), rect, GraphicsUnit.Pixel);
                }
                this.DialogResult = DialogResult.OK;
                this.Close();
            }
            else
            {
                this.DialogResult = DialogResult.Cancel;
                this.Close();
            }
            base.OnMouseUp(e);
        }
        protected override void OnPaint(PaintEventArgs e)
        {
            base.OnPaint(e);
            if (rect != null && rect.Width > 0 && rect.Height > 0)
            {
                // 繪制選區(qū)邊框
                using (Pen pen = new Pen(Color.Red, 2))
                {
                    e.Graphics.DrawRectangle(pen, rect);
                }
            }
        }
        protected override void OnKeyDown(KeyEventArgs e)
        {
            if (e.KeyCode == Keys.Escape)
            {
                this.DialogResult = DialogResult.Cancel;
                this.Close();
            }
            base.OnKeyDown(e);
        }
    }
}

PreviewForm.cs(截圖預(yù)覽與保存)

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Windows.Forms;
namespace ScreenshotTool
{
    public partial class PreviewForm : Form
    {
        private Bitmap screenshot;
        public PreviewForm(Bitmap bmp)
        {
            InitializeComponent();
            this.screenshot = bmp;
            this.pictureBox1.Image = bmp;
            this.pictureBox1.SizeMode = PictureBoxSizeMode.Zoom;
        }
        private void btnSave_Click(object sender, EventArgs e)
        {
            using (SaveFileDialog sfd = new SaveFileDialog())
            {
                sfd.Filter = "PNG圖片|*.png|JPEG圖片|*.jpg";
                sfd.DefaultExt = "png";
                if (sfd.ShowDialog() == DialogResult.OK)
                {
                    ImageFormat format = sfd.FilterIndex == 1 ? ImageFormat.Png : ImageFormat.Jpeg;
                    screenshot.Save(sfd.FileName, format);
                    MessageBox.Show("保存成功!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
                }
            }
        }
        private void btnCopy_Click(object sender, EventArgs e)
        {
            Clipboard.SetImage(screenshot);
            MessageBox.Show("已復(fù)制到剪貼板", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
        }
        private void btnCancel_Click(object sender, EventArgs e)
        {
            this.Close();
        }
    }
}

十二、總結(jié)

從結(jié)果看,這個(gè)工具在工程上值得展開的,是一組清晰的坐標(biāo)模型與產(chǎn)品形態(tài)決策。多顯示器、per-monitor DPI、Avalonia 坐標(biāo)模型這三者之間的關(guān)系是最容易出 bug 的地方,處理的核心思路是"全程 DIP、落地才轉(zhuǎn)物理",所有幾何在 DIP 空間內(nèi)運(yùn)算,最后在寫入/裁剪時(shí)統(tǒng)一換算到物理像素——這一決策直接消除了 90% 的高 DPI 適配問題。產(chǎn)品形態(tài)上則要克制,不堆長截屏、滾動(dòng)截屏、OCR 這些功能,讓"打開 → 截圖 → 關(guān)閉"這條主鏈路盡量短。

這套截屏模塊的設(shè)計(jì)從一開始就是奔著"可剝離發(fā)布"去的。ScreenShot.Core 只做平臺(tái)無關(guān)的接口與數(shù)據(jù)模型,ScreenShot.UI 把 Avalonia 控件與窗口封裝成一個(gè)庫,LumScreenShot 是一個(gè)極薄的啟動(dòng)殼——它們之間通過 ProjectReference 串聯(lián),沒有循環(huán)依賴,沒有共享私有代碼段,也沒有強(qiáng)制的配置注入點(diǎn)。要把它單獨(dú)發(fā)布出去很簡單:把 CoreUI 拷到一個(gè)新倉庫,加一個(gè) LumScreenShot 啟動(dòng)項(xiàng)目(App.axaml.csProgram.cs 加起來不到五十行),dotnet publish -c Release -r win-x64 就能得到一個(gè)獨(dú)立的 AOT exe。發(fā)布產(chǎn)物體積在 Native AOT 模式下壓到了單個(gè)可執(zhí)行文件級別,冷啟動(dòng)到進(jìn)入選區(qū)狀態(tài)在 200ms 以內(nèi);運(yùn)行時(shí)不需要 .NET 運(yùn)行時(shí),不需要任何 NuGet 依賴文件,復(fù)制到任意一臺(tái) Windows 機(jī)器上雙擊就能跑。

最自然的部署方式是把 exe 放到桌面或者 C:\Tools\ 之類固定位置,然后創(chuàng)建快捷方式并指定一個(gè)全局快捷鍵(Windows 自帶的"快捷方式 → 快捷鍵"字段、或者 AutoHotkey 之類的小工具都行)。按下快捷鍵,調(diào)起截屏,框選,標(biāo)注,保存到預(yù)設(shè)目錄或復(fù)制到剪貼板,進(jìn)程退出——整個(gè)過程不會(huì)留下任何托盤圖標(biāo)、后臺(tái)服務(wù)、注冊表項(xiàng)。如果已經(jīng)習(xí)慣用其他全局快捷鍵工具(比如 PowerToys、uTools、Raycast 的 Windows 替代品),那直接把它當(dāng)作一個(gè)可執(zhí)行命令注冊進(jìn)去也很順滑,不會(huì)和原有工作流沖突。

整個(gè)截屏模塊已經(jīng)作為子項(xiàng)目集成進(jìn)了 tds 項(xiàng)目的 tds/screenShot/ 目錄中,新朋友關(guān)注螢火初芒回復(fù) tds 即可獲取倉庫地址。tds 是一個(gè)文件搜索軟件,截屏只是其中一個(gè)能力;如果只想用截屏這一塊,單獨(dú)剝離也毫無負(fù)擔(dān),三五分鐘就能拉出獨(dú)立倉庫并完成首次 publish。

以上就是使用C#從零開始實(shí)現(xiàn)一個(gè)截屏工具的詳細(xì)內(nèi)容,更多關(guān)于C#截屏工具的資料請關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

最新評論

深泽县| 和硕县| 文成县| 东台市| 通山县| 涿鹿县| 化隆| 孟津县| 金塔县| 绥宁县| 台北县| 休宁县| 始兴县| 金阳县| 伊宁市| 仪陇县| 宁蒗| 巴彦淖尔市| 游戏| 永济市| 龙井市| 阳信县| 万荣县| 泸定县| 滁州市| 甘德县| 合水县| 南昌市| 金溪县| 乳山市| 津市市| 滁州市| 瑞丽市| 桑植县| 博湖县| 桦南县| 漾濞| 乌拉特中旗| 绥德县| 新乡市| 云安县|