
1. 项目缘起为什么我们还需要自己写二维码生成工具最近在重构一个老的后台管理系统里面有个功能是给每个生成的订单附上一个二维码用户扫码就能直接跳转到订单详情页。一开始用的是网上找的一个工具类跑起来也没啥问题。但上周测试同事反馈当订单号特别长、并且包含中文的时候生成的二维码在部分安卓手机上扫不出来。我一看代码好家伙那个工具类用的是最基础的QRCodeWriter对内容编码的处理非常粗暴直接getBytes()连字符集都没指定遇到中文和特殊符号编码不一致解码自然就出问题了。这让我意识到一个健壮的二维码生成工具远不是调用一个库、画个黑白方块那么简单。它涉及到编码纠错、容错级别选择、Logo嵌入的美观性、不同输出格式比如直接响应给前端或者保存为文件供邮件发送的支持甚至还有性能考量。网上现成的代码片段往往只解决了“从无到有”的问题但在生产环境面对复杂需求时就显得捉襟见肘。所以我决定结合这次踩坑的经验重新梳理并封装一个更完善、更实用的QRCodeUtils工具类。这个工具类不仅要能生成二维码还要能优雅地处理各种边界情况比如内容过长自动选择合适版本、带Logo时自动调整参数避免识别失败、提供便捷的Base64输出以便前端直接渲染等。接下来我就把这个工具类的设计思路、核心实现以及那些容易踩的坑毫无保留地分享出来。2. 核心依赖选型ZXing还是QRGen要生成二维码Java生态里最知名的库非ZXingZebra Crossing莫属。它是一个功能强大的、支持多种条形码和二维码格式的开源库。另一个选择是QRGen它是对ZXing的一层薄封装API更友好一些。这里我直接选择了ZXing原因有三点原生与可控性ZXing是事实标准更新维护更活跃遇到问题网上资料和解决方案也最多。直接使用ZXing意味着我们对整个生成过程有更底层的控制权方便进行深度定制比如自定义渲染样式。功能完整性ZXing不仅支持生成Encoding还支持解码Decoding。虽然我们这次主要做生成但同一个项目里很可能也会有扫码解析的需求使用统一的库能减少依赖冲突和管理成本。轻量无冗余QRGen确实简化了API但它引入的抽象层对我们想要的精细控制来说有时反而是一种束缚。直接使用ZXing的core和javase两个模块依赖非常干净。Maven依赖如下我们只需要核心库和用于生成图片的J2SE扩展dependency groupIdcom.google.zxing/groupId artifactIdcore/artifactId version3.5.3/version /dependency dependency groupIdcom.google.zxing/groupId artifactIdjavase/artifactId version3.5.3/version /dependency这里有个小细节javase模块依赖于core但显式声明两个可以避免某些构建工具的传递依赖解析问题让依赖树更清晰。3. 工具类骨架设计与核心参数解析在动手写代码之前我们先要明确这个工具类需要哪些核心配置参数。ZXing在生成二维码时通过一个HashMapEncodeHintType, Object类型的hints对象来传递参数。我们的工具类应该将这些参数暴露为可配置的选项并提供合理的默认值。3.1 关键参数及其作用字符集 (CHARACTER_SET)这是解决我开头提到的中文乱码问题的关键。它指定了将文本内容转换为字节数组时使用的编码。必须明确指定为UTF-8这是目前最通用、支持最广的编码方式。如果不指定ZXing会使用平台默认编码在跨平台部署时极易出问题。纠错级别 (ERROR_CORRECTION)这是二维码的灵魂特性之一。它定义了二维码在部分损坏比如污损、遮挡时依然能被正确扫描的能力。级别从低到高分为L (Low): 约可恢复7%的数据码字。空间占用最小。M (Medium): 约可恢复15%的数据码字。推荐默认级别在容量和容错间取得良好平衡。Q (Quartile): 约可恢复25%的数据码字。H (High): 约可恢复30%的数据码字。抗损能力最强但相同内容下生成的二维码最复杂黑点最多可能影响识别速度。 对于普通链接或文本默认使用M级别是个稳妥的选择。如果生成的二维码需要打印在易磨损的物体表面或者要嵌入较大的Logo则建议使用H级别。边距 (MARGIN)二维码图片四周的空白区域。有些扫码器对紧贴边缘的二维码识别不好所以需要留白。默认值通常是4单位是模块宽度即一个黑点或白点的宽度。我一般设置为1在保证识别率的前提下让二维码看起来更紧凑美观。注意有些旧版教程会使用EncodeHintType.MARGIN但在3.x版本中正确的键是EncodeHintType.MARGIN其值应为整数类型。版本 (VERSION)二维码的版本1到40决定了其数据容量和尺寸版本越大尺寸越大容量越高。通常我们不指定版本让ZXing根据输入内容和纠错级别自动选择最小可用版本这样最省空间。只有在有特殊尺寸要求时才需要强制指定。基于以上分析我们可以先定义工具类的基础结构它应该是一个包含静态方法的最终类final class并提供一些重载的入口方法以适应不同场景。4. 基础生成方法实现与内存陷阱我们先从最核心的生成位图BufferedImage的方法开始。这是所有其他输出格式文件、Base64、流的基础。import com.google.zxing.BarcodeFormat; import com.google.zxing.EncodeHintType; import com.google.zxing.MultiFormatWriter; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; public final class QRCodeUtils { // 私有构造器防止实例化 private QRCodeUtils() {} /** * 生成二维码图片对象 (BufferedImage) * param content 二维码内容 * param width 图片宽度像素 * param height 图片高度像素 * param logoPath Logo图片路径可选为null则不添加 * return BufferedImage 对象 */ public static BufferedImage generateQRCodeImage(String content, int width, int height, String logoPath) { // 参数校验 if (content null || content.isEmpty()) { throw new IllegalArgumentException(QRCode content cannot be null or empty.); } if (width 0 || height 0) { throw new IllegalArgumentException(Width and height must be positive numbers.); } MapEncodeHintType, Object hints new HashMap(); // 关键设置1字符集 hints.put(EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name()); // 关键设置2纠错级别 hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); // 关键设置3边距 hints.put(EncodeHintType.MARGIN, 1); try { // 核心生成步骤 BitMatrix bitMatrix new MultiFormatWriter().encode( content, BarcodeFormat.QR_CODE, width, height, hints ); // 转换为BufferedImage BufferedImage qrImage MatrixToImageWriter.toBufferedImage(bitMatrix); // 如果提供了Logo路径则进行合成 if (logoPath ! null !logoPath.trim().isEmpty()) { qrImage addLogoToQRCode(qrImage, logoPath); } return qrImage; } catch (Exception e) { throw new RuntimeException(Failed to generate QR code image, e); } } }看起来很简单对吧但这里隐藏着一个性能与内存的深坑。注意MultiFormatWriter.encode方法的width和height参数。它们直接决定了生成的BitMatrix和最终BufferedImage的大小。如果你传入width500, height500那么内存中就会创建一个500x500的二进制矩阵和一张500x500的RGB图片对象。问题来了二维码的本质是黑白二值图理论上尺寸不需要太大。过大的尺寸比如超过1000x1000不仅浪费内存和CPU在生成高并发时可能导致频繁的GC甚至OutOfMemoryError。更糟糕的是有些前端显示区域可能只有200x200像素你生成一个1000x1000的图前端再压缩显示效果反而可能变模糊。正确的做法是根据使用场景定义一个合理的默认尺寸如300x300并提供一个无需指定尺寸的重载方法内部使用这个默认值。同时在方法注释里明确提醒调用者非必要勿设置过大尺寸。private static final int DEFAULT_SIZE 300; public static BufferedImage generateQRCodeImage(String content) { return generateQRCodeImage(content, DEFAULT_SIZE, DEFAULT_SIZE, null); } public static BufferedImage generateQRCodeImage(String content, String logoPath) { return generateQRCodeImage(content, DEFAULT_SIZE, DEFAULT_SIZE, logoPath); }5. Logo合成不仅仅是居中贴图给二维码加Logo是常见需求但做不好很容易导致二维码无法识别。addLogoToQRCode方法是实现的关键。/** * 向二维码图片中添加Logo * param qrImage 原始的二维码图片 * param logoPath Logo图片的路径 * return 合成后的图片 */ private static BufferedImage addLogoToQRCode(BufferedImage qrImage, String logoPath) throws IOException { // 1. 读取Logo图片 File logoFile new File(logoPath); if (!logoFile.exists()) { // 这里可以选择记录日志并返回原图或者直接抛出异常。为了健壮性我选择返回原图。 // log.warn(Logo file not found: {}, logoPath); return qrImage; } BufferedImage logoImage ImageIO.read(logoFile); if (logoImage null) { return qrImage; } // 2. 计算Logo的合适尺寸 - 这是关键 // Logo不能太大否则会覆盖太多关键信息。通常取二维码图片宽度的1/5到1/6。 int logoMaxWidth qrImage.getWidth() / 6; int logoMaxHeight qrImage.getHeight() / 6; int logoWidth logoImage.getWidth(); int logoHeight logoImage.getHeight(); // 等比例缩放Logo if (logoWidth logoMaxWidth || logoHeight logoMaxHeight) { double widthRatio (double) logoMaxWidth / logoWidth; double heightRatio (double) logoMaxHeight / logoHeight; double scaleRatio Math.min(widthRatio, heightRatio); // 取缩放比例小的确保不超过最大边界 logoWidth (int) (logoWidth * scaleRatio); logoHeight (int) (logoHeight * scaleRatio); // 使用高质量缩放 Image scaledLogo logoImage.getScaledInstance(logoWidth, logoHeight, Image.SCALE_SMOOTH); BufferedImage resizedLogo new BufferedImage(logoWidth, logoHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d resizedLogo.createGraphics(); g2d.drawImage(scaledLogo, 0, 0, null); g2d.dispose(); logoImage resizedLogo; } // 3. 创建新的画布绘制二维码和Logo // 使用二维码图片的类型通常是TYPE_INT_RGB BufferedImage combined new BufferedImage( qrImage.getWidth(), qrImage.getHeight(), BufferedImage.TYPE_INT_RGB ); Graphics2D g (Graphics2D) combined.getGraphics(); // 先绘制整个二维码作为背景 g.drawImage(qrImage, 0, 0, null); // 设置抗锯齿使Logo边缘更平滑 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 计算Logo绘制的位置居中 int x (qrImage.getWidth() - logoWidth) / 2; int y (qrImage.getHeight() - logoHeight) / 2; // 绘制一个白色背景圆角矩形可选增加Logo区域识别度 int padding 2; int arc 8; // 圆角半径 g.setColor(Color.WHITE); g.fillRoundRect(x - padding, y - padding, logoWidth 2 * padding, logoHeight 2 * padding, arc, arc); // 绘制Logo g.drawImage(logoImage, x, y, null); g.dispose(); return combined; }这里有几个非常重要的经验点Logo尺寸是玄学1/5或1/6是一个经验值。你可以尝试1/5如果发现识别率下降就改用1/6。纠错级别必须设置为HHigh为Logo预留出足够的容错空间。白色底衬很重要直接绘制透明背景的PNG Logo在黑白棋盘上可能会因为颜色交错影响定位和识别。加一个小的白色圆角矩形底衬能显著提高识别成功率尤其是Logo本身颜色较深时。资源释放Graphics2D对象和ImageIO.read产生的流虽然这里封装了都是资源要注意在finally块中释放或确保方法结束前释放。上面的代码在g.dispose()处做了处理。更严谨的做法是使用try-with-resources但ImageIO.read和Graphics2D的创建方式不太直接支持所以显式调用dispose是关键。6. 多样化输出文件、流与Base64生成BufferedImage之后我们需要把它输出成各种形式以满足不同场景。工具类应该提供一站式解决方案。6.1 保存为本地文件/** * 生成二维码并保存为图片文件 * param content 内容 * param width 宽 * param height 高 * param logoPath Logo路径 * param filePath 保存的文件路径如 /tmp/qrcode.png * param formatName 图片格式PNG, JPEG等 */ public static void generateQRCodeToFile(String content, int width, int height, String logoPath, String filePath, String formatName) throws IOException { BufferedImage image generateQRCodeImage(content, width, height, logoPath); File outputFile new File(filePath); // 确保父目录存在 File parentDir outputFile.getParentFile(); if (parentDir ! null !parentDir.exists()) { if (!parentDir.mkdirs()) { throw new IOException(Failed to create directory: parentDir.getAbsolutePath()); } } if (!ImageIO.write(image, formatName, outputFile)) { throw new IOException(No appropriate image writer found for format: formatName); } } // 提供便捷的重载方法 public static void generateQRCodeToFile(String content, String filePath) throws IOException { generateQRCodeToFile(content, DEFAULT_SIZE, DEFAULT_SIZE, null, filePath, PNG); }注意ImageIO.write的返回值很重要。它返回一个boolean表示是否成功找到对应的writer并写出。如果指定的formatName不支持它会返回false而不是抛出异常。因此主动检查返回值并抛出异常是更健壮的做法。6.2 输出到输出流用于Web响应这是Web应用中最常用的场景直接将二维码图片写入HttpServletResponse的OutputStream返回给前端。/** * 生成二维码并写入输出流适用于Web响应 * param content 内容 * param width 宽 * param height 高 * param logoPath Logo路径 * param outputStream 输出流 * param formatName 图片格式 */ public static void generateQRCodeToStream(String content, int width, int height, String logoPath, OutputStream outputStream, String formatName) throws IOException { BufferedImage image generateQRCodeImage(content, width, height, logoPath); if (!ImageIO.write(image, formatName, outputStream)) { throw new IOException(No appropriate image writer found for format: formatName); } // 重要通常由调用者负责关闭OutputStream工具类一般不关闭它。 // outputStream.flush(); // 可以刷新但不关闭 }在Spring MVC的Controller中可以这样用GetMapping(/qrcode) public void generateQRCode(RequestParam String content, HttpServletResponse response) throws IOException { response.setContentType(image/png); response.setHeader(Cache-Control, no-store); // 建议不缓存二维码内容可能实时变化 QRCodeUtils.generateQRCodeToStream(content, 300, 300, null, response.getOutputStream(), PNG); }6.3 生成Base64字符串用于前端Img标签或JSON API前端有时不希望单独发一个图片请求而是希望后端直接返回一个Base64格式的图片数据可以直接放在img srcdata:image/png;base64,...里。/** * 生成二维码并转换为Base64编码字符串 * param content 内容 * param width 宽 * param height 高 * param logoPath Logo路径 * param formatName 图片格式 * return Base64编码的图片字符串不含data:image/png;base64,前缀 */ public static String generateQRCodeToBase64(String content, int width, int height, String logoPath, String formatName) throws IOException { BufferedImage image generateQRCodeImage(content, width, height, logoPath); ByteArrayOutputStream baos new ByteArrayOutputStream(); if (!ImageIO.write(image, formatName, baos)) { throw new IOException(No appropriate image writer found for format: formatName); } byte[] imageBytes baos.toByteArray(); // 使用java.util.Base64 (Java 8) return Base64.getEncoder().encodeToString(imageBytes); } /** * 生成二维码并返回完整的Data URL可直接用于img.src * param content 内容 * param width 宽 * param height 高 * param logoPath Logo路径 * return 完整的Data URL字符串 */ public static String generateQRCodeToDataURL(String content, int width, int height, String logoPath) throws IOException { String base64 generateQRCodeToBase64(content, width, height, logoPath, PNG); return data:image/png;base64, base64; }性能提示ByteArrayOutputStream默认大小是32字节对于一张300x300的PNG图片可能几十KB来说太小会导致内部数组频繁扩容。可以预估大小进行初始化// 预估大小300*300像素RGB每个通道1字节加上PNG压缩预估50KB ByteArrayOutputStream baos new ByteArrayOutputStream(50 * 1024);7. 高级特性与生产环境优化一个基础工具类上线后随着业务发展总会遇到新的需求。这里分享几个我实践中加入的高级特性和优化点。7.1 支持自定义颜色与样式黑白二维码看腻了ZXing生成的BitMatrix本质是一个二维布尔数组true代表黑点false代表白点。我们可以完全控制渲染过程。public static BufferedImage generateColorfulQRCodeImage(String content, int width, int height, Color foregroundColor, Color backgroundColor, String logoPath) throws Exception { // ... 生成BitMatrix的代码同上 ... BitMatrix bitMatrix new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints); // 自定义渲染 int matrixWidth bitMatrix.getWidth(); int matrixHeight bitMatrix.getHeight(); BufferedImage image new BufferedImage(matrixWidth, matrixHeight, BufferedImage.TYPE_INT_RGB); int foregroundRGB foregroundColor.getRGB(); int backgroundRGB backgroundColor.getRGB(); for (int x 0; x matrixWidth; x) { for (int y 0; y matrixHeight; y) { image.setRGB(x, y, bitMatrix.get(x, y) ? foregroundRGB : backgroundRGB); } } // ... 后续添加Logo等操作 ... return image; }你甚至可以实现渐变色、圆点样式等更复杂的效果核心就是遍历BitMatrix根据坐标和规则计算每个像素的颜色。7.2 内容长度与版本容量的估算二维码的版本Version决定了其数据容量。虽然ZXing会自动选择版本但有时我们需要提前知道内容是否会超出容量限制或者想估算生成的二维码大概尺寸。ZXing库内部有一个Encoder类但它的容量计算逻辑比较复杂。一个实用的经验法则是数字容量最大Version 40-L 可容纳约7000个数字。字母数字0-9, A-Z, 空格及$%*-./:次之。二进制/汉字UTF-8容量最小。对于包含中文的URL或文本如果长度超过500字符就很可能需要较高的版本大尺寸。你可以在工具类里添加一个预警方法public static void checkContentLength(String content, ErrorCorrectionLevel level) { int length content.getBytes(StandardCharsets.UTF_8).length; // 这是一个非常粗略的估算实际容量取决于字符类型分布。 // 对于UTF-8文本假设平均每个字符2字节使用M纠错。 int roughCapacity 0; switch (level) { case L: roughCapacity 1500; break; // 约750汉字 case M: roughCapacity 1200; break; // 约600汉字 case Q: roughCapacity 900; break; // 约450汉字 case H: roughCapacity 700; break; // 约350汉字 } if (length roughCapacity) { // log.warn(QR code content length ({}) may exceed recommended capacity for level {}. May require higher version., length, level); // 或者抛出异常 // throw new IllegalArgumentException(Content too long for selected error correction level.); } }7.3 生成性能优化与缓存策略在高并发场景下频繁生成相同内容的二维码是巨大的资源浪费。我们可以引入简单的缓存。注意缓存图片对象BufferedImage本身可能占用较大内存且内容相同的二维码可能因尺寸、Logo不同而不同缓存键的设计要小心。一个可行的方案是缓存编码后的BitMatrix因为生成BitMatrix编码计算是相对耗时的而将其渲染为图片MatrixToImageWriter则较快。我们可以基于内容、宽度、高度和纠错级别生成缓存键。import java.util.concurrent.ConcurrentHashMap; public class QRCodeUtils { private static final ConcurrentHashMapString, BitMatrix BITMATRIX_CACHE new ConcurrentHashMap(); private static String generateCacheKey(String content, int width, int height, ErrorCorrectionLevel level) { return String.format(%s|%d|%d|%s, content, width, height, level.name()); } private static BitMatrix getOrCreateBitMatrix(String content, int width, int height, MapEncodeHintType, Object hints) throws Exception { String key generateCacheKey(content, width, height, (ErrorCorrectionLevel) hints.get(EncodeHintType.ERROR_CORRECTION)); return BITMATRIX_CACHE.computeIfAbsent(key, k - { try { return new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints); } catch (Exception e) { throw new RuntimeException(e); // computeIfAbsent要求Function不能抛受检异常所以包装一下 } }); } // 然后在generateQRCodeImage方法中用getOrCreateBitMatrix替换new MultiFormatWriter().encode(...) }缓存警告这只是一个简单示例。生产环境需要考虑缓存大小限制使用LinkedHashMap实现LRU或使用Guava/Caffeine、过期策略、以及如果二维码内容是动态的如带时间戳的token缓存命中率会很低等问题。对于绝大多数应用如果QPS不是极高直接生成的开销是可以接受的。8. 完整工具类代码与使用示例将上述所有功能整合形成一个完整的、生产可用的QRCodeUtils工具类。由于代码较长这里给出最终的结构概览和典型用法。工具类核心方法列表generateQRCodeImage(...) 核心生成方法返回BufferedImage。generateQRCodeToFile(...) 生成并保存为文件。generateQRCodeToStream(...) 生成并写入输出流。generateQRCodeToBase64(...)/generateQRCodeToDataURL(...) 生成Base64字符串。generateColorfulQRCodeImage(...) 生成自定义颜色的二维码。(可选)checkContentLength(...) 内容长度预警。典型使用示例生成带Logo的二维码并保存String content https://www.yourdomain.com/order/123456; String logoPath /path/to/company_logo.png; String outputPath /tmp/order_qr.png; try { QRCodeUtils.generateQRCodeToFile(content, 400, 400, logoPath, outputPath, PNG); System.out.println(QR code saved to: outputPath); } catch (IOException e) { e.printStackTrace(); }在Spring Boot Controller中返回二维码图片RestController RequestMapping(/api/qrcode) public class QRCodeController { GetMapping(value /generate, produces MediaType.IMAGE_PNG_VALUE) public void generateQRCode(HttpServletResponse response, RequestParam String text) throws IOException { response.setContentType(image/png); response.setHeader(Cache-Control, max-age0, no-cache, must-revalidate); // 使用工具类生成并输出 QRCodeUtils.generateQRCodeToStream(text, 300, 300, null, response.getOutputStream(), PNG); } }前端通过Base64直接显示// 后端API GetMapping(/base64) public MapString, String getQRCodeBase64(RequestParam String text) throws IOException { String base64QRCode QRCodeUtils.generateQRCodeToBase64(text, 300, 300, null, PNG); MapString, String result new HashMap(); result.put(imageData, base64QRCode); return result; }!-- 前端 -- img :srcdata:image/png;base64, imageData altQR Code这个工具类从一次生产环境的小故障演化而来涵盖了从基础生成、Logo处理、多格式输出到性能优化的方方面面。它可能不是功能最全的但力求在易用性、健壮性和性能之间取得一个良好的平衡。在实际项目中你可以根据具体需求继续扩展它比如增加对SVG矢量格式的支持、集成更复杂的缓存策略或者添加监控埋点来统计生成失败率等。希望这个经过实战检验的工具类和背后的思考能帮助你少踩一些坑。