HarmonyOS Vision Kit 视觉AI实战:把官方 Demo 改造成一套能长期复用的组件库

HarmonyOS Vision Kit 视觉AI实战:把官方 Demo 改造成一套能长期复用的组件库

很多同学问过我:「我照着官方文档敲,功能能跑,为啥一发帖就像在贴文档?」

这篇就试着不再逐条翻接口,而是聊聊我在一个真实项目里,怎么一步一步把 Vision Kit 变成团队都敢用、愿意用的那套"视觉 AI 组件库"。


一、背景:官方 Demo 能跑,业务同学却还是不敢接

先说一段自己踩坑的经历。

去年我在做一个面向老年人的「生活助手」应用,Vision Kit 的四大能力几乎把当时的需求都包圆了:

  • 人脸活体检测:给老人做无密码登录 / 安全二次验证
  • 卡证识别:录入租客、就诊人、办事人等身份证信息
  • 文档扫描:把收租凭证、病历本、缴费单变成电子文档
  • AI 识图:识别药品说明、账单小票、通知公告上的小字

一开始我也和大多数人一样:

"抄下官方 Demo、能跑起来就行了。"

结果真落到项目里,问题马上就来了,和想象中的"集成一下就完事"完全不是一回事:

  • 每个页面都在自己申请权限、自己调 Vision Kit 接口、自己处理错误,代码复制粘贴一大片;
  • 活体、识图、扫描等入口分散在各个页面,UX 完全不统一
  • 真机上各种边缘情况(设备不支持、权限拒绝、识别失败)要在每个页面各写一遍;
  • 之后要做 Android + HarmonyOS 双端统一能力时,每个页面都要改,维护成本特别高

当时项目组就我一个人稍微熟一点 Vision Kit,很多接入工作都是"我写一个 Demo,大家各自 copy 一份"。刚开始觉得还能顶得住,等第三个业务线也要接入的时候,我就明显感觉到------如果不抽象一层出来,后面任何一次改动都会变成体力活。

我很快意识到:

「现在我只是 把 Vision Kit 跑起来了,但还没把它真的变成项目里的基础能力。」

于是我抽空把项目里所有零散的调用拉出来,重新整理了一套 「视觉 AI 组件库」 ------

把通用逻辑抽成服务 & 组件,让业务方尽量只关心一句话:

"这里我要做人脸验证 / 身份证识别 / 文档扫描 / 图片识别。"

这篇文章就按我当时的思路,把这个过程捋一遍,顺带把几个关键坑和取舍都讲清楚。


二、先想清楚:我们到底想从 Vision Kit 身上"要什么"?

我一开始是先把遇到的痛点都写在纸上,反复看了几轮,才慢慢收敛到三条比较核心的设计目标:

  1. 统一入口

    • 页面层不直接调用 Vision Kit 原始 API,只通过封装好的服务或 UI 组件;
    • 以后 Vision Kit 版本升级 / 能力调整,只改服务层,不动业务代码。
  2. 统一结果模型

    • 每个能力(活体、卡证、扫描、识图)都定义清晰的结果类型,例如:
      • LivenessAuthResult
      • IdCardResult
      • DocumentScanResult
      • ImageTextAnalyzeResult
    • 业务逻辑只面向这些领域模型,而不是一堆 SDK 原始字段。
  3. 内置体验与降级策略

    • 权限申请、设备能力检测、错误处理、重试机制全部放在服务层;
    • 针对老年用户,统一做「适老化」处理:文案、字号、流程引导;
    • 对无法支持的设备/场景,给出稳定的「手工模式」兜底。

最后拆出来的结构,其实很简单,就两层:

  • 能力服务层(不带 UI)

    • LivenessService:人脸活体验证
    • ImageAnalyzerService:AI 识图
    • CardOcrService:卡证 OCR
    • DocumentScanService:文档扫描
  • UI 组件层(ArkUI 封装)

    • LivenessButton:一键刷脸验证按钮
    • IdCardCaptureView:身份证拍照 + 自动识别组件
    • DocumentScanButton:文档扫描入口按钮
    • SmartImageAnalyzer:可长按识图、支持业务解析的图片控件

下面重点展开其中两块:人脸活体检测AI 识图,分别展示「从 Demo 到组件库」的完整演进过程。


三、人脸活体检测:从"能过"到"敢用"的刷脸登录

3.1 页面真正想要的,只是一个"黑盒"的结果

换位思考一下业务同学,他们真正希望写出的代码,应该是这种级别的:

ts 复制代码
// 伪代码:登录页
Button('刷脸登录')
  .onClick(async () => {
    const result = await LivenessService.startLivenessAuth()
    if (result.success) {
      // 拿到可信 token,走正常登录流程
      await loginWithLiveness(result.token)
    } else {
      showToast(result.message) // 已内置适老化文案
    }
  })

而不是:

  • 每个页面自己申请 CAMERA 权限;
  • 自己拼 InteractiveLivenessConfig
  • 自己 try/catch BusinessError
  • 自己决定「失败几次切换成密码登录」。

所以第一步,我做的是把这些"脏活累活",全部塞进一个 LivenessService 里。

3.2 能力服务封装:统一入口 + 统一结果

下面是简化后的封装示例(HarmonyOS ArkTS),真实项目可以在此基础上扩展。

一开始我把动作数量设成了 4 个,语音提示也开得很"勤快",结果内部测试时几个阿姨级别的测试同事纷纷吐槽"太累了、说太快了"。后来我们改成 3 个动作,并且把提示语精简了一版,通过率和好感度都高了不少,这也是为什么我在封装层里给了默认参数,而不是让每个业务随意填。

ts 复制代码
// vision-service/livenessService.ets
import { common, abilityAccessCtrl, Permissions } from '@kit.AbilityKit'
import { interactiveLiveness } from '@kit.VisionKit'
import { BusinessError } from '@kit.BasicServicesKit'
import { hilog } from '@kit.PerformanceAnalysisKit'

export interface LivenessAuthResult {
  success: boolean
  similarity?: number
  token?: string  // 业务可选:后端签发 / 本地生成会话标识
  code?: number
  message?: string
}

class LivenessService {
  private context: common.UIAbilityContext
  private perms: Array<Permissions> = ['ohos.permission.CAMERA']

  constructor(ctx: common.UIAbilityContext) {
    this.context = ctx
  }

  async startLivenessAuth(options?: {
    silentMode?: boolean
    actionsNum?: number
    routeMode?: 'back' | 'replace'
  }): Promise<LivenessAuthResult> {
    const granted = await this.requestCameraPermission()
    if (!granted) {
      return {
        success: false,
        code: -1,
        message: '需要使用相机来确认您的身份,就像工作人员看您的证件一样'
      }
    }

    if (!this.canUseLiveness()) {
      return {
        success: false,
        code: -2,
        message: '当前设备暂不支持人脸活体检测,可以改用短信验证码登录'
      }
    }

    const config: interactiveLiveness.InteractiveLivenessConfig = {
      isSilentMode: options?.silentMode ?? false,
      routeMode: options?.routeMode ?? 'replace',
      actionsNum: options?.actionsNum ?? 3
    }

    try {
      await interactiveLiveness.startLivenessDetection(config)
      // 检测结束后获取结果,官方建议延迟一定时间
      const result = await interactiveLiveness.getInteractiveLivenessResult()
      hilog.info(0x0001, 'LivenessService', `Liveness result: ${JSON.stringify(result)}`)

      if (result.isSuccess) {
        return {
          success: true,
          similarity: result.similarity,
          token: this.generateSessionToken(result)
        }
      } else {
        return {
          success: false,
          code: result.errorCode,
          message: this.mapFailReason(result.errorCode)
        }
      }
    } catch (err) {
      const e = err as BusinessError
      hilog.error(0x0001, 'LivenessService', `Liveness failed: ${e.code} ${e.message}`)
      return {
        success: false,
        code: e.code,
        message: '本次人脸验证没有通过,可以再试一次,或者改用密码 / 验证码登录'
      }
    }
  }

  private async requestCameraPermission(): Promise<boolean> {
    const atManager = abilityAccessCtrl.createAtManager()
    const res = await atManager.requestPermissionsFromUser(this.context, this.perms)
    for (let i = 0; i < res.permissions.length; i++) {
      if (res.permissions[i] === 'ohos.permission.CAMERA') {
        return res.authResults[i] === 0
      }
    }
    return false
  }

  private canUseLiveness(): boolean {
    // 实际项目中使用官方 canIUse
    return canIUse('SystemCapability.AI.Component.LivenessDetect')
  }

  private generateSessionToken(result: any): string {
    // 简化:真实项目中可以结合用户ID + 时间戳 + 签名
    return `${Date.now()}_${Math.floor((result.similarity ?? 0) * 1000)}`
  }

  private mapFailReason(code?: number): string {
    // 按照老人可理解的语言做映射
    switch (code) {
      case 1001:
        return '没有清楚地看到您的脸,请正对屏幕再试一次'
      case 1002:
        return '动作有点快,可以慢一点,跟着提示再来一次'
      default:
        return '本次验证没有通过,可以再试一次,或者改用短信验证码登录'
    }
  }
}

export function createLivenessService(ctx: common.UIAbilityContext): LivenessService {
  return new LivenessService(ctx)
}

这个封装解决了三个痛点:

  • 页面不用再写权限申请逻辑;
  • 统一的错误文案,适合老年用户阅读;
  • 预留了 token 的扩展点,方便和后端风控打通。

3.3 UI 组件层:给「产品和运营」一个稳定入口

在能力服务之上,我额外封装了一个 ArkUI 组件 LivenessButton

ts 复制代码
// 伪代码:示意用
@Component
struct LivenessButton {
  @Prop text: string = '刷脸登录'
  @Prop onSuccess: (result: LivenessAuthResult) => void

  private ctx: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext
  private service: LivenessService = createLivenessService(this.ctx)

  build() {
    Button(this.text)
      .onClick(async () => {
        const result = await this.service.startLivenessAuth()
        if (result.success) {
          this.onSuccess(result)
        } else {
          showToast(result.message ?? '验证失败,请稍后再试')
        }
      })
  }
}

这样一来:

  • 运营想改文案 / 样式:改一个组件即可,所有用到的页面自动更新;
  • 产品想在刷脸前加一段说明弹窗:直接在组件内部加一个对话框,而不用全项目找调用点;
  • 活体业务逻辑变复杂 :统一在 LivenessService 内扩展,页面完全无感。

从"API 文档视角"来看,这个组件没增加新能力;

但从"产品和团队协作视角"来看,这一步是非常关键的一次抽象。


四、AI 识图:让一张图拥有「聪明的交互」

人脸活体更偏 流程型安全能力 ,而 AI 识图则是 内容理解能力,可以给图片加上「长按就有惊喜」的交互。

在老年助手项目里,我重点做了一个「药品识别」场景:

  • 老人用相机对准药盒拍照;
  • AI 识图识别出说明书中的文字;
  • 二次解析出:药名、用法用量、有效期等关键信息;
  • 用大字号、高对比度的方式展示出来,必要时还能朗读。

4.1 目标:业务只关心"我要识别这张图"

业务页面希望写出来的是这样:

ts 复制代码
// 伪代码:药品识别场景
SmartImageAnalyzer({
  imageSrc: this.medicineImage,
  onMedicineParsed: (info) => {
    this.medicineInfo = info
  }
})

而不是:

  • 页面自己创建 VisionImageAnalyzerController
  • 自己注册一堆 on('textAnalysis') 的回调;
  • 自己写正则从文本里抠药名、剂量、有效期;
  • 页面销毁时自己记得 off

4.2 控制器封装:一次注册,处处复用

我先把 Vision Kit 的 VisionImageAnalyzerController 封装成一个服务类,统一处理事件监听和业务解析逻辑。

ts 复制代码
// vision-service/imageAnalyzerService.ets
import { visionImageAnalyzer } from '@kit.VisionKit'
import { BusinessError } from '@kit.BasicServicesKit'
import { hilog } from '@kit.PerformanceAnalysisKit'

export interface MedicineInfo {
  name: string
  dosage: string
  expiry: string
}

type TextHandler = (rawText: string, parsed?: MedicineInfo) => void

export class ImageAnalyzerService {
  private controller = new visionImageAnalyzer.VisionImageAnalyzerController()
  private onTextHandler?: TextHandler

  constructor() {
    this.registerListeners()
  }

  getController() {
    return this.controller
  }

  onText(handler: TextHandler) {
    this.onTextHandler = handler
  }

  private registerListeners() {
    // 文本分析结果
    this.controller.on('textAnalysis', (text: string) => {
      hilog.info(0x0001, 'ImageAnalyzerService', `Text analysis: ${text}`)
      const parsed = this.tryParseMedicine(text)
      this.onTextHandler?.(text, parsed)
    })

    // 错误处理
    this.controller.on('analyzerFailed', (error: BusinessError) => {
      hilog.error(0x0001, 'ImageAnalyzerService', `Analyzer failed: ${JSON.stringify(error)}`)
    })
  }

  private tryParseMedicine(text: string): MedicineInfo | undefined {
    // 根据药品说明书的典型写法做一个轻量规则解析
    const nameRegex = /[\u4e00-\u9fa5A-Za-z0-9()()]+(片|丸|胶囊|颗粒|滴眼液|注射液)/
    const dosageRegex = /[一二三四五六七八九十0-9.]+(片|粒|mg|毫克|ml|毫升)[,,每]{0,1}[日天][一二三四五六七八九十0-9.]+次/g
    const expiryRegex = /(有效期至|有效期)[\s::]*[0-9]{4}[-./年][0-9]{1,2}[-./月][0-9]{1,2}/

    const name = text.match(nameRegex)?.[0]
    const dosage = text.match(dosageRegex)?.join(';')
    const expiry = text.match(expiryRegex)?.[0]

    if (!name && !dosage && !expiry) {
      return undefined
    }

    return {
      name: name ?? '未知药品',
      dosage: dosage ?? '请参考说明书或咨询医生',
      expiry: expiry ?? '未识别到有效期,请人工确认'
    }
  }

  dispose() {
    this.controller.off('textAnalysis')
    this.controller.off('analyzerFailed')
  }
}

这个解析规则肯定不可能覆盖所有药品文案,所以我在设计上刻意只把它当成"增强体验",而不是"绝对准确的结构化数据来源"。真正关键的数据,仍然会在后端做一次校验,这样前端既能帮老人提炼关键信息,又不会因为误判把逻辑写死。

这一层的设计要点:

  • Vision Kit 的所有事件监听逻辑集中在一个类里;
  • 解析逻辑可根据业务(药品、账单、通知)自由扩展;
  • 对外只暴露「原始文本 + 解析结果」两个信号,页面不必知道内部细节。

4.3 UI 组件封装:业务只负责"把图给我"

在服务之上,我封装了一个 SmartImageAnalyzer 组件。

ts 复制代码
// ui/SmartImageAnalyzer.ets
import { Image } from '@kit.ArkUI'
import { ImageAnalyzerService, MedicineInfo } from '../vision-service/imageAnalyzerService'

@Component
export struct SmartImageAnalyzer {
  @Prop imageSrc: ResourceStr
  @Prop onMedicineParsed?: (info: MedicineInfo) => void

  @State private analyzerService: ImageAnalyzerService = new ImageAnalyzerService()

  aboutToAppear() {
    this.analyzerService.onText((raw, parsed) => {
      if (parsed && this.onMedicineParsed) {
        this.onMedicineParsed(parsed)
      }
    })
  }

  aboutToDisappear() {
    this.analyzerService.dispose()
  }

  build() {
    Image(this.imageSrc, {
      types: [
        visionImageAnalyzer.ImageAnalyzerType.TEXT,
        visionImageAnalyzer.ImageAnalyzerType.SUBJECT
      ],
      aiController: this.analyzerService.getController()
    })
      .width('100%')
      .height(300)
      .enableAnalyzer(true)
      .objectFit(ImageFit.Contain)
  }
}

业务页面的代码就变得非常简单、语义非常清晰:

ts 复制代码
// 药品识别页面片段
SmartImageAnalyzer({
  imageSrc: this.medicineImage,
  onMedicineParsed: (info: MedicineInfo) => {
    this.medicineInfo = info
  }
})

这一步在"技术上"只是封装了一层组件;

但在"协作上"极大降低了业务同学使用 Vision Kit 的门槛。


五、跨能力统一设计:权限、降级、适老化、性能

从「写代码」角度看,上面两节已经足够跑通业务。

但从「想拿精华」的角度,还需要讲清楚几件"工程化"层面的事情。

5.1 权限:一次说明,所有能力通用

Vision Kit 的几个能力都离不开 CAMERA / 存储 等敏感权限。

为了避免在各个地方反复解释,我做了一件小事:

  • 写了一个 VisionPermissionService,统一处理:
    • 权限申请;
    • 拒绝后的友好提示;
    • 再次打开应用时的「去设置里开启权限」引导;
  • 针对老年用户,文案全部改成「生活化说法」:

「我们需要使用相机来帮您拍照识别证件和药品信息,

只是像工作人员看一下您的证件,所有处理都在本机完成,不会上传到网上。」

这一块技术难度不大,但对提升信任感非常关键。

这一段权限说明文案我实际写了三版,最后是拉着一个做运营的同事和一个社区志愿者一起改的,他们会比我更敏感哪些说法容易引起老年用户的警惕。

5.2 降级策略:不要把用户"锁死"在 AI 能力里

任何 AI 能力都有失败的时候,真正好的体验一定要有优雅的降级。

我在项目里统一做了几条规则:

  • 活体失败 3 次以上

    • 自动弹出提示:「没关系,我们可以改用短信验证码来登录」;
    • 引导用户走更"传统"的路径,而不是让他卡在刷脸环节。
  • AI 识图 / 文档扫描连续失败

    • 提示拍照要点(光线、角度、清晰度);
    • 同时提供「拍照后手动输入关键字段」的表单入口。
  • 设备不支持某项 Vision Kit 能力

    • 不在 UI 上显示该入口,而是直接展示手工录入表单;
    • 并用一句话解释「该功能需要更新手机系统版本后才能使用」。

降级策略统一写在服务/组件内部,而不是散落在页面代码里,这样:

  • 以后规则改了,只要改一处;
  • 产品可以放心地基于这些能力做流程设计。

关于"连续失败三次自动切换为短信登录"这个阈值,我们在需求评审会上其实讨论了很久。最后选 3 次,是在安全同学、产品和我们几个开发之间拉扯出来的结果:既给了 AI 能力足够的尝试空间,又不会把不太熟练的老人困在一个流程里。

5.3 适老化体验:技术背后的人

因为我的目标用户是老人,所以我额外做了几件「非技术」但对效果非常重要的小事:

  • 统一的字号与对比度

    • 识别结果(药名、剂量、有效期)全部用大字号、高对比度颜色;
    • 非关键信息用次要颜色和稍小字号,减轻信息负担。
  • 语气友好的错误提示

    • 不写「验证失败」「识别失败」这种冷冰冰的词;
    • 改成「没关系,我们再来一次」「可以换一种方式试试」。
  • 一键分享给子女

    • 药品识别、文档扫描结果页面,都加了一个「发给家人」按钮;
    • 实现上就是系统分享,但对老人来说非常有用。

这些东西,很难从官方文档里学到,但却直接决定了产品口碑。

5.4 性能与资源管理:别让 AI 悄悄"吃光"电量

Vision Kit 自身做了不少优化,但作为接入方,仍然要注意几点:

  • 所有分析操作都放在异步,不阻塞 UI 线程;
  • 页面销毁时,一定要记得取消订阅、释放控制器;
  • 不要在后台无节制地跑识别逻辑,只在用户明确触发时才启动;
  • 对大图片适当做缩放预处理,在保证识别效果的前提下减少计算量。

这些都是比较基础的工程实践,但集合起来,能明显提升体验和续航。


六、总结:从「会用 Vision Kit」到「用 Vision Kit 做产品」

回头看这次重构,我最大的感受是:

  • 会用 Vision Kit → 能把 Demo 跑起来;
  • 用 Vision Kit 做产品 → 把能力抽象成「稳定、可复用、好维护」的组件和服务。

本文没有再去罗列每个能力的 API,而是聚焦在:

  • 如何围绕 Vision Kit 设计统一的 能力服务层
  • 如何基于这些服务封装出业务友好的 UI 组件层
  • 如何在真实场景下(尤其是老年用户)平衡体验、安全与性能。

如果你也在做 HarmonyOS / HMS 相关项目,我非常推荐你:

  1. 把 Vision Kit 当成「基础能力」,先抽象出自己的 VisionService
  2. 围绕核心场景(登录、证件、文档、图片)设计几个通用组件;
  3. 再考虑如何让这些组件"说人话",真正服务到你的目标用户。

技术会不断更新,但好的抽象和好的体验,是可以复用很久的。

这也是我写这篇文章最大的收获和想和大家分享的东西。

如果你在接 Vision Kit 的过程中也踩过什么比较奇怪的坑,或者在适老化这块有更好的做法,欢迎在评论区留言,我后面也考虑把卡证识别和文档扫描这两块单独拆出来写一篇"翻车合集"。


标签:#HarmonyOS #HMS Core #Vision Kit #视觉AI #人脸活体检测 #AI识图 #组件化 #实战总结

相关推荐
够快云库1 小时前
能源行业非结构化数据治理实战:从数据沼泽到智能资产
大数据·人工智能·机器学习·企业文件安全
Eloudy2 小时前
CHI 开发备忘 08 记 -- CHI spec 08
人工智能·arch·hpc
homelook2 小时前
Transformer与电池管理系统(BMS)的结合是当前 智能电池管理 的前沿研究方向
人工智能·深度学习·transformer
ZPC82102 小时前
docker 镜像备份
人工智能·算法·fpga开发·机器人
ZPC82102 小时前
docker 使用GUI ROS2
人工智能·算法·fpga开发·机器人
ssshooter2 小时前
免费和付费 AI API 选择指南
人工智能·aigc·openai
掘金酱2 小时前
「寻找年味」 沸点活动|获奖名单公示🎊
前端·人工智能·后端
AI周红伟2 小时前
周红伟:智能体全栈构建实操:OpenClaw部署+Agent Skills+Seedance+RAG从入门到实战
大数据·人工智能·大模型·智能体