资讯动态

Java后端用ZXing生成二维码:普通码、Logo码与文字码全攻略

发布时间:2026/10/14 21:47:18 来源:尧图企业网站定制
在实际项目里二维码接入的需求几乎隔一阵就会出现一次小程序码、门店物料、活动海报或者后台生成一批带Logo和说明文字的二维码图片。大多数时候我们第一反应是调第三方在线API但涉及数据隐私、图片定制、批量生成的时候本地生成永远是更稳妥的路子。这篇内容就是围绕“用com.google.zxing在 Java 后端自己做二维码”展开覆盖普通二维码、带 Logo 的二维码、底部加文字的二维码以及怎么把结果以流OutputStream和byte[]字节数组两种方式交到前端手里。先亮明结论这活儿用 ZXing 完全可以搞定而且整个技术链路并不复杂——核心就三步生成BitMatrix、转成BufferedImage、输出或拼接。难的是那些不在教程里写的细节Logo 大小多大会影响扫码、底部文字用什么字体不会乱码、返回流的时候缓存头怎么设、静区quiet zone丢了为什么识别率骤降。下面就从选型开始把整个链路完整拆一遍。1. 二维码库选型为什么最后还是选了 ZXing刚接手这个需求的时候我其实是犹豫过的。当时手上可选的方案大致有四类纯手写二维码生成算法疯了吧、调用第三方 HTTP 接口、直接用现成工具类、引入 ZXing 这个开源库。第三方接口试了一版网络抖动、每日限额、图片要传回服务器存一遍这些事太麻烦直接被否了。剩下的就是本地方案。1.1 ZXing 和常见替代品的对比如果你也在选型可以直接看这张对比表维度ZXingSwetakeQRCodeHutool QrCodeUtil纯手写算法维护活跃度长期维护社区大年久失修issue 没人回由 Hutool 封装底层仍是 ZXing 或类似的库不现实解码能力自带多种格式解码只管编码通常只编码不现实定制灵活性可控制BitMatrix、颜色、边距、拼接一般一般可控但成本爆炸中文字符支持默认 ISO-8859-1需要手动处理 UTF-8有中文支持但细节少容易上手—学习成本中等核心 API 面窄低低极高Hutool 其实很方便但它屏蔽了底层很多细节遇到要“底部加文字”“Logo 放到中心”“用流直接回前端”这类组合需求时你最终还是得回到BitMatrix和BufferedImage这一层手动操作。所以我的建议是哪怕项目里已经用了 Hutool也值得把 ZXing 的核心 API 单独掌握一遍。1.2 两个核心概念先理解透BitMatrix 和 MatrixToImageWriterZXing 生成的二维码并不是一张图而是一个二维布尔矩阵——BitMatrix。你可以把BitMatrix理解成一个由boolean组成的格子棋盘true代表黑色模块false代表白色模块。真正的二维码图案就是这张“棋盘”的可视化。要把它变成 PNG就得依靠MatrixToImageWriter这个工具类本质上做的事是遍历BitMatrix逐个像素写入BufferedImage的 RGB 数组。所以后续所有定制换颜色、加 Logo、拼文字都是基于这个思路先把矩阵转成BufferedImage再用Graphics2D对BufferedImage做二次加工。1.3 引入依赖时的版本注意点Maven 坐标很简单dependency groupIdcom.google.zxing/groupId artifactIdcore/artifactId version3.5.3/version /dependency dependency groupIdcom.google.zxing/groupId artifactIdjavase/artifactId version3.5.3/version /dependency注意core和javase的版本必须保持一致否则有可能出现方法签名对不上的情况。javase包里才有MatrixToImageWriter只用core的话得自己写矩阵转图片的工具方法所以我建议直接两个都引。提示如果你用的是 Spring Boot很多时候java-se这个库会被标记为“已废弃”但它依然是当前最稳的选择。不要手痒去换那些还不太成熟的替代品。2. 普通二维码从矩阵到图片再交给前端先把最基础的打通。一个标准的普通二维码生成流程写成代码大致是这个样子public BufferedImage createQrImage(String content, int width, int height) throws WriterException { QRCodeWriter writer new QRCodeWriter(); // 这里的 hints 可以用来设置纠错级别、字符编码、留白边距 MapEncodeHintType, Object hints new HashMap(); hints.put(EncodeHintType.CHARACTER_SET, UTF-8); hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); hints.put(EncodeHintType.MARGIN, 2); // 设置静区大小 BitMatrix bitMatrix writer.encode(content, BarcodeFormat.QR_CODE, width, height, hints); return MatrixToImageWriter.toBufferedImage(bitMatrix); }这里有一个非常重要的细节writer.encode()的width和height指的不是图片的物理像素而是 QR 码矩阵的模块数量。传入的尺寸最终会被内部换算成“版本 模块数”如果尺寸和内容长度不匹配可能出现尺寸被自动放大的情况。所以更推荐的做法是先以较小的模块尺寸生成BitMatrix再通过MatrixToImageWriter或自行绘制的方式缩放到目标像素尺寸。这样既能精确控制输出 PNG 的分辨率也能保证二维码不会因为内容过长而密得扫不出来。2.1 以流的形式直接返回前端后端接口直接写成图片流输出前端用一个img src/qrcode/generate就能拿到图这是最省事的做法GetMapping(/qrcode/generate) public void generateQr(HttpServletResponse response) throws IOException, WriterException { String content https://example.com/product/12345; int size 300; response.setContentType(image/png); response.setHeader(Cache-Control, no-cache); // 也可以加一个 Content-Length但拼接完再输出更方便 BufferedImage image createQrImage(content, size, size); ImageIO.write(image, png, response.getOutputStream()); }这里有几个细节要强调一下千万不要在写流之前调用response.getWriter()因为Writer和OutputStream不能在同一个响应里混用否则会直接抛IllegalStateException。需要设置Content-Type为image/png否则浏览器可能当成application/octet-stream直接下载而不是显示。动态生成二维码的接口建议设置Cache-Control: no-cache除非内容固定且生成开销可控。2.2 以 byte[] 的方式返回byte[]的常规用途是 JSON 接口里返回 Base64或者走文件导出的逻辑。两者本质是一样的先把图片写进ByteArrayOutputStream再取出字节数组。public byte[] generateQrBytes(String content, int size) throws IOException, WriterException { BufferedImage image createQrImage(content, size, size); ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(image, png, baos); return baos.toByteArray(); }配合 Spring 接口GetMapping(value /qrcode/base64, produces MediaType.APPLICATION_JSON_VALUE) public MapString, String generateQrBase64(RequestParam String content) throws Exception { byte[] data generateQrBytes(content, 300); String base64 Base64.getEncoder().encodeToString(data); return Map.of(qrCode, data:image/png;base64, base64); }前端拿到后直接这样渲染const img document.createElement(img); img.src data:image/png;base64,${response.qrCodeData};2.3 两种返回方式怎么选流返回适合当前页面直接展示比如订单详情页动态生成二维码后端不需要额外存储前端也无须关心图片来源。byte[] / Base64 返回适合需要把二维码图片作为 JSON 数据的一部分传给对方的场景比如服务间调用、批量导出 Excel 附带二维码、或者前端需要放进 canvas 再加工。这两种方式不是互斥的实际项目中我一般会把byte[]作为底层方法流返回只是从byte[]变相输出的一种形式。这样逻辑统一也方便做缓存。3. 加 Logo 二维码中心合成不能只是“贴上去”带 Logo 的二维码从原理上很好理解先生成一张普通二维码再把 Logo 图片按合适比例贴到中心区域。但实际操作时你会发现贴的位置、大小、透明背景、抗锯齿都会影响最终的扫码效果。3.1 Logo 大小和纠错级别的关系二维码的纠错等级分为 L约7%、M约15%、Q约25%、H约30%。Logo 遮盖的区域相当于“人为制造错误”所以遮得越多需要的纠错能力越强。经验值是这样的Logo 占整张二维码边长的1/5 到 1/4是安全的。超过 1/3哪怕用 H 级纠错也很危险尤其是二维码内容密集、版本较高的时候。推荐纠错等级用H或至少Q。如果用 MLogo 稍微大一点边缘就可能扫不出来。Logo 形状最好接近圆形或正方形避免不规则形状遮挡关键的定位图案。定位图案在三个角上Logo 放在中心天然避开了它们这也是为什么中心是唯一安全的位置。3.2 合成 Logo 的代码实现public BufferedImage addLogo(BufferedImage qrImage, BufferedImage logoImage, int logoSize) { int qrWidth qrImage.getWidth(); int qrHeight qrImage.getHeight(); BufferedImage combined new BufferedImage(qrWidth, qrHeight, BufferedImage.TYPE_INT_RGB); Graphics2D g2d combined.createGraphics(); g2d.drawImage(qrImage, 0, 0, null); // 抗锯齿画 Logo 更平滑 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); int x (qrWidth - logoSize) / 2; int y (qrHeight - logoSize) / 2; g2d.drawImage(logoImage, x, y, logoSize, logoSize, null); // 画一个细边框让 Logo 和二维码之间有视觉分隔也能减少识别干扰 g2d.setColor(Color.WHITE); g2d.setStroke(new BasicStroke(4)); g2d.drawRoundRect(x - 2, y - 2, logoSize 4, logoSize 4, 10, 10); g2d.dispose(); return combined; }这里之所以要重新创建一个BufferedImage是因为Graphics2D直接画到原图上也可以但复用原图可能导致原图被污染。尤其在后面要叠加底部文字时多个操作的执行顺序很重要——如果先加文字再贴 Logo拼图计算会复杂很多。3.3 一个比较隐蔽的坑Logo 图片的透明通道我实际遇到过一种情况Logo 图片是 PNG本身带透明背景但合成之后整个二维码变成了黑底白图或者颜色很怪异。原因出在BufferedImage的类型上。如果原图是TYPE_INT_ARGB带透明通道用Graphics2D绘制的某些操作会导致 Alpha 通道被覆盖。稳妥的做法是强制统一为TYPE_INT_RGB或者先把 Logo 转成白色背景的小图再贴上去。// 如果 logo 本身有透明区域建议先垫一层白色背景再合成 BufferedImage whiteBgLogo new BufferedImage(logoSize, logoSize, BufferedImage.TYPE_INT_RGB); Graphics2D lg whiteBgLogo.createGraphics(); lg.setColor(Color.WHITE); lg.fillRect(0, 0, logoSize, logoSize); lg.drawImage(logoImage, 0, 0, logoSize, logoSize, null); lg.dispose();4. 底部加文字画布怎么拼、字体怎么不翻车底部文字的二维码需求很常见活动海报上要让用户知道“扫码下载 App”“长按识别领取优惠券”或者直接在二维码图片里带上公众号名称。但如果只是简单把二维码画在上面文字长一点就会溢出图片边界。正确处理方式是动态计算文字区域高度再创建一块更大的画布。4.1 动态计算文本高度的方案public BufferedImage appendText(BufferedImage qrImage, String text, Font font, Color textColor) { Graphics2D measureG2d new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).createGraphics(); measureG2d.setFont(font); FontMetrics fm measureG2d.getFontMetrics(); int textWidth fm.stringWidth(text); int textHeight fm.getHeight(); int padding 20; // 文字区域宽度不能超过二维码宽度超了要换行或截断 int maxTextWidth qrImage.getWidth() - padding * 2; int lineCount (int) Math.ceil(textWidth / (double) maxTextWidth); int textBlockHeight lineCount * textHeight 16; BufferedImage result new BufferedImage(qrImage.getWidth(), qrImage.getHeight() textBlockHeight, BufferedImage.TYPE_INT_RGB); Graphics2D g2d result.createGraphics(); g2d.setColor(Color.WHITE); g2d.fillRect(0, 0, result.getWidth(), result.getHeight()); g2d.drawImage(qrImage, 0, 0, null); g2d.setColor(textColor); g2d.setFont(font); g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); // 多行文字按行绘制 int currentY qrImage.getHeight() textHeight 8; String[] lines splitText(text, font, maxTextWidth); for (String line : lines) { int x (result.getWidth() - fm.stringWidth(line)) / 2; g2d.drawString(line, x, currentY); currentY textHeight; } g2d.dispose(); measureG2d.dispose(); return result; }splitText需要考虑中英文混排的情况不能用简单的substring截断否则会出现半个中文。稳妥的做法是基于FontMetrics.stringWidth逐个字符累加超过最大宽度就换行。4.2 中文字体乱码与缺失这是最常被问的问题如果在本地 Windows 上跑得好好的部署到 Linux 服务器上突然变成“口口口”那是因为服务器没装中文字体或者 JVM 找不到对应字体。解决方案有两类使用系统自带字体Linux 上装fonts-wqy-zenhei或者fontconfig后用new Font(WenQuanYi Zen Hei, Font.PLAIN, size)。打包自定义字体把simhei.ttf黑体放进resources/fonts目录运行时通过Font.createFont加载这也是最可控的方式。// 从 classpath 加载字体 InputStream is YourClass.class.getResourceAsStream(/fonts/simhei.ttf); Font font Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(24f);注意Font.createFont创建的字体只是临时注册不能在不同Graphics2D实例间反复使用同一份对象否则可能出现字体不生效的怪问题。最好是加载完成后手动放一个静态变量里。4.3 三种需求合体一个方法统一完成最终接口总不能让人分别调三个方法再自己拼实际项目里我都是封装成一个统一的QrCodeService.generate()public BufferedImage generate(String content, int size, String logoPath, String bottomText, ErrorCorrectionLevel level) throws Exception { // 1. 生成普通二维码 BufferedImage qr createQrImage(content, size, size, level); // 2. 叠加 Logo if (StringUtils.hasText(logoPath)) { BufferedImage logo ImageIO.read(new File(logoPath)); qr addLogo(qr, logo, size / 5 0 ? 60 : size / 5); } // 3. 叠加底部文字 if (StringUtils.hasText(bottomText)) { qr appendText(qr, bottomText, QrFontHolder.DEFAULT_FONT, new Color(51, 51, 51)); } return qr; }对外暴露时再结合前面提到的HttpServletResponse或byte[]返回逻辑即可。这样一套下来所有调用方只需要关心业务参数不用碰任何 ZXing API。5. 踩坑记录那些差点让我怀疑人生的细节二维码生成看着是个小功能一旦在生产环境出问题排查起来还挺费劲。下面这几个坑是我实际遇到过、也在好几个客户现场协助过的。5.1 白色静区丢失导致扫码识别率骤降第一次交付时我生成的二维码直接就是 300×300图片边缘几乎没有任何留白。结果在部分安卓机上扫码特别慢甚至扫不出来。后来查了资料才知道二维码规范要求周围有至少 4 个模块宽的静区quiet zone如果贴得太满很多劣质扫码算法就懵了。在 ZXing 里设置EncodeHintType.MARGIN才有效但这个 margin 是“模块”单位不是像素。我习惯传2或4。如果你要完全控制白边的像素数更好的方式是生成后再用画布扩展一圈白色像素private BufferedImage appendQuietZone(BufferedImage image, int quietPixels) { int w image.getWidth(), h image.getHeight(); BufferedImage out new BufferedImage(w 2 * quietPixels, h 2 * quietPixels, BufferedImage.TYPE_INT_RGB); Graphics2D g2d out.createGraphics(); g2d.setColor(Color.WHITE); g2d.fillRect(0, 0, out.getWidth(), out.getHeight()); g2d.drawImage(image, quietPixels, quietPixels, null); g2d.dispose(); return out; }这个方法在后续拼 Logo、加文字时都要用最好生成完成后统一处理。5.2 内容过长导致二维码版本升级密度瞬间上升很多业务方会把一长串 URL 参数塞进二维码比如带一堆utm_source和traceId的链接。内容一长QR 码自动切换更高版本模块数量变多同样的图片尺寸下单个模块变小扫码自然变难。遇到这种情况解决思路是先把长 URL 转换为短链接再生成二维码。适当调大图片尺寸但图片尺寸不能无限大打印时分辨率也要匹配。纠错等级不要无脑用 H内容超长时 H 会让模块更密反而更难扫。所以我推荐默认用 M带 Logo 用 Q 或 H不牺牲体验。5.3 输出流被提前关闭或响应头冲突有一个很典型的错误在GetMapping里先调用了response.getOutputStream()后面又用response.getWriter()写返回信息结果直接报错。另外用ImageIO.write写完图片后不要再对 Response 做JSON转换避免重复提交响应。还有一个容易忽视的某些网关或代理会对响应做缓冲如果图片比较大比如 1024×1024 的 PNG最好设置足够大的Content-Length或确保OutputStreamflush。一般流式接口里结束后调用一次response.getOutputStream().flush()是必要的。5.4 内存翻车并发生成大尺寸二维码二维码看似轻量但它其实是 CPU 密集和内存占用都偏高的操作。一个 1000×1000 的BufferedImage在TYPE_INT_RGB下约占用 3MB 内存如果接口被高频调用且没有缓存内存会快速上涨。我的建议相同内容的二维码结果缓存到本地ConcurrentHashMap或者Caffeinekey 可以是content size logoHash text。把QRCodeWriter声明成单例。它本身是线程安全的但频繁创建会有额外开销。对传入的size做限制比如最大 2000防止有人恶意传一个 5000 直接 OOM。6. 把生成组件做成一个可复用的基础设施二维码需求不会只出现一次。这周要做带 Logo 的活动码下周可能就要做带序号的产品标签码。与其每次写一套临时逻辑不如花半小时整理出一个干净的服务层后面所有业务都能直接调用。6.1 对外 API 设计建议我一般会拆成两层底层QrCodeGenerator只负责图片合成不关心输出方式入参全是基础类型出参统一为BufferedImage或byte[]。上层QrCodeController负责接 HTTP 参数、设置响应头、决定返回流还是 JSON Base64。这样未来如果要做 MQ 异步生成、OSS 上传底层完全不用改。一个推荐的业务入参{ content: https://example.com?id123, size: 400, logo: data:image/png;base64,..., bottomText: 扫码查看订单详情, errorLevel: M, marginPixels: 20 }Logo 允许content形式而不是文件路径主要是为了分布式环境下不需要共享文件系统。如果公司内部有对象存储也可以传 OSS key后端自己拉流。6.2 日志与监控不要忽略这个“小功能”在网关侧或服务内做好计数统计比如短信渠道经常会遇到“二维码生成超时”的反馈。建议把生成耗时、图片大小记到日志里。针对content长度超 200 字符的调用单独打印一条 WARN 日志方便及时发现内容滥用问题。6.3 一套代码搞定多种输出格式下面是我在项目里常用的完整工具方法把流返回和 byte[] 返回都封装好了public class QrCodeService { private static final QRCodeWriter WRITER new QRCodeWriter(); public BufferedImage generate(String content, int size, BufferedImage logo, String bottomText, ErrorCorrectionLevel level) throws WriterException { MapEncodeHintType, Object hints new HashMap(); hints.put(EncodeHintType.CHARACTER_SET, UTF-8); hints.put(EncodeHintType.ERROR_CORRECTION, level null ? ErrorCorrectionLevel.M : level); hints.put(EncodeHintType.MARGIN, 1); BitMatrix matrix WRITER.encode(content, BarcodeFormat.QR_CODE, size, size, hints); BufferedImage qrImage MatrixToImageWriter.toBufferedImage(matrix); if (logo ! null) { qrImage addLogo(qrImage, logo); } if (StringUtils.hasText(bottomText)) { qrImage appendBottomText(qrImage, bottomText); } return appendQuietZone(qrImage, 20); } public byte[] generateAsBytes(...) { ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(image, png, baos); return baos.toByteArray(); } public void writeToResponse(HttpServletResponse response, ...) throws IOException { byte[] data generateAsBytes(...); response.setContentType(image/png); response.setContentLength(data.length); response.getOutputStream().write(data); response.getOutputStream().flush(); } }有人可能会问为什么把静区放在最后而不是在 encode 阶段直接设置MARGIN4因为MARGIN只能设置“模块数”但当我们已经对BufferedImage做过缩放、拼接后模块数对应的像素是变化的。统一在做完所有加工后再补像素级白边效果更可控视觉效果也更统一。最后聊几个个人实操感受这套东西做完之后我最大的感触是二维码生成不是一个能“百度一段代码复制就跑”的活生产环境会逼你把细节补齐。比如字体文件有没有随包部署、静区够不够、Logo 加了之后纠错等级提没提、给前端的流有没有设置 Content-Length这些东西单独拎出来都不难但漏掉任何一个都可能在某个环节给你上眼药。我现在的习惯是把QrCodeService作为基础组件放进公司的公共 starter 里任何新项目要用直接传参就行。不同的业务方只需要关心content和 UI 展示层图片高度定制都由这一个服务兜底。如果你刚开始接二维码需求建议你先从普通版跑通整个链路再逐步叠加 Logo 和文字。每次只改一个变量出了问题也知道是哪一步引起的。上述代码我保留了一个最小可运行版本在内部仓库里遇到问题照着一行行核对基本能覆盖掉 90% 的坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑