经过 Mono 团队的不懈努力,原本专属于 Win32 平台的 GDI+ 终于可以跨平台了,不过这中间还有好多的故事和好多的坑。
本文带你了解 System.Drawing 命名空间的跨平台。
在了解本文的后续内容之前,你可能需要先了解一些基本的名词,不然后面极可能看得云里雾里。
System.Drawing 有两个意思,第一个是 System.Drawing.dll 程序集,第二个是 System.Drawing 命名空间。
如果进行 .NET Framework 项目的开发,那么对 System.Drawing 一定不陌生,框架自身对位图的处理基本都是用的这套库,很多第三方图像处理库也都基于 System.Drawing 程序集进行二次封装。比如 JimBobSquarePants/ImageProcessor 库实际上就是对 System.Drawing 的封装,AForge.NET 库作为计算机视觉库也对 System.Drawing 有较大的依赖。
Mono 是一个诞生以来就为了让 .NET Framework 跨平台的开源项目。开发基于 Mono 运行时的项目时,使用的框架 API 也是兼容 .NET Framework 的,因此也可以在 Mono 中直接依赖 System.Drawing 程序集进行开发。
System.Drawing 固然强大,但它却只是 Win32 GDI+ 的一层很薄很薄的封装。然而其他平台上没有原生对 GDI+ 的实现,所以跨平台是一个比较棘手的问题(本文后面会说到如何做到跨平台)。
.NET Core 也是为跨平台而生,不过它走的路线与 Mono 有些不同。它从 API 级别就分离出 .NET Framework 中不跨平台的部分,然后把它们从 .NET 的核心仓库中移除,换成 .NET 的扩展框架(如 WPF / Windows Forms)。那么面对 System.Drawing 部分的 API 时 .NET Core 是怎么做的呢?一开始做了一个兼容库 CoreCompat.System.Drawing(仓库在这里和这里)做了一部分的兼容,而后由于 Mono 的努力做出了 GDI+ 在其他平台上的实现(mono/libgdiplus),.NET Core 就有幸将 System.Drawing 纳入 .NET Core 中作为一个扩展库存在。而这个库就是 System.Drawing.Common(仓库在 这里)。
我们小结一下:
libgdiplus 是在非 Windows 操作系统上提供 GDI+ 兼容 API 的 Mono 库,而其跨平台图形绘制的大部分关键实现靠的是 Cairo 库。
libgdiplus 的开源仓库:
目前,其几乎就是为 System.Drawing 命名空间下的位图处理作为实现的。System.Drawing 的跨平台的能力几乎完全靠的是 libgdiplus 库。
安装方法见仓库 README。
目前 libgdiplus 还有一些没能完全实现的部分:
–with-pango
选项开启 pango 引擎,但没实现的功能更多Mono 和 .NET Core 目前均已完成基于 libgdiplus 的 System.Drawing 命名空间的跨平台。当然,这个跨平台迁移的唯一目的是“兼容”,是为了让现有的基于 System.Drawing 的代码能够跨平台跑起来。仅此而已,不会有任何的性能优化或者设计优化。(想要优化的版本可以参考本文最后推荐的其他图形库)。
但依然值得注意的是,这个跨平台依然不是完全的跨所有平台:
这里将其他的基于 .NET / Windows 平台的图形实现放到一起来做对比:
| Win32 | UWP | macOS | Linux / 其他 |
---|---|---|---|---|
.NET Framework (GDI+) | ✔️ | ❌ | ❌ | ❌ |
Direct2D / Win2D | ✔️ | ✔️ | ❌ | ❌ |
Mono / .NET Core (libgdiplus) | ✔️ | ❌ | ✔️ | ✔️ |
Xamarin (CoreGraphics) | ❌ | ❌ | ✔️ | ❌ |
其他第三方 .NET 库 | ✔️ | ✔️ | ✔️ | ✔️ |
回到 System.Drawing 上,现在我们知道应该使用 System.Drawing.dll 还是使用 System.Drawing.Common 库了吗?
盲猜应该使用 System.Drawing.Common 库吧?因为这个库里面既带了 Windows 平台下的实现(对 GDI+ 做一层很薄的封装),又带了 Linux 和 macOS 下的实现(使用 libgdiplus)。
然而事情并没有那么简单!我来问几个问题:
System.Drawing
命名空间中的参数呢?首先来看看问题一。我们新建一个 .NET Framework 的项目,一个 .NET Core 的项目,两者都安装 System.Drawing.Common 包,然后调用一下这个包里面的方法:
1 2 3 4 5 6 7 | class Program { private static void Main() { var bitmap = new Bitmap(@"D:\walterlv\test.png"); } } |
---|
会发现,两者都是可以正常运行的。
将 net48 框架项目下引用的 System.Drawing.Common.dll 反编译来看,可以发现,这是一个空的程序集,里面几乎没有任何实质上的类型。里面所有的类型都通过 TypeForwardedTo
特性转移到 System.Drawing.dll 程序集了,现在剩下的只是一个垫片。关于 TypeForwarding 可以阅读这篇博客了解:C# dotnet TypeForwarding 的用法,微软也有其他通过此方式做的 NuGet 包,可参见 微软官方的 NuGet 包是如何做到同时兼容新旧框架的? - walterlv。
将 netcoreapp3.1 框架项目下引用的 System.Drawing.Common.dll 反编译来看,可以发现,这个程序集里面所有的类型所有的方法实现都是抛出 PlatformNotSupportedException
。
这就有些奇怪了,如果所有的方法都抛出 PlatformNotSupportedException
那如何才能正常运行呢?
打开 netcoreapp3.1 输出目录下的 *.deps.json 文件,可以注意到,里面记录了在不同的运行目标下应该使用的真实的 System.Drawing.Common.dll 的文件路径:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | "runtimeTargets": { "runtimes/unix/lib/netcoreapp3.0/System.Drawing.Common.dll": { "rid": "unix", "assetType": "runtime", "assemblyVersion": "4.0.2.0", "fileVersion": "4.700.19.56404" }, "runtimes/win/lib/netcoreapp3.0/System.Drawing.Common.dll": { "rid": "win", "assetType": "runtime", "assemblyVersion": "4.0.2.0", "fileVersion": "4.700.19.56404" } } |
---|
去相应的路径下找,可以找到 win 版本的 System.Drawing.Common.dll 和 unix 版本的 System.Drawing.Common.dll。
这些指定的依赖,在发布此程序之后会换成真实的依赖,而不再包含多个不同平台下的 dll 了:
1 | dotnet publish -c Release -f netcoreapp3.1 -r win10-x64 --self-contained true |
---|
我们去 nuget.org 上下载下来 System.Drawing.Common 包拆开来看,会发现这个包有两个很关键的文件夹:
其中,lib 里面包含这些不同的目标框架:
net461 里包含的 dll 就是前面我们说到的“垫片”,所有的类型都通过 TypeForwardedTo
转移到 .NET Framework 版本的 System.Drawing.dll。
netstandard2.0 适用于 .NET Core 框架,里面包含的 dll 就是前面我们说到的所有方法都抛出 PlatformNotSupportedException
的版本。
其他所有框架里都是 . 文件,是个空的文件,仅用来告诉 NuGet 这个包支持这些框架安装,但不引用任何 dll。
另外,NuGet 包的 runtimes 文件夹里面包含了前面我们说到的 win 和 unix 不同实现版本的 System.Drawing.Common.dll。前面已经给出了反编译的截图,应该足够了解了。你也可以自己去解包,了解里面的目录结构,去反编译看。
现在,是时候来决定应该使用 System.Drawing.dll 还是使用 System.Drawing.Common 包了。那么,这里我整理一张表:
| System.Drawing.dll | System.Drawing.Common |
---|---|---|
.NET Framework 4.6 及以下版本 | ✔️ | ❌ |
.NET Framework 4.6.1 及以上版本 | ✔️ | ✔️ |
.NET Core 1.x | ❌ | ❌无法安装包 |
.NET Core 2.0 - .NET Core 2.1 | ❌ | ❌运行时抛出PlatformNotSupportedException |
.NET Core 3.0 及以上版本 | ❌ | ✔️ |
Mono / Xamarin | ✔️ | ❌ |
✔️表示可以使用,没有问题;❌表示不支持此引用方式。
另外,这里还要额外说一下 Unity 的支持情况。
Unity 有两种不同的 C# 脚本后端可选:Mono 和 IL2CPP。然而 Unity 不能原生支持 NuGet 包,而 System.Drawing.Common 包要能够在编译时自动选择正确的 dll 去引用,是需要 3.4 版本以上的 NuGet 程序来支持的。如果不能完全实现此版本 NuGet 的功能,那么编译时是无法将正确的 dll 拷贝到输出目录的。不幸的是,目前流行于 Unity 的第三方 NuGet 管理器不能正确拷贝此包的 dll 到输出目录。
更具体的,是受以下设置的影响(在编译设置里面):
| 脚本后端 | Api 兼容级别 | System.Drawing.dll | System.Drawing.Common |
---|---|---|---|---|
组合1 | Mono | .NET 4.x | ✔️ | ❌相当于没引用 |
组合2 | Mono | .NET Standard 2.0 | ❌相当于没引用 | ❌第三方 NuGet 包管理器会拷贝错误的 dll |
组合3 | IL2CPP | .NET 4.x | ❌可在编辑器运行,但打包后会出现异常 | ❌未引用任何库 |
组合4 | IL2CPP | .NET Standard 2.0 | ❌相当于没引用 | ❌第三方 NuGet 包管理器会拷贝错误的 dll |
是不是很悲惨?只有 Mono / .NET 4.x 这个组合可以正常使用 System.Drawing。当然,如果你愿意用部分手工或自己的脚本/工具来代替第三方 NuGet 包的部分功能,选择出正确的 dll 的话,那么对应的方案也是能行的。
表中的“❌相当于没引用”指的是引用此 dll 相当于没引用 dll,安装此包相当于没有引用此包:
1 2 3 4 5 | // .NET 4.x 的 Api 兼容级别报此错误 The type name '{0}' could not be found in the namespace 'System.Drawing'. This type has been forwarded to assembly 'System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a' Consider adding a reference to that assembly. // .NET Standard 2.0 的 Api 兼容级别报此错误 The type or namespace name 'Imaging' does not exist in the namespace 'System.Drawing' (are you missing an assembly reference?) |
---|
IL2CPP 里在编辑器里可以正常使用(当然能正常,因为编辑器又没用 IL2CPP),打包后出现的异常如下(所有的 System.Drawing 方法调用都有异常):
1 2 | NotSupportedException: System.Drawing.Image::FromHbitmap System.Drawing.Image.FromHbitmap (System.IntPtr hbitmap) (at <00000000000000000000000000000000>:0) |
---|
关于 Unity 的部分,本文不打算细说。如果你有其他疑问,我就挖个坑,再写一篇来填。
如果你当前的开发平台依然无法使用到 System.Drawing 命名空间,那么可以考虑使用另外的一些替代品。这里给出一些推荐:
如果你需要的是图像处理,而不需要与 Windows API 有太多关联的话,那么使用这些库会比使用 System.Drawing 带来更优秀的用法、更好的性能以及更现代化的维护方式。
参考资料
本文会经常更新,请阅读原文: https://blog.walterlv.com/post/system-drawing-common.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 ([email protected]) 。