ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ImageProcessor实战:.NET Framework图像处理管道化开发指南

ImageProcessor实战:.NET Framework图像处理管道化开发指南

早两年但凡在 .NET Framework 项目里碰过图片处理的人,应该都绕不开那个经典组合:System.Drawing加上一堆自己封装的辅助类。缩略图、水印、裁剪、格式转换,每个功能都像是从石头里往外抠。内存泄漏、GDI+ 报错、不同环境下的诡异行为,光是处理这些就能耗掉半个迭代周期。直到后来我接触到了 ImageProcessor,才意识到原来在老框架下做图像处理也能有现代库的体验——强类型 API、流利语法、统一的处理管道、内置缓存。这篇就把我对这个库的完整理解写出来,从设计思路到生产级配置,从正常流程到坑点排查,希望给还在维护老项目的开发者一个可落地的参考。

这个库本质上解决的是这样一件事:在不引入重量级原生依赖、不改变项目目标框架的前提下,把“输入图片 -> 一系列处理操作 -> 输出图片”这条链路标准化。它适合三类人:还在维护 .NET Framework 老项目的开发、需要在 ASP.NET 应用里快速集成动态图片处理的人、以及那些被 System.Drawing 反复折磨但暂时无力迁移到跨平台框架的团队。

1. 为什么还需要一个面向 .NET Framework 的图像处理库

1.1 System.Drawing 的“凑合能用”与隐藏代价

很多老项目里图片处理代码长得很像:先Bitmap.FromFile或者Bitmap.FromStream,然后Graphics.FromImage,接着DrawImage、Save,最后还要小心翼翼地把Bitmap、Graphics、Image一个个 Dispose。这套流程简单场景下确实能用,但问题在于它把所有复杂度都暴露给了调用方。

最典型的是非托管资源泄漏。Bitmap和Graphics背后是 GDI+ 的句柄,一旦在异常路径上忘记释放,句柄累积到某个阈值后就会出现OutOfMemoryException——名字叫内存异常,实际是 GDI 句柄耗尽。这种错误在本地测很难复现,上生产环境后高并发下突然冒出来,排查起来极其痛苦。另一个坑是Graphics对象对 PixelFormat 和图像深度很敏感,直接把 8-bit 灰度图扔给DrawImage,某些机器上可能直接抛 ”A generic error occurred in GDI+",而同样的代码在另一台机器上却正常。这种环境相关的不确定性,是最消耗信任感的。

还有就是代码结构层面的问题。几乎所有基于 System.Drawing 的封装,最终都会变成一大串顺序调用的工具方法:GenerateThumbnail、AddWatermark、CropImage…… 每个方法内部都是几十行相似度极高的 GDI+ 操作。等业务要求变成“先缩略、再加水印、再转格式、再加锐化”,通常就只能在方法里加参数,或者再套一层方法,参数列表越来越长,调用关系越来越乱。

1.2 ImageProcessor 的设计思路:把处理行为编排成管道

ImageProcessor 想改变的就是这个局面。它的核心思路是把每一个图像处理行为建模成组件,然后装配出一条有序的执行管道。这有点像工厂里的流水线:一张原图进去,经过缩放工位、滤镜工位、文字水印工位、编码工位,最后出来一张成品图。每个工位只负责一件事,工位之间通过中间状态传递图像数据。

实现上,ImageProcessor是一个静态入口,通过ProcessImage系列方法接收字节数组、流或文件路径。处理行为通过Processor委托来定义,委托内部通过ImageFactory实例的Operations集合逐个添加操作。每个操作都继承自IImageProcessor,有自己的DynamicParameter、OnProcess等机制,最终在统一的环境里顺序执行。

这样做带来的直接好处是组合性。加一个水印不用再写一个方法,只需要往Operations集合里.Watermark(...)一下;想调整顺序,调换代码行顺序即可。同时,因为操作是对象化的,所以它们可以被缓存、被复用、被测试,也更容易支持配置化——比如按照请求参数动态构造处理链。

1.3 和后来跨平台库的定位差异

现在很多人提到 .NET 图像处理,第一反应是跨平台的 ImageSharp 或者 SkiaSharp。但 ImageProcessor 和它们不是同一层的东西。ImageSharp 是原生的托管实现,目标是全平台、全场景独立图像处理;SkiaSharp 则是绑定谷歌 Skia 图形引擎的封装,性能上限高,但引入的是原生库。

ImageProcessor 选择的是绑定在 .NET Framework 生态上的纯托管路子。它内部默认使用基于 WPF 的WindowsCodecExtensions来做编码解码,也就是说它依赖 Windows 平台能力,但换来的是对传统格式、传统编码器的良好兼容。在只针对 Windows 部署的老项目里,这种取舍是务实的:不用额外发布原生 DLL,不用考虑跨平台兼容性,直接 Add Reference 就能用。

还有一点很实际——它的内存管理和并发模型是针对 ASP.NET 场景设计的。后面我会单独说缓存和线程安全部分,这里先提一句:在老框架项目里,它是一个很“重量级恰好”的补充,既有抽象层,又没有过度工程化。

2. 理解 ImageProcessor 的核心处理管线

2.1 处理委托、ImageFactory 和操作集合

用代码说话。这是一个最基础的调用:

using ImageProcessor; byte[] photoBytes = File.ReadAllBytes(@"D:\photos\raw.jpg"); byte[] processedBytes; using (var imageFactory = new ImageFactory()) { imageFactory .Load(photoBytes) .Resize(new Size(400, 0)) .Rotate(90) .Save(out processedBytes); }

这里有几个关键点。ImageFactory是核心门面,Load接受字节数组或者流,会解析图像并把它包装成内部状态;Save负责按目标格式编码输出。中间串联的Resize、Rotate、Watermark等都是扩展方法,它们不会立刻执行,而是把操作项添加到imageFactory.CurrentImage.Operations列表里——这就是我前面说的流水线模型。

Resize(new Size(400, 0))里的宽度 400、高度 0,表示按宽度等比缩放。这是最常用的缩略图逻辑,比手动算高度省心得多。Rotate(90)能旋转,注意是顺时针角度,这是和某些图形库不同的地方。

Save(out processedBytes)这个重载很有用,它直接输出字节数组。在老项目中,你完全可以把这段逻辑放到一个 WebApi 接口里,接收上传图片字节,内部处理完后返回处理后的字节,调用方完全不用感知底层细节。

2.2 六步处理顺序和中间状态传递

文档里写得很清楚,ImageFactory的处理过程是有严格顺序的。这个顺序很多人没细看,导致组合操作时出现预期外的结果。标准顺序是:

  1. AutoRotate自动根据 EXIF 方向信息旋转
  2. Resize尺寸调整
  3. 其他各类处理操作,如Rotate、Crop、Filter、EntropyCrop等
  4. Watermark文字或图片水印
  5. BackgroundColor背景色填充(如果输出格式支持透明度会受影响)
  6. Format格式转换,本质是设定编码器

这个顺序意味着什么?举例来说,BackgroundColor(Colors.White)理论上应该在旋转和裁剪之后才生效。如果你在一个需要透明度补偿的场景里,把背景色操作放在最前面,就会在后续处理中产生额外的边缘干扰。我自己踩过的一个例子是:给一张偏暗的照片加白色背景模拟白底商品图,起初把BackgroundColor和Brightness同时加进去,结果因为顺序问题,图片边缘出现一圈不自然的浅色过渡。后来按文档明确顺序调整,把背景色放到最后一步,才得到干净的白底效果。

Format放在最后也很有意思:如果你要输出 Jpeg,但前面对透明 PNG 做了保留透明度的操作,那么最终的 Jpeg 会把透明区域用默认背景色填掉。要避免这个问题,得在Format之前明确设置背景色。这属于“不读文档就一定会遇到”的典型坑。

2.3 图像格式化器、颜色空间和参数配置

ImageProcessor 支持常见的 Jpeg、Png、Gif、Bmp、Tiff。每种格式对应一个ISupportedImageFormat实现,它们负责编码器参数。平时最常用到的是 Jpeg 的质量参数:

byte[] compressedBytes; using (var imageFactory = new ImageFactory()) { imageFactory .Load(photoBytes) .Resize(new Size(800, 0)) .Quality(85) .Format(new JpegFormat { Quality = 85 }) .Save(out compressedBytes); }

Quality(85)是设置编码质量百分比。需要澄清的是,它和JpegFormat { Quality = 85 }在效果上是等同的,只是前者更内联。.Quality(85)有一个值得注意的行为:它如果后续没有跟着Format,则会改变当前图像编码器实例的默认质量。很多人写了.Quality(85)之后直接Save,以为 Png 也会被压缩,实际上 Png 是有损参数不生效的,必须明确转成 Jpeg 编码。所以我的习惯是,只要涉及质量参数一定带上对应的Format,避免语义模糊。

Quality参数的作用范围是 Jpeg 这一层,不会影响像素数据本身。过度压缩到 50 以下时会出现明显块状噪声,这在商品大图场景中尤其致命。我的经验是,非高精度展示型图片控制在 70 到 85 之间,头像一个小图 60 就够;如果原图是带文字的截图,建议保持 80 以上,否则文字边缘会很脏。

3. 用 ImageProcessor 搭建一个真实可用的缩略图服务

3.1 环境准备与项目引用

这部分基于常见实践做一个完整示例。假设你手上是一个老牌的 ASP.NET MVC 项目,目标框架是 .NET Framework 4.6.1,现在需要给商品列表页增加缩略图能力,并且要求对原图不做物理修改,处理结果走缓存。

第一步,右键你的 Web 项目,打开 NuGet 包管理器,搜索ImageProcessor,安装最新稳定版。同时建议安装ImageProcessor.Web,这是给 ASP.NET 用的集成模块,后面会讲它的缓存和 URL 重写机制。

如果你用的是 .NET Framework 4.7.2 以下版本,需要注意依赖兼容性。ImageProcessor核心包依赖System.ValueTuple和System.Runtime.CompilerServices.Unsafe,这些在 NuGet 还原时会自动带上,一般不会出问题。真正容易出问题的是老项目用了低版本的 Newtonsoft.Json,如果版本冲突严重,可以考虑直接用核心包自己封装 API,不引入 Web 集成包。

3.2 核心处理逻辑:统一封装一个图片处理服务

我先把最核心的封装写出来,这个服务类是所有图片处理逻辑的中转站:

public class ImageProcessService { public byte[] CreateThumbnail(byte[] source, int width, string watermarkText = null) { using (var factory = new ImageFactory()) { factory.Load(source); // 自动旋转,整理相机照片的方向 factory.AutoRotate(); // 等比缩放,宽度锚定 factory.Resize(new Size(width, 0)); // 微调清晰度,让缩略图看起来更干净 factory.Sharpen(3); // 可选文字水印,放右下角 if (!string.IsNullOrWhiteSpace(watermarkText)) { factory.Watermark( new TextLayer(watermarkText) { FontFamily = "Microsoft YaHei", FontSize = 18, FontColor = Color.White, StrokeColor = Color.Black, Position = new Point(100, 100), Opacity = 80 }); } // 统一输出为 JPEG,质量 80 factory.Quality(80) .Format(new JpegFormat { Quality = 80 }); byte[] output; factory.Save(out output); return output; } } }

这段代码做几件事:加载原图;AutoRotate把手机拍摄时记录的 EXIF 旋转信息处理掉;按宽度等比缩放;Sharpen(3)做轻度锐化补偿缩放造成的模糊;在选项里加水印;最后统一输出 Jpeg。

这里有几个参数值得展开。Sharpen的数值范围是 0 到 100,并不是越大越好。缩略图场景下我实测 2 到 5 最自然,超过 10 就会在物体边缘出现明显的白边和光晕,尤其是有高对比边缘的图片。字体选择上用系统中文字体要比英文字体保险,Microsoft YaHei在绝大多数 Windows Server 上都存在,如果你要内嵌特殊字体,TextLayer也可以直接指定字体文件路径。

Opacity = 80表示水印透明度 80% 左右,这个参数是整数类型,取值 0 到 100,不是有些库里的 0.0 到 1.0 浮点数。这里如果写成 0.8 会出问题,需要特别注意。这是我早期移植其他库经验时常犯的错。

3.3 缓存策略和 URL 重写:生产环境的关键配置

直接写一个 API 处理图片是最基础的做法,但生产环境不能每次请求都重新处理一遍。ImageProcessor.Web 提供了一套内置的基于磁盘的缓存机制。它拦截请求,把处理后的图片结果缓存在服务端指定目录,后续相同参数请求直接读缓存文件。

启用方式是在web.config里增加配置。最基本的是注册 HttpModule 或者 HttpHandler,配置一个虚拟路径,比如所有以/media/开头的请求都交给图片处理器:

<configuration> <configSections> <section name="imageProcessor" type="ImageProcessor.Web.Configuration.ImageProcessorConfig, ImageProcessor.Web" requirePermission="false" /> </configSections> <imageProcessor> <caching current="DiskCache"> <add name="DiskCache" type="ImageProcessor.Web.Caching.DiskCache, ImageProcessor.Web"> <settings key="VirtualPath" value="~/app_data/image_cache" /> <settings key="MaxDays" value="30" /> </add> </caching> </imageProcessor> <system.webServer> <modules> <add name="ImageProcessorModule" type="ImageProcessor.Web.HttpModules.ImageProcessorModule, ImageProcessor.Web" /> </modules> </system.webServer> </configuration>

这里面最关键的是DiskCache的MaxDays,它控制缓存保留天数。我在一个模拟项目中设过 90 天,结果商品图片改版后,部分老缩略图还在缓存目录里占空间。后来调整成 30 天并把清理任务挂到应用维护窗口,情况好很多。另外VirtualPath指向~/app_data/image_cache,这个目录需要预先创建并给进程写权限,否则会静默失败或者抛未授权异常。

配合 URL 重写后,前端代码可以写成这样:

<img src="/media/large_home.jpg?width=300&height=300&mode=crop&quality=75" />

请求进入后,ImageProcessor.Web 会解析查询字符串参数,自动构建处理管道,按width、height、mode、quality这几个参数依次处理。最终响应是处理后的图片字节流,浏览器端拿到的是一张普通 Jpeg,完全无感知。

这里我建议在生产环境按业务场景限制可用的参数白名单。虽然 ImageProcessor.Web 本身在性能上做了不少优化,但开放一整条处理链会带来风险——比如攻击者可以通过传一个极大的尺寸参数消耗服务器内存。通过前置的 URL 路由策略来限制参数范围,或者只允许经过签名验证的 URL 请求处理接口,都是常见做法。

4. 我在生产环境里踩过的那些坑

4.1 透明 PNG 导出后变黑底的根因与修复

非常典型的一个问题:处理一张带透明通道的 PNG,原图在水印模式下几乎是半透明文字浮在图案上,但最终输出结果里透明区域变成了纯黑色。排查后原因在两方面。

第一,Png 格式输出时,透明度信息默认保留,但如果你在这个流程里调用过BackgroundColor(Color.Black)或者处理过程中需要操作像素格式而不是标准 Alpha 格式,透明度就容易被破坏。第二,是把透明 PNG 转成 Jpeg 输出——Jpeg 格式本身不支持透明通道,这时透明部分会被填上黑色或白色,取决于内部默认值。

我的建议是:如果原图带透明度且只是加水印、缩放这类操作,始终输出为 PNG 格式,不要转 Jpeg。

factory.Format(new PngFormat());

如果你确实需要转成 Jpeg,那么在Format之前显式调用.BackgroundColor(Color.White),这样透明区域就会变成白色底,而不是默认的黑色。这一点尤其影响那些拿白底透明图标转 Jpeg 做商品主图的场景。

factory .BackgroundColor(Color.White) .Format(new JpegFormat());

4.2 EXIF 方向信息和相机旋转问题

手机照片和部分专业相机照片会带有 EXIF 元数据里的 Orientation 字段。有些设备拍出来的图,在图片查看器里看是正的,但程序读出来像素数据本身就是旋转过的。如果不处理 Orientation,直接做缩略图就会得到歪的或者左右颠倒的结果。

AutoRotate()就是为此设计的。它会在处理链最前面读取 EXIF 方向信息并自动旋转图像。但需要注意,如果原图本身已经被别的工具处理过,EXIF Orientation 字段可能已经被移除,或者保留的是错误的值。遇到这种情况,AutoRotate()不但起不了正向作用,可能还会多转一次。

我在实际项目里是这样处理的:在封装处理逻辑时增加一个参数,控制是否执行AutoRotate()。如果图片可能来自手机上传,就默认开启;如果图片来自已经经过一次处理的服务端(比如原始图已经由上传端修正过方向),就关闭。这种开关设计看着很简单,但在真实业务里能省掉大量方向错误的客诉。

4.3 大图并发处理时的内存峰值与池化建议

ImageProcessor 的纯托管实现虽然避免了 GDI 句柄泄露,但处理大图时内存占用依旧可观。一张 4000 万像素的原图,加载为未压缩像素数据后会占几百 MB 内存,再加上处理中间状态的多个副本,内存峰值很容易上 GB。在并发量上来后,如果没有合理控制,应用池极容易因内存上限触发回收。

我的经验是两层策略。第一层,在入口处校验图片大小。根据常见实践,读取流后先看图片头部信息或直接限制上传文件大小,超过阈值(比如 2000 万像素)就拒绝处理或走异步降级流程。第二层,对处理接口做并发信号量控制。用SemaphoreSlim限制同一时刻最多只有固定数量的处理任务并发,超出就排队或者返回 503,避免把所有请求都压进内存里。

private static readonly SemaphoreSlim _throttle = new SemaphoreSlim(4); public async Task<byte[]> ProcessWithThrottleAsync(byte[] source, Func<byte[], byte[]> processor) { await _throttle.WaitAsync(); try { return processor(source); } finally { _throttle.Release(); } }

控制好并发后,再结合 DiskCache,绝大多数生产场景都不会因为图片处理把应用压垮。核心思想是:处理是低频的、受控的,缓存是高频的、廉价的。

4.4 物理缓存目录和锁竞争

多线程同时请求相同参数时,ImageProcessor.Web 内部会写同一个缓存文件。框架本身做了一定程度的写入保护,但高并发下仍可能出现短时间的文件访问冲突,表现为偶发的IOException: The process cannot access the file ... because it is being used by another process。

排查这类问题比修复更关键。出现这个异常时,我先把缓存目录挪到了独立磁盘路径,并检查了站点进程对目录的“只允许单一写入者”权限设置。实际上,最有效的修复手段是预处理预热:在站点的初始化阶段,主动把热门商品的缩略图预先处理一遍生成缓存文件。这样业务高峰时的请求基本都命中缓存,生产路径上很少出现并发写同一文件的情况。

另外,定期清理缓存目录也是必要的。MaxDays设得再合理,也会因为各种参数组合产生大量冗余文件。我习惯写一个简单的清理任务,删除最后一次访问时间超过 60 天、且大小超过阈值的缓存文件,防止磁盘被占满。

5. 选型边界:什么场景该用它,什么场景该重新考虑

5.1 与常见方案的整体对比

很多团队选型时会在 System.Drawing、ImageProcessor、SkiaSharp、ImageSharp 之间反复横跳。下面这个表是我基于实际维护体验做的粗粒度对比,不同项目结论可能不同,但大方向可以参考:

方案平台依赖处理能力缓存机制上手成本适合场景
System.Drawing仅 Windows基础操作可完成无低但坑多一次性脚本、极简场景
ImageProcessor仅 Windows丰富,且封装度好内置磁盘缓存低老 Framework 项目、ASP.NET 场景
SkiaSharp跨平台强,覆盖全面需自建中跨平台客户端/服务端
ImageSharp跨平台强,持续更新需自建中追求全托管、跨平台

对于还在用 .NET Framework、且部署目标是 Windows Server 的团队来说,ImageProcessor 的优势集中在封装度和缓存机制上。它不需要你重新理解底层像素操作,也不需要为输出格式和编码器单独写适配层。同时,它内置缓存这一点在构建 Web 服务时非常省事——其他方案几乎都需要你额外实现一套缓存策略。

5.2 什么时候不要继续用它

有三个信号出现时,我会认真考虑迁移。第一,项目明确要上容器、要跨平台部署。ImageProcessor 依赖 Windows 图像编码能力和物理磁盘路径,在 Linux 容器里几乎没法正常工作。第二,处理逻辑开始涉及大量自定义像素级操作(卷积核、自定义滤镜、局部色调映射),这时候库提供的操作集合就变成瓶颈了,直接用底层托管库会更自由。第三,安全审计要求全链路无 GDI+,虽然 ImageProcessor 不是 GDI+ 直接封装,但它的编码层走 Windows Codec,在某些严苛环境下可能过不了审计。

另外,如果团队开始做新的开源组件或类库,并打算把图像处理能力作为公共依赖提供给下游,我会优先选跨平台库,而不是继续捆绑在 .NET Framework + Windows 上。毕竟依赖的方向决定了下游的使用边界。

5.3 一个务实的迁移路径

如果你已经积累了大量基于 ImageProcessor 的处理代码,但团队决定未来要迁移到 .NET 6 之类的新平台,我的建议是不要直接在业务代码里替换库,而是先抽象出一个IImageProcessService接口,当前实现保持用 ImageProcessor,后续新平台实现切换成 ImageSharp 或者 SkiaSharp。这样业务层面对处理细节的感知为零,迁移时只需要补一个实现类,跑一轮接口回归测试。

这也是我维护老项目时的一个习惯:库的选择可以变,但应用层和库之间一定要有一层防腐层。精细操作放在实现类里,上层业务永远只依赖意图明确的接口。这个方法听着老土,但在技术栈切换时能救命的。

6. 一些值得收藏的实践细节

6.1 图形参数配置的最佳实践

public const int ThumbnailQuality = 80; public const int WatermarkOpacity = 60; public static readonly Color WatermarkStrokeColor = Color.FromArgb(128, 0, 0, 0); public static readonly FontFamily WatermarkFontFamily = new FontFamily("Microsoft YaHei");

我这里没直接在上层业务里写死每个参数,而是放到一个静态参数类里集中管理。原因是一个项目里通常有多个图片处理场景——商品缩略图质量要求高、头像要求小且快、运营图要求带水印。如果每处调用都写一遍数值,改一次品牌水印模板就得全局搜索替换。

水印文字的描边颜色,Color.FromArgb(128, 0, 0, 0)表示半透明黑色,这类参数调试时肉眼感受很强,放常量的好处是换肤时只要改一个地方。字体的选择上也建议在部署前测试目标服务器是否安装了相应字体,避免输出成不可读的缺省字体。

6.2 异步化处理避免阻塞 Worker 线程

在 ASP.NET 场景里,图片处理属于 CPU 密集任务,而且涉及文件 IO 和大字节数组操作。老框架下如果直接在请求线程同步执行,会很快耗尽线程池可用线程,严重时表现为接口雪崩。结合经验,建议把所有图片处理封装成异步方法,配合ConfigureAwait(false)减少上下文切换。

public async Task<byte[]> CreateThumbnailAsync(byte[] source, int width, string watermarkText = null) { return await Task.Run(() => CreateThumbnail(source, width, watermarkText)) .ConfigureAwait(false); }

注意Task.Run只是把任务丢给线程池,真正的并发控制还是靠前面提到的信号量或者队列机制。异步化处理解决的是线程模型问题,不会自动解决内存和并发上限问题,这两件事要分清楚。

6.3 配合 CDN 使用时的一个建议

如果整个站点图片最终要走 CDN,我建议不要直接把 ImageProcessor.Web 的动态 URL 暴露给外部。因为 CDN 节点会缓存 URL 对应的内容,如果图片参数可以任意组合,CDN 上会积累海量缓存碎片,回源率也很难控制。

我常用的做法是:动态处理 URL 只对内部服务开放,外部访问统一走静态化之后的地址。也就是每次图片经过ImageProcessor.Web处理并写入缓存后,前端资源地址拼成一个固定路径,最后通过 Nginx 之类的静态服务直接把缓存目录暴露出去。这样既有实时处理能力,又不会让动态参数污染对外链接。

这个方案的关键点是设置合适的缓存过期头(Cache-Control),让 CDN 能放心缓存静态文件,而不需要每次回源校验。

写在最后的个人体会

每个技术方案都有它的时代背景,ImageProcessor 正是 .NET Framework 时代一个把“图片处理”这件事做得足够舒服的库。它的存在让老项目不用大改架构就能获得接近现代开发体验的图像处理能力。但我也清楚,技术选型没有一劳永逸,尤其是当平台趋势向跨平台、容器化演进时,早期依赖 Windows 生态的方案都会面临重新审视。好在只要应用层边界清晰,替换永远不会太疼。

在实际使用中,我感触最深的是它那个处理管道设计带来的思维变化——把图片处理当作一系列可组合、可复用、可测试的操作单元,而不是一坨塞满了DrawImage的工具方法。这个思路本身就值得借鉴,哪怕换到别的语言、别的平台,也是通用的设计哲学。最后再提醒一次,透明图转 Jpeg 记得先设背景色,这是我在这个库上栽过最大的跟头。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进