校园嗨购 · 某购物节特别版:HarmonyOS 7「拍照搜同款 + 扫码比价」端侧识图实战
校园嗨购 · 某购物节特别版:HarmonyOS 7「拍照搜同款 + 扫码比价」端侧识图实战

官方文档对齐基准(2026-10-26 逐条核对 developer.huawei.com,函数名 / 字段名 / 枚举值 / 错误码全部来自官网当前版本)
能力 模块(真实导入路径) 官方文档 更新时间 主体分割(抠出商品本体) import { subjectSegmentation } from '@kit.CoreVisionKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-subjectsegmentation-api2026-08-29 通过文本搜索图片(商品图库检索) import { textSearchImage } from '@kit.CoreVisionKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-text-search-image-api2026-08-29 默认界面扫码 import { scanBarcode, scanCore } from '@kit.ScanKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/scan-scanbarcode-api2026-08-29 图像识码(相册图里找码) import { detectBarcode } from '@kit.ScanKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/scan-imagedecode— 码图生成(比价结果分享码) import { generateBarcode } from '@kit.ScanKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/scan-generatebarcode2026-07-28 扫码公共数据类型 / 枚举 import { scanCore } from '@kit.ScanKit';developer.huawei.com/consumer/cn/doc/harmonyos-references/scan-scancore2026-08-29 产品版本:校园嗨购 v2.8.0 · 某购物节特别版(2026-11)
技术基线:HarmonyOS 7 / API 26 · Stage 模型 · ArkTS
核心关键字:subjectSegmentation.doSegmentation·SegmentationResult.fullSubject·textSearchImage.search·scanCore.ScanType.ONE_D_CODE·scanBarcode.startScanForResult·detectBarcode.decode·generateBarcode.createBarcode
本文配图:mockups/14_visual_compare_cover.html(封面)·mockups/15_visual_compare_arch.html(全链路架构图)
一、比价这件事,为什么「端侧」能省掉一半流量
「校园嗨购」是面向高校学生的校园电商 App(与《高校周边通》共用一套账号体系)。某购物节期间最典型的三个动作是:看到室友穿的衣服想找同款、宿舍楼下货架上看到同款想比价、超市价签上看到条码想查历史最低价。传统做法全部要「拍下来 → 回去 → 手动打字搜」:
| 用户痛点 | 传统 H5 / 小程序做法 | 数据表现 |
|---|---|---|
| ① 想说说不清:看到室友的卫衣很好看,但不知道叫什么、品牌是哪个 | 只能靠手动描述关键词,搜索结果命中率极低 | 学生用户「描述式搜索」无结果率 63% |
| ② 拍了也白拍:拍下商品照片,小程序不支持"用图找图" | 只能人肉识别品类后重新打字 | 拍照入口点击 → 最终下单转化仅 4.1% |
| ③ 价签要手输:超市货架上的 EAN-13 条码要一位一位敲进搜索框 | 手工输入 13 位数字,错误率极高 | 条码手输错误率 17%,平均耗时 26 秒 |
这三件事在 HarmonyOS 7 上都有端侧原生解法:
- 说不清 → Core Vision Kit 主体分割(
subjectSegmentation):拍一张照,系统把商品从背景里「抠」出来,用户第一眼就能确认「是不是它」,再也不用描述。 - 拍了白拍 → Core Vision Kit 文搜图(
textSearchImage):把商品图库预先建成本地索引,用一句话(或由抠图结果确认后的品类词)在端侧检索同款,零云端流量、零图片外传。 - 价签手输 → Scan Kit 默认界面扫码(
scanBarcode):控制中心一划、对准条码,「嘀」一声拿到 EAN-13,无需申请相机权限。 - 照片里的码 → Scan Kit 图像识码(
detectBarcode):连拍照都省了,直接从相册照片里把条码读出来。
| 指标 | 接入前 | 接入后 | 变化 |
|---|---|---|---|
| 拍照入口 → 商品详情页到达率 | 4.1% | 31.6% | ↑ 27.5pp(抠图确认把"说不清"变成"点一下") |
| 商品图库检索平均耗时(端侧) | — | 86ms | 无网络往返 |
| 条码录入平均耗时 | 26s | 1.4s | ↓ 94.6% |
| 条码录入错误率 | 17% | 0.2% | ↓ 98.8% |
| 比价页次日回访率 | 12% | 38% | 扫码直达 + 历史低价卡片 |
一句话总结:比价的核心不是「数据多」,而是 「把用户想表达的东西,用最少的手势表达出来」。
二、官方 API 完整速查表(端侧识图 3 大模块)
2.1 Core Vision Kit — 主体分割 subjectSegmentation
真实导入模块:
import { subjectSegmentation } from '@kit.CoreVisionKit';
接口清单(官方真实签名):
| 接口 | 签名 | 说明 |
|---|---|---|
subjectSegmentation.init() | Promise<boolean> | 初始化,true 为成功 |
subjectSegmentation.release() | Promise<void> | 释放,与 init 成对调用 |
subjectSegmentation.doSegmentation() | (visionInfo: VisionInfo, config?: SegmentationConfig): Promise<SegmentationResult> | 执行主体分割 |
入参 / 出参字段真值:
interface VisionInfo {
pixelMap: image.PixelMap; // ⚠️ 官方约束:仅支持 RGBA_8888
}
interface SegmentationConfig {
maxCount?: number; // [1,20],默认 6,按面积占比降序取前 N 个
enableSubjectDetails?: boolean; // 默认 false;true 才返回 subjectDetails
enableSubjectForegroundImage?: boolean; // 默认 false;true 才输出前景图
}
interface Rectangle { left: number; top: number; width: number; height: number; } // 单位 px
interface SubjectResult {
foregroundImage: image.PixelMap; // 主体前景图
mattingList: Int32Array; // 与原图同尺寸的一维掩码:0=背景,255=主体,中间值为概率
subjectRectangle: Rectangle; // 主体内切框
}
interface SegmentationResult {
subjectCount: number; // 主体数量
fullSubject: SubjectResult; // 默认输出的「合并主体」
subjectDetails?: Array<SubjectResult>; // enableSubjectDetails = true 时返回
}
官方错误码:
| 错误码 | 含义 |
|---|---|
200 | 运行超时 |
401 | 参数错误(常见原因:PixelMap 不是 RGBA_8888) |
1011000001 | 运行失败 |
1011000002 | 服务异常 |
1011000003 | 模型运行失败 |
1011000004 | 模型运行超时 |
系统能力与约束:
SystemCapability.AI.Vision.SubjectSegmentation,仅 Stage 模型可用。- 支持设备:Phone 5.0.0(12)+ / Tablet 5.0.0(12)+ / PC·2in1 5.0.1(13)+;模拟器不支持。
- 主体面积需 ≥ 原图面积的 千分之五;输入图宽高需满足 20px < 宽/高 < 9000px,宽高比建议在 3:1 以内。
2.2 Core Vision Kit — 文搜图 textSearchImage
真实导入模块:
import { textSearchImage } from '@kit.CoreVisionKit';
| 接口 | 签名 | 说明 |
|---|---|---|
textSearchImage.init() | Promise<boolean> | 初始化 |
textSearchImage.insertImage() | (imagePath: string, scope: string): Promise<boolean> | 把单张图片特征插入 scope |
textSearchImage.search() | (query: string, scope: string, topKey?: number): Promise<Array<ImageObject>> | 在 scope 内检索 |
textSearchImage.deleteImage() | (imagePath: string, scope: string): Promise<boolean> | 删除单张图片特征 |
textSearchImage.clearData() | Promise<boolean> | 清空所有 scope |
textSearchImage.release() | Promise<boolean> | 释放 |
interface ImageObject {
imagePath: string; // 图片沙箱路径
scope: string; // 图片作用域
similarity: number; // 相似度 [-1, 1],越大越相似
}
官方约束(写代码前必须知道):
query长度[1, 100],且官方明确 不支持纯数字和纯字母(这一点也直接说明它是「文搜图」而不是「以图搜图」)。topKey默认 100,取值范围[0, 100]。- 起始版本 26.0.0(HarmonyOS 7);
SystemCapability.AI.Vision.VisionBase;仅 Stage 模型。 - 错误码:
1013100001图像不可用 ·1013100002服务异常 ·1013100003能力已更新(需先clearData) ·1018700001业务异常。
⚠️ 关键勘误:Core Vision Kit 端侧没有「以图搜图」API
网上大量「鸿蒙以图搜图」教程把
textSearchImage当成「用图找图」,这是错的。
- 存在的:
textSearchImage—— 文字 → 图片(search的入参是query: string)。- 不存在的:
imageSearch/ 「以图搜图」/「视觉搜索」这类开发者可调用的端侧 API。Core Vision Kit 的 ArkTS 模块清单只有:textRecognition、faceDetector、faceComparator、subjectSegmentation、visionBase、objectDetection、skeletonDetection、imageSuperResolution、textSearchImage。- 系统里确实有「识图搜索」交互,但它属于 Vision Kit 的 AI 识图控件(
visionImageAnalyzer),是控件级内置交互,不是可编程调用的搜索接口。- 另外注意:卡证识别(CardRecognition)属于 Vision Kit,不在 Core Vision Kit。
那么「拍照搜同款」到底怎么做?本文给出的、全部由官方端侧 API 组成的可行链路是:
主体分割(抠出商品本体,解决"说不清") → 用户点选确认 → 用一句话/品类词调textSearchImage.search()在自建商品图库索引中检索同款 → 按similarity排序。
真正的「图 → 图」向量检索(端侧特征向量化 + 向量库比对)官方未开放相关 API,必须自建或走云端,本文不假装它存在。
2.3 Scan Kit — 扫码 / 识码 / 生成码
真实导入模块:
import { scanBarcode, scanCore, detectBarcode, generateBarcode } from '@kit.ScanKit';
Scan Kit 的 ArkTS 模块一共 5 个:customScan(自定义界面扫码)、scanBarcode(默认界面扫码)、detectBarcode(图像识码)、generateBarcode(码图生成)、scanCore(公共数据类型)。
① 默认界面扫码 scanBarcode
// Promise 版
function startScanForResult(context: common.Context, options?: ScanOptions): Promise<ScanResult>;
// callback 版
function startScanForResult(context: common.Context, callback: AsyncCallback<ScanResult>): void;
function startScanForResult(context: common.Context, options: ScanOptions,
callback: AsyncCallback<ScanResult>): void;
- 第一个参数的真实类型是
common.Context(不是common.UIAbilityContext)。 startScan/startScanForResult中的startScan系列已废弃(自 4.1.0(11) 起),新代码一律用startScanForResult。- 默认界面扫码无需相机权限:官方明确「默认界面扫码对系统相机权限做了预授权,无需开发者再次申请」。
- 从 6.0.0(20) 起支持悬浮屏、分屏场景。
interface ScanOptions {
scanTypes?: Array<scanCore.ScanType>; // 默认 ALL(全部码类型)
enableMultiMode?: boolean; // 默认 false(false=单码,true=多码)
enableAlbum?: boolean; // 默认 true(相册识码仅支持单码)
}
interface ScanResult {
scanType: scanCore.ScanType; // 码类型
originalValue: string; // 码值
scanCodeRect?: ScanCodeRect; // 位置(默认界面扫码不返回)
cornerPoints?: Array<Point>; // 仅 decodeImage 返回(5.0.0(12)+)
isGS1?: boolean; // 6.0.2(22)+
source?: scanCore.ScanSource; // 6.0.2(22)+,仅默认界面扫码返回(CAMERA / PHOTO)
}
⚠️ 官方
ScanOptions只有这三个字段。网上流传的enableAutoZoom/enableLight/enableVibrate/ 深色模式配置都不是官方字段,写进去编译不过或行为未定义。
② scanCore 的枚举真值(枚举在 scanCore,不在 scanBarcode)
enum ScanType {
FORMAT_UNKNOWN = 0, AZTEC_CODE = 1, CODABAR_CODE = 2, CODE39_CODE = 3, CODE93_CODE = 4,
CODE128_CODE = 5, DATAMATRIX_CODE = 6, EAN8_CODE = 7, EAN13_CODE = 8, ITF14_CODE = 9,
PDF417_CODE = 10, QR_CODE = 11, UPC_A_CODE = 12, UPC_E_CODE = 13, MULTIFUNCTIONAL_CODE = 14,
ONE_D_CODE = 100, // 条形码集合
TWO_D_CODE = 101, // 二维码集合
ALL = 1001 // 全部
}
enum ScanErrorCode { INTERNAL_ERROR = 1000500001, SCAN_SERVICE_CANCELED = 1000500002 }
enum ScanSource { CAMERA = 0, PHOTO = 1 } // 6.0.2(22)+
scanCore 在 26.0.0 新增了两个能力查询接口,可以用于做「能力探测」:
function isDefaultScanSupported(): boolean; // 是否支持默认界面扫码
function isCustomScanSupported(): boolean; // 是否支持自定义界面扫码
⚠️ 网上常见的
scanCore.isScanAvailable、scanBarcode.ScanType.SCAN_TYPE_QR_CODE等在官方文档中不存在,正确写法是scanCore.ScanType.QR_CODE。
③ 图像识码 detectBarcode
function decode(inputImage: InputImage, options?: scanBarcode.ScanOptions):
Promise<Array<scanBarcode.ScanResult>>;
function decodeImage(image: ByteImage, options?: scanBarcode.ScanOptions): Promise<DetectResult>; // 5.0.0(12)+
interface InputImage { uri: string; }
interface ByteImage { byteBuffer: ArrayBuffer; width: number; height: number; format: ImageFormat; }
enum ImageFormat { NV21 = 0 }
interface DetectResult { scanResults: Array<scanBarcode.ScanResult>; zoomValue: number; }
⚠️
decode的入参类型是scanBarcode.ScanOptions,返回Array<scanBarcode.ScanResult>。官方没有DecodeOptions/DecodeResult这两个类型;decodeImage不支持并行调用。
④ 码图生成 generateBarcode
function createBarcode(content: string, options: CreateOptions): Promise<image.PixelMap>;
function createBarcode(content: ArrayBuffer, options: CreateOptions): Promise<image.PixelMap>; // 5.0.0(12)+,仅 QR
interface CreateOptions {
scanType: scanCore.ScanType; // 必填
width: number; // 必填,[200, 4096] px
height: number; // 必填,[200, 4096] px
margin?: number; // 可选,[1, 10],默认 1
level?: ErrorCorrectionLevel; // 可选,默认 LEVEL_H,仅 QR 有效
backgroundColor?: number; // 可选,HEX,默认 0xFFFFFF
pixelMapColor?: number; // 可选,HEX,默认 0x000000
}
enum ErrorCorrectionLevel { LEVEL_L = 0, LEVEL_M = 1, LEVEL_Q = 2, LEVEL_H = 3 }
不支持生成 MULTIFUNCTIONAL_CODE;SystemCapability.Multimedia.Scan.GenerateBarcode。
Scan Kit 官方错误码:
| 错误码 | 含义 |
|---|---|
401 | 参数错误 |
1000500001 | 内部错误 |
1000500002 | 用户取消扫码(必须静默处理,不要弹错) |
三、完整实现:拍照搜同款 + 扫码比价
3.1 module.json5 配置
{
"module": {
"name": "entry",
"type": "entry",
// 默认界面扫码无需相机权限;只有自定义界面扫码(customScan)才需要 CAMERA
"requestPermissions": [],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"exported": true,
// 「扫码直达」:把线下货架码 / 商品页二维码一步直达比价页
"skills": [
{ "actions": ["ohos.want.action.viewData"] }
]
}
]
}
}
说明:
subjectSegmentation与textSearchImage官方均未要求声明任何权限;默认界面扫码也由系统预授权相机。所以本功能的权限配置是空的——这本身就是端侧方案的优势。
3.2 SubjectCutoutService.ets —— 把商品从背景里抠出来
/**
* @file SubjectCutoutService.ets
* @brief 主体分割:拍照 / 选图后抠出商品本体
* API 对齐 @kit.CoreVisionKit · subjectSegmentation(HarmonyOS 7 / API 26)
*/
import { subjectSegmentation } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'SubjectCutout';
/** 抠图结果(对外只暴露业务需要的字段) */
export interface CutoutResult {
fullForeground: image.PixelMap; // 合并主体前景图
subjectCount: number; // 主体数量
mattingList: Int32Array; // 掩码:0=背景,255=主体
subjectRectangle: subjectSegmentation.Rectangle; // 主体内切框(px)
firstSubjectForeground?: image.PixelMap; // 第一个主体的前景图
}
export class SubjectCutoutService {
/** 分割器为进程级单例,官方要求 init / release 成对调用 */
private static inited: boolean = false;
static async init(): Promise<boolean> {
if (SubjectCutoutService.inited) {
return true;
}
const ok: boolean = await subjectSegmentation.init();
SubjectCutoutService.inited = ok;
hilog.info(DOMAIN, TAG, `subjectSegmentation.init = ${ok}`);
return ok;
}
static async release(): Promise<void> {
if (!SubjectCutoutService.inited) {
return;
}
await subjectSegmentation.release();
SubjectCutoutService.inited = false;
hilog.info(DOMAIN, TAG, 'subjectSegmentation released');
}
/**
* 抠出商品主体
* @param uri 相册选图 / 拍照返回的 uri
*/
static async cutout(uri: string): Promise<CutoutResult | null> {
if (!await SubjectCutoutService.init()) {
return null;
}
// ① uri → PixelMap。官方约束:必须是 RGBA_8888,否则 doSegmentation 报 401
const file = await fileIo.open(uri, fileIo.OpenMode.READ_ONLY);
const source: image.ImageSource = image.createImageSource(file.fd);
const pixelMap: image.PixelMap = await source.createPixelMap({
desiredPixelFormat: image.PixelMapFormat.RGBA_8888
});
await fileIo.close(file);
// ② 官方入参 VisionInfo
const visionInfo: subjectSegmentation.VisionInfo = { pixelMap: pixelMap };
// ③ 官方配置:maxCount 默认 6、范围 [1,20];不打开 enableSubjectDetails 就没有分主体结果
const config: subjectSegmentation.SegmentationConfig = {
maxCount: 3,
enableSubjectDetails: true,
enableSubjectForegroundImage: true
};
try {
const result: subjectSegmentation.SegmentationResult =
await subjectSegmentation.doSegmentation(visionInfo, config);
const full: subjectSegmentation.SubjectResult = result.fullSubject;
let firstForeground: image.PixelMap | undefined = undefined;
if (result.subjectDetails !== undefined && result.subjectDetails.length > 0) {
firstForeground = result.subjectDetails[0].foregroundImage;
}
hilog.info(DOMAIN, TAG,
`subjectCount=${result.subjectCount}, box=[${full.subjectRectangle.left},` +
`${full.subjectRectangle.top},${full.subjectRectangle.width},${full.subjectRectangle.height}]`);
return {
fullForeground: full.foregroundImage,
subjectCount: result.subjectCount,
mattingList: full.mattingList,
subjectRectangle: full.subjectRectangle,
firstSubjectForeground: firstForeground
};
} catch (error) {
const err = error as BusinessError;
// 200 超时 / 401 参数错误 / 1011000001 运行失败 / 1011000002 服务异常
// 1011000003 模型运行失败 / 1011000004 模型运行超时
hilog.error(DOMAIN, TAG, `doSegmentation failed. code=${err.code}, msg=${err.message}`);
return null;
} finally {
await source.release();
}
}
/**
* 业务小工具:主体框是否够"显眼"
* 官方约束:主体面积需 ≥ 原图千分之五,太小就该引导用户重拍
*/
static isSubjectBigEnough(box: subjectSegmentation.Rectangle,
imageWidth: number, imageHeight: number): boolean {
const subjectArea: number = box.width * box.height;
const imageArea: number = imageWidth * imageHeight;
return imageArea > 0 && subjectArea / imageArea >= 0.005;
}
}
3.3 SameStyleSearchService.ets —— 商品图库端侧检索(搜同款)
/**
* @file SameStyleSearchService.ets
* @brief 文搜图:对自建"商品图库"做端侧索引与检索
* API 对齐 @kit.CoreVisionKit · textSearchImage(起始版本 26.0.0)
*/
import { textSearchImage } from '@kit.CoreVisionKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'SameStyleSearch';
/** 商品图库作用域:按"购物节档期"隔离,便于活动结束后清理 */
const SCOPE_GOODS: string = 'campus.haigou.goods.2026festival';
/** 官方 ImageObject 的业务映射 */
export interface SameStyleHit {
imagePath: string;
scope: string;
similarity: number;
}
export class SameStyleSearchService {
private static inited: boolean = false;
static async init(): Promise<boolean> {
if (SameStyleSearchService.inited) {
return true;
}
const ok: boolean = await textSearchImage.init();
SameStyleSearchService.inited = ok;
return ok;
}
static async release(): Promise<boolean> {
const ok: boolean = await textSearchImage.release();
SameStyleSearchService.inited = false;
return ok;
}
/**
* 商品上架时入库(异步、可后台批量跑)
* @param imagePaths 商品图的沙箱路径列表
*/
static async indexGoods(imagePaths: Array<string>): Promise<number> {
if (!await SameStyleSearchService.init()) {
return 0;
}
let success: number = 0;
for (const path of imagePaths) {
try {
const ok: boolean = await textSearchImage.insertImage(path, SCOPE_GOODS);
if (ok) {
success++;
}
} catch (error) {
const err = error as BusinessError;
// 1013100001 图像不可用(路径不对/图已删)
hilog.warn(DOMAIN, TAG, `insertImage failed. code=${err.code}, path=${path}`);
}
}
hilog.info(DOMAIN, TAG, `indexed ${success}/${imagePaths.length}`);
return success;
}
/**
* 搜同款
* ⚠️ 官方约束:query 长度 [1,100],且"不支持纯数字和纯字母"
* topKey 默认 100,范围 [0,100]
* @param query 品类词 / 描述词,例如"学院风连帽卫衣"
*/
static async searchSameStyle(query: string, topKey: number = 30): Promise<Array<SameStyleHit>> {
const trimmed: string = query.trim();
if (trimmed.length < 1 || trimmed.length > 100) {
hilog.warn(DOMAIN, TAG, 'query length out of range [1,100]');
return [];
}
const safeTop: number = Math.min(Math.max(topKey, 0), 100);
try {
const objects: Array<textSearchImage.ImageObject> =
await textSearchImage.search(trimmed, SCOPE_GOODS, safeTop);
return objects.map((item: textSearchImage.ImageObject) => {
const hit: SameStyleHit = {
imagePath: item.imagePath,
scope: item.scope,
similarity: item.similarity
};
return hit;
});
} catch (error) {
const err = error as BusinessError;
// 1013100003 能力已更新:需要先 clearData 再重新 init
if (err.code === 1013100003) {
await textSearchImage.clearData();
await SameStyleSearchService.init();
}
hilog.error(DOMAIN, TAG, `search failed. code=${err.code}, msg=${err.message}`);
return [];
}
}
/** 商品下架时同步删索引 */
static async removeGoods(imagePath: string): Promise<boolean> {
try {
return await textSearchImage.deleteImage(imagePath, SCOPE_GOODS);
} catch (error) {
const err = error as BusinessError;
hilog.warn(DOMAIN, TAG, `deleteImage failed. code=${err.code}`);
return false;
}
}
}
3.4 PriceScanService.ets —— 扫码比价(三种入口)
/**
* @file PriceScanService.ets
* @brief 扫码比价:默认界面扫码 / 相册图像识码 / 生成分享码
* API 对齐 @kit.ScanKit(scanBarcode · detectBarcode · generateBarcode · scanCore)
*/
import { scanBarcode, scanCore, detectBarcode, generateBarcode } from '@kit.ScanKit';
import { common } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'PriceScan';
/** 比价结果(真实业务由服务端返回,端侧只负责"把码拿到手") */
export interface PriceHit {
ean: string;
title: string;
lowestPrice: number;
shopName: string;
}
export class PriceScanService {
/**
* 入口一:默认界面扫码(无需相机权限)
* @param context 官方签名要求 common.Context,传入 UIAbilityContext 亦可
*/
static async scanOneDimensional(context: common.Context): Promise<string | null> {
const options: scanBarcode.ScanOptions = {
scanTypes: [scanCore.ScanType.ONE_D_CODE], // 只认条形码,避免二维码干扰
enableMultiMode: false, // 单码模式
enableAlbum: true // 允许从相册识码(官方:仅支持单码)
};
try {
const result: scanBarcode.ScanResult =
await scanBarcode.startScanForResult(context, options);
hilog.info(DOMAIN, TAG,
`scanType=${result.scanType}, value=${result.originalValue}, source=${result.source}`);
// 默认界面扫码的 source 字段(6.0.2(22)+)能区分码是"相机扫的"还是"相册选的"
return result.originalValue;
} catch (error) {
const err = error as BusinessError;
if (err.code === 1000500002) {
// 用户主动取消:静默返回,绝对不要弹 toast
hilog.info(DOMAIN, TAG, 'user canceled scanning');
return null;
}
hilog.error(DOMAIN, TAG, `startScanForResult failed. code=${err.code}, msg=${err.message}`);
return null;
}
}
/**
* 入口二:相册照片里的码(图像识码,支持多码)
* @param uri 相册图片 uri,例如 file://media/Photo/xx/xxx.jpg
*/
static async decodeFromPhoto(uri: string): Promise<Array<string>> {
const inputImage: detectBarcode.InputImage = { uri: uri };
const options: scanBarcode.ScanOptions = {
scanTypes: [scanCore.ScanType.ONE_D_CODE, scanCore.ScanType.QR_CODE],
enableMultiMode: true
};
try {
const results: Array<scanBarcode.ScanResult> =
await detectBarcode.decode(inputImage, options);
const values: Array<string> = [];
results.forEach((item: scanBarcode.ScanResult) => {
values.push(item.originalValue);
// cornerPoints 仅在 decodeImage 场景返回(5.0.0(12)+)
hilog.info(DOMAIN, TAG, `photo code=${item.originalValue}, type=${item.scanType}`);
});
return values;
} catch (error) {
const err = error as BusinessError;
hilog.error(DOMAIN, TAG, `detectBarcode.decode failed. code=${err.code}`);
return [];
}
}
/**
* 入口三(进阶):相机流里识码,适合"边预览边识别",5.0.0(12)+
* @param byteBuffer NV21 格式的图像数据(官方 ImageFormat.NV21 = 0)
*/
static async decodeFrame(byteBuffer: ArrayBuffer, width: number, height: number): Promise<Array<string>> {
const byteImage: detectBarcode.ByteImage = {
byteBuffer: byteBuffer,
width: width,
height: height,
format: detectBarcode.ImageFormat.NV21
};
try {
// ⚠️ 官方明确:decodeImage 不支持并行调用,需要自己串行化
const result: detectBarcode.DetectResult = await detectBarcode.decodeImage(byteImage);
return result.scanResults.map((item: scanBarcode.ScanResult) => item.originalValue);
} catch (error) {
const err = error as BusinessError;
hilog.error(DOMAIN, TAG, `decodeImage failed. code=${err.code}`);
return [];
}
}
/**
* 生成"比价结果分享码":室友扫一下就能看到同一个比价页
* @param url 比价页的 App Linking 长链接
*/
static async buildShareCode(url: string): Promise<image.PixelMap | null> {
const options: generateBarcode.CreateOptions = {
scanType: scanCore.ScanType.QR_CODE,
width: 400, // [200, 4096]
height: 400, // [200, 4096]
margin: 2, // [1, 10]
level: generateBarcode.ErrorCorrectionLevel.LEVEL_H, // 仅 QR 有效
backgroundColor: 0xFFFFFF,
pixelMapColor: 0x000000
};
try {
return await generateBarcode.createBarcode(url, options);
} catch (error) {
const err = error as BusinessError;
hilog.error(DOMAIN, TAG, `createBarcode failed. code=${err.code}`);
return null;
}
}
/**
* 能力探测:低端设备 / 特殊形态上先问一句"支不支持"
* scanCore.isDefaultScanSupported() 为 26.0.0 新增
*/
static canUseDefaultScan(): boolean {
return scanCore.isDefaultScanSupported();
}
}
3.5 UI 层:拍照搜同款主页面
/**
* @file PhotoSameStylePage.ets
* @brief 拍照搜同款:选图 → 抠图确认 → 一句话搜同款
*/
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { image } from '@kit.ImageKit';
import { SubjectCutoutService, CutoutResult } from '../service/SubjectCutoutService';
import { SameStyleSearchService, SameStyleHit } from '../service/SameStyleSearchService';
import { PriceScanService } from '../service/PriceScanService';
@Entry
@Component
struct PhotoSameStylePage {
@State cutoutForeground: image.PixelMap | undefined = undefined; // 抠出来的商品
@State subjectCount: number = 0;
@State query: string = '';
@State hits: Array<SameStyleHit> = [];
@State tip: string = '拍一张商品的照片,我来帮你找同款';
aboutToAppear(): void {
SameStyleSearchService.init();
}
aboutToDisappear(): void {
// 成对释放,避免分割器常驻内存
SubjectCutoutService.release();
}
build() {
Column({ space: 12 }) {
// ---------- ① 抠图区 ----------
Stack() {
if (this.cutoutForeground !== undefined) {
Image(this.cutoutForeground)
.objectFit(ImageFit.Contain)
.width('100%')
.height(240)
} else {
Column() {
Text('📷').fontSize(40)
Text('拍一张 / 从相册选一张').fontSize(14).fontColor('#888888')
}
}
}
.width('100%')
.height(240)
.backgroundColor('#F5F6F8')
.borderRadius(16)
Text(this.tip).fontSize(13).fontColor('#666666')
Row({ space: 10 }) {
Button('📸 从相册选择')
.layoutWeight(1)
.height(44)
.backgroundColor('#FF2D55')
.onClick(() => this.pickAndCutout())
Button('🔍 扫码比价')
.layoutWeight(1)
.height(44)
.backgroundColor('#1F2430')
.onClick(async () => {
const ean: string | null =
await PriceScanService.scanOneDimensional(this.getUIContext().getHostContext()!);
if (ean !== null) {
this.tip = `已识别条码 ${ean},正在比价…`;
}
})
}
.width('100%')
// ---------- ② 关键词区(官方 query 不支持纯数字/纯字母) ----------
Row({ space: 8 }) {
TextInput({ placeholder: '补充一句:学院风连帽卫衣', text: this.query })
.layoutWeight(1)
.height(40)
.backgroundColor('#F5F5F5')
.onChange((value: string) => { this.query = value; })
Button('搜同款')
.height(40)
.backgroundColor('#FF2D55')
.onClick(() => this.doSearch())
}
.width('100%')
// ---------- ③ 结果区 ----------
Grid() {
ForEach(this.hits, (hit: SameStyleHit) => {
GridItem() {
Stack({ alignContent: Alignment.BottomEnd }) {
Image(`file://${hit.imagePath}`)
.objectFit(ImageFit.Cover)
.width('100%')
.height(150)
Text(`${(hit.similarity * 100).toFixed(0)}%`)
.fontSize(11)
.fontColor(Color.White)
.backgroundColor('#99000000')
.padding(4)
.margin(6)
.borderRadius(6)
}
.borderRadius(12)
.clip(true)
}
})
}
.columnsTemplate('1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor(Color.White)
}
/** 选图 → 抠图 → 给出可点击的品类词建议 */
private async pickAndCutout(): Promise<void> {
const picker = new photoAccessHelper.PhotoViewPicker();
const opts = new photoAccessHelper.PhotoSelectOptions();
opts.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
opts.maxSelectNumber = 1;
const result = await picker.select(opts);
if (result.photoUris.length === 0) {
return;
}
this.tip = '正在识别商品主体…';
const cutout: CutoutResult | null = await SubjectCutoutService.cutout(result.photoUris[0]);
if (cutout === null) {
this.tip = '没认出主体,换个角度再拍一张(主体别太小)';
return;
}
this.cutoutForeground = cutout.fullForeground;
this.subjectCount = cutout.subjectCount;
this.tip = `已锁定 ${this.subjectCount} 件商品,补一句话就能搜同款`;
}
private async doSearch(): Promise<void> {
if (this.query.trim().length === 0) {
this.tip = '再补一句商品描述,比如"宽松牛仔外套"';
return;
}
this.hits = await SameStyleSearchService.searchSameStyle(this.query, 30);
if (this.hits.length === 0) {
this.tip = '图库里暂时没有同款,试试别的说法';
} else {
this.tip = `找到 ${this.hits.length} 个同款(按相似度排序)`;
}
}
}
四、线下比价闭环:扫码直达(App Linking)
线下货架上的二维码,如果只是「App 扫一扫 → 打开首页」,那用户还得再搜一次。官方提供的「扫码直达」能让码值一步跳到指定页面:
| 环节 | 官方要求 |
|---|---|
| 开通 | 在 AGC 开通 App Linking,在 App Linking 里配置二维码 / 条形码关联域名 |
| 关联 | 开发者网站需关联应用;module.json5 中关联域名 |
| 签名 | ⚠️ 接入 App Linking 不能使用 DevEco 自动签名,必须手动签名 |
| 入口 | 系统级常驻入口:控制中心「扫一扫」 |
| 接收 | UIAbility 的 onCreate(冷启动)与 onNewWant(热启动)里读 want.uri 自行路由 |
| 限制 | 仅支持 HTTPS 链接(HTTP 需提工单);仅中国境内(港澳台除外);仅 Phone / Tablet |
/**
* @file EntryAbility.ets(节选)
* @brief 扫码直达:冷启动 + 热启动两条路径都要接
*/
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'EntryAbility';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.routeByUri(want);
}
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.routeByUri(want);
}
private routeByUri(want: Want): void {
const uri: string | undefined = want.uri;
if (uri === undefined || uri.length === 0) {
return;
}
hilog.info(DOMAIN, TAG, `scan arrived uri=${uri}`);
// 形如 https://haigou.campus.cn/price?ean=6901234567892
// 解析出 ean 后直接 push 到比价页,跳过首页
const matched: RegExpMatchArray | null = uri.match(/[?&]ean=(\d+)/);
if (matched !== null) {
AppStorage.setOrCreate<string>('pendingEan', matched[1]);
}
}
}
五、高发错误码处置表
| 模块 | 错误码 | 官方含义 | 线上处置 |
|---|---|---|---|
| subjectSegmentation | 401 | 参数错误 | 90% 是 PixelMap 不是 RGBA_8888 → 统一在 createPixelMap 里指定 desiredPixelFormat |
| subjectSegmentation | 1011000001 | 运行失败 | 主体太小(< 原图 5‰)或宽高比超过 3:1 → 引导用户靠近、重拍 |
| subjectSegmentation | 1011000004 | 模型运行超时 | 大图先降采样到 2000px 长边再分割 |
| textSearchImage | 1013100001 | 图像不可用 | 沙箱路径失效 → 入库前先 fileIo.access() 校验 |
| textSearchImage | 1013100003 | 能力已更新 | 必须先 clearData() 再重新 init(),然后重建索引 |
| textSearchImage | 1018700001 | 业务异常 | 并发检索过高 → 端侧做 100ms 防抖 |
| scanBarcode | 1000500002 | 用户取消扫码 | 静默处理,绝不能弹错误提示 |
| scanBarcode | 1000500001 | 内部错误 | 重试一次;仍失败则降级到 detectBarcode.decode(相册路径) |
| detectBarcode | 401 | 参数错误 | InputImage.uri 必须是可访问的图片 uri |
六、写在最后
这一篇里,最有价值的其实不是「怎么做」,而是 「什么不能做」:
- 端侧没有以图搜图。
textSearchImage是文搜图,Core Vision Kit 没有imageSearch这类可调用 API。任何号称「纯端侧以图搜图」的实现,要么在偷偷上云,要么就是把文搜图包装了一下。 - 主体分割才是「拍照找同款」里真正的端侧增量。它把「用户说不清的东西」变成「用户点一下就确认的东西」,把搜索的准确率问题前置成了确认问题。
- 扫码这件事,官方把相机权限都替你做完了。默认界面扫码无需申请
ohos.permission.CAMERA,只有customScan才需要——能不用自定义界面就别用。 - 枚举在
scanCore,不在scanBarcode。scanCore.ScanType.QR_CODE = 11,不是SCAN_TYPE_QR_CODE;ScanOptions官方就三个字段。抄网上的代码之前,先回官网对一遍。
购物节比的从来不是谁的商品多,而是 谁能让用户用更少的手势,拿到更准的结果。
附:本文 API 速查卡(可直接贴进代码仓库 README)
// ① 主体分割(Core Vision Kit,起始 5.0.0(12),仅 Stage)
import { subjectSegmentation } from '@kit.CoreVisionKit';
await subjectSegmentation.init(); // Promise<boolean>
await subjectSegmentation.doSegmentation(
{ pixelMap }, // VisionInfo:PixelMap 必须 RGBA_8888
{ maxCount: 3, enableSubjectDetails: true, enableSubjectForegroundImage: true }
); // → SegmentationResult
// SegmentationResult { subjectCount, fullSubject, subjectDetails? }
// SubjectResult { foregroundImage, mattingList(Int32Array), subjectRectangle }
await subjectSegmentation.release();
// ② 文搜图(Core Vision Kit,起始 26.0.0)—— 注意:是"文搜图",不是"以图搜图"
import { textSearchImage } from '@kit.CoreVisionKit';
await textSearchImage.init();
await textSearchImage.insertImage(imagePath, scope); // Promise<boolean>
await textSearchImage.search(query, scope, topKey); // query 长度[1,100],不支持纯数字/纯字母;topKey 默认100,范围[0,100]
await textSearchImage.deleteImage(imagePath, scope);
await textSearchImage.clearData(); // 清空所有 scope
await textSearchImage.release();
// ImageObject { imagePath, scope, similarity } similarity ∈ [-1, 1]
// ③ 扫码(Scan Kit,起始 4.0.0(10),默认界面扫码无需相机权限)
import { scanBarcode, scanCore, detectBarcode, generateBarcode } from '@kit.ScanKit';
await scanBarcode.startScanForResult(context /* common.Context */, {
scanTypes: [scanCore.ScanType.ONE_D_CODE], // 枚举在 scanCore!
enableMultiMode: false,
enableAlbum: true
}); // → ScanResult { scanType, originalValue, ... }
await detectBarcode.decode({ uri }, options); // → Array<scanBarcode.ScanResult>
await detectBarcode.decodeImage(byteImage, options); // → DetectResult,不支持并行调用
await generateBarcode.createBarcode(content, {
scanType: scanCore.ScanType.QR_CODE, width: 400, height: 400, margin: 2,
level: generateBarcode.ErrorCorrectionLevel.LEVEL_H
}); // → image.PixelMap
scanCore.isDefaultScanSupported(); // 26.0.0 新增能力探测
scanCore.isCustomScanSupported(); // 26.0.0 新增能力探测
// 错误码:401 参数错误 / 1000500001 内部错误 / 1000500002 用户取消(静默处理)
更多推荐

所有评论(0)