HarmonyOS技术精讲-Scan Kit:实战——商品扫码应用

在这里插入图片描述

这个API到底能做什么

HarmonyOS NEXT 开发里,扫码功能几乎每个带相机的应用都可能用到。Scan Kit(统一扫码服务)提供了两种接入方式:默认界面扫码和自定义界面扫码。对于大部分业务场景,默认界面已经足够——一个完整的扫码器UI,连相机预览、对焦、结果回调都替你包好了。你只需要调一个API,处理回调结果就行。

但实际项目里,很多人发现官方示例能跑通,一整合到自己的页面流程就出问题:扫码结果丢了、页面返回后卡死了、数据库重复插入了。这些问题都和生命周期、状态管理有关。这篇文章用一个完整的商品扫码App,把整个流程串起来:默认扫码→解析条码→获取商品信息(模拟数据)→存入关系型数据库→列表展示→详情页。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机

核心实现:从扫码到展示的完整链路

工程结构

entry/src/main/ets/
├── pages/
│   ├── Index.ets          // 主页面:扫描记录列表
│   └── DetailPage.ets     // 商品详情页
├── model/
│   └── ScanRecord.ets     // 数据模型
├── database/
│   └── ScanDatabase.ets   // 数据库操作封装
└── utils/
    └── ProductApi.ets     // 模拟商品API

1. 数据模型

每一条扫描记录包含条码值、商品名称、价格、扫描时间。

// model/ScanRecord.ets
export class ScanRecord {
  barcode: string;        // 条码
  productName: string;    // 商品名称
  price: number;          // 价格
  timestamp: number;      // 扫描时间戳

  constructor(barcode: string, productName: string, price: number, timestamp: number) {
    this.barcode = barcode;
    this.productName = productName;
    this.price = price;
    this.timestamp = timestamp;
  }
}

2. 模拟商品API

实际开发中可对接真实商品数据库,这里返回固定模拟数据,重点展示流程。

// utils/ProductApi.ets
export function getProductInfo(barcode: string): { productName: string; price: number } {
  // 模拟根据条码返回商品信息
  // 可以换成网络请求
  const productMap: Record<string, { productName: string; price: number }> = {
    '6901234567890': { productName: '矿泉水 550ml', price: 2 },
    '6921234567890': { productName: '薯片 原味', price: 5.5 },
    '6931234567890': { productName: '纸巾 3层', price: 12.9 },
  };
  return productMap[barcode] || { productName: '未知商品', price: 0 };
}

3. 数据库操作

使用关系型数据库@ohos.data.relationalStore存储记录。

// database/ScanDatabase.ets
import relationalStore from '@ohos.data.relationalStore';
import { ScanRecord } from '../model/ScanRecord';

const DB_NAME = 'scan_history.db';
const TABLE_NAME = 'scan_records';
const SQL_CREATE_TABLE = `
  CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    barcode TEXT NOT NULL,
    productName TEXT NOT NULL,
    price REAL NOT NULL,
    timestamp INTEGER NOT NULL
  )
`;

let rdbStore: relationalStore.RdbStore | null = null;

async function getRdbStore(context: Context): Promise<relationalStore.RdbStore> {
  if (rdbStore) return rdbStore;
  const config: relationalStore.StoreConfig = {
    name: DB_NAME,
    securityLevel: relationalStore.SecurityLevel.S1,
  };
  rdbStore = await relationalStore.getRdbStore(context, config);
  await rdbStore.executeSql(SQL_CREATE_TABLE);
  return rdbStore;
}

export async function insertRecord(context: Context, record: ScanRecord): Promise<void> {
  const store = await getRdbStore(context);
  const valueBucket: relationalStore.ValueBucket = {
    'barcode': record.barcode,
    'productName': record.productName,
    'price': record.price,
    'timestamp': record.timestamp,
  };
  // 避免重复扫描同一条码短时间内重复插入(可根据业务调整去重逻辑)
  // 这里简单做:查询是否已存在相同条码+时间戳相差5秒内
  const fiveSecAgo = record.timestamp - 5000;
  const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
  predicates.equalTo('barcode', record.barcode);
  predicates.greaterThan('timestamp', fiveSecAgo);
  const existing = await store.query(predicates, ['id']);
  if (existing.rowCount > 0) {
    existing.close();
    return; // 视为已存在,不重复插入
  }
  existing.close();
  await store.insert(TABLE_NAME, valueBucket);
}

export async function getAllRecords(context: Context): Promise<ScanRecord[]> {
  const store = await getRdbStore(context);
  const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
  predicates.orderByDesc('timestamp');
  const resultSet = await store.query(predicates, ['barcode', 'productName', 'price', 'timestamp']);
  const records: ScanRecord[] = [];
  while (resultSet.goToNextRow()) {
    const barcode = resultSet.getString(resultSet.getColumnIndex('barcode'));
    const productName = resultSet.getString(resultSet.getColumnIndex('productName'));
    const price = resultSet.getDouble(resultSet.getColumnIndex('price'));
    const timestamp = resultSet.getLong(resultSet.getColumnIndex('timestamp'));
    records.push(new ScanRecord(barcode, productName, price, timestamp));
  }
  resultSet.close();
  return records;
}

4. 扫码入口页面(主页面)

主页面显示历史记录列表,并提供扫码按钮。

// pages/Index.ets
import { getProductInfo } from '../utils/ProductApi';
import { ScanRecord } from '../model/ScanRecord';
import { insertRecord, getAllRecords } from '../database/ScanDatabase';
import common from '@ohos.app.ability.common';

@Entry
@Component
struct Index {
  @State records: ScanRecord[] = [];
  private context = getContext(this) as common.UIAbilityContext;

  aboutToAppear() {
    this.loadRecords();
  }

  async loadRecords() {
    const list = await getAllRecords(this.context);
    this.records = list;
  }

  // 调用 Scan Kit 默认扫码界面
  async startScan() {
    try {
      // 注意:scanBarcode 是全局函数,需要先导入:import { scanBarcode } from '@kit.ScanKit';
      const result = await scanBarcode(); // 返回 { originalValue: string; scanType: number; ... }
      const barcode = result.originalValue;
      if (!barcode || barcode.trim() === '') {
        console.warn('扫码结果为空');
        return;
      }
      // 获取商品信息
      const product = getProductInfo(barcode);
      const record = new ScanRecord(barcode, product.productName, product.price, Date.now());
      // 保存到数据库
      await insertRecord(this.context, record);
      // 刷新列表
      await this.loadRecords();
    } catch (error) {
      console.error('扫码失败: ' + JSON.stringify(error));
    }
  }

  build() {
    Column() {
      // 扫码按钮
      Button('扫描商品')
        .width('80%')
        .height(50)
        .margin({ top: 20, bottom: 20 })
        .onClick(() => {
          this.startScan();
        })
      // 历史记录列表
      List() {
        ForEach(this.records, (record: ScanRecord, index: number) => {
          ListItem() {
            Row() {
              Text(record.productName)
                .fontSize(16)
                .layoutWeight(1)
              Text(record.barcode)
                .fontSize(12)
                .fontColor(Color.Gray)
            }
            .height(50)
            .padding({ left: 16, right: 16 })
            .width('100%')
            .onClick(() => {
              // 跳转详情页
              router.pushUrl({
                url: 'pages/DetailPage',
                params: { record }
              });
            })
          }
        })
      }
      .width('100%')
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
  }
}

5. 详情页

展示单条记录的完整信息。

// pages/DetailPage.ets
import router from '@ohos.router';
import { ScanRecord } from '../model/ScanRecord';

@Entry
@Component
struct DetailPage {
  @State record: ScanRecord = router.getParams() as ScanRecord;

  build() {
    Column() {
      Text('商品详情')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 40 })
      Text(`条码:${this.record.barcode}`)
        .fontSize(16)
        .margin(8)
      Text(`名称:${this.record.productName}`)
        .fontSize(16)
        .margin(8)
      Text(`价格:¥${this.record.price.toFixed(2)}`)
        .fontSize(16)
        .margin(8)
      Text(`扫描时间:${new Date(this.record.timestamp).toLocaleString()}`)
        .fontSize(14)
        .fontColor(Color.Gray)
        .margin(8)
    }
    .width('100%')
    .padding(16)
  }
}

6. 权限声明

module.json5中添加相机权限和读取外部存储权限(部分设备需要)。

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA",
        "reason": "用于扫码"
      },
      {
        "name": "ohos.permission.READ_MEDIA",
        "reason": "用于扫码库读取图片"
      }
    ]
  }
}

常见问题与踩坑记录

这部分是实际开发中高频出现的问题,Scan Kit 文档里不会提这么细。

坑1:页面返回后扫码结果丢失

现象:扫码成功后回调里拿到originalValue,但还没存入数据库就跳转到另一个页面,结果数据没保存。

原因scanBarcode 是异步调用,返回的 Promise 在主页面关闭后可能被丢弃。默认扫码界面有自己的生命周期,如果主页面在扫码过程中被销毁,回调里的代码不会执行。

解法:不要依赖扫码界面的返回回调来做关键数据持久化,而是在扫码成功后立即写入数据库,再跳转。上面代码里startScan已经先insertRecord再刷新列表,跳转详情页时从数据库读取,这样即使页面重建也有数据。

坑2:重复插入同一条码

现象:连续扫同一件商品,列表中出现多条相同记录。

原因:Scan Kit 扫描成功一次就会回调一次,用户可能短时间内扫多次。如果数据库不做去重,就会重复插入。

解法:上面数据库代码里用了时间戳窗口去重:同一条码5秒内的记录视为重复,不再插入。更严格的去重可以加唯一索引,但注意条码本身不是唯一的(同一条码可能扫两次记录不同时间)。根据业务需求,可以保留所有记录但去重逻辑改为“同一条码5分钟内只保留最新一条”,使用REPLACE或先删除再插入。

坑3:权限弹窗后应用无响应

现象:首次打开扫码页面,相机权限弹窗出现,点击“允许”后,扫码头界面黑屏或卡住。

原因scanBarcode 内部会自动请求权限,但如果应用同时在其他地方也请求了相同权限,可能产生冲突。另外,某些系统版本上,权限请求和scanBarcode调用时序不对会导致相机资源被占用。

解法:在调用scanBarcode之前主动调用AbilityAccessCtrl.requestPermissions确保权限已授权。更保险的做法:在aboutToAppear中一次性完成权限请求。

import abilityAccessCtrl from '@ohos.abilityAccessCtrl';
import bundleManager from '@ohos.bundle.bundleManager';

async requestCameraPermission() {
  let atManager = abilityAccessCtrl.createAtManager();
  let permissions = ['ohos.permission.CAMERA'];
  let tokenId = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION).appInfo.accessTokenId;
  let result = await atManager.requestPermissionsForUser(tokenId, permissions);
  if (result.authResults[0] !== 0) {
    // 权限被拒绝,提示用户
    AlertDialog.show({ message: '需要相机权限才能扫码' });
  }
}

最佳实践

  1. 不要在build()中频繁创建对象
    ForEach循环渲染时,成员变量每变化都会重新创建子组件。ScanRecord对象如果每次都在build()new,ArkUI会频繁触发组件重建。推荐将数据存储在@State数组中,只更新数组引用。

  2. 异步回调里不要直接修改UI状态
    上面代码中,扫码成功回调里使用了await,然后修改@State变量。这没问题,因为主线程会处理UI更新。但如果在其他线程(比如数据库查询的非UI线程)里直接给@State赋值,会报错。必须通过@SendableTaskPoolcallMainThread机制回到主线程。上面的数据库操作getAllRecords适当时机在UI方法中调用,确保在主线程。

  3. 扫码成功后手动释放资源
    scanBarcode会自动关闭扫码界面,但如果你在自定义扫码场景中手动调用了startScanForResult,记得在onPageHideaboutToDisappear中调用stopScan释放相机资源,否则可能导致下次扫码黑屏。

FAQ

Q:为什么真机扫描正常,模拟器总是返回空结果?
A:模拟器没有真实相机硬件,Scan Kit的默认界面需要相机驱动。模拟器上只能通过导入图片的方式测试,但scanBarcode默认只打开相机预览,不会给模拟器返回有效图片。建议真机调试。

Q:扫码成功后,页面返回主页面,但列表没有立刻刷新?
A:检查loadRecords是否在扫码回调中被调用。如果扫码回调正在执行await insertRecord,需要确保loadRecords在插入之后执行,并且@State数组重新赋值。上面代码已经正确处理。如果仍然不刷新,检查insertRecord是否有异常导致提前返回。

Q:第一次扫码提示“无法访问相机”,但设置里已经授权了?
A:部分系统版本上,Scan Kit需要ohos.permission.CAMERAohos.permission.MICROPHONE(虽然扫码不需要麦克风,但相机库有依赖)。在module.json5中添加麦克风权限可解决。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐