41 二维码生成与扫描:Scan Kit 实战

前言

在这里插入图片描述

图:41 二维码生成与扫描:Scan Kit 实战 运行效果截图(HarmonyOS NEXT)

在"鹿鹿·笔迹心理分析"App 中,二维码是碰一碰绑定的重要备选方案。当双方无法通过 NFC 靠近时,可以通过扫一扫二维码或手动输入 ID 完成关系绑定。

本文从项目中的二维码应用场景出发,深入解析鸿蒙 Scan Kit 的扫码能力和二维码生成方案。

鸿蒙官方·Scan Kit 文档:developer.huawei.com
项目源码仓库:harmony-app GitHub

二维码生成与扫描流程示意图

图:鸿蒙二维码生成(ImageKit)与扫描(Scan Kit)的完整流程对比

扫描

成功

取消

扫码入口

scanCore.startScanForResult
拉起系统扫码页

识别结果

解析 scanResult.originalValue

返回上页

处理数据
绑定关系/跳转链接

生成

用户ID/链接

image.createQRImage
生成二维码 PixelMap

Image 组件展示

一、二维码在项目中的三种场景

1.1 场景一:碰一碰页面的二维码备选

TapSharePage 在 NFC 无法使用时的备选方案——生成二维码让对方扫码:

// TapSharePage.ets — 生成二维码按钮
Button('生成二维码')
  .type(ButtonType.Capsule)
  .backgroundColor(AppColors.BG_DEEP)
  .fontColor(AppColors.TEXT)
  .fontSize(AppFonts.BODY)
  .layoutWeight(1)
  .height(44)
  .borderRadius(AppRadius.M)
  .onClick(() => this.generateQRCode())

1.2 场景二:关系绑定二维码

BindRelationPage 提供三种邀请方式,二维码是其中之一:

private readonly methods: InviteMethod[] = [
  { key: 'tap', icon: '⌘', title: '碰一碰邀请', sub: '两台鸿蒙手机背靠背 · 自动配对', color: '#5B7FB6' },
  { key: 'qrcode', icon: '⊞', title: '扫一扫二维码', sub: '让 TA 扫描你的关系码', color: '#A8907A' },
  { key: 'id', icon: '#', title: '输入 TA 的鹿鹿 ID', sub: '8 位字母数字 · 例 LU 8847', color: '#9BAEA8' }
]

1.3 场景三:分享海报二维码

PosterTemplate 海报底部的二维码区域——扫码直达笔迹分析结果:

// PosterTemplate.ets — 二维码占位区域
Column() {
  Stack() {
    // 二维码区域(实际为占位设计)
    Column()
      .width(64).height(64)
      .backgroundColor('#FFFFFF')
      .borderRadius(6)
  }
  Text('扫码查看报告').fontSize(9).fontColor('#CCCCCC').margin({ top: 6 })
}

1.4 场景对比

场景 页面 二维码内容 扫码后的操作
分享备选 TapSharePage 笔迹分析结果链接 打开报告详情页
关系绑定 BindRelationPage 关系邀请码 显示邀请信息 → 接受绑定
海报分发 PosterTemplate 元服务直达链接 启动元服务卡片

二、Scan Kit 扫码实现

2.1 模块导入

// 方式一:@kit 统一导入(推荐)
import { scanCore } from '@kit.ScanKit'

// 方式二:@ohos 传统导入
// import { scanCore } from '@ohos.multimodalInput.scanCore'

2.2 扫码流程概览

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│ 初始化扫码器  │ ──→ │ 开启扫码预览   │ ──→ │ 接收扫码结果  │
│  ScanCore    │     │  Camera预览   │     │  OnResult   │
└─────────────┘     └──────────────┘     └─────────────┘
                          │                      │
                          ↓                      ↓
                    ┌──────────────┐     ┌─────────────┐
                    │ 画面实时追踪   │     │ 停止扫码器   │
                    │  取景框 UI    │     │  释放资源    │
                    └──────────────┘     └─────────────┘

2.3 初始化解码器

import { scanCore } from '@kit.ScanKit'
import { common } from '@kit.AbilityKit'

class QrScanner {
  private scanEngine: scanCore.ScanEngine | null = null

  /**
   * 初始化解码器
   */
  async initScanner(context: common.UIAbilityContext): Promise<void> {
    try {
      // 创建解码器配置
      const config: scanCore.ScanConfig = {
        scanTypes: [scanCore.ScanType.QR_CODE],  // 仅识别二维码
        scanMode: scanCore.ScanMode.CAMERA,       // 相机模式
        isAutoZoomEnabled: true,                  // 自动变焦(远距离自动拉近)
        isMultiFeedbackEnabled: false             // 单次只返回一个结果
      }

      // 创建解码引擎实例
      this.scanEngine = scanCore.createScanEngine(context, config)
      hilog.info(0x0000, 'QrScanner', '扫码引擎初始化成功')

      // 注册解码结果回调
      this.scanEngine.on('scanResult', (result: scanCore.ScanResult) => {
        this.onScanResult(result)
      })
    } catch (error) {
      hilog.error(0x0000, 'QrScanner', '扫码引擎初始化失败: %{public}s', JSON.stringify(error))
    }
  }

  /**
   * 扫码结果回调
   */
  private onScanResult(result: scanCore.ScanResult): void {
    hilog.info(0x0000, 'QrScanner', '扫码结果: %{public}s', JSON.stringify(result))
    // result.originalValue — 原始解码内容
    // result.scanType — 码类型
    // result.rect — 码在画面中的位置
  }
}

2.4 ScanConfig 配置详解

配置项 类型 默认值 说明
scanTypes ScanType[] [QR_CODE] 识别的码类型(QR/Bar/PDF417等)
scanMode ScanMode CAMERA 扫码模式(相机/图片)
isAutoZoomEnabled boolean true 自动变焦适配远近
isMultiFeedbackEnabled boolean false 是否返回多个码结果
enableRecognizability boolean false 返回码的识别置信度

支持的码类型:

import { scanCore } from '@kit.ScanKit'

// 常用码类型
enum ScanType {
  QR_CODE      = 0,   // 二维码
  AZTEC        = 1,   // Aztec码
  BAR_CODE     = 2,   // 条形码(EAN-13/UPC-A)
  PDF_417      = 3,   // PDF417
  DATA_MATRIX  = 4,   // Data Matrix
  CODE_128     = 5,   // Code 128
}

2.5 相机预览集成

import { scanCore } from '@kit.ScanKit'

@Entry
@Component
struct ScanPage {
  private scanEngine: scanCore.ScanEngine | null = null

  aboutToAppear() {
    this.initScanEngine()
  }

  aboutToDisappear() {
    this.releaseScanEngine()
  }

  async initScanEngine(): Promise<void> {
    try {
      const config: scanCore.ScanConfig = {
        scanTypes: [scanCore.ScanType.QR_CODE],
        scanMode: scanCore.ScanMode.CAMERA
      }
      this.scanEngine = scanCore.createScanEngine(getContext(this), config)
      this.scanEngine.on('scanResult', (result) => this.handleScanResult(result))
    } catch (e) {
      hilog.error(0x0000, 'ScanPage', '初始化扫码失败: %{public}s', JSON.stringify(e))
    }
  }

  private handleScanResult(result: scanCore.ScanResult): void {
    // 停止扫码
    this.scanEngine?.stop()

    // 处理结果
    const content = result.originalValue
    hilog.info(0x0000, 'ScanPage', '扫码内容: %{public}s', content)

    // 根据内容路由
    if (content.startsWith('lulu://')) {
      this.handleLuluScan(content)
    } else if (content.startsWith('http')) {
      // 普通链接
      this.getUIContext().getPromptAction().showToast({
        message: '已识别链接: ' + content,
        duration: 2000
      })
    }
  }

  private handleLuluScan(content: string): void {
    // 解析 lulu:// 协议
    // lulu://bind?code=LU8847
    // lulu://share?reportId=42
    const url = new URL(content)
    if (url.host === 'bind') {
      const code = url.searchParams.get('code')
      // 跳转到绑定页
      this.getUIContext().getRouter().pushUrl({
        url: 'pages/BindRelationPage',
        params: { inviteCode: code }
      })
    } else if (url.host === 'share') {
      const reportId = parseInt(url.searchParams.get('reportId') ?? '0')
      // 跳转到报告详情
      this.getUIContext().getRouter().pushUrl({
        url: 'pages/ReportDetailPage',
        params: { reportId }
      })
    }
  }

  releaseScanEngine(): void {
    this.scanEngine?.stop()
    this.scanEngine?.off('scanResult')
    this.scanEngine?.release()
    this.scanEngine = null
  }

  build() {
    // QrCodeScanner — 系统提供的扫码预览组件
    Column() {
      // 取景框区域由系统扫码组件提供
      // 自定义覆盖 UI
      Column() {
        Text('将二维码放入框内')
          .fontSize(14).fontColor(Color.White)
      }
      .width(240).height(240)
      .border({ width: 2, color: Color.White })
      .position({ x: '50%', y: '50%' })
    }
    .width('100%').height('100%')
    .backgroundColor(Color.Black)
  }
}

2.6 图片扫码(从相册选择二维码)

import { scanCore } from '@kit.ScanKit'
import { image } from '@kit.ImageKit'
import { photoAccessHelper } from '@kit.MediaLibraryKit'

async function scanFromAlbum(): Promise<string | null> {
  try {
    // 1. 选择图片
    const photoPicker = new photoAccessHelper.PhotoViewPicker()
    const result = await photoPicker.select({ maxSelectNumber: 1 })
    if (!result.photoUris || result.photoUris.length === 0) return null

    const uri = result.photoUris[0]

    // 2. 创建图片源
    const source = image.createImageSource(uri)
    const pixelMap = await source.createPixelMap({
      desiredPixelFormat: image.ImagePixelFormat.RGBA_8888
    })

    // 3. 图片扫码
    const config: scanCore.ScanConfig = {
      scanTypes: [scanCore.ScanType.QR_CODE],
      scanMode: scanCore.ScanMode.IMAGE       // ← 图片模式
    }
    const scanEngine = scanCore.createScanEngine(getContext(this), config)

    // 4. 解码图片
    // 注意:实际 API 可能使用不同的解码方式
    // 此处为示意逻辑
    scanEngine.on('scanResult', (scanResult) => {
      hilog.info(0x0000, 'ScanFromAlbum', '图片扫码结果: %{public}s', scanResult.originalValue)
    })

    // 5. 释放资源
    source.release()

    return null
  } catch (error) {
    hilog.error(0x0000, 'ScanFromAlbum', '图片扫码失败: %{public}s', JSON.stringify(error))
    return null
  }
}

三、二维码生成方案

3.1 方案对比

方案 实现方式 优点 缺点
Canvas 绘制 使用 Canvas 2D API 绘制码图案 可控性强,无需额外依赖 实现复杂,需解码库配合
Image Kit 编码 image.createQRImage() 官方 API,简单可靠 功能有限
第三方库 嵌入 qrcode 等 JS 库 功能丰富 需要 ohpm 包管理
服务端生成 后端 API 返回二维码图片 无客户端负担 需要联网

3.2 使用 Image Kit 生成二维码

鸿蒙 Image Kit 提供了 createQRImage 方法,可以快速生成二维码:

import { image } from '@kit.ImageKit'

/**
 * 生成二维码 PixelMap
 */
async function generateQRCode(
  content: string,
  size: number = 200
): Promise<image.PixelMap | null> {
  try {
    const qrImage = image.createQRImage({
      content,                      // 二维码内容
      size,                         // 图片尺寸(px)
      margin: 10,                   // 白边宽度
      foreground: '#000000',        // 前景色
      background: '#FFFFFF'         // 背景色
    })

    // 转为 PixelMap 用于显示
    const pixelMap = await qrImage.getPixelMap()
    return pixelMap
  } catch (error) {
    hilog.error(0x0000, 'QRGenerate', '生成二维码失败: %{public}s', JSON.stringify(error))
    return null
  }
}

3.3 保存二维码到文件

import { image } from '@kit.ImageKit'
import { fileIo as fs } from '@kit.CoreFileKit'

async function saveQRToFile(content: string, filePath: string): Promise<void> {
  const pixelMap = await generateQRCode(content, 400)
  if (!pixelMap) return

  const packer = image.createImagePacker()
  const packedData = await packer.packing(pixelMap, {
    format: 'image/png',
    quality: 100
  })

  const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE)
  const buf = packedData.data.buffer
  file.writeSync(buf)
  fs.closeSync(file)

  packer.release()
  hilog.info(0x0000, 'QRGenerate', '二维码已保存: %{public}s', filePath)
}

3.4 项目中的二维码生成流程

// TapSharePage.ets — 二维码生成完整流程
private async generateQRCode(): Promise<void> {
  this.isSharing = true
  hilog.info(0x0000, 'TapSharePage', '生成二维码')

  try {
    // 1. 构造二维码内容
    const shareContent = this.buildShareContent()

    // 2. 生成二维码 PixelMap
    const qrPixelMap = await generateQRCode(shareContent, 300)

    if (qrPixelMap) {
      // 3. 展示二维码弹窗或跳转到二维码页面
      AppStorage.setOrCreate<image.PixelMap>('qr_pixel_map', qrPixelMap)

      // 4. 提示用户
      this.getUIContext().getPromptAction().showToast({
        message: '二维码已生成,请对方扫码',
        duration: 2000
      })
    }
  } catch (error) {
    hilog.error(0x0000, 'TapSharePage', '二维码生成失败: %{public}s', JSON.stringify(error))
    this.getUIContext().getPromptAction().showToast({
      message: '生成失败,请重试',
      duration: 1500
    })
  }
}

/**
 * 构建分享协议内容
 */
private buildShareContent(): string {
  const userId = AppStorage.get<number>('user_id') ?? 1
  const userName = AppStorage.get<string>('user_name') ?? ''

  // 使用自定义协议
  // 分享场景:lulu://share?userId=1&name=小李&ts=1749876543
  return `lulu://share?userId=${userId}&name=${encodeURIComponent(userName)}&ts=${Date.now()}`
}

四、二维码与 NFC 的互补设计

4.1 多通道策略

在实际场景中,NFC 和二维码各有优劣,项目采用多通道备选策略

/**
 * NFC + 二维码 + ID 输入的多通道分享策略
 *
 * 优先级:
 *   ① NFC 碰一碰(最快捷,无需视觉交互)
 *   ② 扫二维码(视觉对准,距离远)
 *   ③ 输入鹿鹿 ID(完全远程,手动输入)
 */

4.2 使用场景

条件 推荐方式 原因
两台鸿蒙手机靠近 NFC 碰一碰 最快,只需背靠背
一台扫码/线下海报 扫二维码 摄像头对准,远程分享
远程/不在身边 输入鹿鹿 ID 仅需文字,可截图发送
NFC 不可用时 二维码备选 自动降级

4.3 统一数据协议

/**
 * 统一分享协议格式
 *
 * NFC NDEF 和二维码内容使用相同的协议格式,
 * 确保接收端以相同方式解析:
 *
 * NFC NDEF:
 *   记录类型: lulu.com:share
 *   载荷: lulu://share?userId=1&ts=1749876543
 *
 * 二维码:
 *   内容: lulu://share?userId=1&ts=1749876543
 *
 * 鹿鹿 ID:
 *   手动输入: LU8847 → 后端查 userId
 */

五、权限与配置

5.1 module.json5 权限

{
  "requestPermissions": [
    {
      "name": "ohos.permission.CAMERA",
      "reason": "$string:camera_permission_reason",
      "usedScene": {
        "abilities": ["EntryAbility"],
        "when": "inuse"
      }
    }
  ]
}

Scan Kit 的扫码功能依赖相机权限,但解码器本身不需要额外权限。

5.2 oh-package.json5 依赖

Scan Kit 是系统内置 Kit,无需额外 ohpm 依赖,直接 @kit.ScanKit 导入即可。

六、UI 交互建议

6.1 取景框设计

@Builder
ScanViewfinder() {
  Stack() {
    // 半透明遮罩
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.5)')

    // 透明取景框
    Column()
      .width(240).height(240)
      .border({ width: 2, color: Color.White })
      .borderRadius(12)
      .clip(new Rect({ width: 240, height: 240 }))

    // 四角引导符
    // 左上角、右上角、左下角、右下角
    CornerGuides()
  }
}

6.2 扫码成功反馈

// 扫码成功震动反馈 + 提示音
function notifyScanSuccess(): void {
  // 1. 视觉反馈 — 取景框闪烁
  // 2. 振动反馈(需要 @ohos.multimodalInput.vibrator)
  // 3. 提示音(系统默认扫码音效)
}

七、二维码数据安全

7.1 防篡改

/**
 * 二维码内容安全策略
 *
 * 1. 不直接在二维码中包含用户隐私信息
 * 2. 只包含引用 ID 和时间戳
 * 3. 敏感数据通过 DB 查询获取
 *
 * 不安全做法:
 *   lulu://share?name=真实姓名&phone=138xxxx&report=完整报告内容
 *
 * 安全做法:
 *   lulu://share?token=encrypted_token&expiry=1749876543
 */

7.2 过期机制

function isQRCodeExpired(qrContent: string): boolean {
  try {
    const url = new URL(qrContent)
    const expiry = parseInt(url.searchParams.get('expiry') ?? '0')
    if (expiry === 0) return false  // 无过期时间
    return Date.now() > expiry
  } catch {
    return false
  }
}

八、注意事项与常见问题

8.1 开发注意事项

在实际开发过程中,需特别注意以下几点:

  • API 兼容性:部分接口仅在特定 HarmonyOS NEXT 版本中可用,需做版本条件判断
  • 权限模型:采用静态声明(module.json5)+ 动态申请(requestPermissionsFromUser)的两阶段授权
  • 生命周期:合理使用 aboutToAppear()aboutToDisappear() 管理资源初始化与释放
  • 状态同步:跨页面数据通过 AppStorage 共享,组件内状态使用 @State / @Prop / @Link 装饰器

8.2 常见错误与解决方案

常见问题快速排查表:

问题类型 排查方向 参考方法
应用崩溃 查看 hilog 错误日志 hilog.error(TAG, "...", e.message)
状态丢失 检查 AppStorage 键名拼写 统一使用常量管理键名
动画不流畅 避免在 animateTo 回调中执行 I/O 动画与数据操作分离

总结

本文从项目中的二维码应用场景出发,完整解析了鸿蒙二维码生成与扫描技术:

  1. 三种场景:分享备选二维码、关系绑定二维码、海报分发二维码
  2. Scan Kit 扫码ScanConfig 配置 → 相机/图片扫码 → scanResult 回调处理
  3. 二维码生成image.createQRImage() 官方 API 快速生成
  4. 多通道策略:NFC + 二维码 + ID 输入三级备选方案
  5. 安全设计:不包含隐私信息、引用 ID 加时间戳、过期机制

下一篇文章将介绍 图片分享——通过 pasteboard 和 Share Kit 实现多种渠道分享。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


参考资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐