多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于WPF和OpenCvSharp4的多边形ROI标注工具开发实践

基于WPF和OpenCvSharp4的多边形ROI标注工具开发实践 1. 为什么需要自研多边形ROI一个标注场景的痛点拆解1.1 来源项目中的真实需求几个月前接了一个图像初检工具的活儿核心场景很简单一批高分辨率的零件图上真正需要算法关注的区域往往只占整张图的一小部分而且形状不规则。矩形ROI能框出大概范围但会把很多背景也圈进来后续做灰度统计或者边缘检测时噪声特别大。对方提的需求很直接在界面里用鼠标手动圈出一个多边形区域把这个区域单独抠出来剩下的部分不影响后续处理。说白了就是图像处理里常说的感兴趣区域提取只不过这次交互层要自己做。接手之前我想得比较简单WPF里放一个Canvas图片显示上去鼠标点几下线一连顶点坐标传给OpenCvSharp4去抠图完事。真做起来才发现整个链路里最麻烦的并不是OpenCV那边而是坐标系换算、鼠标状态管理、闭合判定这些UI细节。这篇博文就完整记录一下整个实现过程包括踩过的坑。适合正在做WPF图像标注、ROI选取工具或者准备把OpenCV接入.NET桌面应用的读者。1.2 为什么不用现成全屏截图工具动手之前我先确认了一遍市面上有没有现成的控件能直接干活。试过一些截图标注工具它们确实内置了矩形、椭圆甚至多边形选区但问题是这些工具要么把截图当成最终输出要么保存的是图片文件而不是坐标数据。我们这个场景需要的是把多边形顶点坐标实时回传给算法层后续还要基于这套ROI坐标做批量处理现成工具根本无法满足这种坐标即资产的需求。另一个原因是版本迭代。工具后续要支持多种标注类型、ROI列表管理和坐标持久化自研虽然前期成本高一点但数据结构完全可控功能扩展不受制于人。这个决策在后来的开发中证明是对的因为客户果然在验收前提了一堆追加需求如果当时用了现成品改起来要麻烦得多。2. 技术选型复盘Canvas、OpenCvSharp4搭配的取舍2.1 为什么UI层锁定WPF和Canvas技术选型时第一争议是WinForms和WPF选哪个。WinForms上手快GDI画多边形也简单但考虑到后续要做图层叠加、顶点拖拽、缩放平移这些交互我更倾向于WPF。WPF的Canvas是一个极其灵活的面板子元素可以通过Left和Top属性进行绝对定位天然适合标记点的坐标管理。而且WPF的图形元素Line、Polygon、Ellipse都支持矢量渲染缩放不模糊用来做交互标注体验远好于WinForms。Canvas这个控件本身不复杂但它为核心交互提供了良好的基础每个顶点是一个Ellipse每条边是一条Line我只需要维护一个顶点集合然后根据集合内容动态增删Canvas的子元素即可。相比手动Paint重绘整个界面操作粒度细、代码结构清晰也不容易出现闪烁。2.2 OpenCvSharp4相比其他封装方案的优势图像处理侧起初有几个备选直接调用C OpenCV的CLI封装、Emgu CV、OpenCvSharp。我最终选了OpenCvSharp4理由是它几乎纯C#托管API风格和OpenCV官方非常接近文档全社区案例也多。而且它同时支持.Net Framework和.Net Core/6/7配WPF没有历史包袱。Emgu CV也用过但它的某些API命名和类型转换不如OpenCvSharp4直观调试时心智负担更重。OpenCvSharp4的NuGet包会把OpenCV运行时一并带上安装完直接能用不需要手动配置环境变量这对交付工具类的项目来说省了不少事。版本号建议直接上OpenCvSharp4当前较新的小版本记得同时安装对应平台的运行时包比如OpenCvSharp4.runtime.win。2.3 技术栈边界划分整个架构的边界一开始就要划清楚UI层只负责人机交互不碰像素数据算法层只接收坐标数据不关心界面长什么样。我设计了一个中间数据模型保存每一个ROI的顶点列表归一化坐标、名称、创建时间。UI层把Canvas坐标换算成图像像素坐标后写入模型算法层在真正执行提取时才从模型读取坐标传给OpenCvSharp4。这个解耦在后来的测试中帮了大忙坐标问题可以单独验证图像处理问题也可以单独排查不会互相干扰。3. 第一步实战WPF项目搭建与图像显示坐标建模3.1 创建项目与引用OpenCvSharp4环境用的是Visual Studio 2022 (.NET 6)新建WPF应用程序项目后NuGet安装两个包Install-Package OpenCvSharp4 Install-Package OpenCvSharp4.runtime.win安装完成后在代码里引入命名空间using OpenCvSharp;一个容易忽略的坑OpenCvSharp4依赖的VC运行库版本可能和本机不同部署到客户机器上如果报错缺少runtime要么装对应运行库要么考虑自包含发布。我后来直接把VC运行库打进了安装包里省去了现场排查的麻烦。3.2 XAML布局与Canvas容器设计窗口布局用Grid分两块左侧是原图显示区右侧是ROI列表和操作按钮。显示区放一个ScrollViewer里面套一个Canvas图片用Image控件铺在Canvas底部。这里有一点很关键Image的width和height要显式设置不要只依赖Stretch属性否则后面做坐标换算时拿到的实际显示尺寸会不稳定。Grid Grid.ColumnDefinitions ColumnDefinition Width*/ ColumnDefinition Width320/ /Grid.ColumnDefinitions Border Grid.Column0 BorderBrush#ccc BorderThickness1 ScrollViewer x:NameImageScrollViewer HorizontalScrollBarVisibilityAuto VerticalScrollBarVisibilityAuto Background#222 Canvas x:NameDrawCanvas Width800 Height600 BackgroundTransparent VerticalAlignmentTop HorizontalAlignmentLeft Image x:NameMainImage StretchNone Width800 Height600 RenderOptions.BitmapScalingModeHighQuality/ /Canvas /ScrollViewer /Border StackPanel Grid.Column1 Margin10 Button x:NameOpenImageButton Content1. 打开图片 Margin0,0,0,8/ Button x:NameStartDrawButton Content2. 开始标注多边形 Margin0,0,0,8/ Button x:NameExtractButton Content3. 提取当前ROI Margin0,0,0,8/ Button x:NameClearButton Content4. 清空顶点 Margin0,0,0,8/ ListBox x:NameRoiListBox Margin0,12,0,0 Height360/ /StackPanel /Grid注意Image初始没有设Source加载图片后再实际设置。Canvas的宽度高度初始值跟随图片尺寸同步更新。3.3 坐标体系转换Canvas坐标与图像原始像素的映射真正的核心逻辑在这里。Canvas上显示的图片尺寸不一定是原始图像尺寸所以鼠标在Canvas上的坐标并不等于图像里的像素坐标。两者之间需要一个比例系数转换。加载图片后我记录两个值作为基准_originalWidth原始图片的像素宽度_originalHeight原始图片的像素高度同时把Canvas和Image的宽高都设置为原始像素宽高这里先不考虑缩放后面缩放版本再讨论。换算公式很直接public class CoordinateTransform { public static Point CanvasToImage(Point canvasPoint, Size originalSize, Size displaySize) { double scaleX originalSize.Width / displaySize.Width; double scaleY originalSize.Height / displaySize.Height; return new Point(canvasPoint.X * scaleX, canvasPoint.Y * scaleY); } }这个转换类的单元测试我写了很多组覆盖图片宽高一致、按比例缩小、非整数缩放等情况。坐标换算是整个项目的命脉这一步错了后面所有提取结果都会歪。测试用例里有一条非常经典Canvas上一个点1530原始图是800×600显示尺寸是400×300期望换算结果是3060这个用例能挡住很大一部分越写越偏的代码。3.4 加载图片并绑定显示加载图片时使用System.Windows.Media.Imaging的BitmapImage注意要把CacheOption设为OnLoad否则文件句柄一直占着不放。代码如下private void LoadImage(string filePath) { var bitmap new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption BitmapCacheOption.OnLoad; bitmap.UriSource new Uri(filePath); bitmap.EndInit(); _originalWidth bitmap.PixelWidth; _originalHeight bitmap.PixelHeight; MainImage.Source bitmap; MainImage.Width _originalWidth; MainImage.Height _originalHeight; DrawCanvas.Width _originalWidth; DrawCanvas.Height _originalHeight; _vertices.Clear(); Redraw(); }我特意把Canvas和Image设成相同的尺寸目的就是让Canvas坐标系和图片显示坐标系完全重合。不重合的做法也能做但每次都要减偏移量交互代码会埋很多隐患。4. 多边形标注交互设计点、线、闭合与拖拽调整4.1 顶点数据模型与绘制状态机标注交互本质上是个状态机空闲态、绘制中、已闭合。我用一个枚举管理当前状态同时维护一个顶点集合public enum DrawState { Idle, Drawing, Finished } private DrawState _state DrawState.Idle; private ObservableCollectionPoint _vertices new ObservableCollectionPoint(); private ListEllipse _vertexEllipseList new ListEllipse(); private ListLine _edgeLineList new ListLine();绘制中状态表示已经点了至少一个顶点但还没闭合。已闭合后禁止继续加点用户必须点击开始标注重新建一个ROI或者选中某个ROI编辑顶点。4.2 鼠标事件的绑定与顶点添加逻辑鼠标操作都挂在DrawCanvas上用MouseLeftButtonDown处理加点用MouseMove处理预览线用MouseRightButtonDown做撤销逻辑集中、清晰。private void DrawCanvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (_state DrawState.Finished) return; var pos e.GetPosition(DrawCanvas); _vertices.Add(pos); if (_vertices.Count 1) { _state DrawState.Drawing; } else { // 检查是否点击到起点附近触发闭合 double firstDist Distance(_vertices[0], pos); if (firstDist 12 _vertices.Count 3) { // 用第一个点替换最新点保证闭合平滑 _vertices[_vertices.Count - 1] _vertices[0]; _state DrawState.Finished; } } Redraw(); }闭合判定用的是距离阈值法初版用12像素。很多人会写成双击闭合或者按下S闭合实际用下来还是点回起点附近最符合直觉因为双击和右键经常误触发。阈值不能太大否则如果起点附近恰好有密集点会误闭合也不能太小否则用户要很精确点回起点。12在1920×1080屏幕上体验不错。右键撤销逻辑经常被忽略但它非常重要。用户手滑点多了一个顶点如果只能清空重来极其影响体验。实现很便宜private void DrawCanvas_MouseRightButtonDown(object sender, MouseButtonEventArgs e) { if (_state DrawState.Drawing _vertices.Count 1) { _vertices.RemoveAt(_vertices.Count - 1); if (_vertices.Count 1) _state DrawState.Finished; Redraw(); } }4.3 动态绘制顶点圆点和网格线如何同步刷新Redraw方法负责把顶点集合渲染到Canvas上。实现思路是每次全量清空绘制层子元素再重新生成数据量不大一个多边形几十个点时性能毫无压力。private void Redraw() { for (int i _edgeLineList.Count - 1; i 0; i--) DrawCanvas.Children.Remove(_edgeLineList[i]); for (int i _vertexEllipseList.Count - 1; i 0; i--) DrawCanvas.Children.Remove(_vertexEllipseList[i]); _edgeLineList.Clear(); _vertexEllipseList.Clear(); if (_vertices.Count 1) return; for (int i 0; i _vertices.Count; i) { var ellipse new Ellipse { Width 10, Height 10, Fill Brushes.Yellow, Stroke Brushes.Black, StrokeThickness 2 }; Canvas.SetLeft(ellipse, _vertices[i].X - 5); Canvas.SetTop(ellipse, _vertices[i].Y - 5); DrawCanvas.Children.Add(ellipse); _vertexEllipseList.Add(ellipse); if (i 0) { var line new Line { X1 _vertices[i - 1].X, Y1 _vertices[i - 1].Y, X2 _vertices[i].X, Y2 _vertices[i].Y, Stroke Brushes.OrangeRed, StrokeThickness 2 }; DrawCanvas.Children.Add(line); _edgeLineList.Add(line); } } if (_state DrawState.Finished _vertices.Count 3) { var closeLine new Line { X1 _vertices[_vertices.Count - 1].X, Y1 _vertices[_vertices.Count - 1].Y, X2 _vertices[0].X, Y2 _vertices[0].Y, Stroke Brushes.OrangeRed, StrokeThickness 2 }; DrawCanvas.Children.Add(closeLine); _edgeLineList.Add(closeLine); } }一个性能细节不要用DrawCanvas.Children.Clear()因为Image也在Children里会被一并清掉。我只维护自己的两个列表只清自己的Image不受影响。这里提到的预览线鼠标移动时的橡皮筋线没有包含在上面代码里我在MouseMove里额外画了一条临时Line追踪当前鼠标位置方便用户看到下一条边会变成什么形状。private Line _tempLine; private void DrawCanvas_MouseMove(object sender, MouseEventArgs e) { if (_state ! DrawState.Drawing || _vertices.Count 0) return; var pos e.GetPosition(DrawCanvas); if (_tempLine null) { _tempLine new Line { Stroke Brushes.Orange, StrokeThickness 1.5, StrokeDashArray new DoubleCollection { 4, 4 } }; DrawCanvas.Children.Add(_tempLine); } _tempLine.X1 _vertices[_vertices.Count - 1].X; _tempLine.Y1 _vertices[_vertices.Count - 1].Y; _tempLine.X2 pos.X; _tempLine.Y2 pos.Y; }闭合后应移除_tempLine避免它残留成脏数据。这类清理细节虽然小但漏了就会在界面上画幽灵线。4.4 让标注可用性提升的拖拽调点功能只是能加点连线还不算好用真正实用必须支持拖拽调整已有点的位置。方法是MouseLeftButtonDown时先做命中检测命中优先于新增顶点。命中逻辑用一个简单距离函数private int HitTestVertex(Point pos) { double minDist 12; int hitIndex -1; for (int i 0; i _vertices.Count; i) { double d Distance(_vertices[i], pos); if (d minDist) { minDist d; hitIndex i; } } return hitIndex; }如果返回的索引不是-1进入拖拽模式并在MouseMove里更新该顶点坐标然后Redraw。加了一个IsDragging标志防止拖拽时重复判定。拖拽过程中也要更新之后的点记录否则新计算ROI时会拿到过期坐标。这个功能初版没做测试直接上真机出现了拖动过程中顶点漂移的严重问题——原因在于MouseMove用e.GetPosition(DrawCanvas)拿到的是相对Canvas的坐标可拖拽的点坐标本身也在Canvas坐标系两者单位一致不该有偏差。排查后发现是Image的Stretch被设成了Uniform图片实际显示尺寸跟Canvas尺寸不一致导致坐标错位。最终把Stretch改成了None让图片严格按像素尺寸铺在Canvas上问题立刻消失。5. 核心一步用OpenCvSharp4完成ROI提取与保存5.1 ROI顶点如何从UI坐标转换为图像像素坐标当用户完成一个多边形后顶点集存储的还是Canvas上的显示坐标。由于我在加载图片时已经让Canvas尺寸等于图片原始像素尺寸这一步坐标转换退化为恒等变换。不过为了代码健壮性和后续扩展缩放功能还是统一调用CoordinateTransform转了一遍确保未来加入图片缩放后不用改这段逻辑。ListOpenCvSharp.Point roiPoints new ListOpenCvSharp.Point(); foreach (var v in _vertices) { var imgPoint CoordinateTransform.CanvasToImage(v, new Size(_originalWidth, _originalHeight), new Size(MainImage.ActualWidth, MainImage.ActualHeight)); roiPoints.Add(new OpenCvSharp.Point((int)Math.Round(imgPoint.X), (int)Math.Round(imgPoint.Y))); }特别注意像素坐标必须取整否则OpenCV的Point不接受浮点。取整时优先用Math.Round而不是强转int直接强转是截断误差图像边缘容易偏一个像素在提取掩码做边界判断时会有肉眼可见的锯齿偏移。5.2 用FillPoly生成掩码并执行BitwiseAnd提取所有坐标准备完毕后提取分三步创建与原图同尺寸的掩码矩阵、在掩码上填充多边形区域为白色、用掩码做按位与操作。private Mat ExtractRoi(Mat src, ListOpenCvSharp.Point roiPoints) { Mat mask new Mat(src.Size(), MatType.CV_8UC1, Scalar.All(0)); OpenCvSharp.Point[] vertices roiPoints.ToArray(); Cv2.FillPoly(mask, new OpenCvSharp.Point[][] { vertices }, new Scalar(255)); Mat result new Mat(); Cv2.BitwiseAnd(src, src, result, mask); return result; }为什么用FillPoly因为它能处理凹多边形。Cv2.FillConvexPoly只能处理凸多边形名字里就带Convex凹多边形用它会出现错误填充。而用户在图上随手圈出的区域大多不是严格的凸多边形所以必须用支持任意多边形的FillPoly。另一个值得提醒的细节如果只是想象本文最开始需求那样把ROI区域提取出来BitwiseAnd即可结果保留了原图色彩如果还要把ROI之外的区域统一变为纯色则在获得mask后执行Mat bg new Mat(); Cv2.BitwiseNot(mask, bg); Mat background new Mat(src.Size(), src.Type(), new Scalar(0, 0, 0)); src.CopyTo(background, bg);5.3 提取结果另存为图片并刷新到界面提取完成后把Mat转成WPF可显示的BitmapSource预览展示在界面右侧同时提供保存按钮。Mat转BitmapSource的代码在网上一搜一大把注意释放中间资源避免内存堆积。private BitmapSource MatToBitmapSource(Mat mat) { if (mat null || mat.Empty()) return null; int channels mat.Channels(); PixelFormat format channels 1 ? PixelFormats.Gray8 : PixelFormats.Bgr24; byte[] buffer new byte[mat.Width * mat.Height * channels]; System.Runtime.InteropServices.Marshal.Copy(mat.Data, buffer, 0, buffer.Length); BitmapSource bitmap BitmapSource.Create( mat.Width, mat.Height, 96, 96, format, null, buffer, mat.Width * channels); return bitmap; }保存时用Cv2.ImWrite直接写文件即可如果保存的只是提取ROI胶片含透明区域外包矩形建议先裁剪到ROI的外接矩形Cv2.BoundingRect否则保存成图片后整张图还是原来的矩形尺寸周围大片黑色看不出抠的效果。Rect boundingRect Cv2.BoundingRect(vertices); Mat cropped new Mat(result, boundingRect); Cv2.ImWrite(roi_output.jpg, cropped);裁剪之后既能减小文件体积也方便后续算法只关注ROI小图不用每次处理整张原图。6. 实测踩坑坐标漂移、DPI缩放与Mat内存管理6.1 坑一DPI缩放导致标注点偏移半条街第一版在开发机上一切正常拿到高DPI笔记本电脑上跑多边形标注位置整体偏移点得越远偏差越大。排查后确认是WPF默认按DPI虚拟化在高DPI显示器上元素实际渲染尺寸和逻辑像素尺寸不一致。解决方式是在App启动时显式声明DPI感知[System.Runtime.InteropServices.DllImport(user32.dll)] static extern bool SetProcessDPIAware(); protected override void OnStartup(StartupEventArgs e) { SetProcessDPIAware(); base.OnStartup(e); }更规范的现代方式是添加app.manifest设置PerMonitorV2 DPI感知。使用SetProcessDPIAware简单直接在绝大多数场景下够用。这一改之后坐标绘制和鼠标点击对齐了问题消失。6.2 坑二Mat对象不释放导致内存翻倍OpenCvSharp4的Mat在托管层看似是普通类底层却持有非托管内存。初版代码频繁打开新图片、构建掩码、提取结果内存占用不断攀升。OpenCV自己的规则是谁分配谁负责释放但在C#环境下很多人容易忘记Dispose。解决办法所有临时Mat都用using语句包住或者手动调用Dispose。尤其注意FillPoly创建的mask、BitwiseAnd得到的result都不能裸奔。重构后的提取方法长这样private Mat ExtractRoiWithMemorySafe(Mat src, ListOpenCvSharp.Point roiPoints) { using (Mat mask new Mat(src.Size(), MatType.CV_8UC1, Scalar.All(0))) { OpenCvSharp.Point[] vertices roiPoints.ToArray(); Cv2.FillPoly(mask, new OpenCvSharp.Point[][] { vertices }, new Scalar(255)); Mat result new Mat(); Cv2.BitwiseAnd(src, src, result, mask); return result; } }mask在using块结束自动释放result作为方法返回值由调用方负责释放。写代码时形成这个习惯后长时间反复开图测试内存稳定在初始水平附近没有再涨。6.3 坑三预览线与真实绘图元素互相覆盖、残留开发初期MouseMove里的临时预览线直接Add到Canvas结果闭合后忘移除界面上出现一条跟随鼠标跑的虚线非常尴尬。后来统一封装了一个_dynamicLayer所有临时性元素预览线、拖拽提示都放这个专用Canvas层里每次鼠标状态切换时先清空该层。代码更整洁也不会误删主绘制层的内容。还有一个相关的小坑EdgeLine列表在Redraw时如果遗漏某条线界面上会出现旧线残留问题。后来我总结出规律只要维护了专门的List 删除和添加必须保证同一清单不要一部分靠List记录、一部分靠Children索引两个来源一旦不同步就是疑难杂症。6.4 坑四多ROI管理时坐标记录不同步项目后来演进到支持保存多个多边形ROI每个ROI除了顶点坐标外还要记录名称、置信度等附加信息。一开始我把顶点直接放ObservableCollection 但发现修改一个ROI会影响另一个——原因是列表里存的是同一个Point对象的引用。调试半天才意识到Point是结构体不是类本不该有此问题真正的原因是我在代码某处把ROI对象浅拷贝了。最终改为深拷贝列表再没有出现相互污染。这提醒我涉及引用类型的ROI列表深拷贝不是可选项是必要项。序列化成JSON时也要谨慎配置ReferenceLoopHandling避免多边形对象自引用导致死循环。7. 从单一ROI到量产工具坐标持久化与批量处理思路7.1 坐标数据如何持久化标注工具的价值一半在于标注数据的积累。多边形ROI提取完成后坐标信息必须能保存和重新加载否则每次打开工具都要重新圈一遍体验极差。我将ROI数据序列化为JSON格式保存成与图片同路径的sidecar文件。public class RoiRecord { public string Name { get; set; } public Listdouble[] Polygon { get; set; } public double Confidence { get; set; } public DateTime CreateTime { get; set; } }每个顶点存成[x, y]的double数组读取时再转成Point这样做的好处是JSON结构简单且不依赖UI层的Point类型任何语言都能解析后续接入Python训练Pipeline毫无障碍。7.2 批量提取的并行化实现当同类图片几十上百张时一张一张手动圈显然不行。好在ROI坐标通常是预设的模板坐标来自一张标准图批量提取就是把这些坐标按比例映射到每张待测图上然后用同一个掩码流程执行提取。OpenCvSharp4支持并行处理我用的Parallel.For对文件列表做并行提取。这里踩过一个线程安全坑OpenCV的Mat操作在不同线程间是安全的但Cv2.ImWrite的保存路径如果并发写同一文件会冲突。解决方式是为每张图生成唯一文件名图片名加ROI名彻底避免并发写。Parallel.For(0, fileList.Count, i { using (Mat src Cv2.ImRead(fileList[i], ImreadModes.Color)) { if (src.Empty()) return; using (Mat roi ExtractRoi(src, mappedPoints)) { Rect rect Cv2.BoundingRect(mappedPoints); using (Mat cropped new Mat(roi, rect)) { Cv2.ImWrite(Path.Combine(outDir, ${Path.GetFileNameWithoutExtension(fileList[i])}_roi.jpg), cropped); } } } });7.3 后续扩展方向完成了多边形ROI提取之后这个工具已经不仅仅是个抠图工具了。ROI坐标可以喂给模板匹配可以统计区域灰度均值和方差可以接入OCR识别特定区域的文字也可以作为缺陷检测的排除范围。我还做了一步很实用的扩展支持在ROI提取结果上叠加网格统计按行和列把区域切分输出各小格的数值方便做区域差异分析。这些扩展都建立在坐标数据和图像处理链路解耦的基础上只要初始的多边形标注交互稳定可靠上层怎么玩都很顺手。从我这个项目的经验来说先把标注这层地基打牢比急着堆功能重要得多。最后分享一个实操小技巧如果你的多边形顶点特别多绘制时建议每加一个点就先移除再重绘整个Canvas而是对新增点只做一次增量绘制尾部补一条新边。否则上百个顶点的多边形每次微调都会卡一下用户体验天差地别。我的处理是在Redraw之外加了一个AppendVertex方法专门处理增量场景性能开销从毫秒级降到微秒级涂抹体验完全不一样。这块瓶颈虽然不复杂但做了之后流畅度提升非常明显。
返回列表