
图片水印这个需求很多人觉得不就是“在图上叠一行字嘛”但真在 .NET Core 8.0 这个环境下做一次你才会发现选库、定位、透明度、跨平台部署到处是坑。我这次用 SkiaSharp 做了一套支持右上角、左上角、右下角、左下角和居中五种位置还带透明度控制的水印服务整个过程从选型到上生产遇到的问题比想象中多得多。这篇文章就把完整方案和踩坑记录写下来给后面要动这块的朋友省点时间。1. 为什么是SkiaSharp在.NET Core里画水印的选型复盘1.1 需求一开始的样子需求本身不复杂用户上传图片后端按配置加水印水印可以放在四角或中央透明度可以调最终把处理完的图返回给前端。但“不复杂”三个字是在做完之后才能说的当时摆在面前的第一道坎就是选型。部署环境是 Linux 容器项目是 .NET Core 8.0图片处理要作为公共服务被多个业务方调用并发量并不低。这意味着图形库不仅要能用还得在 Linux 上稳定运行、性能足够好、API 能覆盖文字绘制和图片叠加这些基础场景。我在评估阶段列了一个简单的候选清单System.Drawing.Common、ImageSharp、SkiaSharp。1.2 为什么第一时间排除System.Drawing和ImageSharp很多老项目还在用 System.Drawing.Common。它在 .NET Framework 时代确实顺手但 .NET Core 6.0 之后官方明确把它标记为仅支持 Windows在 Linux 上虽然能跑但依赖 libgdiplus 等一堆系统库不同发行版下的渲染效果不完全一致字体渲染更是随系统环境变化。我们的运维环境是精简过的容器镜像装 libgdiplus 的过程本身就容易出问题所以它从一开始就不在考虑范围内。ImageSharp 是纯托管实现部署简单社区里也有现成的水印扩展。但它的问题是底层图像编码解码全部用 C# 实现处理大图时内存占用和耗时明显偏高。我们在压测环境里试过一张 4000x3000 的 JPEGImageSharp 做缩放加水印大约需要 200 多毫秒而 SkiaSharp 只要 60 毫秒左右。对于图片服务这种高并发场景性能差距直接决定选谁。1.3 SkiaSharp的核心优势SkiaSharp 是 Google Skia 图形引擎的 .NET 绑定Chrome、Android、Flutter 底层都在用 Skia它的图形渲染能力是经过大规模生产验证的。对 .NET 开发者来说SkiaSharp 提供了一套非常完整的 2D 绘图 API绘制文字、绘制位图、路径操作、颜色混合模式、图像滤镜几乎你能想到的图形操作它都有而且和 GDI 的编程模型很接近——创建画布、创建画笔、执行绘制学过 System.Drawing 的人上手很快。更关键的是跨平台能力。SkiaSharp 通过 NativeAssets 包内置了各平台的原生库Windows、Linux、macOS 都有对应的二进制包只要 NuGet 包选对代码不需要任何平台分支就能跑。这一点对容器化部署特别友好。我用一个表格总结一下当时的三方对比方案跨平台能力性能表现部署复杂度API 完整度System.Drawing.Common仅 Windows 稳定中等Linux 需装 libgdiplus基础绘图够用ImageSharp全托管跨平台大图处理偏慢极低绘图能力偏上层SkiaSharpLinux/Windows/macOS高需安装对应 NativeAssets完整 2D 绘图最终结论很明确在高并发的 Linux 图片服务场景下SkiaSharp 是当前阶段最稳的选择。2. 工程接入NuGet包、原生依赖与Linux部署三件事2.1 NuGet包怎么选新建一个 .NET Core 8.0 的类库或者直接在 WebAPI 项目里引用先安装 SkiaSharp 主包dotnet add package SkiaSharp这只是第一步。很多人在这里就踩坑了只装了主包本地 Windows 开发没问题一 publish 到 Linux 容器就报 “Unable to load shared library libSkiaSharp”。原因是主包默认带了 Windows 原生库但 Linux 上需要额外的原生资产包。我实际使用的包组合是这样的PackageReference IncludeSkiaSharp Version2.88.8 / PackageReference IncludeSkiaSharp.NativeAssets.Linux.NoDependencies Version2.88.8 /如果是在 Docker 镜像里跑还建议把SkiaSharp.NativeAssets.Linux也加上这个包依赖 fontconfig 等系统库而 NoDependencies 版本则不需要。两者的区别在于NoDependencies 版本内部把 freetype、fontconfig 静态编译进了 libSkiaSharp对精简容器更友好但如果你的项目还需要处理复杂字体族回退完整依赖版本可能更稳。我在生产环境用的是 NoDependencies几个月跑下来没有遇到问题。2.2 Linux容器里的原生依赖如果你的 Dockerfile 是基于mcr.microsoft.com/dotnet/aspnet:8.0这类镜像用 NoDependencies 包基本不需要额外安装系统库。但如果某些基础镜像缺少libfontconfig1你可能会在运行时收到类似下面的错误Unhandled exception. System.TypeInitializationException: The type initializer for SkiaSharp.SKAbstractManagedStream threw an exception.这个错误不一定和字体有关而是原生库加载失败。排查时可以先用一个最小控制台程序只调用SKBitmap.Decode加载一张图片试试。如果加载图片正常、绘制文字时才崩再排查字体问题。我在 7.1 节会专门讲字体。3. 第一条水印SKCanvas绘制文字与透明度控制3.1 画布和画笔的基本关系SkiaSharp 的绘图模型可以类比成“物理世界的画布和画笔”SKBitmap是一块内存画布SKCanvas是拿在手里的笔刷操作器SKPaint定义了画出的效果——颜色、字体、透明度、描边还是填充。基本流程是固定的四步创建位图、创建画布、配置画笔、执行绘制。文字水印的最小示例是这样的using SkiaSharp; public static byte[] DrawTextWatermark(byte[] imageBytes, string watermarkText) { using var inputStream new MemoryStream(imageBytes); using var bitmap SKBitmap.Decode(inputStream); using var canvas new SKCanvas(bitmap); using var paint new SKPaint { Color new SKColor(255, 255, 255), TextSize 32, IsAntialias true, Typeface SKTypeface.FromFamilyName(Microsoft YaHei) }; var textWidth paint.MeasureText(watermarkText); canvas.DrawText(watermarkText, 50, 50, paint); canvas.Flush(); using var output new MemoryStream(); using (var image SKImage.FromBitmap(bitmap)) using (var data image.Encode(SKEncodedImageFormat.Png, 100)) { data.SaveTo(output); } return output.ToArray(); }这段代码的逻辑是把字节数组 decode 成 SKBitmap创建基于该位图的画布然后用白色画笔在坐标 (50,50) 处绘制文字最后把位图编码成 PNG 字节流返回。3.2 透明度不是“变浅”而是Alpha通道很多人第一次接触透明度会有一个误解以为调透明度就是把颜色值改淡一点。实际上透明度的本质是 Alpha 通道即颜色的第 4 个字节取值范围 0 到 2550 表示完全透明255 表示完全不透明。在 SkiaSharp 中SKColor的构造函数接收四个参数SKColor(byte red, byte green, byte blue, byte alpha)。透明度 50% 就是 alpha 12825% 就是 alpha 64。如果不传第四个参数默认是 255也就是完全不透明这往往是“我设置了颜色怎么还是全不透明”的坑。我习惯直接把透明度参数转换成 alpha 再构造颜色public static SKColor GetColorWithAlpha(byte r, byte g, byte b, double opacityPercent) { var alpha (byte)Math.Clamp((int)(opacityPercent * 255.0 / 100.0), 0, 255); return new SKColor(r, g, b, alpha); }这里有个容易忽略的细节alpha 值和“百分比”是线性关系但人眼感知的透明度并不是线性的。如果想做到视觉上更均匀的淡入淡出效果理论上要做 gamma 校正。不过对于水印这种场景线性映射完全够用不必过度设计。3.3 文字尺寸测量定位的前提绘制文字和绘制矩形不一样文字的宽度高度必须先测出来才能做居中和贴边计算。SkiaSharp 里最常用的方法是paint.MeasureText(text)它返回的是文本的宽度单位是像素。高度的获取稍微绕一点paint.FontMetrics里有Ascent和Descent字段文字的实际高度约等于Descent - Ascent。因为 Ascent 是负值基线以上Descent 是正值基线以下两者之差就是字体从最高处到最低处的总高度。这个值比直接用 TextSize 更真实因为不同字体的实际度量不一样。我封装了一个简单的方法public static (float width, float height) MeasureTextSize(string text, SKPaint paint) { var width paint.MeasureText(text); var fontMetrics paint.FontMetrics; var height fontMetrics.Descent - fontMetrics.Ascent; return (width, height); }有了这个尺寸接下来的五位置定位就水到渠成了。4. 五位置定位算法测量先行、四角与居中一次算清4.1 统一坐标模型水印位置的本质是在目标图片坐标系里为水印内容选择一个合适的左上角锚点。假设目标图片宽度为imageWidth高度为imageHeight水印内容测量出的宽度为textWidth高度为textHeight再约定水印距离图片边缘的水平边距为marginX垂直边距为marginY那么五个位置的左上角坐标可以统一计算位置X坐标Y坐标左上角marginXmarginY右上角imageWidth - textWidth - marginXmarginY右下角imageWidth - textWidth - marginXimageHeight - textHeight - marginY左下角marginXimageHeight - textHeight - marginY居中(imageWidth - textWidth) / 2(imageHeight - textHeight) / 2这个模型的关键点在于先把水印内容的大小测量出来然后用“图片尺寸减去水印尺寸再减去边距”得到贴边的起笔位置。理解了这个你就不必为每个位置写独立的 if 分支逻辑。4.2 用枚举表达位置我不建议用字符串传位置参数容易拼错且不便于扩展。定义一个枚举更清晰public enum WatermarkPosition { TopLeft 0, TopRight 1, BottomRight 2, BottomLeft 3, Center 4 }然后写一个统一的计算方法public static SKPoint CalculatePosition(SKSize imageSize, SKSize watermarkSize, WatermarkPosition position, float marginX, float marginY) { return position switch { WatermarkPosition.TopLeft new SKPoint(marginX, marginY), WatermarkPosition.TopRight new SKPoint(imageSize.Width - watermarkSize.Width - marginX, marginY), WatermarkPosition.BottomRight new SKPoint(imageSize.Width - watermarkSize.Width - marginX, imageSize.Height - watermarkSize.Height - marginY), WatermarkPosition.BottomLeft new SKPoint(marginX, imageSize.Height - watermarkSize.Height - marginY), WatermarkPosition.Center new SKPoint((imageSize.Width - watermarkSize.Width) / 2, (imageSize.Height - watermarkSize.Height) / 2), _ throw new ArgumentOutOfRangeException(nameof(position), position, null) }; }有一点要特别提醒DrawText的 Y 坐标指的是文字的基线不是文字顶部。所以拿到锚点 Y 后还要加上fontMetrics.Ascent的绝对值偏移或者直接用基线参与计算。具体做法是把锚点的 Y 加上Math.Abs(fontMetrics.Ascent)这样文字顶部才能精确落在你计算的位置上。这个偏移在文字垂直居中时尤其明显不加的话居中水印整体会偏下几像素。4.3 边距参数与边界保护边距marginX和marginY可以是固定值也可以根据图片尺寸动态计算。比如希望水印距离边缘始终是图片宽度的 2%就可以传入imageWidth * 0.02f。严格接近边缘在水印场景里其实并不好看留一点呼吸感反而更专业。如果水印内容比图片还大比如文字长度超过图片宽度直接绘制会把文字截断。我习惯在计算前加一个保护逻辑var finalTextWidth Math.Min(textWidth, imageSize.Width - marginX * 2);但更常见的做法是限制水印最大长度当测量宽度超过图片宽度的 80% 时自动缩短文本内容而不是硬塞进去。5. 图片水印图片源加载、缩放与组合绘制5.1 从文件或字节流加载Logo图片水印纯文字水印能满足一部分需求但很多业务场景需要叠加 Logo 或二维码。SkiaSharp 处理图片水印的流程和文字水印非常像只是把“画字”换成“画图”。加载图片水印我通常用字节流方式而不是直接传文件路径——因为 WebAPI 场景下图片水印内容往往通过接口上传或者从对象存储拉取最终都是字节数组using var logoMemStream new MemoryStream(logoBytes); using var logoBitmap SKBitmap.Decode(logoMemStream);然后通过canvas.DrawBitmap把它画到主图指定位置canvas.DrawBitmap(logoBitmap, positionX, positionY, paint);这里的paint同样控制透明度。SKPaint.Color的 alpha 通道对DrawBitmap同样生效也就是说想控制图片水印透明度不需要先把图片本身的像素改淡只要给画笔设置一个带 alpha 的颜色值即可。5.2 缩放与透明度组合二维码水印的典型场景如果 Logo 原始尺寸很大直接画上去会盖掉半边主图。通常需要先缩放。SkiaSharp 缩放位图的标准方式是用Resize方法var desiredWidth 120; var desiredHeight (int)((float)logoBitmap.Height / logoBitmap.Width * desiredWidth); using var resizedLogo logoBitmap.Resize( new SKImageInfo(desiredWidth, desiredHeight), new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear));这里我用的SKSamplingOptions是 SkiaSharp 2.88 里推荐的新 API替代了旧的SKImageFilter或SKPaint.FilterQuality。SKFilterMode.Linear表示线性插值缩放质量足够好如果对高清有更高要求可以考虑SKFilterMode.LinearSKMipmapMode.Linear或者直接用SKFilterHighQuality级别的采样。实测下来缩放 透明度组合最常见的业务场景是二维码水印既要保证二维码可识别、不能被压得太小又不能让它完全遮挡主图信息。我一般把二维码水印缩放到 96x96 像素透明度设在 55% 左右这样既能扫描又不影响主图观感。5.3 透明像素的坑PNG水印的黑底问题如果你导入的水印图片是 JPG 格式它根本没有 Alpha 通道这时候无论你怎么调画笔 alpha水印区域都是一个不透明的矩形。很多人在这一步卡住明明设置了透明度画上去还是整个矩形盖住背景。解决方案是确保水印源图是带透明通道的 PNG。如果业务方给的是 JPG 但要求“边缘透明”那就得先做抠图处理这不是 SkiaSharp 的范畴。从开发角度出发我在接口文档里明确要求图片水印必须使用带 Alpha 通道的 PNG 格式。这个小规范避免了大量线上问题。如果你确实拿到了没有 Alpha 通道的位图还有一种硬处理方式遍历像素把接近白色的像素 alpha 设为 0。这种“伪透明”效果好但性能差4000x3000 图遍历一遍要几十毫秒不建议在高并发接口里做。6. 服务化封装一个能直接交给前端调用的水印接口6.1 完整的水印服务实现把上面的碎片能力串成一个可复用的服务代码结构大概是这样的一个WatermarkService类对外暴露ProcessWatermarkAsync方法接收原始图片字节流、水印文本、位置枚举、透明度、输出格式等参数返回处理后的字节流。public class WatermarkService { public byte[] AddTextWatermark( byte[] sourceImageBytes, string watermarkText, WatermarkPosition position, double opacityPercent 50, int marginX 20, int marginY 20) { if (string.IsNullOrWhiteSpace(watermarkText)) return sourceImageBytes; using var input new MemoryStream(sourceImageBytes); using var bitmap SKBitmap.Decode(input); if (bitmap null) throw new ArgumentException(无法解析图片文件); using var canvas new SKCanvas(bitmap); using var paint new SKPaint { Color new SKColor(255, 255, 255, (byte)(opacityPercent / 100.0 * 255)), TextSize GetAdaptiveFontSize(bitmap.Width, watermarkText.Length), IsAntialias true, Typeface SKTypeface.FromFamilyName(Microsoft YaHei) }; var (textWidth, textHeight) MeasureTextSize(watermarkText, paint); var anchor CalculatePosition( new SKSize(bitmap.Width, bitmap.Height), new SKSize(textWidth, textHeight), position, marginX, marginY); var drawY anchor.Y Math.Abs(paint.FontMetrics.Ascent); canvas.DrawText(watermarkText, anchor.X, drawY, paint); canvas.Flush(); return EncodeToPng(bitmap); } private static (float width, float height) MeasureTextSize(string text, SKPaint paint) { var width paint.MeasureText(text); var metrics paint.FontMetrics; return (width, metrics.Descent - metrics.Ascent); } private static byte[] EncodeToPng(SKBitmap bitmap) { using var image SKImage.FromBitmap(bitmap); using var data image.Encode(SKEncodedImageFormat.Png, 100); using var ms new MemoryStream(); data.SaveTo(ms); return ms.ToArray(); } }这样封装之后上层调用只需要一行代码var result _watermarkService.AddTextWatermark(fileBytes, 示例水印, WatermarkPosition.BottomRight, 65);6.2 内存流处理不落盘操作注意上面的实现全程没有创建临时文件输入是字节数组输出也是字节数组。这在 WebAPI 场景里很重要图片可能是从对象存储拉取的也可能是前端 MultipartFormData 上传的做成“输入字节、输出字节”的纯内存函数方便在各种环境里复用也方便做单元测试。控制器里接收上传文件然后返回处理结果的代码也很简洁[HttpPost(add-watermark)] public IActionResult AddWatermark(IFormFile file, [FromQuery] string text, [FromQuery] int position) { using var ms new MemoryStream(); file.CopyTo(ms); var originalBytes ms.ToArray(); var watermarkedBytes _watermarkService.AddTextWatermark( originalBytes, text, (WatermarkPosition)position, 60); return File(watermarkedBytes, image/png, watermarked.png); }这里有一件事必须提醒SKBitmap.Decode在输入流不是有效图片时不会抛异常而是返回 null。所以一定要判断 null否则下一步创建SKCanvas会抛ObjectDisposedException或者直接空引用。我在服务里已经加了保护。6.3 输出格式选择PNG还是JPEG我在上面统一输出 PNG因为带透明通道时 PNG 不会失真。但如果原图是 JPEG、水印也不透明输出 PNG 会把文件体积撑大好几倍——一张 4000x3000 的 JPEG 原图约 2MB转成 PNG 可能变成 12MB接口响应体明显变大。更合理的策略是跟随原图格式检测原图编码格式如果原图是 JPEG 则输出 JPEG原图是 PNG 则输出 PNG。SkiaSharp 可以通过SKImage.FromBitmap().Encode(SKEncodedImageFormat.Jpeg, quality)直接指定质量参数JPEG 质量设 90 左右人眼基本看不出差异文件体积可控。7. 生产环境排查中文字体、内存与并发三座大山7.1 中文字体变方框的问题这个问题基本是每个用 SkiaSharp 画中文的人都会遇到的Windows 本地开发一切正常部署到 Linux 容器后水印里的中文全部变成方框。原因很简单Skia 本身只是个图形引擎不负责字体管理和渲染字体文件它依赖操作系统的字体配置。Windows 上有微软雅黑你传Microsoft YaHei能命中但精简 Linux 镜像里根本没有这个字体SkiaSharp 的FromFamilyName找不到字体就会回退到默认字体而默认字体又不支持中文于是画出了方框。解决办法有两个方向。第一个方向在 Dockerfile 里安装中文字体包。基于 Debian 的镜像可以执行RUN apt-get update \ apt-get install -y fonts-wqy-zenhei fonts-wqy-microhei \ rm -rf /var/lib/apt/lists/*这个方案简单直接但会增大镜像体积。第二个方向部署时不依赖系统字体直接把字体文件带进项目用SKTypeface.FromFile加载自定义字体文件。这样最可控不受运行环境影响。我用的是第二种把微软雅黑的 .ttf 文件放在项目的Fonts目录下并设置为“复制到输出目录”Typeface SKTypeface.FromFile(Path.Combine(AppContext.BaseDirectory, Fonts, msyh.ttf))实测下来的效果是无论部署到哪个系统水印渲染效果都和本地一致。这里额外提醒一句字体文件是有版权的商用项目里要确认你使用的字体是否有合法授权。开源的思源黑体、文泉驿正黑在多数场景下是更安全的选择。7.2 高分辨率大图的内存控制曾有一张大图尺寸 8000x6000SkiaSharp 解码后是 BGRA8888 格式每个像素占 4 字节8000x6000 的位图内存是 8000 * 6000 * 4 192MB。如果在高并发下同时处理几张这样的大图内存会迅速飙升进程可能直接 OOM 被容器杀掉。我的应对方案有三个层次限制上传图片的最大尺寸。在服务入口先检查图片的像素维度超过 5000x5000 的图片先按比例缩小到允许范围再加水印。用SKImageInfo显式指定解码分辨率。SkiaSharp 的SKBitmap.Decode是全量解码如果只需要生成缩略图可以配合SKCodec只解码到目标大小大幅降低内存占用。用using保证所有非托管资源及时释放。SKBitmap、SKCanvas、SKPaint、SKImage、SKData都实现了IDisposable严格使用using或try/finally。这在长生命周期服务里非常关键释放不及时会让内存被图片对象占满。压测时我还发现一个现象SKBitmap的垃圾回收不会立刻归还非托管内存即使Dispose了进程的内存计数也不会立刻下降。这是正常现象不代表内存泄漏。判断是否泄漏要看长时间运行后内存是否持续上涨到峰值不回落而不是看短时间波动。7.3 并发安全的三个要点WatermarkService被我注册成了单例这里有一个隐患SKTypeface和SKPaint内部不是完全线程安全的如果在绘制方法里共享同一个画笔实例高并发下可能随机崩溃。我最初的实现里把SKTypeface.FromFile的返回值缓存成 static 字段复用结果线上偶发崩溃排查后改为每次绘制都重新创建 Typeface 和 Paint。这个改法会增加一点创建开销但对安全性的提升是巨大的。实测每次创建 Typeface 的开销在微秒级完全不影响整体性能。另一个要点是SKBitmap.Decode内部不是线程安全的吗其实它每一次调用都会创建独立的原生对象不同线程操作不同实例是安全的。但如果你要复用同一个SKBitmap作为水印 Logo 源那就不安全了。我处理图片水印时每次请求都把 Logo 字节重新SKBitmap.Decode一次虽然多花了几毫秒但避免了共享资源竞争。最后一个容易被忽视的是随机数和文件路径问题。如果在 Linux 容器里用Path.GetTempFileName产生中间文件高并发下可能会有文件名冲突。我用的是全程内存流方案绕开了这个问题。如果你的场景必须中间落盘建议用Guid.NewGuid().ToString(N)生成唯一文件名。7.4 日志与监控建议水印服务属于 IO 密集和计算密集混合型应用上线后我加了几个监控指标单次处理耗时、输入图片尺寸分布、输出文件大小、异常计数。其中“输入图片尺寸分布”非常有用它能帮你预判内存风险——如果发现 8000x6000 的大图占比持续升高就该在上游加限制否则再怎么优化底层也只是被动应对。日志方面重点记录 Decode 失败的情况。SKBitmap.Decode返回 null 而不是抛异常很多业务日志不会记录“解码返回空”导致用户传了损坏文件却无从查起。我在服务里面每次 decode 失败都会记一条 Warn 级日志包含请求来源和文件大小线上排查效率高了不少。最后再分享一个我实践中的体会整套方案从开发到上线最值钱的不是那几行绘制代码而是对坐标模型的理解和对跨平台环境的敬畏。写代码的时候多问自己一句“这行放到 Linux 上还能不能跑”能避免大多数生产事故。如果你也打算在 .NET Core 8.0 项目里引入水印能力建议从这篇文章的骨架开始先跑通纯文字水印再扩展图片水印最后再考虑性能优化和部署问题。一次做太多反而容易踩进坑里出不来。后面如果有时间我还会整理一份用 SkiaSharp 做图片裁剪压缩的实战记录那个方向的坑也一点不少。