WebP4j: Easy WebP Image Format Support for Java Projects
Table of Contents
[2026-02-15 修复 GIF 解码功能存在堆缓冲区溢出以及不再支持 arm32 架构 ]
[2026-02-11 为动画添加 WebP 解码功能以及增加 windows-aarch64 支持 ]
[2026-01-14 重构 ]
[2026-01-13 增加创建动画 WebP 功能 ]
[2026-01-10 增加 gif-to-webp ]
[2026-01-05 兼容 Java8,修复在 QEMU 环境下编译动态链接库 GCC 编译器奔溃 ]
[2025-10-19 重构 JNI 接口为静态方法并增加 GitHub Action 自动化编译动态链接库和发布]
[2025-08-12 增加无损压缩方法]
去年在开发一套漫画系统存放自己收藏的漫画时,我发现一个痛点:当用户请求一章包含几十张图片的漫画时,大量高质量图片在网络传输中会产生两个致命问题:对于服务端来说,带宽压力巨大,成本高昂;对于用户端来说,需要更多流量,加载缓慢,体验较差。
随着移动互联网的普及,用户更倾向于使用移动设备阅读漫画。对于移动端用户来说,适度压缩图片质量既能节省流量、加快加载速度,又不会明显影响阅读体验,是一个双赢的解决方案。
通过使用 Google 的搜索,发现 Google 开源的 libwebp 项目在图片压缩领域具有无可替代的地位,相同质量的 JPEG/PNG 图片,WebP 格式的文件体积减少 25%-35%。
所以我在 Github 和 Maven 仓库搜索第三方有关 WebP 的 Java 依赖,发现目前大部分仓库相关的 Maven 包要么功能不全或者处于一个归档或者停止维护的状态;在 Google 的时候发现另一种实现方案,即使用 Java 调用 libwebp 命令行工具的方式去实现对图片的编解码,这种方式性能和集成度比较差,就直接放弃考虑了。后面我借鉴 webp-imageio 项目的实现方案开发了此项目并开源。
核心实现
为了让 Java 代码可以调用 C/C++ 本地仓库,Google 可以使用 JNI(Java Native Interface) 封装 libwebp 实现。
JNI 接口设计
WebP4j 的核心是 NativeWebP 类,它直接封装了 libwebp 的底层 API。通过 JNI 技术,我将 C 语言的 libwebp 函数映射为 Java native 方法:
public static native boolean getInfo(byte[] data, int[] dimensions);
public static native int getFeatures(byte[] data, int dataSize, WebPBitstreamFeatures features);
public static native byte[] encodeRGB(byte[] image, int width, int height, int stride, float quality);
public static native byte[] encodeRGBA(byte[] image, int width, int height, int stride, float quality);
public static native byte[] encodeLosslessRGB(byte[] image, int width, int height, int stride);
public static native byte[] encodeLosslessRGBA(byte[] image, int width, int height, int stride);
public static native boolean decodeRGBInto(byte[] data, byte[] outputBuffer, int outputStride);
public static native boolean decodeRGBAInto(byte[] data, byte[] outputBuffer, int outputStride);编码/解码逻辑封装
为了让用户更方便地使用 WebP4j,我在 NativeWebP 之上封装了 WebPCodec 类,提供了与 Java BufferedImage 无缝集成的高级 API:
这个封装层处理了以下关键任务:
- 自动检测图片是否包含 Alpha 通道
- 在
BufferedImage和字节数组之间进行转换 - 根据压缩模式选择合适的 native 方法
- 提供统一的异常处理
/**
* 有损压缩编码
*/
public static byte[] encodeImage(BufferedImage bufferedImage, float quality) throws IOException;
/**
* 可选有损/无损编码
*/
public static byte[] encodeImage(BufferedImage bufferedImage, float quality, boolean lossless) throws IOException;
/**
* 无损编码
*/
public static byte[] encodeLosslessImage(BufferedImage bufferedImage) throws IOException;
/**
* 解码 WebP 图片
*/
public static BufferedImage decodeImage(byte[] webPData) throws IOException;内存管理和性能优化
JNI 编程中,内存管理是一个关键挑战。WebP4j 从 C 和 Java 两层来确保内存安全和性能:
1. 及时释放 Native 内存
在 C 层,libwebp 编码后会分配内存存储 WebP 数据。我们需要确保这些内存被及时释放:
JNIEXPORT jbyteArray JNICALL Java_dev_matrixlab_webp4j_NativeWebP_encodeRGB
(JNIEnv *env, jclass clazz, jbyteArray image, jint width, jint height, jint stride, jfloat quality) {
...
// Create a new Java byte array for the output
jbyteArray result = (*env)->NewByteArray(env, output_size);
if (result == NULL) {
WebPFree(output); // Ensure the output buffer is freed
return NULL; // Memory allocation failed
}
// Copy the encoded output to the Java byte array
(*env)->SetByteArrayRegion(env, result, 0, output_size, (jbyte*) output);
// Free the WebP output
WebPFree(output);
...
}2. 双重检查锁定(Double-Checked Locking)
在 Java 层,确保 native 库只加载一次,避免重复加载的性能开销和潜在问题:
static void loadNativeLibrary() {
if (!nativeLibraryLoaded) { // 第一次检查(无锁)
synchronized (NativeWebP.class) {
if (!nativeLibraryLoaded) { // 第二次检查(有锁)
NativeLibraryLoaderUtils.loadLibrary();
nativeLibraryLoaded = true;
}
}
}
}3. 预分配缓冲区复用
在 Java 层,解码时支持传入预分配的缓冲区,在批量处理场景下可以复用缓冲区,减少 GC 压力:
byte[] outputBuffer = new byte[height * outputStride];
try {
// Decode the WebP data into the provided RGB/RGBA buffer.
boolean success = hasAlpha
? NativeWebP.decodeRGBAInto(webPData, outputBuffer, outputStride)
: NativeWebP.decodeRGBInto(webPData, outputBuffer, outputStride);
if (!success) {
throw new IOException("Failed to decode WebP data into RGB buffer.");
}
// Convert the decoded RGB/RGBA byte array into a BufferedImage.
return WebPCodec.convertBytesToBufferedImage(width, height, outputBuffer);
} finally {
// Clear the contents of the outputBuffer to remove sensitive data.
Arrays.fill(outputBuffer, (byte) 0);
}跨平台支持
跨平台是一个工具包必须满足的条件,大部分项目开发都是在 Windows 或者 macOS 平台开发最终部署在 Linux 系统上。为保证项目能在各个系统正常运行,需要编译对应系统的动态链接库。因为不同操作系统和 CPU 架构不一样,对应的动态链接库即 Windows (.dll)、Linux (.so)、macOS (.dylib) 的构建流程不同。
最开始我考虑并尝试在 Windows 或 Linux 环境使用交叉编译生成动态链接库,但最终失败告终。当时为了使项目顺利运行起来,我采用手动编译的方式。在各个平台上分别编译各自系统以及 CPU 架构的动态链接库,然后导出动态链接库再重新编译项目然后回到对应的环境测试。整个过程非常繁琐且容易出错。而当每次升级底层 JNI 接口,上述步骤又需要重复一遍,效率较低。
在 2025 年 10 月升级 libwebp 库时,将底层部分代码重构并且使用 GitHub Actions 实现了自动化构建和发布:
- 多平台并行编译:在 Windows、Linux、macOS 上同时构建
- 多架构支持:x86、x64、ARM64
- 自动化测试:确保每个平台的库都能正常工作
- 一键发布到 Maven Central:省去了手动上传的麻烦
这套自动化流程大大提升了开发效率,现在每次发布新版本只需要打一个 tag,剩下的工作全部自动完成。
快速开始
Maven / Gradle 依赖
Maven,在 pom.xml 文件中添加以下依赖:
<dependency>
<groupId>dev.matrixlab.webp4j</groupId>
<artifactId>webp4j-core</artifactId>
<version>2.1.1</version>
</dependency>Gradle:
implementation 'dev.matrixlab.webp4j:webp4j-core:2.1.1'从 1.x 升级到 2.x:v2.0 引入了动画 WebP 解码功能,同时包含以下破坏性变更:
- Group ID:
dev.matrixlab→dev.matrixlab.webp4j- Artifact ID:
webp4j→webp4j-core
使用示例
静态图片编解码
使用 WebPCodec 类的 encodeImage() 和 decodeImage() 方法可以轻松实现 JPG/PNG 与 WebP 格式之间的转换。该库同时支持有损和无损压缩模式。
有损压缩(适合 JPG 图片):
BufferedImage image = ImageIO.read(new File("input.jpg"));
float quality = 75.0f; // 质量参数:0.0f(最差)到 100.0f(最佳)
byte[] webpData = WebPCodec.encodeImage(image, quality);
try (FileOutputStream fos = new FileOutputStream("output.webp")) {
fos.write(webpData);
}无损压缩(适合 PNG 图片):
BufferedImage image = ImageIO.read(new File("input.png"));
byte[] webpData = WebPCodec.encodeLosslessImage(image);
try (FileOutputStream fos = new FileOutputStream("output_lossless.webp")) {
fos.write(webpData);
}解码 WebP 图片:
import java.nio.file.Files;
import java.nio.file.Paths;
byte[] webpData = Files.readAllBytes(Paths.get("input.webp"));
BufferedImage image = WebPCodec.decodeImage(webpData);
// 保存为其他格式
ImageIO.write(image, "jpg", new File("output.jpg"));GIF 转 WebP
WebP4j 内置 giflib 原生解码器(并提供 Java ImageIO 作为备用方案),可将 GIF 动图直接转换为 WebP 格式。
默认转换(有损,quality=75):
byte[] gifData = Files.readAllBytes(Paths.get("input.gif"));
byte[] webp = WebPCodec.encodeGifToWebP(gifData);
Files.write(Paths.get("output.webp"), webp);自定义配置:
byte[] gifData = Files.readAllBytes(Paths.get("input.gif"));
GifToWebPConfig config = GifToWebPConfig.createLossyConfig(90.0f)
.setCompressionMethod(6)
.setMinimizeSize(true);
byte[] webp = WebPCodec.encodeGifToWebP(gifData, config);
Files.write(Paths.get("output.webp"), webp);无损转换:
byte[] gifData = Files.readAllBytes(Paths.get("input.gif"));
byte[] webp = WebPCodec.encodeGifToWebPLossless(gifData);
Files.write(Paths.get("output.webp"), webp);创建动画 WebP
可以从一组 BufferedImage 帧创建动画 WebP 文件。
// 加载帧
List<BufferedImage> frames = Arrays.asList(
ImageIO.read(new File("frame1.png")),
ImageIO.read(new File("frame2.png")),
ImageIO.read(new File("frame3.png"))
);
// 归一化帧到相同尺寸(动画必须)
frames = FrameNormalizer.normalize(frames);
// 设置每帧延迟(毫秒)
int[] delays = {100, 100, 100};
// 创建动画 WebP
GifToWebPConfig config = GifToWebPConfig.createLosslessConfig();
byte[] webp = WebPCodec.createAnimatedWebP(frames, delays, config);
Files.write(Paths.get("animated.webp"), webp);如需对不同尺寸的帧进行更精细的归一化控制:
List<BufferedImage> normalized = FrameNormalizer.normalize(
frames,
800, // 目标宽度
600, // 目标高度
FitMode.CONTAIN, // 保持宽高比,不足处填充背景
false, // 不放大小图
new Color(0, 0, 0, 0) // 透明背景
);
// FitMode 可选:CONTAIN(保持比例)、COVER(填充裁剪)、STRETCH(拉伸)解码动画 WebP
将动画 WebP 文件解码为独立帧及延迟信息。
byte[] webpData = Files.readAllBytes(Paths.get("animated.webp"));
AnimatedWebPData result = WebPCodec.decodeAnimatedWebP(webpData);
System.out.println("帧数: " + result.getFrameCount());
System.out.println("画布尺寸: " + result.getCanvasWidth() + "x" + result.getCanvasHeight());
System.out.println("循环次数: " + result.getLoopCount());
int[] delays = result.getDelays();
for (int i = 0; i < result.getFrames().size(); i++) {
AnimatedWebPFrame frame = result.getFrames().get(i);
BufferedImage image = frame.getImage();
System.out.println("Frame " + i + ": delay=" + delays[i] + "ms");
// 保存每帧为 PNG
ImageIO.write(image, "png", new File("frame_" + i + ".png"));
}开发中遇到的挑战
JNI 开发的复杂性
JNI 开发需要同时掌握 Java 和 C/C++ 两种语言,并处理它们之间的类型转换、内存管理、异常处理等问题。在开发 WebP4j 的过程中,我遇到了几个典型的挑战:
- 类型转换陷阱:Java 的 byte 是有符号的(-128 到 127),而 C 的 uint8_t 是无符号的(0 到 255)。在处理图像数据时,必须正确处理这个差异,否则会导致图像失真。
- 内存泄漏风险:JNI 代码中,如果忘记释放 C 层分配的内存或 JNI 引用,就会导致内存泄漏。
- 异常处理机制差异:C 层的错误需要正确地转换为 Java 异常,否则会导致程序崩溃或行为异常。
跨平台编译的难点
不同平台的编译工具链、系统库、编译选项都不相同。最初我尝试使用交叉编译,但在实际操作中遇到了各种问题:
- 工具链配置复杂:需要为每个目标平台配置对应的交叉编译工具链
- 依赖库不兼容:某些平台的系统库版本不一致,导致编译失败
- 测试困难:交叉编译出来的库无法在当前平台测试,需要复制到目标平台
最终通过 GitHub Actions 的云构建环境解决了这些问题,每个平台在自己的原生环境中编译,简单可靠。
不过 GitHub Actions 并没有免费的 Linux ARM 机器可用,ARM 架构的动态链接库只能借助 QEMU 模拟器在 x86 机器上进行交叉编译。QEMU 模拟的性能极低,每次构建耗时较长,且在早期开发中还踩到过 QEMU 环境下 GCC 编译器崩溃的问题(当时通过降级编译器版本绕过)。后来我自己提供了一台 Linux ARM64 的物理机器接入 GitHub Actions 作为 self-hosted runner,ARM64 的构建速度和稳定性才得以显著改善。
ARM32 的支持则在 v2.1.1 中正式移除,原因有三:一是 32 位操作系统在市场上已逐渐被边缘化;二是较新版本的 JDK 已不再提供 32 位支持;三是在排查 GIF 堆缓冲区溢出漏洞时发现,32 位平台上的整数溢出风险更加难以管控,维护成本与实际价值已不成比例。
颜色通道识别问题
这是我在开发中遇到的一个非常棘手的 Bug。某些图片转换为 WebP 后会出现蓝色色调偏移,看起来就像图片蒙了一层蓝色滤镜。
经过排查,我发现问题出在颜色通道识别上:
- PNG 等格式可能包含 Alpha 透明通道(RGBA,4 个字节)
- JPG 格式只有 RGB 三个通道(3 个字节)
- 如果用 RGB 编码方法处理 RGBA 数据,或反之,就会导致颜色错乱
解决方法是在编码前检查 BufferedImage 的颜色模型:
boolean hasAlpha = bufferedImage.getColorModel().hasAlpha();
if (hasAlpha) {
// 使用 RGBA 编码
return NativeWebP.encodeRGBA(imageData, width, height, stride, quality);
} else {
// 使用 RGB 编码
return NativeWebP.encodeRGB(imageData, width, height, stride, quality);
}除了通道数量的问题,还有一个更隐蔽的陷阱:RGB 颜色通道的顺序。
不同的图像库和格式对颜色通道的排列顺序可能不同:
- RGB:红-绿-蓝(标准顺序)
- BGR:蓝-绿-红(OpenCV、某些 Windows API 使用)
- ARGB/RGBA/BGRA:透明通道位置不同
Java 的 BufferedImage 默认使用的是 BGR 顺序存储像素数据,而 libwebp 期望的是 RGB 顺序。如果直接传递数据而不转换,红色和蓝色会互换,导致整体色调偏移。例如,一个红色像素 (255, 0, 0) 如果按 BGR 顺序传递,会被 libwebp 误认为蓝色 (0, 0, 255)。
解决方法是在提取像素数据时针对不同的 BufferedImage 类型进行通道转换:BufferedImage 有多种内部存储格式,如 TYPE_INT_RGB、TYPE_INT_BGR、TYPE_3BYTE_BGR、TYPE_4BYTE_ABGR 等,每种格式的颜色通道排列顺序都不同。
通过这种方式,确保 RGB 顺序与 libwebp 期望的格式一致,再结合前面的通道数量检测,就能解决大部分图片颜色失真问题,同时获得最佳性能。
针对每种类型分别处理,具体可以阅读 convertBufferedImageToBytes(BufferedImage image) 代码。
内存泄漏
因为在 BufferedImage 转字节数组的时候会占用大量内存空间,尤其在批量转换的时候。如果没有正确释放掉内存,很容易使机器内存暴涨最终导致机器宕机。
解决方法是每次转换完字节数组后,将字节数组置为 0,方便 GC 回收。
Arrays.fill(outputBuffer, (byte) 0);gif-to-webp 时 loop_count 读取为 1
我发现问题了!在解析 NETSCAPE2.0 扩展时,字节顺序弄错了。
NETSCAPE2.0 扩展的数据格式是:
- extension[0] = 3 (数据长度)
- extension[1] = 1 (sub-block ID)
- extension[2] = loop count 低字节
- extension[3] = loop count 高字节
但代码中使用的是:
*loop_count = (extension[2] << 8) | extension[1];这把 extension[1] (sub-block ID = 1) 当作了 loop count 的低字节,所以当实际 loop count 是 0 时,读取出来变成了 1。
正确的代码应该是:
*loop_count = (extension[3] << 8) | extension[2];https://chromium.googlesource.com/webm/libwebp/%2B/0.3.0/examples/gif2webp.c#398
GIF 解码堆缓冲区溢出
在 gif_decoder.c 的 DecodeGifFromMemory() 函数中,存在一个由整数溢出引发的堆缓冲区溢出漏洞(Issue #6 )。
根本原因:32 位有符号整数溢出
问题出在计算画布所需内存大小的这行代码:
int canvas_size = result->canvas_width * result->canvas_height * 4;当 GIF 画布尺寸达到 46341 × 46341 时,乘法结果 8,589,953,124 超出了 int(32 位有符号整数)的最大值 2,147,483,647,发生溢出回绕,实际计算结果仅为 18,532 字节。于是 malloc() 只分配了约 18KB 的内存,而随后的 ClearCanvas() 却尝试向这块内存写入 8.5GB 的数据,直接造成堆越界写入。
危害:攻击者仅需构造一个约 35 字节的恶意 GIF 文件即可触发此漏洞,轻则导致进程崩溃(远程 DoS),重则可能通过堆内存破坏实现远程代码执行(RCE)。
修复方案:将画布尺寸计算改为 64 位整数运算:
// 使用 size_t(64 位)避免整数溢出
size_t canvas_size = (size_t)result->canvas_width * result->canvas_height * 4;此漏洞已在 89771b2 提交中修复,并随 v2.1.1 发布(同时移除了 ARM32 支持)。
总结与展望
项目现状
WebP4j 目前已稳定运行在生产环境中,主要特性包括:
- ✅ 支持 WebP 编码和解码
- ✅ 同时支持有损和无损压缩
- ✅ GIF 转 WebP(giflib 原生解码 + Java ImageIO 备用)
- ✅ 动画 WebP 创建(从 BufferedImage 帧列表生成)
- ✅ 动画 WebP 解码(提取帧图像和延迟信息)
- ✅ 帧归一化(统一不同尺寸的帧)
- ✅ 跨平台支持(Windows/Linux/macOS,x86-64/ARM64)
- ✅ 兼容 Java 8+(使用 JDK 21 编译,目标字节码为 Java 8)
- ✅ 简洁易用的 API
- ✅ 基于 libwebp 1.6.0 官方实现,性能优异
- ✅ 完整的自动化构建和发布流程
- ✅ 已发布至 Maven Central
未来规划
- 多线程编码:并行处理动画 WebP 的各帧编码
- 批量处理 API:优化大量图片的批量转换场景
- 内存优化:减少内存分配和 GC 压力
- 零拷贝优化:减少 Java 与 Native 层之间的数据复制
欢迎贡献
WebP4j 是一个开源项目(MIT 许可证),欢迎大家:
- 提交 Issue 反馈问题和建议
- 提交 PR 贡献代码
- Star 和 Fork 支持项目发展
项目地址:https://github.com/MrNanko/webp4j
致谢:感谢 Google libwebp 团队提供的优秀基础库,感谢开源社区的贡献者们。