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 身上"要什么"?
我一开始是先把遇到的痛点都写在纸上,反复看了几轮,才慢慢收敛到三条比较核心的设计目标:
-
统一入口
- 页面层不直接调用 Vision Kit 原始 API,只通过封装好的服务或 UI 组件;
- 以后 Vision Kit 版本升级 / 能力调整,只改服务层,不动业务代码。
-
统一结果模型
- 每个能力(活体、卡证、扫描、识图)都定义清晰的结果类型,例如:
LivenessAuthResultIdCardResultDocumentScanResultImageTextAnalyzeResult
- 业务逻辑只面向这些领域模型,而不是一堆 SDK 原始字段。
- 每个能力(活体、卡证、扫描、识图)都定义清晰的结果类型,例如:
-
内置体验与降级策略
- 权限申请、设备能力检测、错误处理、重试机制全部放在服务层;
- 针对老年用户,统一做「适老化」处理:文案、字号、流程引导;
- 对无法支持的设备/场景,给出稳定的「手工模式」兜底。
最后拆出来的结构,其实很简单,就两层:
-
能力服务层(不带 UI)
LivenessService:人脸活体验证ImageAnalyzerService:AI 识图CardOcrService:卡证 OCRDocumentScanService:文档扫描
-
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 相关项目,我非常推荐你:
- 把 Vision Kit 当成「基础能力」,先抽象出自己的
VisionService; - 围绕核心场景(登录、证件、文档、图片)设计几个通用组件;
- 再考虑如何让这些组件"说人话",真正服务到你的目标用户。
技术会不断更新,但好的抽象和好的体验,是可以复用很久的。
这也是我写这篇文章最大的收获和想和大家分享的东西。
如果你在接 Vision Kit 的过程中也踩过什么比较奇怪的坑,或者在适老化这块有更好的做法,欢迎在评论区留言,我后面也考虑把卡证识别和文档扫描这两块单独拆出来写一篇"翻车合集"。
标签:#HarmonyOS #HMS Core #Vision Kit #视觉AI #人脸活体检测 #AI识图 #组件化 #实战总结