HarmonyOS技术精讲-Scan Kit:实战——商品扫码应用
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: '需要相机权限才能扫码' });
}
}
最佳实践
-
不要在
build()中频繁创建对象ForEach循环渲染时,成员变量每变化都会重新创建子组件。ScanRecord对象如果每次都在build()里new,ArkUI会频繁触发组件重建。推荐将数据存储在@State数组中,只更新数组引用。 -
异步回调里不要直接修改UI状态
上面代码中,扫码成功回调里使用了await,然后修改@State变量。这没问题,因为主线程会处理UI更新。但如果在其他线程(比如数据库查询的非UI线程)里直接给@State赋值,会报错。必须通过@Sendable或TaskPool的callMainThread机制回到主线程。上面的数据库操作getAllRecords适当时机在UI方法中调用,确保在主线程。 -
扫码成功后手动释放资源
scanBarcode会自动关闭扫码界面,但如果你在自定义扫码场景中手动调用了startScanForResult,记得在onPageHide或aboutToDisappear中调用stopScan释放相机资源,否则可能导致下次扫码黑屏。
FAQ
Q:为什么真机扫描正常,模拟器总是返回空结果?
A:模拟器没有真实相机硬件,Scan Kit的默认界面需要相机驱动。模拟器上只能通过导入图片的方式测试,但scanBarcode默认只打开相机预览,不会给模拟器返回有效图片。建议真机调试。
Q:扫码成功后,页面返回主页面,但列表没有立刻刷新?
A:检查loadRecords是否在扫码回调中被调用。如果扫码回调正在执行await insertRecord,需要确保loadRecords在插入之后执行,并且@State数组重新赋值。上面代码已经正确处理。如果仍然不刷新,检查insertRecord是否有异常导致提前返回。
Q:第一次扫码提示“无法访问相机”,但设置里已经授权了?
A:部分系统版本上,Scan Kit需要ohos.permission.CAMERA和ohos.permission.MICROPHONE(虽然扫码不需要麦克风,但相机库有依赖)。在module.json5中添加麦克风权限可解决。
更多推荐

所有评论(0)