校园嗨购 · 某购物节特别版: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无网络往返
条码录入平均耗时26s1.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]);
    }
  }
}

五、高发错误码处置表

模块错误码官方含义线上处置
subjectSegmentation401参数错误90% 是 PixelMap 不是 RGBA_8888 → 统一在 createPixelMap 里指定 desiredPixelFormat
subjectSegmentation1011000001运行失败主体太小(< 原图 5‰)或宽高比超过 3:1 → 引导用户靠近、重拍
subjectSegmentation1011000004模型运行超时大图先降采样到 2000px 长边再分割
textSearchImage1013100001图像不可用沙箱路径失效 → 入库前先 fileIo.access() 校验
textSearchImage1013100003能力已更新必须先 clearData() 再重新 init(),然后重建索引
textSearchImage1018700001业务异常并发检索过高 → 端侧做 100ms 防抖
scanBarcode1000500002用户取消扫码静默处理,绝不能弹错误提示
scanBarcode1000500001内部错误重试一次;仍失败则降级到 detectBarcode.decode(相册路径)
detectBarcode401参数错误InputImage.uri 必须是可访问的图片 uri

六、写在最后

这一篇里,最有价值的其实不是「怎么做」,而是 「什么不能做」:

  1. 端侧没有以图搜图。textSearchImage 是文搜图,Core Vision Kit 没有 imageSearch 这类可调用 API。任何号称「纯端侧以图搜图」的实现,要么在偷偷上云,要么就是把文搜图包装了一下。
  2. 主体分割才是「拍照找同款」里真正的端侧增量。它把「用户说不清的东西」变成「用户点一下就确认的东西」,把搜索的准确率问题前置成了确认问题。
  3. 扫码这件事,官方把相机权限都替你做完了。默认界面扫码无需申请 ohos.permission.CAMERA,只有 customScan 才需要——能不用自定义界面就别用。
  4. 枚举在 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 用户取消(静默处理)
Logo

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

更多推荐