
前阵子项目里要做一个活动海报需要在 Harmony 应用里生成一批带品牌 logo 的二维码。查了一圈官方自带的 QRCode 组件只能把字符串变成黑白二维码想往中心塞一张图基本没戏。于是我把二维码生成、图片合成、Canvas 绘制、导出保存这条链路完整走了一遍最后做出来的效果比预想中还好扫码速度也没受影响。这篇文章就把这套 Harmony 侧生成带图片二维码的完整方案拆开讲清楚包括两条不同实现路线怎么选、核心代码怎么写、实际踩过的坑怎么排。适合刚接触鸿蒙开发、或者已经在用 ArkTS 做应用但第一次碰二维码需求的朋友项目里需要做品牌物料码、活动宣传码、应用下载码的场景都可以直接参考。1. 需求拆解与方案选型为什么官方组件不够用1.1 这个需求在真实项目中长什么样先交代一下“带图片的二维码”到底指什么。它不是那种纯黑白的普通码而是二维码中心有一张品牌 logo、头像、产品图等内容图整体看起来更像一张“品牌卡片”。这类二维码在真实项目里出现频率很高比如活动海报角落的报名码、产品包装上的防伪码、应用商店的下载引导码、线下门店的桌贴码等都是同一个套路二维码负责承载信息中心图片负责承载品牌识别。很多第一次做这个需求的人会下意识觉得这不就是在二维码上面叠一张图片吗从视觉上看确实是叠图但从技术实现上看要分两个层面考虑你是只需要在应用界面上“显示”这个效果还是需要把这张合成后的二维码图片“导出/保存/分享”出去。这两个目标对应的技术方案差距非常大。我强烈建议你在动手前先想清楚这个原始诉求。如果只是界面展示官方 QRCode 组件配合 Stack 叠图就能搞定代码量很少但如果要保存成图片发到微信、打印到海报上、或者让用户长按保存就必须走“生成二维码底图 图片合成 编码导出”的完整链路。这两种方案我都实现了下面分别给你拆解原理和取舍。1.2 二维码里为什么能塞图片纠错机制与视觉遮蔽在讲实现之前有必要花点时间说清楚一个基础问题二维码中心放了图片为什么扫码还能识别出来这其实涉及到二维码的纠错机制。二维码的存储结构大致分三块功能图形三个角的定位方块、校正图形、数据区编码后的信息、纠错区用于恢复错误数据的冗余码。其中定位方块在扫码时用来确定方向和大小是绝对不能遮挡的这也是为什么几乎所有带 logo 的二维码图片都必须放在正中央而不是偏一点的位置。数据区和纠错区则有一定的容错能力。二维码的纠错等级分四级从上到下分别是 L、M、Q、H对应的最大纠错率约 7%、15%、25%、30%。也就是说在 H 级别下二维码即使有 30% 的区域被污损、遮挡、缺角扫描端仍然有办法把原始信息还原出来。我们在中心放 logo本质上就是主动“破坏”一部分二维码数据靠纠错能力把缺失的信息补回来。这里的关键就是遮挡比例和纠错级别的匹配关系。按我的实测经验logo 短边控制在二维码总尺寸的 20% 到 25% 之间配合 Q 或 H 纠错级别绝大多数扫码场景都能稳定识别。如果 logo 占到 30% 以上即便用 H 级别也比较危险尤其是那种隔了半米远、角度还歪着的扫码场景识别率会断崖式下跌。这背后的直觉也很好理解纠错冗余是有限的你遮掉的数据越多对方需要“脑补”的信息就越多而二维码找位、对齐本身还要消耗一部分数据留给 logo 的余量真的没那么宽裕。1.3 两条技术路线对比叠加实现 vs Canvas 合成接下来是方案选型。我在 Harmony 里实测过两条可行路线各有明显优缺点。第一条是“界面叠加方案”。用官方 QRCode 组件生成二维码再用 Stack 布局在中心叠一个 Image 组件看起来就是带 logo 的二维码。这个方案实现极快十几行代码就能跑通但致命问题在于它只是“视觉上”带图片你没法把合成结果当成一张真正的图片保存下来。QRCode 组件也没有提供直接把渲染结果导出成 PixelMap 的公开接口所以一旦需求变成“保存/分享/打印”这条路就断了。第二条是“Canvas 合成方案”。先用三方库 ZXing 把内容编码成二维码 PixelMap再加载 logo 图片的 PixelMap用 CanvasRenderingContext2D 把两张图绘制到同一个画布上最后通过 getPixelMap 截取画布内容用 ImagePacker 打包导出。这个方案代码量多一个量级但换来的是完整的可导出能力、可自定义的样式控制、以及后续批量生成的可能性。我做了个对比表方便你按实际需求快速决策对比维度Stack 叠加方案Canvas 合成方案实现成本低十几行代码中高依赖三方库代码量大中心图片支持支持支持导出保存图片不支持支持自定义背景/圆角/描边较弱灵活批量生成能力无可支持二维码内容生成方式官方组件ZXing 三方库适合场景应用内临时展示物料、分享、保存、批量导出如果你只需要在应用页面里展示一个带 logo 的二维码Stack 方案完全够用没必要引入额外依赖但如果你和我一样需要把最终成品发给别人、存到相册、或者印刷出来老老实实走 Canvas 合成方案这才是“生成图片”的本意。2. 环境准备与基础依赖2.1 工程创建与 API 版本选择我用的开发环境是 DevEco Studio创建工程时选择 Empty Ability 模板语言默认 ArkTS。这里提醒一句API 版本建议不要低于 12因为我后面要用的 Canvas 截取接口 getPixelMap、以及一些异步图片处理能力在高版本 API 上更稳定。如果你项目里已经跑在 HarmonyOS NEXT 上那 API 版本基本都是 12 以上直接照做就行。工程创建好后先确认一下项目的 module 配置。ZXing 生成二维码是在纯逻辑层完成编码不涉及 UI 组件所以对工程结构没有特殊要求。项目里一般会有一个 entry 模块你要把后面生成的图片导出逻辑放在哪个页面就把代码写在哪里结构上不需要为二维码功能单独开模块。有一点值得提前说明HUAWEI 官方并没有提供“生成带图片二维码”的封装组件三方库生态里 ohos/zxing 是社区用得比较多的 ZXing 移植版本支持编码和解码。我选它不是因为没得选而是因为它的 API 方式和 Java 版 ZXing 很像做过安卓开发的人几乎零学习成本。库的维护活跃度和文档完整度也比其他可选方案好一些。2.2 引入 ZXing 三方库与权限配置引入 ohos/zxing 很简单在 DevEco Studio 的 Terminal 里执行ohpm install ohos/zxing装完之后在代码里 import 就能用了。如果你公司内部有私有 ohpm 仓库可能还需要在 .npmrc 或者 oh-package.json5 里配置一下仓库地址这个属于环境差异不展开说了。接下来是权限配置。如果不做导出保存只生成在内存里展示其实不需要任何权限。但只要你的目标是把二维码图片存到相册就需要在 module.json5 里声明写入图片的权限requestPermissions: [ { name: ohos.permission.WRITE_IMAGEVIDEO, reason: 保存生成的二维码图片到相册, usedScene: { abilities: [EntryAbility], when: inuse } } ]声明完权限并不代表直接可用鸿蒙对相册写入权限是运行时动态授权所以还要在代码里发起申请。我习惯把权限申请封装成一个公共方法在主流程入口统一调用一次避免在页面里到处散落申请逻辑import { abilityAccessCtrl, common, Permissions } from kit.AbilityKit; async function requestWritePermission(context: common.UIAbilityContext): Promiseboolean { const atManager abilityAccessCtrl.createAtManager(); const permissions: Permissions[] [ohos.permission.WRITE_IMAGEVIDEO]; const result await atManager.requestPermissionsFromUser(context, permissions); return result.authResults.length 0 result.authResults[0] 0; }这里返回的 authResults 数组0 表示授权成功其他值就是用户拒绝或者不识别这个权限。真机上调试时我经常遇到用户第一次点了拒绝、之后应用再也不弹窗的情况所以对已拒绝过权限的场景要额外做一次引导设置页跳转这已经是老生常谈了但确实容易漏。2.3 一个容易忽视的坑ZXing 库的 API 版本差异引入第三方库的时候最怕的就是“照着文档敲代码却编译不过”。ohos/zxing 的 API 在几个版本之间有过调整尤其是错误级别枚举和 encode 方法的返回类型。早期版本返回的是 ImageData后来的版本有用 PixelMap 作为返回类型的封装。我建议你在写完编码调用之前先看一眼 node_modules 里实际安装的 d.ts 类型声明文件确认 encode 的签名。这一步本身不解决什么业务问题但它能帮你省掉至少半小时的排查时间。我刚开始做的时候照着老博客的 API 写结果发现新版本里构造参数都变了那种看着报错却找不到原因的挫败感经历一次就够了。3. 核心实现从字符串到带图二维码3.1 第一步用 ZXing 生成二维码底图先写最核心的一段把一串字符链接、文本、数字都可以编码成二维码的 PixelMap。以我当时用的 ohos/zxing 版本为例代码大概是这样的import { ZXing, BarcodeFormat, EncodeHintType, ErrorCorrectionLevel } from ohos/zxing; import { image } from kit.ImageKit; function createQrPixelMap(content: string, size: number): image.PixelMap { const zxing new ZXing(); const hints new MapEncodeHintType, Object(); // 纠错等级H确保中心图片遮挡后仍可扫码 hints.set(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); // 外边距白边宽度 hints.set(EncodeHintType.MARGIN, 1); // 具体参数以实际安装版本的 API 为准 return zxing.encode(content, BarcodeFormat.QR_CODE, size, size, hints); }这里有两个参数我重点解释一下。第一个是纠错等级我固定用 H最高级因为后续要在中心放图片必须给遮挡预留尽量多的恢复空间如果你只生成纯二维码、后续也不打算叠图用 M 就够了H 级别会让二维码更密集扫码识别速度反而会略慢一点点。第二个是 size我这里传的是像素尺寸一般建议至少 512 或者 600。码太小的话后面叠加 logo 的时候 logo 占的像素点可能不够细腻导出大图再缩小时也会有发虚的风险。生成完的 PixelMap 可以直接传给 Canvas 的 drawImage也可以作为底图保存。需要注意多个二维码复用同一个底图时不要重复调用 encode把 PixelMap 缓存起来刷新内容时才重新生成这样页面滚动的性能会明显好一些。3.2 第二步加载并处理 Logo 图片二维码底图有了接着处理 logo 图。我建议把 logo 放在工程的 rawfile 目录下而不是 media 目录因为 rawfile 可以通过资源管理器直接读取原始文件流适合这种需要动态加载为 PixelMap 的场景。加载的核心代码import { common } from kit.AbilityKit; import { image } from kit.ImageKit; import { util } from kit.ArkTS; async function loadLogoPixelMap(rawFilePath: string): Promiseimage.PixelMap { const context getContext(this) as common.UIAbilityContext; const data await context.resourceManager.getRawFileContent(rawFilePath); const buffer data.buffer as ArrayBuffer; const imageSource image.createImageSource(buffer); return await imageSource.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 }); }这一步看着简单实际上很容易在小细节上翻车。createImageSource 这个接口接收的入参是 ArrayBuffer而 getRawFileContent 返回的对象在不同 SDK 版本里结构不太一样有的是Uint8Array有的直接就是 ArrayBuffer我建议在拿到 data 后先判断一下类型必要时用new Uint8Array(data).buffer包一层。还有一种情况你拿到的 logo 图源是网络 URL。这种要先下载到临时文件再用 fileIo 读文件流创建 imageSource。我的建议是 logo 图尽量内置到应用包里因为网络加载会引入延迟、缓存、失败重试一系列问题对一张 100K 的小图来说得不偿失。3.3 第三步Canvas 合成与样式微调底图和 logo 图都齐了接下来是核心的 Canvas 合成环节。在 ArkUI 里Canvas 的绘制上下文是 CanvasRenderingContext2D通过 Canvas 组件的 onReady 回调拿到绘制时机。我的页面结构大致这样Entry Component struct QrWithLogoPage { private settings: RenderingContextSettings new RenderingContextSettings(true); private ctx: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings); private qrSize: number 600; State qrPixelMap: image.PixelMap | undefined undefined; State logoPixelMap: image.PixelMap | undefined undefined; State qrContent: string ; build() { Column({ space: 20 }) { Canvas(this.ctx) .width(this.qrSize) .height(this.qrSize) .onReady(() { this.drawQrWithLogo(); }) Button(生成二维码) .onClick(() { this.generate(); }) } .width(100%) .padding(20) } async generate() { // 自定义二维码内容比如拼上活动参数 this.qrContent https://example.com/activity?id10086; this.qrPixelMap createQrPixelMap(this.qrContent, this.qrSize); this.logoPixelMap await loadLogoPixelMap(logo.png); this.drawQrWithLogo(); } drawQrWithLogo() { if (!this.qrPixelMap || !this.logoPixelMap) { return; } const ctx this.ctx; const side this.qrSize; ctx.clearRect(0, 0, side, side); // 第一步铺二维码底图 ctx.drawImage(this.qrPixelMap, 0, 0, side, side); // 第二步计算 logo 尺寸约为二维码的 22% const logoSize Math.floor(side * 0.22); const offset Math.floor((side - logoSize) / 2); // 第三步在中心绘制白色底衬避免 logo 与二维码黑点混淆 const padding 6; ctx.fillStyle #FFFFFF; ctx.fillRect(offset - padding, offset - padding, logoSize padding * 2, logoSize padding * 2); // 第四步绘制 logo ctx.drawImage(this.logoPixelMap, offset, offset, logoSize, logoSize); } }这里有几个细节我用加粗标出来。logo 占比我特意写在注释里控制在 22% 左右这个数字不是拍脑袋定的。前面讲过H 级纠错理论上能扛 30% 的污损但实际扫码场景里还会有光照不均、角度倾斜、镜头对焦等因素占掉一部分纠错预算所以 logo 实际面积我建议留 25% 以内的冗余。你可以在真机上用不同占比测试二维码扫不出来的时候先怀疑这一条。白色底衬也是关键技巧。很多 logo 本身是透明 PNG直接放在二维码黑白色块上透明区域会让黑色模块透出来视觉上变成一坨不规则形状扫码时干扰特别大。给 logo 垫一层白色圆角底框相当于在图片和二维码之间加了一道隔离带识别率能提高不少。如果你想要更精致的效果可以把白底改成圆角矩形先用 beginPath 画圆角路径再 fill视觉上更接近“卡片式”二维码。还有一点是关于 Canvas 的坐标精度。我的二维码尺寸固定为 600logoSize 算出来是 132offset 是 234这些都是整数drawImage 的时候就不会出现小数坐标导致的像素偏移和抗锯齿毛边。如果你不用固定尺寸而是用百分比宽度记得在绘制前把尺寸整型化。3.4 第四步导出与保存绘制只是第一步真正让我放弃 Stack 方案的原因在导出。CanvasRenderingContext2D 提供了 getPixelMap 方法可以把画布上指定区域的内容截取成 PixelMap这是整个导出链路的关键入口。我封装了一个保存函数import { image } from kit.ImageKit; import { fileIo as fs } from kit.CoreFileKit; import { common, Permissions, abilityAccessCtrl } from kit.AbilityKit; import { systemShare } from kit.ShareKit; async function saveCanvasToFile(ctx: CanvasRenderingContext2D, width: number, height: number, savePath: string): Promisestring { // 截取画布内容 const pixelMap ctx.getPixelMap(0, 0, width, height); // 打包成 png const packer image.createImagePacker(); const options: image.PackingOption { format: image/png, quality: 100 }; const buffer await packer.packing(pixelMap, options); // 写入沙箱文件 const file fs.openSync(savePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); fs.writeSync(file.fd, buffer); fs.closeSync(file); // 释放资源 pixelMap.release(); return savePath; }写完文件后有两种常用出口。一种是保存到相册用 photoAccessHelper 提供的接口把沙箱文件插入媒体库另一种是直接唤起系统分享面板把生成好的图片发给微信、钉钉等应用。分享这块用系统分享接口写起来最省事async function shareQrImage(filePath: string) { const context getContext(this) as common.UIAbilityContext; await systemShare.share({ title: 分享二维码, uri: file:// filePath, mimeType: image/png }, context); }这里一个高频出现的坑是分享面板唤起时报“文件不存在”或者“无法预览”。原因通常是 uri 拼错了或者沙箱路径没有转成可共享的临时只读 URI。鸿蒙对应用沙箱内文件直连分享有收紧趋势稳妥的做法是先把文件复制到 cacheDir 下再通过 ShareKit 构造共享 URI。具体接口每个 SDK 版本略有变化你只要记住思路分享的是“可被外部访问的 uri”而不是普通沙箱路径。我自己的建议是如果只是保存到相册供用户使用优先走 photoAccessHelper如果是把二维码发给别人直接用分享面板。两者都做好功能才算完整。4. 实测中的坑与排查技巧4.1 扫码失败排查表每一条都是真实踩过的带图片二维码最大的翻车现场就是扫码失败而且失败的呈现形式五花八门有的扫码枪完全不响应有的微信扫出来是乱码有的手机离近了才识别。我把自己实测中遇到的典型问题整理成了排查表现象可能原因解决方案扫码无响应logo 遮挡面积过大把 logo 尺寸降到二维码的 20%-25%扫码识别慢纠错级别设置过低生成时指定 H 级纠错只有特定角度能扫白边被裁剪或过窄增大 MARGIN 参数保留 1-2 个模块的留白识别出内容但内容错误内容字符串被截断或拼接错检查编码前内容比较字符串的 UTF-8 长度近距离能扫、远距离失败二维码分辨率太低生成尺寸提高到 600px 以上反复识别失败但图看似正常logo 透明区域暴露二维码黑点给 logo 加白色底衬或描边这张表里的问题我基本都撞过一遍。最典型的是第一次做的时候把 logo 比例调到了 33%觉得视觉上很饱满结果拿微信一扫偶尔能出来偶尔卡死最后比对下来就是遮挡面积超了。降回 22% 之后就非常稳定。4.2 绘制模糊与尺寸失真搞懂 Canvas 的两种尺寸Canvas 组件在 ArkUI 里有两种尺寸概念布局尺寸组件宽高和绘制缓冲区尺寸。如果你在组件上设置了 width 和 height 为 300但绘制时使用 600x600 的 PixelMap系统默认会做一次缩放采样。问题在于这个缩放如果发生在 drawImage 的插值阶段出来的图片边缘会有明显的锯齿和模糊感。我的做法是让 Canvas 的绘制缓冲区尺寸和布局尺寸保持一致并且都设置为目标输出尺寸。如果你需要在不同屏幕上自适应可以先用 px2vp 之类的工具换算但最终绘图坐标一律使用物理像素。导出时 getPixelMap 传的宽高参数也必须和绘制缓冲区的实际尺寸一致否则截取出来的图片就是错位的。说实话二维码这种规则几何图形对插值算法非常敏感。黑白方块一旦因为缩放出现灰边扫码头在二值化时可能把边界判断错轻则识别变慢重则直接失败。所以宁可代码里多算两步也别在这块偷懒。4.3 白边、圆角与 logo 描边影响识别率的细节二维码周围的白色留白区专业上叫 quiet zone是扫码器定位的基础条件。ZXing 的 MARGIN 参数可以控制留白大小但我一开始把它设成 0想着导出后再自己裁边结果发现有的扫码应用在边缘识别时会出现定位失败。后来我把 MARGIN 设回 1问题就消失了。从模块宽度来算二维码四周最好保留至少 4 个模块的静区MARGIN 设为 1 基本能满足这个要求。另一个细节是 logo 描边。如果 logo 本身是深色图片且没有透明背景直接放在二维码中心深色色块会与二维码的黑色模块混在一起人眼看得出区别但扫码器不一定分得清。我建议无论 logo 是什么底色都包一个 2-4px 的白色描边或者直接垫白底圆角矩形这个操作能把误识别率压到最低。要是希望更美观可以在白色底框外再加一圈浅灰色的细线形成卡片效果但灰色不要太深避免被扫码器当成二维码模块。4.4 性能与内存隐患PixelMap 不释放引发的抖动生成二维码这个操作本身不算重但如果用户在列表页频繁生成、切换PixelMap 对象不及时释放内存会稳步上涨。ArkTS 的自动回收不像 Java 那么积极特别是在 Canvas 里持有 PixelMap 引用的情况下很容易出现“页面退了内存还不降”的情况。所以我建议每次重新生成二维码时先把旧的 qrPixelMap 和 logoPixelMap 调 release()再赋新值。ImagePacker 用完后及时释放打包出来的 ArrayBuffer 是内存大户。如果单个二维码尺寸超过 1024px要考虑是否需要这么大海报印刷建议用 SVG/矢量方案而不是位图硬扩。不要在每次 onReady 里创建新的 CanvasRenderingContext2D 实例它应该跟着组件生命周期走。另外提一句Canvas 的 getPixelMap 在页面不可见时调用是会失败的。如果你在后台任务里生成二维码请保证页面处于前台可见状态或者换用离屏 Canvas 方案。这个限制在文档里写得很隐晦我是真机调试时才发现的。5. 实测效果与我的几点经验整套流程跑通之后我拿了三种内容做了实测一是普通网址二是带中文参数的长链接三是纯文本内容。三种场景下生成的带 logo 二维码都能被主流扫码应用稳定识别其中 logo 占比 22%、H 纠错、600px 出图的组合最稳基本是秒出结果。这里分享几个我自己的经验给正要动手的同行参考。第一把二维码内容生成和 UI 绘制解耦。我在项目里把 createQrPixelMap 抽成了一个纯工具函数不依赖任何 UI 上下文方便在任意页面调用也为后续批量生成留下空间。你还可以在这一层加内容长度校验、非法字符过滤避免用户输入一些特殊字符导致编码失败时页面直接崩。第二logo 尽量用简洁的图形。我之前试过把一张拍摄的产品照片缩到 22% 塞进二维码结果扫码是能扫出来但人眼根本看不清图片内容整体观感很奇怪。二维码中心图片最适合的是线条简洁、对比明显的 logo 图形摄影类图和文字过多的图在这里效果都不好。第三如果你需要在列表里大量生成这一类二维码千万不要每一条都走一遍 Canvas 绘制和 ImagePacker 打包。正确的做法是生成一批只做一次把结果缓存成文件再次使用只读文件路径。我的实际压测里同样的二维码内容重复生成 50 次时间消耗基本线性上升加了缓存之后除了第一次后面每次都是毫秒级返回。第四别忘了保留原始内容的编码记录。批量生成二维码时如果有一张出了问题你能拿原始内容重新编码排查而不是对着图片猜里面存的是什么。这个习惯在问题排查时能省不少事。这套方案做出来后我又顺手扩展了两个小功能一种是根据不同主题色生成前景色可变的二维码另一种是把生成的图片配上文字说明直接分享到社交渠道。其实核心链路都是一样的换的无非是 Canvas 里的绘制细节。对于你项目里的具体需求先确定“只展示”还是“要导出”再按对应的方案落地基本不会走弯路。