☰
Java后端用ZXing生成二维码:普通码、Logo码与文字码全攻略
2026/10/14 21:47:09 网站建设 项目流程

在实际项目里,二维码接入的需求几乎隔一阵就会出现一次:小程序码、门店物料、活动海报,或者后台生成一批带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 和 MatrixToImageWriter

ZXing 生成的二维码并不是一张图,而是一个二维布尔矩阵——BitMatrix。你可以把BitMatrix理解成一个由boolean组成的格子棋盘,true代表黑色模块,false代表白色模块。真正的二维码图案就是这张“棋盘”的可视化。

要把它变成 PNG,就得依靠MatrixToImageWriter,这个工具类本质上做的事是:遍历BitMatrix,逐个像素写入BufferedImage的 RGB 数组。所以后续所有定制(换颜色、加 Logo、拼文字)都是基于这个思路:先把矩阵转成BufferedImage,再用Graphics2D对BufferedImage做二次加工。

1.3 引入依赖时的版本注意点

Maven 坐标很简单:

<dependency> <groupId>com.google.zxing</groupId> <artifactId>core</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.google.zxing</groupId> <artifactId>javase</artifactId> <version>3.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 可以用来设置纠错级别、字符编码、留白边距 Map<EncodeHintType, 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 Map<String, 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。如果用 M,Logo 稍微大一点,边缘就可能扫不出来。
  • 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 找不到对应字体。解决方案有两类:

  1. 使用系统自带字体:Linux 上装fonts-wqy-zenhei或者fontconfig后,用new Font("WenQuanYi Zen Hei", Font.PLAIN, size)。
  2. 打包自定义字体:把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或者Caffeine,key 可以是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?id=123", "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 { Map<EncodeHintType, 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 阶段直接设置MARGIN=4?因为MARGIN只能设置“模块数”,但当我们已经对BufferedImage做过缩放、拼接后,模块数对应的像素是变化的。统一在做完所有加工后再补像素级白边,效果更可控,视觉效果也更统一。

最后,聊几个个人实操感受

这套东西做完之后,我最大的感触是:二维码生成不是一个能“百度一段代码复制就跑”的活,生产环境会逼你把细节补齐。比如字体文件有没有随包部署、静区够不够、Logo 加了之后纠错等级提没提、给前端的流有没有设置 Content-Length,这些东西单独拎出来都不难,但漏掉任何一个都可能在某个环节给你上眼药。

我现在的习惯是:把QrCodeService作为基础组件放进公司的公共 starter 里,任何新项目要用,直接传参就行。不同的业务方只需要关心content和 UI 展示层,图片高度定制都由这一个服务兜底。

如果你刚开始接二维码需求,建议你先从普通版跑通整个链路,再逐步叠加 Logo 和文字。每次只改一个变量,出了问题也知道是哪一步引起的。上述代码我保留了一个最小可运行版本在内部仓库里,遇到问题照着一行行核对,基本能覆盖掉 90% 的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询