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 页面真正想要的,只是一个“黑盒”的结果

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

// 伪代码:登录页
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 个动作,并且把提示语精简了一版,通过率和好感度都高了不少,这也是为什么我在封装层里给了默认参数,而不是让每个业务随意填。

// 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

// 伪代码:示意用
@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 目标:业务只关心“我要识别这张图”

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

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

而不是:

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

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

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

// 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 组件。

// 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)
  }
}

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

// 药品识别页面片段
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识图 #组件化 #实战总结

Logo

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

更多推荐