即時(shí)刷新與雙緩沖優(yōu)化實(shí)戰(zhàn))
簡(jiǎn)介面向 .NET 桌面開發(fā)者的 WinForm 流程圖繪制示例基于 C# 與 GDI 實(shí)現(xiàn)重點(diǎn)解決流程圖形狀在畫布上的動(dòng)態(tài)拖動(dòng)與即時(shí)刷新問(wèn)題適合需要自建工作流設(shè)計(jì)器、流程編輯工具或想深入理解 GDI 繪圖機(jī)制的初中級(jí)開發(fā)者參考。壓縮包共 39 個(gè)文件約 176KB以 cs 源碼、resources 與 resx 資源、config 配置、exe 可執(zhí)行程序及 png 效果圖為主另含 sln 解決方案與 csproj 項(xiàng)目文件可直接用 Visual Studio 打開運(yùn)行。資源已有 3214 人學(xué)習(xí)下載具備一定參考基礎(chǔ)。代碼圍繞 Graphics、Pen、SolidBrush 等繪圖類展開涵蓋自定義形狀類與 Draw 繪制方法、MouseDown/MouseMove/MouseUp 拖動(dòng)事件處理、Invalidate 重繪刷新機(jī)制以及形狀與連接線的數(shù)據(jù)結(jié)構(gòu)管理并配有較詳細(xì)注釋便于理解與二次擴(kuò)展。讀者可據(jù)此掌握 GDI 圖形繪制與 WinForm 交互的完整實(shí)現(xiàn)思路并在此基礎(chǔ)上擴(kuò)展更復(fù)雜的流程圖工具。1. 從一張能拖能連的流程圖說(shuō)起這個(gè) WinForm 項(xiàng)目到底能干什么很多人第一次在 WinForm 里畫流程圖都是從Panel上擺幾個(gè)Button開始的結(jié)果一拖動(dòng)就閃爍、一連線就錯(cuò)位、一刷新整塊畫布重繪最后只能推倒重來(lái)。這個(gè)基于 .NET WinForm 和 GDI 的流程圖繪制項(xiàng)目解決的正是這類問(wèn)題節(jié)點(diǎn)可以自由拖動(dòng)拖動(dòng)過(guò)程中連線即時(shí)跟隨刷新畫布不閃、不糊、不卡頓。它適合兩類人一類是需要在桌面端工控、審批、拓?fù)渑渲媒缑胬锴度肓鞒叹庉嬆芰Φ拈_發(fā)者另一類是剛接觸 GDI 雙緩沖、想找一個(gè)能跑起來(lái)、能改得動(dòng)的完整示例來(lái)啃的人。整個(gè)項(xiàng)目不依賴第三方繪圖庫(kù)純 C# 手寫繪制邏輯拿到源碼后改節(jié)點(diǎn)樣式、改連線規(guī)則、加導(dǎo)出功能都有明確的落點(diǎn)。2. 技術(shù)選型與整體架構(gòu)為什么是 WinForm 加 GDI 而不是 WPF2.1 選型邏輯桌面端輕量繪圖的最短路徑這個(gè)項(xiàng)目沒(méi)有用 WPF也沒(méi)有引入任何圖形框架核心原因在于部署成本和上手門檻。WinForm 在 .NET Framework 環(huán)境下幾乎是零配置啟動(dòng)目標(biāo)機(jī)器上只要有對(duì)應(yīng)運(yùn)行時(shí)就能跑不需要額外的渲染層依賴。GDI 作為 WinForm 自帶的繪圖接口Graphics對(duì)象直接掛在控件的Paint事件上畫線、畫矩形、畫貝塞爾曲線都是一行調(diào)用的事。代價(jià)也很明確GDI 是即時(shí)模式繪制沒(méi)有場(chǎng)景圖、沒(méi)有命中測(cè)試框架、沒(méi)有自動(dòng)重繪管理。節(jié)點(diǎn)拖動(dòng)時(shí)的刷新、連線的坐標(biāo)換算、鼠標(biāo)命中的判定全部要自己寫。這個(gè)項(xiàng)目的價(jià)值就在于把這些“自己寫”的部分拆得足夠清楚讓后來(lái)者不用從零推導(dǎo)。從架構(gòu)上看項(xiàng)目大致分成四層職責(zé)邊界比較干凈層次職責(zé)典型類型數(shù)據(jù)模型層描述節(jié)點(diǎn)、連線、畫布的屬性節(jié)點(diǎn)類、連線類、畫布狀態(tài)繪制層把模型轉(zhuǎn)成 GDI 繪制指令重寫OnPaint的繪制方法交互層鼠標(biāo)按下、移動(dòng)、抬起的命中與狀態(tài)切換鼠標(biāo)事件處理邏輯刷新層雙緩沖與局部重繪控制DoubleBuffered、Invalidate調(diào)用這種分層不是學(xué)院派的架構(gòu)設(shè)計(jì)而是一線寫 GDI 時(shí)被閃爍和錯(cuò)位逼出來(lái)的組織方式。數(shù)據(jù)模型和繪制分離之后改節(jié)點(diǎn)外觀不用動(dòng)交互邏輯改拖動(dòng)規(guī)則不用碰繪制代碼二次開發(fā)時(shí)心里有底。2.2 目錄結(jié)構(gòu)一個(gè)能直接對(duì)照源碼的骨架項(xiàng)目正文沒(méi)有給出完整目錄樹但按這類 WinForm 繪圖項(xiàng)目的常見組織方式源碼大致是下面這個(gè)結(jié)構(gòu)。拿到壓縮包后可以按這個(gè)骨架去對(duì)號(hào)入座找不到對(duì)應(yīng)文件時(shí)優(yōu)先在Forms和Controls兩個(gè)目錄里翻FlowChartDemo/ ├── Forms/ │ ├── MainForm.cs 主窗體承載畫布與工具欄 │ ├── MainForm.Designer.cs 設(shè)計(jì)器生成代碼 │ └── MainForm.resx 窗體資源 ├── Controls/ │ ├── FlowCanvas.cs 畫布控件重寫 OnPaint 與鼠標(biāo)事件 │ └── NodeShape.cs 節(jié)點(diǎn)繪制與命中判定 ├── Models/ │ ├── FlowNode.cs 節(jié)點(diǎn)數(shù)據(jù)模型 │ ├── FlowLink.cs 連線數(shù)據(jù)模型 │ └── CanvasState.cs 畫布狀態(tài)選中項(xiàng)、拖拽偏移等 ├── Utils/ │ └── GraphicsHelper.cs 繪圖輔助方法圓角矩形、箭頭等 └── Program.cs 程序入口這個(gè)結(jié)構(gòu)的關(guān)鍵點(diǎn)在于FlowCanvas和NodeShape的分離。畫布負(fù)責(zé)整體重繪和事件分發(fā)節(jié)點(diǎn)自己負(fù)責(zé)“我長(zhǎng)什么樣、我的邊界在哪、這個(gè)點(diǎn)算不算點(diǎn)在我身上”。后面講命中測(cè)試時(shí)會(huì)看到這個(gè)分離直接決定了拖動(dòng)邏輯能不能寫干凈。2.3 雙緩沖解決閃爍的第一道防線WinForm 里 GDI 繪圖最常見的翻車就是拖動(dòng)時(shí)整片白閃。原因是每次Invalidate觸發(fā)Paint系統(tǒng)先用背景色刷一遍再畫內(nèi)容兩次繪制之間被用戶看到了。項(xiàng)目的做法是在畫布控件構(gòu)造函數(shù)里打開雙緩沖public FlowCanvas() { // 關(guān)鍵開啟雙緩沖避免拖動(dòng)時(shí)整片閃爍 this.DoubleBuffered true; // 減少重繪時(shí)的背景擦除配合雙緩沖使用 this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.UpdateStyles(); }DoubleBuffered true是最省事的一行它讓控件先在內(nèi)存位圖上畫完再整體貼到屏幕。SetStyle那三行是加強(qiáng)版AllPaintingInWmPaint把擦背景的動(dòng)作合并進(jìn)PaintUserPaint聲明自己負(fù)責(zé)繪制OptimizedDoubleBuffer進(jìn)一步優(yōu)化緩沖策略。只寫第一行在簡(jiǎn)單場(chǎng)景夠用節(jié)點(diǎn)一多、連線一密還是得把SetStyle補(bǔ)上。這是我在多個(gè)繪圖控件里反復(fù)驗(yàn)證過(guò)的組合少一行都可能在高頻拖動(dòng)時(shí)露出破綻。3. 核心繪制鏈路一個(gè)節(jié)點(diǎn)從數(shù)據(jù)到屏幕的完整過(guò)程3.1 節(jié)點(diǎn)模型先定數(shù)據(jù)再談繪制節(jié)點(diǎn)類是整個(gè)項(xiàng)目的地基。它至少要攜帶位置、尺寸、標(biāo)題、選中狀態(tài)這幾項(xiàng)位置用左上角坐標(biāo)尺寸用寬高選中狀態(tài)影響描邊顏色。常見寫法如下public class FlowNode { public string Id { get; set; } // 節(jié)點(diǎn)唯一標(biāo)識(shí)連線靠它找端點(diǎn) public string Title { get; set; } // 節(jié)點(diǎn)顯示文字 public Rectangle Bounds { get; set; } // 位置與尺寸繪制和命中都用它 public bool IsSelected { get; set; } // 是否選中決定描邊樣式 // 判斷一個(gè)點(diǎn)是否落在節(jié)點(diǎn)內(nèi)拖動(dòng)和選中都依賴它 public bool HitTest(Point p) { return Bounds.Contains(p); } }Bounds用Rectangle而不是分開存四個(gè) int是因?yàn)?GDI 的DrawRectangle、FillRectangle直接吃這個(gè)類型命中測(cè)試也有現(xiàn)成的Contains。Id的存在是為了連線不直接持有節(jié)點(diǎn)對(duì)象引用避免拖動(dòng)時(shí)對(duì)象引用和坐標(biāo)不同步的玄學(xué)問(wèn)題。連線只存兩個(gè)Id繪制時(shí)再去節(jié)點(diǎn)集合里查端點(diǎn)坐標(biāo)這樣節(jié)點(diǎn)移動(dòng)后連線自然跟著走不需要額外的同步邏輯。3.2 繪制入口OnPaint 里該做什么、不該做什么畫布的重繪入口是重寫的OnPaint。這里的原則是只做繪制不做數(shù)據(jù)修改不做耗時(shí)計(jì)算。節(jié)點(diǎn)坐標(biāo)換算、連線端點(diǎn)查找這些準(zhǔn)備工作應(yīng)該在數(shù)據(jù)層完成OnPaint里拿到的應(yīng)該是可以直接畫的坐標(biāo)。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; // 抗鋸齒圓角和斜線不出現(xiàn)鋸齒邊 g.SmoothingMode SmoothingMode.AntiAlias; // 先畫連線再畫節(jié)點(diǎn)保證節(jié)點(diǎn)蓋住連線端點(diǎn) foreach (var link in _links) { DrawLink(g, link); } foreach (var node in _nodes) { DrawNode(g, node); } } private void DrawNode(Graphics g, FlowNode node) { // 選中態(tài)用高亮描邊普通態(tài)用常規(guī)色 using (var pen new Pen(node.IsSelected ? Color.DodgerBlue : Color.Gray, 2)) using (var brush new SolidBrush(Color.WhiteSmoke)) { g.FillRectangle(brush, node.Bounds); g.DrawRectangle(pen, node.Bounds); // 文字居中繪制留出內(nèi)邊距 TextRenderer.DrawText(g, node.Title, this.Font, node.Bounds, Color.Black, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } }繪制順序是先連線后節(jié)點(diǎn)這個(gè)順序不能反。連線如果后畫端點(diǎn)會(huì)壓在節(jié)點(diǎn)邊框上視覺上像線穿過(guò)了節(jié)點(diǎn)。SmoothingMode.AntiAlias對(duì)圓角矩形和斜線效果明顯但會(huì)略微增加繪制開銷節(jié)點(diǎn)數(shù)量在幾百以內(nèi)可以放心開。Pen和Brush都包在using里GDI 對(duì)象不釋放是 WinForm 繪圖里最隱蔽的內(nèi)存泄漏來(lái)源長(zhǎng)時(shí)間拖動(dòng)后界面變卡十有八九是這里漏了。3.3 連線繪制端點(diǎn)坐標(biāo)怎么算才不飄連線的核心是端點(diǎn)坐標(biāo)。項(xiàng)目里連線存的是起點(diǎn)節(jié)點(diǎn)Id和終點(diǎn)節(jié)點(diǎn)Id繪制時(shí)實(shí)時(shí)查節(jié)點(diǎn)中心點(diǎn)private void DrawLink(Graphics g, FlowLink link) { var from _nodes.FirstOrDefault(n n.Id link.FromId); var to _nodes.FirstOrDefault(n n.Id link.ToId); if (from null || to null) return; // 端點(diǎn)缺失直接跳過(guò)防止空引用 // 取兩個(gè)節(jié)點(diǎn)的中心點(diǎn)作為連線端點(diǎn) Point p1 CenterOf(from.Bounds); Point p2 CenterOf(to.Bounds); using (var pen new Pen(Color.SlateGray, 1.5f)) { g.DrawLine(pen, p1, p2); DrawArrow(g, p1, p2); // 在終點(diǎn)畫箭頭 } } private Point CenterOf(Rectangle r) { return new Point(r.Left r.Width / 2, r.Top r.Height / 2); }用中心點(diǎn)而不是邊框交點(diǎn)是為了避免節(jié)點(diǎn)重疊時(shí)連線端點(diǎn)亂跳。代價(jià)是連線會(huì)從節(jié)點(diǎn)內(nèi)部穿出視覺上被節(jié)點(diǎn)蓋住一截但因?yàn)橄犬嬀€后畫節(jié)點(diǎn)這一截正好被節(jié)點(diǎn)遮住看起來(lái)反而干凈。DrawArrow是獨(dú)立的箭頭繪制方法根據(jù)p1到p2的角度算箭頭兩個(gè)翼的坐標(biāo)這里不展開思路是三角函數(shù)加固定翼長(zhǎng)。端點(diǎn)查不到時(shí)直接return這是防御性寫法刪除節(jié)點(diǎn)后殘留的連線不會(huì)讓程序崩。4. 拖動(dòng)即時(shí)刷新的實(shí)現(xiàn)鼠標(biāo)狀態(tài)機(jī)與局部重繪4.1 鼠標(biāo)事件的三段式狀態(tài)機(jī)拖動(dòng)邏輯的本質(zhì)是一個(gè)狀態(tài)機(jī)按下時(shí)記錄起點(diǎn)和命中的節(jié)點(diǎn)移動(dòng)時(shí)更新節(jié)點(diǎn)坐標(biāo)并請(qǐng)求重繪抬起時(shí)結(jié)束拖拽。三段式寫法如下private FlowNode _draggingNode; // 當(dāng)前被拖動(dòng)的節(jié)點(diǎn) private Point _dragOffset; // 鼠標(biāo)相對(duì)節(jié)點(diǎn)左上角的偏移 protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); // 從后往前找后畫的節(jié)點(diǎn)在上層優(yōu)先命中 for (int i _nodes.Count - 1; i 0; i--) { if (_nodes[i].HitTest(e.Location)) { _draggingNode _nodes[i]; _dragOffset new Point(e.X - _nodes[i].Bounds.Left, e.Y - _nodes[i].Bounds.Top); _nodes[i].IsSelected true; this.Invalidate(); // 選中態(tài)變化請(qǐng)求重繪 return; } } _draggingNode null; // 點(diǎn)空白處取消選中 } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (_draggingNode null) return; // 用偏移量算新位置避免節(jié)點(diǎn)左上角跳到鼠標(biāo)位置 int newLeft e.X - _dragOffset.X; int newTop e.Y - _dragOffset.Y; _draggingNode.Bounds new Rectangle(newLeft, newTop, _draggingNode.Bounds.Width, _draggingNode.Bounds.Height); this.Invalidate(); // 坐標(biāo)變了請(qǐng)求重繪 } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); _draggingNode null; // 結(jié)束拖拽 }_dragOffset是拖動(dòng)手感的關(guān)鍵。如果直接把節(jié)點(diǎn)左上角設(shè)成鼠標(biāo)坐標(biāo)節(jié)點(diǎn)會(huì)“跳”到鼠標(biāo)位置拖起來(lái)像在甩東西。記錄偏移后鼠標(biāo)始終保持在節(jié)點(diǎn)內(nèi)的相對(duì)位置手感才對(duì)。命中測(cè)試從后往前遍歷是因?yàn)楹筇砑拥墓?jié)點(diǎn)繪制在上層用戶點(diǎn)到的應(yīng)該是視覺上最上面那個(gè)。Invalidate()不帶參數(shù)是整畫布重繪節(jié)點(diǎn)數(shù)量少時(shí)夠用節(jié)點(diǎn)多了要換成帶Rectangle參數(shù)的局部重繪只刷舊位置和新位置的并集。4.2 局部重繪節(jié)點(diǎn)多了以后必須做的優(yōu)化整畫布Invalidate在幾十個(gè)節(jié)點(diǎn)時(shí)無(wú)感上百個(gè)節(jié)點(diǎn)高頻拖動(dòng)就會(huì)掉幀。優(yōu)化思路是只重繪受影響區(qū)域// 拖動(dòng)前記錄舊區(qū)域拖動(dòng)后與新區(qū)域合并只刷這一塊 Rectangle oldRect _draggingNode.Bounds; // ... 更新坐標(biāo) ... Rectangle newRect _draggingNode.Bounds; Rectangle invalid Rectangle.Union(oldRect, newRect); // 外擴(kuò)幾個(gè)像素覆蓋描邊和連線端點(diǎn) invalid.Inflate(4, 4); this.Invalidate(invalid);Rectangle.Union取新舊位置的并集Inflate外擴(kuò)是為了把描邊寬度和連線端點(diǎn)也算進(jìn)去否則拖動(dòng)后邊緣會(huì)殘留一條舊線。這個(gè)優(yōu)化不是必須的但節(jié)點(diǎn)數(shù)量上去之后是分水嶺。我一般會(huì)在節(jié)點(diǎn)超過(guò)五十個(gè)時(shí)切到局部重繪再配合雙緩沖拖動(dòng)基本能保持跟手。4.3 刷新時(shí)機(jī)的取舍Invalidate 與 Refresh 的區(qū)別Invalidate是標(biāo)記區(qū)域無(wú)效等消息循環(huán)空閑時(shí)合并重繪Refresh是立即強(qiáng)制重繪。拖動(dòng)過(guò)程中用Invalidate因?yàn)槭髽?biāo)移動(dòng)事件本身很密集合并重繪能減少實(shí)際繪制次數(shù)。只有在需要立刻看到結(jié)果的場(chǎng)景比如點(diǎn)擊按鈕切換視圖才用Refresh。這個(gè)區(qū)別在拖動(dòng)場(chǎng)景里很實(shí)際用Refresh每個(gè)鼠標(biāo)移動(dòng)事件都同步畫一遍鼠標(biāo)一動(dòng)就卡用Invalidate系統(tǒng)幫你合并流暢度明顯不同。這是 GDI 繪圖里容易被忽略但影響手感的一個(gè)點(diǎn)。5. 避坑與排查拖動(dòng)刷新場(chǎng)景下的五條血淚記錄5.1 現(xiàn)象拖動(dòng)時(shí)整片白閃節(jié)點(diǎn)像在眨眼原因控件沒(méi)開雙緩沖每次重繪先擦背景再畫內(nèi)容兩次繪制之間被看到。解決構(gòu)造函數(shù)里設(shè)DoubleBuffered true再補(bǔ)SetStyle的AllPaintingInWmPaint | UserPaint | OptimizedDoubleBuffer。只設(shè)DoubleBuffered在節(jié)點(diǎn)密集時(shí)仍可能閃兩個(gè)一起上才穩(wěn)。5.2 現(xiàn)象連線端點(diǎn)拖完對(duì)不上線飄在節(jié)點(diǎn)外面原因連線緩存了端點(diǎn)坐標(biāo)節(jié)點(diǎn)移動(dòng)后沒(méi)同步。解決連線只存節(jié)點(diǎn)Id繪制時(shí)實(shí)時(shí)查中心點(diǎn)不要存Point。如果為了性能必須緩存就在節(jié)點(diǎn)坐標(biāo)變更處統(tǒng)一觸發(fā)連線坐標(biāo)重算但實(shí)時(shí)查更不容易出錯(cuò)。5.3 現(xiàn)象拖久了界面越來(lái)越卡內(nèi)存緩慢上漲原因Pen、Brush、Font這些 GDI 對(duì)象在OnPaint里 new 出來(lái)沒(méi)釋放。解決全部包using或者提到字段里復(fù)用。OnPaint每幀都執(zhí)行漏一個(gè)對(duì)象就是每幀泄漏一個(gè)句柄幾分鐘就能看出卡頓。這是 WinForm 繪圖最經(jīng)典的坑沒(méi)有之一。5.4 現(xiàn)象點(diǎn)節(jié)點(diǎn)沒(méi)反應(yīng)或者點(diǎn)到了下面的節(jié)點(diǎn)原因命中測(cè)試遍歷順序和繪制順序不一致。解決繪制按集合順序從前往后命中測(cè)試從后往前保證點(diǎn)到的是視覺最上層。另外HitTest用Bounds.Contains時(shí)注意坐標(biāo)是控件坐標(biāo)還是屏幕坐標(biāo)混用會(huì)導(dǎo)致偏移。5.5 現(xiàn)象拖動(dòng)時(shí)節(jié)點(diǎn)邊緣殘留舊線像拖影原因局部重繪的區(qū)域沒(méi)覆蓋舊位置或者沒(méi)算描邊寬度。解決新舊區(qū)域取Union后Inflate幾個(gè)像素把描邊和連線端點(diǎn)包進(jìn)去。整畫布重繪不會(huì)有這個(gè)問(wèn)題一旦切到局部重繪就必須處理。6. 二次開發(fā)落點(diǎn)從能跑到能改的幾個(gè)具體切口拿到這個(gè)項(xiàng)目后最值得先動(dòng)的不是界面美化而是把節(jié)點(diǎn)和連線的數(shù)據(jù)模型抽出來(lái)做成可序列化?,F(xiàn)在節(jié)點(diǎn)和連線都在內(nèi)存里關(guān)掉程序就沒(méi)了加一個(gè)Save和Load就能讓它從演示變成能用的工具。具體做法是給FlowNode和FlowLink加[Serializable]用BinaryFormatter或JsonSerializer序列化整個(gè)_nodes和_links集合存成文件。注意Rectangle在 JSON 里需要自定義轉(zhuǎn)換用System.Text.Json的話寫個(gè)JsonConverterRectangle更省事。第二個(gè)切口是連線規(guī)則?,F(xiàn)在連線是任意兩個(gè)節(jié)點(diǎn)都能連實(shí)際業(yè)務(wù)里往往有方向限制或類型限制。在DrawLink之前加一層校驗(yàn)或者在創(chuàng)建連線的入口處判斷FromId和ToId的節(jié)點(diǎn)類型不合法就不讓連。這個(gè)改動(dòng)集中在交互層不碰繪制代碼風(fēng)險(xiǎn)低。第三個(gè)切口是節(jié)點(diǎn)樣式。DrawNode里現(xiàn)在是白底灰邊改成圓角矩形只需要把DrawRectangle換成GraphicsPath加AddArc再DrawPath。圓角半徑做成節(jié)點(diǎn)屬性不同節(jié)點(diǎn)可以有不同外觀。這一步改完整個(gè)項(xiàng)目的觀感會(huì)明顯不一樣而且改動(dòng)范圍只在NodeShape里。驗(yàn)證方法很直接改完序列化后畫三個(gè)節(jié)點(diǎn)連兩條線保存、關(guān)閉、重開、加載看坐標(biāo)和連線是否一致改完連線規(guī)則后故意連一個(gè)非法方向看是否被攔住改完圓角后拖動(dòng)節(jié)點(diǎn)看圓角邊緣有沒(méi)有殘影有殘影就說(shuō)明局部重繪的Inflate不夠。我自己的習(xí)慣是每次動(dòng)繪制相關(guān)代碼之前先把節(jié)點(diǎn)數(shù)量調(diào)到兩百個(gè)以上拖一遍記錄幀感和內(nèi)存占用改完再拖一遍對(duì)比。這個(gè)基線測(cè)試花不了幾分鐘但能擋住大部分“改完看著沒(méi)問(wèn)題、一上量就翻車”的情況。從那以后我每次改 GDI 繪圖邏輯都強(qiáng)制走一遍這個(gè)基線希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取