作为合格的 Miraier,一定曾想过在插件中加入图像合成功能,例如绘制每日发言排行榜,漂亮的签到卡片,或只是想给色图加个滤镜避免被 QQ 服务器吃掉。
开始进行图像处理的第一步当然是选择合适的 API,选择比努力更重要
在 JVM 平台上,有几个主流的图形处理方案可以选择:
Skia谷歌家的大小姐,地球上最流行的图形API,广泛应用于 Chromium、Android、Flutter 等知名软件中。
虽然笔者并未在 JVM 平台上直接使用过 Skia,但以下总结的优势来自于笔者在 Rust-Skia 中的经验:
性能:支持 GPU 加速,并允许编写 GPU 着色器(使用类似 GLSL 的 SKSL 语法)。 漂亮:相比于调教不足的 AWT,Skia 渲染的图像无需额外调教就十分精甚细腻。Skia 在 JVM 平台上的短板也很明显:
JNI:每个平台和架构都需要单独分发代码,且本地库编译后的文件大小约为 10MB。 文档:网上很难找到针对特定问题的 Java 绑定解决方案,相关的第三方库也很少。 编译:如果想要使用 Skia 的扩展功能(如 SVG 段落排版等),不得不经历一场痛苦的编译与配置过程,而且为每个平台交叉编译的方式与 JVM 平台的开发习惯并不匹配。 JavaFX十分古老的新技术,适合构建 UI 应用,文档和第三方库非常稀缺,因此后文不再提及。
AWT作为 JDK 的一部分,AWT 是 Java 中最为流行的图形 API,无需额外依赖,基于 AWT 的知名软件包括 JetBrains 全家桶 和 Eclipse。
与 Skia 相比,AWT 的优势如下:
生态:AWT 长期以来是 Java UI 和图像处理的唯一选择,因此可以找到详尽的文档资料,调教指南与第三方库(例如本文)。 依赖:捆绑在几乎所有 jvm 中,无需额外依赖。又不能选装,不用白不用劣势也很明显:
古老:AWT 不支持许多现代特性,例如彩色 emoji 字体和亚像素级抗锯齿等。 性能:编写高性能的代码需要付出大量的努力,尤其是在 AWT 中。 欠草:渲染漂亮图像的过程中会遇到许多迷惑的历史遗留特性,需要加大力度调教,这也是本文诞生的原因之一。总而言之,笔者强烈推荐大家选择 Skia。如果您不幸入坑了 AWT,希望本文能帮助你避开一些常见的坑。
AWT 七宗罪 锯齿在 AWT 诞生的年代,给文本和图片加上抗锯齿会严重降低性能,后来为了保证和之前版本的一致性,抗锯齿选项也没有默认启用。
g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2d.setRenderingHint( RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g2d.setRenderingHint( RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); g2d.setRenderingHint( RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); g2d.setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint( RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);e99f4b74-bb0a-49a9-940a-676df30bae1b-image.png
以上选项仅适用于文本与 Shape 抗锯齿,您会发现图片缩放绘制时还是会有锯齿和奇怪的像素点,这时就要说到...
重采样drawImage
想象一下这段代码
g2d.drawImage(img, x, y, w, h, null);这几乎是 AWT 中最常见的绘制图像操作,虽然速度很快,但对于缩放图像来说效果是相当糟糕的。
ee7b3aaf-b1b7-4c43-82f6-1ebebc068be3-image.png
所幸 AWT 中还有更漂亮的方法:
getScaledInstance
img.getScaledInstance(w, h, Image.SCALE_SMOOTH);5134b434-53a8-4a62-9027-6bf359f89fc7-image.png
确实漂亮了很多,然而代价是性能损失了几乎 1000 倍甚至 900 倍,在基准测试中,上个方法耗时为 0ms,这个方法耗时 771ms,很显然也是不能接受的。
可以看出,AWT 对图像的缩放算法相当糟糕,还好我们有更现代的第三方库:
Thumbnails.size
Thumbnails.of(image).size(w, h).asBufferedImage();143fefe2-8e96-4e5c-8408-e8ebfc30078f-image.png
得益于第三方库实现的快速重采样算法,缩放这张图片只花费了 94ms
回到 drawImage
在缩放图像分辨率与实际绘制分辨率相近的情况下,使用 Thumbnails.size 反而会造成额外的性能开销,经过古人的实践与总结,缩放倍率在 0.5 以内使用 drawImage 是最优解,超过此倍率就要引入额外的缩放流程来保证质量。
举例:
当原始分辨率为 200 * 200 时, 缩放到 101 * 101 或 299 * 299 应使用 drawImage; 缩放到 99 * 99 或 301 * 301 应加入额外的缩放流程。
网络请求 【Miraier 限定】 【腾讯粉丝福利】
聪明的 Miraier 应该也想到了从网络请求入手,请求更低分辨率的图像,既能减少图片下载时间又能提升缩放时的性能。
这是 Mirai 源码中获取用户头像的代码
public interface ContactOrBot : CoroutineScope { /** * 头像下载链接, 规格默认为 [AvatarSpec.LARGEST] */ public val avatarUrl: String get() = avatarUrl(spec = AvatarSpec.LARGEST) /** * 头像下载链接. * @param spec 头像的规格. */ @JvmName("getAvatarUrl") public fun avatarUrl(spec: AvatarSpec): String { return "http://q.qlogo.cn/g?b=qq&nk=${id}&s=${spec.size}" } }另外,域名为 gchat.qpic.cn 的图像貌似也可以请求低分辨率版本,但笔者没有在网上找到更多信息,希望大佬跟帖补充。
上文图像与基准测试引用自 Thumbnailator/wiki
文字排版研表究明,99% 的 AWT 用户在第一次绘制文本多行文本时,一定会写出这段代码:
g2d.drawString("多行\n文本", 20, 30);虽然几乎所有的图形 API 都会有这个问题,但 AWT 的高级排版类(例如 FlowLayout)是 Swing UI 专属,无法直接在图像内绘制文字。
在 Skia 中可以使用段落扩展来快速实现排版,AWT 又输
例如,在 AWT 中使用 LineBreakMeasurer 和 TextLayout 计算坐标实现基本的文本溢出换行:
public void paint(Graphics graphics) { Point2D pen = new Point2D(10, 20); Graphics2D g2d = (Graphics2D)graphics; FontRenderContext frc = g2d.getFontRenderContext(); LineBreakMeasurer measurer = new LineBreakMeasurer(styledText, frc); float wrappingWidth = getSize().width - 15; while (measurer.getPosition() < fStyledText.length()) { TextLayout layout = measurer.nextLayout(wrappingWidth); pen.y += (layout.getAscent()); float dx = layout.isLeftToRight() ? 0 : (wrappingWidth - layout.getAdvance()); layout.draw(graphics, pen.x + dx, pen.y); pen.y += layout.getDescent() + layout.getLeading(); } }(更多示例请参考 甲骨文官方文档)
当然,上方的代码只是甲骨文的卖家秀,实际渲染环境中没机会写出这么简洁的代码,因为会碰到更头痛的问题:
字体回退想象这样一个场景:
var font = StyleContext.getDefaultStyleContext().getFont("漂亮的英文字体", Font.PLAIN, 24); g2d.setFont(font); g2d.drawString("Hello 世界!", 20, 30);上方代码在 Windows 和 Linux 平台上会渲染为 【Hello 口口口】,在 Mac 平台上会正常渲染。
造成这种差异的原因是 JVM 逻辑字体配置 fontconfig.properties,仅在某些平台上生效,深层次的原因是 AWT 在不支持的平台上使用旧版本 HarfBuzz 进行整形,并不会自动回退为支持的字体。
对于这种情况的解决方案:
强迫用户使用 MacOS
强迫用户改用 JetBrainsRuntime JVM
自己实现字体回退逻辑:
例:
Font mainFont = StyleContext.getDefaultStyleContext().getFont("漂亮的英文字体", Font.PLAIN, 24); Font fallbackFont = StyleContext.getDefaultStyleContext().getFont("回退的中文字体", Font.PLAIN, 24); AttributedString str = new AttributedString("Hello 世界!"); int textLength = text.length(); result.addAttribute(TextAttribute.FONT, mainFont, 0, textLength); boolean fallback = false; int fallbackBegin = 0; for (int i = 0; i < text.length(); i++) { boolean curFallback = !mainFont.canDisplay(text.charAt(i)); if (curFallback != fallback) { fallback = curFallback; if (fallback) { fallbackBegin = i; } else { result.addAttribute(TextAttribute.FONT, fallbackFont, fallbackBegin, i); } } } g2d.drawString(str.getIterator(), 20, 30);聪明的读者一定已经发现:
上文中 LineBreakMeasurer () 构造函数刚好需要 str.getIterator() 获取的 AttributedCharacterIterator!
将两段代码结合一下刚好能实现最基础的文本段落渲染。
全局字体看到这里,大部分爱偷懒的 Miraier 都会想到【回退字体也太麻烦了,直接用中文字体渲染就好了】偷懒方案可跳转至下文 #偷懒的最终方案
这会导致以下问题:
无法渲染字体不支持的字符,例如 阿拉伯语,印地语 等。 无法同时用漂亮的英文字体和中文字体回退。 无法渲染 Emoji (很多用户的 ID 包含 Emoji,不能渲染的话就太糟了)于是诞生了终极方案:
默认字体无法显示,启用回退方案。 调用 GraphicsEnvironment.getLocalGraphicsEnvironment().getAllFonts() 取得注册的所有字体进行遍历。 找到能显示的字体应用到字符。程序初始化时通过测试各语言码点分类进行性能优化:
遍历所有字体并测试各语言覆盖范围,例如 AB00-AB5F 范围为越南傣语,测试字体是否支持 AB00 渲染时快速判断字符码点范围查表找出回退字体需要注意的是:
Emoji 和部分扩展区字符会占用多个码点,超出 char 的范围,例如 Emoji 😊 为 Character.toChars(0x1F600)
Emoji 可能包含连字符 0x200d, 被连字符组合的 Emoji 不应该被分开渲染,例如 👩👩👧👧 包含 4 个子表情与 3 个连字符,占用 11 个 char
52e51909-d65e-4dd6-8cac-a18e9d6559be-image.png
Emoji 没有颜色😭868e08b0-817a-47b7-8716-89c0aee117a7-image.png
遗憾的是, AWT 使用的 HarfBuzz 版本不支持 CPAL, SBIX 等彩色字体扩展,只能通过定制 JVM 并替换 HarfBuzz 依赖解决。
万幸,JetBrains 在开发 IDE 时注意到了这点,目前在 AWT 平台渲染彩色 Emoji 唯一的解决方案是使用 JetBrainsRuntime JVM。
偷懒的最终方案在 AWT 中,可以使用 "Dialog" 获取当前系统字体, 而且在任何平台都能正确应用 JVM 逻辑字体配置 fontconfig.properties 中定义的回退方案。
没有调用 g2d.setFont() 时绘制的文字也会使用 "Dialog" 字体
var font = StyleContext.getDefaultStyleContext().getFont("Dialog", Font.PLAIN, 24); g2d.setFont(font);作为偷懒的惩罚,使用自定义字体时不支持的字符会变为 【口口口】,详见上文。
后话本来想写的 AWT 七宗罪只写了四条,请期待本贴持续更新。
最后,以上提到的几点在 Skia 中完全无需任何调教,笔者强烈推荐大家选择 Skia。
本贴欢迎提问 AWT 与 Rust-Skia 相关问题,别的我也不会。
本文随意转载,转载前请告知我。
CC-BY-NC-SA 4.0
相关链接: Petpet