jvm awt 图像处理入坑/退坑指南
-
前言
作为合格的 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);
以上选项仅适用于文本与 Shape 抗锯齿,您会发现图片缩放绘制时还是会有锯齿和奇怪的像素点,这时就要说到...
重采样
-
drawImage
想象一下这段代码
g2d.drawImage(img, x, y, w, h, null);
这几乎是 AWT 中最常见的绘制图像操作,虽然速度很快,但对于缩放图像来说效果是相当糟糕的。
所幸 AWT 中还有更漂亮的方法:
-
getScaledInstance
img.getScaledInstance(w, h, Image.SCALE_SMOOTH);
确实漂亮了很多,然而代价是性能损失了几乎 1000 倍甚至 900 倍,在基准测试中,上个方法耗时为
0ms
,这个方法耗时771ms
,很显然也是不能接受的。可以看出,AWT 对图像的缩放算法相当糟糕,还好我们有更现代的第三方库:
-
Thumbnails.size
Thumbnails.of(image).size(w, h).asBufferedImage();
得益于第三方库实现的快速重采样算法,缩放这张图片只花费了
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
Emoji 没有颜色😭
遗憾的是, 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
-
-
q.qlogo.cn获取头像尺寸的参数应该有至少三种
- s=100 => 100x100
- s=160 => 140x140
- s=640 => 300x300
-
确实,还得是 JBR 好用