十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java图片合并实战:BufferedImage与Graphics2D实现拼接与水印合成

Java图片合并实战:BufferedImage与Graphics2D实现拼接与水印合成 写图片合并这需求十次里有八次是给报表加封面、把多张扫码件拼长图、或者做缩略图墙剩下的两次多半是做车牌识别、票据存档这类偏业务的落地方案。Java 做合并图片这件事核心从来不在于能不能拼而在于你知不知道 BufferedImage、Graphics2D、ImageIO 这三个类怎么配合才能不丢质量、不出内存问题、还能灵活适配横向纵向或者网格布局。这篇文章就从实际项目出发把横向拼接、纵向拼接、网格拼接、水印合成、性能优化这些场景全部拆开讲一遍适合刚接触 Java 图像处理的同学也适合写过一两次但想搞清楚底层原理的开发者。只要你本地装了 JDK不用引入任何第三方依赖就能把合并图片成为一张图片这件事跑通。这也是 Java 在这类小需求上一个很实在的优势——标准库里的 ImageIO 可以读写 JPEG、PNG、BMP、GIF仅读等常见格式Graphics2D 负责在内存画布上做绘制BufferedImage 则是承载像素数据的画布本体三者串起来就是一个完整的图像合成管线。下面我会从最基础的横向拼接讲起一路写到网格缩略图、水印合成、大图优化每一段代码都给全注释也写清楚你直接拿去用就行。1. 布局设计与核心 API 选型1.1 先分清合并图片的几种形态很多人在网上搜java 合并图片只找到一种写法拿回来发现不符合自己场景。实际上合并图片这个需求可以拆成几种完全不同的形态横向拼接多张图并排成一张长图、纵向拼接上下堆叠、常用于聊天记录截图和证件扫描件、网格拼接行列排布、用来批量预览或生成发货单、以及画布合成在底图上叠加 Logo、日期、说明文字这种更像是水印和海报合成。每一种形态底层的绘制逻辑都一样区别只在于输出画布的尺寸计算方式和绘制坐标的递推策略。搞明白这一点你就不用背代码模板而是学会自己推导任何布局需求都能现场写出来。我在项目中遇到最频繁的是横向拼接和网格拼接所以下面重点讲这两种纵向拼接我会在代码里顺带覆盖到。1.2 三个核心类的分工与协作关系既然要用标准库实现必须把三个类的关系吃透。BufferedImage 本质上就是一块内存里的像素矩阵它负责真正存储每个坐标点的颜色值Graphics2D 则像一个带画笔的画板所有 drawImage、drawString、fillRect 操作都是在往 BufferedImage 这块画布上写像素ImageIO 是桥接层负责把磁盘上的图片文件读成 BufferedImage再把内存里的 BufferedImage 落盘成指定格式的文件。用生活化的比喻来说BufferedImage 是一张白纸Graphics2D 是你手里的笔和尺子ImageIO 是扫描仪和打印机。先通过 ImageIO.read 把已有图片扫描进内存再用 Graphics2D 在白纸上摆放这些图片最后 ImageIO.write 把成果打印输出到文件。理解了这一层你在画布上多画一个背景色、多画几条线、多加几个字就都是顺理成章的事。1.3 为什么选 TYPE_INT_RGB 和 TYPE_INT_ARGB创建 BufferedImage 时必须指定图像类型很多人在这踩坑。最常用的两个类型是 TYPE_INT_RGB不含透明度每个像素占 4 字节但 alpha 通道固定为 255和 TYPE_INT_ARGB含透明通道适合 PNG 输出。如果你要合成透明背景的图片比如把一张带透明区域的 PNG 和另一张图片叠加就必须用 TYPE_INT_ARGB否则透明部分直接变黑——这是我见过最多的合并图片变黑问题的根源。如果你只是把多张 JPEG 横向拼成长图输出也是 JPEG用 TYPE_INT_RGB 就够了性能和内存占用都更友好。如果输入图片里混有 PNG 透明图或者你输出的是 PNG统一用 TYPE_INT_ARGB 更省心。我在代码示例里统一用 TYPE_INT_ARGB这样通用性最强也方便你扩展到水印和透明合成场景。2. 环境准备与基础代码框架2.1 JDK 版本建议与依赖说明这个方案不需要任何 Maven 依赖因为用的全是 java.desktop 模块下的类JDK 8 到 JDK 21 都可以跑。如果你用的是 JDK 8注意它自动带 java.desktop 模块无需额外配置如果用的是 JDK 11也无需 module-info 里显式 requires只要不是搞 JPMS 模块化项目直接建一个 class 就能运行。唯一要注意的是ImageIO 原生支持的格式有限JPEG/PNG/BMP 没问题WebP 不支持TIFF 在某些 JDK 版本需要额外 ImageIO 插件。如果你的场景涉及 WebP要么先转格式要么引入第三方库这一步没有捷径。2.2 读图、校验、画布初始化的骨架代码import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; public class ImageMerger { // 读取图片并统一转为 ARGB 类型避免后续绘制时透明通道异常 private static BufferedImage readImage(File file) throws IOException { BufferedImage img ImageIO.read(file); if (img null) { throw new IOException(无法读取图片: file.getAbsolutePath()); } // 如果原图不是 ARGB转成 ARGB保证透明通道可控 BufferedImage argb new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_ARGB); Graphics2D g2d argb.createGraphics(); g2d.drawImage(img, 0, 0, null); g2d.dispose(); return argb; } // 释放资源的小封装方便统一调用 private static void closeQuietly(Graphics2D g2d) { if (g2d ! null) { g2d.dispose(); } } }这里我特意加了一个统一转 ARGB的步骤很多人会忽略。直接用 ImageIO.read 读出来的图格式可能自带的是 TYPE_BYTE_BINARY 或 TYPE_INT_RGB在后续 drawImage 绘制时大概率不影响但一旦涉及透明通道或者质量缩放就会有各种诡异行为。提前统一转换能规避掉大约七成的坑。2.3 Graphics2D 的渲染参数设置顺序很多教程上来就画根本不设置渲染参数导致拼出来的图片文字发虚、缩放锯齿明显。正确做法是在拿到 Graphics2D 之后先把三个核心渲染参数设置好再开始绘制操作setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON)抗锯齿开关影响文字和图形边缘的平滑度。setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BICUBIC)缩放图片时使用的插值算法BICUBIC 质量最好但性能略低BILINEAR 性能更好适合大量缩略图场景。setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY)渲染质量优先而不是速度优先。设置好这三个参数之后再执行任何绘制操作画质都会明显不同。下面这段代码我建议作为你创建 Graphics2D 后的固定动作Graphics2D g2d canvas.createGraphics(); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);2.4 拼图的画布背景色要不要填合并图片时如果每张子图尺寸不一短边区域会露出画布底色。默认新建的 BufferedImage 是全透明的透明背景在浏览器或者图片查看器里会显示成白色或棋盘格输出 JPEG 时透明区域会被强制改为黑色这是 JPEG 格式的限制不是代码 bug。所以我的建议是输出 JPEG 就显式填充白色背景输出 PNG 就保留透明背景并让调用方决定底色。填充白色只多一行代码g2d.setColor(Color.WHITE); g2d.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());这行代码放在所有 drawImage 之前即可。如果你希望背景是浅灰、淡蓝或者干脆让或透明区域的图片显示底色改 setColor 的颜色值就行。3. 横向与纵向拼接的完整实现3.1 横向拼接的尺寸计算与像素无损说明横向拼接的目标是把多张宽度不一的图片排成一行总宽度是所有子图宽度之和总高度是所有子图高度的最大值。这里有一个关键选择子图高度低于最大值时剩余空间是留白还是拉伸我的建议是直接留白不拉伸因为拉伸会导致图片比例失真人眼一眼就能看出变形。假设你有三张图宽度分别是 300、400、500高度分别是 200、300、250那么输出画布宽度就是 300 400 500 1200高度就是 max(200, 300, 250) 300。绘制时第一张图贴在 x0 的位置第二张图贴在 x300 的位置第三张图贴在 x700 的位置。横向坐标按宽度累加即可。这里还要注意像素无损这个概念。如果我们只是把原图平移到画布上不改变缩放比例那么拼接过程不会产生任何像素损失。只有在做缩略图墙、把大图等比缩小的时候才会涉及插值重采样才会产生轻微画质损失。所以如果业务上允许尽量把子图原始像素完整保留不做缩放。3.2 横向拼接完整代码与坐标递推逻辑public static void mergeHorizontally(ListFile inputFiles, File outputFile) throws IOException { if (inputFiles null || inputFiles.isEmpty()) { throw new IllegalArgumentException(输入图片列表不能为空); } // 先把所有图片读入内存统一为 ARGB ListBufferedImage images new ArrayList(); int totalWidth 0; int maxHeight 0; for (File file : inputFiles) { BufferedImage img readImage(file); images.add(img); totalWidth img.getWidth(); maxHeight Math.max(maxHeight, img.getHeight()); } // 创建输出画布 BufferedImage canvas new BufferedImage(totalWidth, maxHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d canvas.createGraphics(); try { // 设置渲染质量 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); // 白色背景如果输出 PNG 想保留透明可以注释掉这两行 g2d.setColor(Color.WHITE); g2d.fillRect(0, 0, totalWidth, maxHeight); // 横向坐标递推 int x 0; for (BufferedImage img : images) { // 把当前图片绘制到 (x, 0) 位置保持原始尺寸 g2d.drawImage(img, x, 0, null); x img.getWidth(); } } finally { closeQuietly(g2d); } // 写入输出文件 String ext getExtension(outputFile.getName()); if (!ImageIO.write(canvas, ext, outputFile)) { throw new IOException(写入图片失败不支持的格式: ext); } }3.3 纵向拼接的一个隐蔽细节最大宽度对齐纵向拼接的思路和横向完全对称总高度是所有子图高度之和总宽度是最大宽度。绘制时 y 坐标递增每一张图贴在 x0 的位置。不过纵向拼接有一个横向没有的隐蔽问题当子图宽度不一致时窄图两侧会出现大量留白视觉上像被缩进了。我在做聊天记录截图拼接时曾想过让窄图居中显示而不是左对齐效果会更好看。实现也不复杂把绘制坐标从x0改为x(maxWidth-img.getWidth())/2即可。这是一个很容易被忽略的小优化但对比一下效果差别很明显。推荐你在纵向拼接的需求里默认加上居中逻辑。3.4 拼接顺序与输出格式对结果的影响合并图片的顺序很重要List 里图片的排列顺序直接决定最终拼图的阅读顺序。在业务中通常按照文件名排序或者按时间字段排序但要注意 File 列表默认不排序如果你用listFiles()直接拿数组顺序是不可预测的。建议先用Comparator.comparing(File::getName)排序后再进入合并流程。输出格式方面JPEG 不支持透明通道PNG 支持JPEG 适合照片类内容体积小PNG 适合截图、文字、Logo 类内容无损且边缘清晰。如果你不确定业务方要什么默认输出 PNG 最稳妥因为 PNG 能覆盖所有场景代价是体积偏大。压缩参数我们后面会讲先记住这个选择原则。4. 进阶网格拼接与缩略图墙的实战方案4.1 网格布局的核心痛点行数列数与单元格尺寸推导网格拼接比单纯横纵拼接复杂一个量级因为它要考虑行数、列数、单元格尺寸、间距、留白。一个常见的实际场景是把 20 张商品图拼成一张 4 列 5 行的预览图或者把用户上传的身份证正反面、合同每一页拼成一张 A4 预览图。网格布局的核心是先定列数再推导行数。假设你要拼 N 张图指定列数为 columns那么行数rows ceil(N / columns)也就是(N columns - 1) / columns。然后定单元格尺寸如果每张原图尺寸不一致单元格尺寸可以取所有图的最大宽高或者固定一个标准宽高。对图片本身做等比缩放然后居中放在单元格中央这样视觉最整齐。4.2 支持空白格填充的网格拼接代码public static void mergeGrid(ListFile inputFiles, File outputFile, int columns, int cellWidth, int cellHeight, int padding, Color bgColor) throws IOException { if (columns 0) { throw new IllegalArgumentException(列数必须大于0); } ListBufferedImage images new ArrayList(); for (File file : inputFiles) { images.add(readImage(file)); } int count images.size(); int rows (count columns - 1) / columns; // 向上取整 int totalWidth columns * cellWidth (columns 1) * padding; int totalHeight rows * cellHeight (rows 1) * padding; BufferedImage canvas new BufferedImage(totalWidth, totalHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d canvas.createGraphics(); try { g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2d.setColor(bgColor ! null ? bgColor : Color.WHITE); g2d.fillRect(0, 0, totalWidth, totalHeight); for (int i 0; i count; i) { int row i / columns; int col i % columns; int targetX padding col * (cellWidth padding); int targetY padding row * (cellHeight padding); BufferedImage img images.get(i); // 等比缩放保持图片比例多余空间留白 double ratio Math.min((double) cellWidth / img.getWidth(), (double) cellHeight / img.getHeight()); int drawW (int) (img.getWidth() * ratio); int drawH (int) (img.getHeight() * ratio); int drawX targetX (cellWidth - drawW) / 2; int drawY targetY (cellHeight - drawH) / 2; g2d.drawImage(img, drawX, drawY, drawW, drawH, null); } } finally { closeQuietly(g2d); } String ext getExtension(outputFile.getName()); if (!ImageIO.write(canvas, ext, outputFile)) { throw new IOException(写入图片失败: ext); } }这里的核心逻辑是ratio min(cellWidth/imgWidth, cellHeight/imgHeight)这个公式的含义是取水平方向和垂直方向缩放比例中较小的那个保证图片在单元格内完整显示且不变形。比如一张 1000x500 的图放进 200x200 的格子水平比例 0.2垂直比例 0.4取 0.2绘制尺寸就是 200x100居中对齐上下留白 50 像素。这种contain模式是缩略图墙最常用的策略。4.3 大量小图拼接场景的内存控制策略网格拼接常遇到几十张甚至上百张小图拼一张图的场景。如果一次性把所有图都 ImageIO.read 进内存JVM 堆可能直接撑爆。以 100 张 2000x2000 的图片为例每张 ARGB 内存占用约 16MB100 张就是 1.6GB。更稳妥的策略是分两遍处理第一遍只读每张图片的宽高信息用 ImageReader.getWidth/getHeight不加载像素计算好整个网格画布的尺寸第二遍再逐张读取图片、绘制、释放引用。这样同一时刻内存里只有一张原图加一张大画布内存占用直线下降。ImageIO 的 ImageReader 可以只读头信息try (ImageInputStream iis ImageIO.createImageInputStream(file)) { IteratorImageReader readers ImageIO.getImageReaders(iis); if (readers.hasNext()) { ImageReader reader readers.next(); reader.setInput(iis); int width reader.getWidth(0); int height reader.getHeight(0); reader.dispose(); } }当然如果图片数量在 10 张以内直接全部读进内存也没问题两遍策略适合大规模场景。5. 扩展玩法画布叠加、水印与多图合一的底层原理5.1 把 Logo、时间、文字直接画到合并结果上合并图片不只是把多张图拼在一起很多时候还要在最终结果上叠加水印、日期、编号。这个操作其实是同一套 Graphics2D 绘制机制的延伸。drawImage 是画图drawString 是画文字setComposite 配合 AlphaComposite 则是控制透明度。比如为拼好的长图加上2025-03-18 归档这样的时间戳或者为合同扫描件加上机密水印都可以在同一块画布上继续绘制。关键参数有三个字体字号、颜色、透明度。如果文字不带透明度AlphaComposite 就不需要设置如果想让水印呈半透明需要用AlphaComposite.SrcOver.derive(0.3f)把透明度降到 30%。这里我强烈建议在正式输出前保留一个 Graphics2D 实例继续绘制而不是合并完成后再二次读图。因为每做一次磁盘读写就是一次有损操作连续 JPEG 读两次写两次画质下降肉眼可见。所有叠加操作应该集中在一次绘制管线里完成。5.2 质量参数与文件大小如何平衡ImageIO.write 是一条道走到黑的 API它不允许你指定压缩质量。如果要设置 JPEG 压缩质量必须用 ImageWriter JPEGImageWriteParam 手动控制。这个需求在图片体积敏感的场景很常见比如后端返回长图给前端展示2MB 和 200KB 的加载体验差距很大。上代码private static void writeJpegWithQuality(BufferedImage image, File outputFile, float quality) throws IOException { IteratorImageWriter writers ImageIO.getImageWritersByFormatName(jpg); if (!writers.hasNext()) { throw new IOException(没有可用的 JPEG 写入器); } ImageWriter writer writers.next(); try (ImageOutputStream ios ImageIO.createImageOutputStream(outputFile)) { writer.setOutput(ios); ImageWriteParam param writer.getDefaultWriteParam(); param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionQuality(quality); // 0.0f ~ 1.0f0.85f 是质量和体积平衡点 writer.write(null, new IIOImage(image, null, null), param); } finally { writer.dispose(); } }质量参数的推荐值如果是产品图、商品图0.9f 以上如果是长图、内容型大图0.85f如果是临时预览图0.7f 也够用。低于 0.6f 会出现明显色块和蚊噪不推荐。PNG 没有压缩质量参数只有压缩级别Deflater 级别体积优化效果不如 JPEG 明显。5.3 透明图片、黑白图、灰度图混拼时的一个注意点当输入图片包含透明 PNG、黑白扫描件、彩色照片混合时最容易翻车的是透明 PNG。统一转 ARGB 之后透明区域在画布上会保留 alpha0如果你的画布是 RGB 类型这个 alpha 信息直接丢失合成出来的效果就是黑底。处理方案很简单所有混合拼图场景统一起画布用 TYPE_INT_ARGB绘制前先填充白底或者目标底色这样透明区域替换为底色非透明区域正常保留。如果你希望透明区域最终呈现为透明输出必须用 PNG且不要在画布上填充背景色。这两个需求在代码上用是否执行 fillRect 来区分就行。6. 常见问题排查与实操踩坑记录6.1 问题速查表现象根因解决方案合并后图片是黑底输出 JPEG 且画布透明绘制前 fillRect 填充白色合并后图片偏色颜色空间不同导致统一转 TYPE_INT_ARGB 或用 SRGB 转换缩放后图片失真严重插值算法默认 NEAREST设置为 BICUBIC 或 BILINEAR内存溢出 OutOfMemoryError一次性加载过多大图改用两遍读取策略或降低画布尺寸图片是旋转的EXIF 方向信息被忽略读取 EXIF 旋转角度并手动旋转绘制中文文字变成方块系统字体中没有该字体使用中文字体如宋体或微软雅黑drawImage 之后图片错位坐标累加写错 或 DPI 不同输出统一 96 DPI坐标用像素计算先解释一下EXIF 方向这个坑。手机拍出来的照片经常带旋转信息预览器会自动旋转但 ImageIO 读入的像素矩阵是原始方向你直接拼接就会得到横竖错位的图。网上方案很多核心思路是用 metadata 读取EXIF Orientation字段如果是 6 或 8就对应旋转 90 度和 270 度在绘制前先做顺时针或逆时针旋转。项目里我遇到过一次用户上传手机拍照合同导致拼接错位的问题就是这个原因修起来要把旋转矩阵封装好。6.2 大图拼接时如何避免 OOM一个真实案例之前做一个商品图批量导出功能用户一次性选了 60 张图每张约 4000x3000。最初我用最直接的写法——全部 ImageIO.read 到 List然后拼成一张长图结果 JVM 直接报 OutOfMemoryError。我用 -Xmx512m 跑了测试内存峰值大约在 1.2GB 左右。后来改成两遍读取策略第一遍只读宽高估算画布尺寸第二遍逐张读取绘制内存峰值降到 200MB 左右。关键是第二遍里绘制完一张就把images.clear()或直接复用BufferedImage变量让 JVM 及时回收。JVM 只要不主动 GC内存占用会一直维持在高位必要时可以在每绘制完 5 张图后调用System.gc()但注意这只是建议性的不能在逻辑上依赖它。6.3 文件名排序问题合并结果顺序不对很多人直接遍历folder.listFiles()就拿去合并结果发现输出顺序和文件名顺序完全对不上。原因很简单listFiles() 的返回顺序是不确定的可能是创建时间、可能是文件系统内部顺序反正不一定是字典序。如果你期待 1.png、2.png、10.png 有序排列字典序排序会得到 1.png、10.png、2.png数字位数不同时会出现这种自然排序问题。解决办法是用一个按数字感知的排序器list.sort(Comparator.comparing(File::getName, (name1, name2) - { // 提取文件名中的数字部分按数值后再按普通字符串比较 String n1 name1.replaceAll(\\D, ); String n2 name2.replaceAll(\\D, ); if (n1.isEmpty() || n2.isEmpty()) { return name1.compareTo(name2); } int i1 Integer.parseInt(n1); int i2 Integer.parseInt(n2); return Integer.compare(i1, i2); }));这段代码会把图片1.png排在图片2.png前面而不是图片10.png排在图片2.png前面。这个坑我在做批量扫码件合成时踩过顺序错了整个归档就乱了一定要在合并前确认排序规则。6.4 不同图片格式混拼时 ImageIO 的限制ImageIO 原生支持的格式是有限的JDK 8 支持 JPEG、PNG、BMP、WBMP、GIF读JDK 11 以后支持 TIFF部分WebP 始终不支持。如果你的来源图片是 WebPImageIO.read 会返回 null而很多代码里没判断空值直接调用img.getWidth()就 NPE 了。我的建议是在 readImage 里统一判空并抛出带文件名的异常不要把 NPE 留给上游。如果业务上必须处理 WebP可以引入 TwelveMonkeys ImageIO 插件WebP、TIFF、SVG 等扩展然后一切代码不变继续用 ImageIO.read 读取。这个方案对已有代码侵入最小。7. 用策略模式统一几种合并方式顺带聊聊工程化7.1 为什么要抽象成策略模式如果你在写一个供多个业务方调用的图片处理服务会发现横向拼接、纵向拼接、网格拼接这些方法会被散落在工具类里以后再加一个九宫格、宫格 文字标题时工具类越来越臃肿。这个场景很适合用策略模式来收敛。策略模式本身不复杂就是把不同的合并算法封装成独立实现对外暴露统一接口。调用方通过一个类型参数告诉服务我要横向拼接还是我要网格拼接服务内部根据类型路由到对应实现。这个设计的好处是新增一种拼接方式时只需要新增实现类不需要改动调用方代码。7.2 策略接口与实现类的代码骨架public interface ImageMergeStrategy { BufferedImage merge(ListBufferedImage images, MergeOptions options); } public class HorizontalMergeStrategy implements ImageMergeStrategy { Override public BufferedImage merge(ListBufferedImage images, MergeOptions options) { // 横向拼接逻辑 return null; } } public class GridMergeStrategy implements ImageMergeStrategy { Override public BufferedImage merge(ListBufferedImage images, MergeOptions options) { // 网格拼接逻辑 return null; } }MergeOptions 可以承载背景色、间距、边距、单元格尺寸、是否居中、是否填充序号等参数。这样一个工具类就变成了一个可维护的图片合成框架后续接水印、加圆角、加阴影都可以在这个框架上扩展。7.3 动态代理在耗时监控上的应用说到工程化另一个能蹭到的热搜点是动态代理。如果你希望在每次合并操作后自动打印耗时、日志或做性能监控与其在每段 merge 逻辑里手写long start System.currentTimeMillis()不如用 JDK 动态代理统一包装策略接口。不过坦白讲小项目里这么做有点过度设计。我自己通常只在方法里加一行long start System.currentTimeMillis()完事了。动态代理更适合在中间件团队、统一监控平台的场景里使用普通业务代码强行套代理反而可读性变差。工程上的取舍一直是够用就好不在一个 200 行的工具类上堆砌设计模式。7.4 合并结果的二次加工裁剪、压缩、Base64 输出合并图片之后经常还需要做二次处理。最常见的是裁剪边缘空白区域比如网格拼接后最右侧和最下侧会有一段空白业务方可能要求裁掉。裁剪逻辑说白了就是新建一个更小尺寸的画布把原画布的指定区域画进去。另一个高频需求是把图片转成 Base64 输出给前端展示而不是落盘。代码很简单ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(canvas, png, baos); String base64 Base64.getEncoder().encodeToString(baos.toByteArray());不过要注意Base64 编码体积膨胀约 33%一张 5MB 的 PNG 转完大约 6.7MB 的字符串前端拼接 data URI 时会比较占内存。对大图建议先用 JPEG 压缩再转 Base64。8. 从合并图片到更大的应用场景写到这里图片合并这个需求本身已经讲得比较透了。但我想多说一句很多看似简单的小功能在真实业务里往往不是独立存在的。比如我最早做合并图片是为了给车牌识别系统生成识别结果预览图把原图、裁剪出来的车牌区域、识别文字拼成一张图方便事后人工复核。后来又在列车调度系统的告警截图归档里用到纵向拼接把一段时间的告警截图拼成一张长图便于回溯。这些场景的共通点是图片合并是手段信息汇总和留档才是目的。所以你在实现合并功能时不要只盯着怎么拼还要想清楚拼完之后谁来用、用什么格式、多大的体积可以接受、需不需要再加标注信息。这些和业务强相关的问题往往比代码本身更影响最终方案的成败。如果只是自己零散使用上面给出的横向拼接和网格拼接代码已经够你应付大部分场景。如果是要落地到生产项目建议把统一转 ARGB、填充背景色、质量控制、排序规则、内存策略这几条原则融入项目里再配合策略模式组织代码。这样不管来的是身份证扫描件、商品截图还是报表长图都可以一套逻辑吃下。最后再分享一个实用小技巧在开发阶段可以写一个简单的命令行入口接收目录路径和拼接方式参数直接跑一个 main 方法看效果比单测调起来快得多。就几行代码却能省下大量调试时间——毕竟图像处理这种东西光靠脑子想很容易漏掉实际视觉呈现上的细节。
返回列表