HarmonyOS 7 文搜图实战 01:Core Vision Kit + ArkUI 构建 PhotoFinder 语义相册【鸿蒙心迹】
看到 HarmonyOS 7 的“文搜图”能力之后,我给自己挖了个小坑:能不能做一个不靠时间、不靠手工标签、直接输入一句话就能找图片的小工具?这一篇先不追求复杂能力,目标很明确——把第一版 PhotoFinder 跑起来,让“文字找图片”这条链路真正闭环。

前段时间看 HarmonyOS 7 新能力介绍时,我注意到了“文搜图”这个功能。说实话,第一眼看到这个名字,我脑子里并没有马上浮现什么高大上的 AI 场景,反而先想到的是一个特别日常的小麻烦:相册里的照片一多,人很容易陷入一种“我知道自己拍过,但就是翻不出来”的状态。
这种感觉很像找文件。你明明知道某个文件就在电脑里,但如果命名不规整、目录又乱,最后只能一层一层翻。照片也是一样。时间维度有用,但不总是够用。因为很多时候,我们记住的不是“这张图是什么时候拍的”,而是“这张图里有什么”。
比如:
- 我想找一张“桌子上放着电脑和咖啡杯”的照片;
- 我想找“晚上拍的建筑”;
- 我想找“海边的照片”;
- 我想找“带小猫的图”。
如果每一张图片都要手工打标签,那这事很快就失去乐趣了。真正有价值的,是我输入一句话,系统自己去理解,再把可能相关的图片找出来。
于是我决定拿这个能力做一个小 Demo,名字我都想好了,就叫 PhotoFinder。第一版不追求“产品化”,先解决最核心的一件事:
给定一批图片,让用户输入一句自然语言,页面返回语义上最接近的图片结果。
这件事说起来不大,但真做起来,马上会遇到几个很现实的问题:
- 图片怎么进入可检索范围?
- 搜索到底是搜文件名,还是搜图片内容?
- 搜索结果怎么展示,怎么排序?
- 没有结果怎么办?
- 页面多次搜索、连续搜索,状态怎么处理?
- Demo 先跑起来,后面能不能扩展成真正的功能?
这一篇我先只做第一阶段:把文搜图能力跑通,让第一版 PhotoFinder 能用起来。
一、别急着写代码,先把第一版目标收紧
刚开始做这种新能力 Demo,最容易犯的错误是目标太大。你一上来就想着做分类、做历史记录、做本地图库、做缓存、做结果高亮、做空状态动画,最后很容易在工程搭起来之前就把自己绕晕了。
所以我先给第一版定了一个非常克制的边界:
当前问题
我需要做一个最小可运行版本,验证 HarmonyOS 7 文搜图能力在项目里能不能形成一条完整链路。
状态变化
从“看到一个新能力,有个想法”,变成“页面真的能输入文字并返回图片结果”。
技术判断
第一版只保留下面 4 个动作:
- 初始化文搜图能力;
- 准备一批测试图片并入库;
- 输入查询文本,执行搜索;
- 用列表展示结果。
这四件事能跑通,第一篇就算成功。至于分类、作用域拆分、增删同步、生命周期进一步收口,这些放到第二篇更合适。
为了不把复杂度一下子拉太高,我给 Demo 准备了一组比较生活化的测试图片,大概十几张,内容覆盖:
- 海边风景;
- 夜景建筑;
- 咖啡杯与桌面;
- 笔记本电脑工作台;
- 宠物猫;
- 公园与树木;
- 室内书桌;
- 城市街景。
图片数量不用太多。第一版不是做性能测试,而是先看搜索结果是不是“像那么回事”。
在这一步,我比较关心的不是“模型多强”,而是工程链路是不是顺的。因为一个新能力再高级,如果接入路径很别扭,Demo 很快就会失控。
二、先搭个像样的界面,不要一开始就堆逻辑
我做 Demo 有个习惯:哪怕只是验证技术点,也尽量别写得太像测试脚本。因为只要 UI 稍微像个样子,后面你做状态变化、排查问题、整理文章内容都会轻松很多。
PhotoFinder 第一版页面很简单:
- 顶部一个标题;
- 一段简短说明;
- 一个输入框;
- 一个搜索按钮;
- 一个“导入测试图片”按钮;
- 一块状态区域;
- 一个结果网格列表。
页面骨架我直接用 ArkUI 搭,代码不复杂,但能把界面节奏理顺。
下面这段代码我把它放在页面入口文件里,比如:
文件位置:entry/src/main/ets/pages/Index.ets
用途:搭建第一版 PhotoFinder 主界面
import promptAction from '@ohos.promptAction';
interface SearchResultItem {
path: string
similarity: number
}
@Entry
@Component
struct Index {
@State keyword: string = ''
@State loading: boolean = false
@State initialized: boolean = false
@State imported: boolean = false
@State statusText: string = '还没有开始,先初始化并导入测试图片。'
@State resultList: SearchResultItem[] = []
build() {
Column() {
Text('PhotoFinder')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.margin({ top: 24 })
Text('输入一句话,试着用文字把图片找出来。')
.fontSize(14)
.opacity(0.7)
.margin({ top: 8, bottom: 20 })
Row() {
TextInput({ placeholder: '比如:海边的照片 / 桌子上的电脑 / 晚上的建筑', text: this.keyword })
.width('72%')
.height(44)
.onChange((value: string) => {
this.keyword = value
})
Button('搜索')
.height(44)
.margin({ left: 10 })
.onClick(() => {
this.handleSearch()
})
}
.width('100%')
Row() {
Button(this.initialized ? '已初始化' : '初始化能力')
.enabled(!this.initialized)
.margin({ top: 16, right: 12 })
.onClick(() => {
this.handleInit()
})
Button(this.imported ? '已导入测试图' : '导入测试图片')
.enabled(this.initialized && !this.imported)
.margin({ top: 16 })
.onClick(() => {
this.handleImport()
})
}
Text(this.statusText)
.width('100%')
.fontSize(13)
.margin({ top: 18, bottom: 16 })
.padding(12)
.backgroundColor('#F5F7FA')
.borderRadius(10)
if (this.loading) {
Text('正在处理中...')
.fontSize(14)
.margin({ top: 16 })
}
Grid() {
ForEach(this.resultList, (item: SearchResultItem) => {
GridItem() {
Column() {
Image(item.path)
.width('100%')
.height(140)
.borderRadius(10)
.objectFit(ImageFit.Cover)
Text(`相似度:${item.similarity.toFixed(3)}`)
.fontSize(12)
.margin({ top: 8 })
.width('100%')
}
.alignItems(HorizontalAlign.Start)
}
})
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.margin({ top: 8 })
.width('100%')
.layoutWeight(1)
}
.padding(16)
.width('100%')
.height('100%')
}
async handleInit() {
promptAction.showToast({ message: '这里先挂初始化逻辑' })
}
async handleImport() {
promptAction.showToast({ message: '这里先挂入库逻辑' })
}
async handleSearch() {
promptAction.showToast({ message: '这里先挂搜索逻辑' })
}
}
这段代码现在还没有真正接上文搜图逻辑,但它已经把页面的主要交互节奏搭起来了。这样做有两个好处:
第一,页面状态先稳定下来。
第二,后面每接一个能力,都知道它该落在哪。
很多 Demo 一上来就先写能力调用,最后界面像个临时实验台。文章写起来也会很散。我的经验是,哪怕是技术验证,也先把“交互骨架”搭出来,这会让后面的逻辑更容易收束。

三、第一条真正的技术链路:初始化能力
页面搭完之后,第一件要做的不是搜索,而是初始化。
这一步看起来平常,但它决定了整个能力后续是不是能稳定运行。新能力接入经常出问题的地方,不是在“核心 API 不会调”,而是在初始化时机、实例保存和资源释放这些地方没想清楚。
我在这个 Demo 里,专门抽了一个小服务类出来,先不把全部逻辑堆在页面里。原因很简单:搜索能力如果和页面状态完全耦合,后面想重构很麻烦。
所以我建了一个服务文件:
文件位置:entry/src/main/ets/common/TextImageSearchService.ets
用途:封装文搜图能力的初始化、图片入库与搜索行为
这里先说明一下:下面的代码是按 Demo 写法组织的接入思路,实际项目里具体接口命名、参数形式和 SDK 导入方式,要以你本地当前版本的 HarmonyOS 7 / API 26 文档和 SDK 为准。文章里我更看重的是组织方式和调用顺序。
export interface SearchImageItem {
path: string
similarity: number
}
class TextImageSearchService {
private static instance: TextImageSearchService
private inited: boolean = false
private engine: Object | null = null
static getInstance(): TextImageSearchService {
if (!TextImageSearchService.instance) {
TextImageSearchService.instance = new TextImageSearchService()
}
return TextImageSearchService.instance
}
async init(): Promise<void> {
if (this.inited) {
return
}
// 这里按当前 SDK 接口初始化文搜图能力
// this.engine = await textSearchImage.init(...)
this.engine = {}
this.inited = true
}
isInited(): boolean {
return this.inited
}
async insertImages(paths: string[]): Promise<void> {
if (!this.inited || !this.engine) {
throw new Error('文搜图能力尚未初始化')
}
for (let i = 0; i < paths.length; i++) {
const path = paths[i]
// await textSearchImage.insertImage(this.engine, path, 'default')
}
}
async search(keyword: string, topK: number = 6): Promise<SearchImageItem[]> {
if (!this.inited || !this.engine) {
throw new Error('文搜图能力尚未初始化')
}
const query = keyword.trim()
if (!query) {
return []
}
// const searchResult = await textSearchImage.search(this.engine, query, 'default', topK)
// 这里为了演示返回结构,先写一个模拟结构
const searchResult = [
{ path: '/data/storage/el2/base/haps/entry/files/demo/sea_01.jpg', similarity: 0.921 },
{ path: '/data/storage/el2/base/haps/entry/files/demo/sea_02.jpg', similarity: 0.882 }
]
return searchResult.map(item => {
return {
path: item.path,
similarity: item.similarity
}
})
}
async release(): Promise<void> {
if (!this.inited) {
return
}
// await textSearchImage.release(this.engine)
this.engine = null
this.inited = false
}
}
export default TextImageSearchService
这段代码有几个地方我故意处理得比较“笨”,但这个“笨”其实是有用的。
技术判断
-
先做单例,不急着做复杂依赖注入
第一版 Demo 里,能力对象不需要到处传。用单例把初始化状态收住,足够了。 -
页面不直接碰底层能力对象
页面只关心“能不能初始化”“能不能搜索”“结果是什么”,不关心引擎内部长什么样。 -
search 的返回值尽量先转成页面友好的结构
页面最关心的是路径和相似度,所以我中间做了一次结构转换。这个动作看起来多余,但它会让后面修改接口适配时舒服很多。
这类封装没有多么高级,但很实用。很多项目后面不好维护,问题不是因为代码能力差,而是因为**“页面状态”和“能力调用细节”从第一天起就缠在一起了**。
四、测试图片别乱放,先把输入数据管理清楚
文搜图最核心的输入不是文本,而是图片。文本只是查询条件,真正被检索的对象是你提供的图片集合。
所以 PhotoFinder 要跑起来,必须先解决一个很朴素的问题:
测试图片从哪来,怎么放,怎么被应用访问到?
第一版我不打算走“用户手动选图”的路线,因为那样会把权限、文件选择器、沙箱路径、持久化这些复杂度提前拉进来。作为第一篇,不划算。
所以我用最直接的办法:项目内准备一批测试资源图片,启动后通过导入动作把它们统一送入文搜图能力。
比如你可以在资源目录里准备这些图片:
sea_01.jpgsea_02.jpgnight_building_01.jpgdesk_pc_coffee_01.jpgcat_sofa_01.jpgpark_tree_01.jpg
这些命名本身不会直接决定“文搜图”结果,但在调试期很有帮助。因为当搜索结果返回后,你至少能快速判断“系统找出来的是哪张图”。
这一步说白了有点像做实验。做实验先得把样本准备好。样本越清楚,排查越轻松。
于是我给页面补上“导入测试图片”的逻辑。
文件位置:entry/src/main/ets/pages/Index.ets
用途:接入初始化与测试图导入
import promptAction from '@ohos.promptAction';
import TextImageSearchService from '../common/TextImageSearchService';
interface SearchResultItem {
path: string
similarity: number
}
@Entry
@Component
struct Index {
@State keyword: string = ''
@State loading: boolean = false
@State initialized: boolean = false
@State imported: boolean = false
@State statusText: string = '还没有开始,先初始化并导入测试图片。'
@State resultList: SearchResultItem[] = []
private service = TextImageSearchService.getInstance()
private testImages: string[] = [
'/data/storage/el2/base/haps/entry/files/demo/sea_01.jpg',
'/data/storage/el2/base/haps/entry/files/demo/sea_02.jpg',
'/data/storage/el2/base/haps/entry/files/demo/night_building_01.jpg',
'/data/storage/el2/base/haps/entry/files/demo/desk_pc_coffee_01.jpg',
'/data/storage/el2/base/haps/entry/files/demo/cat_sofa_01.jpg',
'/data/storage/el2/base/haps/entry/files/demo/park_tree_01.jpg'
]
build() {
// 省略,UI 同前
}
async handleInit() {
try {
this.loading = true
this.statusText = '正在初始化文搜图能力...'
await this.service.init()
this.initialized = true
this.statusText = '初始化完成,可以继续导入测试图片。'
promptAction.showToast({ message: '初始化成功' })
} catch (error) {
this.statusText = `初始化失败:${JSON.stringify(error)}`
promptAction.showToast({ message: '初始化失败' })
} finally {
this.loading = false
}
}
async handleImport() {
try {
this.loading = true
this.statusText = '正在导入测试图片,请稍候...'
await this.service.insertImages(this.testImages)
this.imported = true
this.statusText = `测试图片导入完成,共 ${this.testImages.length} 张,可以开始搜索。`
promptAction.showToast({ message: '测试图片导入成功' })
} catch (error) {
this.statusText = `导入失败:${JSON.stringify(error)}`
promptAction.showToast({ message: '导入失败' })
} finally {
this.loading = false
}
}
async handleSearch() {
// 下一节接
}
}
代码到这里,虽然搜索还没接上,但链路已经清晰起来了:
页面启动
↓
点击初始化
↓
能力准备完成
↓
点击导入测试图
↓
图片集合进入可检索范围
↓
等待用户输入搜索词
这时候你会发现,Demo 的“骨架感”就出来了。文章写到这里,也不会给人那种“上来一段代码糊脸”的感觉。

五、真正有意思的部分到了:输入一句话,把图片找出来
PhotoFinder 的灵魂当然还是搜索。
当“初始化”和“图片入库”都完成后,页面上最重要的动作就变成了:
- 读取用户输入;
- 调用搜索;
- 取回结果;
- 更新 UI。
这里最容易被忽略的一点是:搜索结果不是“命中 / 不命中”的二元结构,而是带相似度排序的一组结果。
这意味着我们不应该把它设计成一个“只有一张图”的场景,而应该天然按“结果集”来思考。哪怕第一名最相关,也不代表后面的图没有价值。
于是我把 handleSearch 补完整。
文件位置:entry/src/main/ets/pages/Index.ets
用途:执行搜索并渲染结果
async handleSearch() {
const query = this.keyword.trim()
if (!this.initialized) {
promptAction.showToast({ message: '请先初始化能力' })
return
}
if (!this.imported) {
promptAction.showToast({ message: '请先导入测试图片' })
return
}
if (!query) {
promptAction.showToast({ message: '请输入搜索内容' })
return
}
try {
this.loading = true
this.statusText = `正在搜索:${query}`
this.resultList = []
const result = await this.service.search(query, 6)
this.resultList = result
if (result.length === 0) {
this.statusText = `没有找到和“${query}”相关的图片。`
} else {
this.statusText = `搜索完成,找到 ${result.length} 张可能相关的图片。`
}
} catch (error) {
this.statusText = `搜索失败:${JSON.stringify(error)}`
promptAction.showToast({ message: '搜索失败' })
} finally {
this.loading = false
}
}
到这里,PhotoFinder 第一版已经具备最基本的工作能力了。
你输入:
- 海边的照片;
- 晚上的建筑;
- 有猫的图片;
- 桌子上的电脑;
页面就会返回一组语义上相关的图片结果。
这一步真正跑通的时候,成就感其实挺强。因为你会明显感觉到,系统处理的已经不是“字符串包含关系”,而是“图片内容和文字描述之间的语义关联”。
当然,这并不意味着结果一定次次完美。恰恰相反,第一版 Demo 最有价值的地方,正是它会暴露出很多“不完美但真实”的情况。
比如:
- 搜“海边”,有时会把天空占比很大的风景图也带出来;
- 搜“电脑”,可能会连工作桌面一起命中;
- 搜“夜晚建筑”,结果排序未必完全符合人的主观期待。
这些现象不是坏事。因为它提醒我们:做这类能力时,开发者真正要思考的,不只是“API 能不能调通”,而是**“如何理解返回结果”**。
也就是说,PhotoFinder 到这一步虽然已经能用,但它还只是“会工作”,距离“好用”还有一段路。这也正是第二篇要继续展开的地方。
六、把结果做得像回事,页面展示不能太敷衍
技术能力跑通之后,马上会遇到另一个现实问题:搜索结果到底怎么展示,用户才能看懂?
如果你只是用一个 Text 把路径打印出来,那当然也算验证成功,但这个 Demo 的可读性会很差。而且你后面根本没法判断排序是否合理,因为光看路径很难建立直觉。
所以结果展示我做了几件简单但必要的事:
一是用网格而不是列表
文搜图找的是图片,天然适合网格布局。两列网格已经足够直观,又不会太挤。
二是同时显示缩略图和相似度
光显示图片不够,因为你还想看系统是怎么排的。把相似度一起放出来,调试时非常有帮助。
三是保留状态文案
比如:
- 正在初始化;
- 导入完成;
- 正在搜索;
- 找到 6 张图片;
- 没有找到相关图片。
这些字看起来不起眼,但在 Demo 阶段特别有用。没有这些状态,你很难知道某一步到底卡在哪。
四是输入和搜索动作尽量收敛
不要一有输入就自动搜索。第一版先保留“点击按钮触发”。原因不是技术做不到,而是自动搜索会把节奏变复杂,尤其在你还没观察清楚返回效果之前,容易带来很多无意义的请求。
这一部分你会发现,很像做普通业务页面。其实也对。所谓“新能力接入”,很多时候并不是另起一套世界,而是把一个能力放进熟悉的页面和状态体系里。

七、第一版最容易遇到的几个坑,我先替自己踩一遍
一个 Demo 能不能变成一篇像样的文章,很大程度上取决于你有没有把“顺利跑通之外的那些不顺利”也讲出来。
如果只写“我调用了 API,返回了结果”,那更像操作说明,不像开发实战。
我在做第一版 PhotoFinder 时,比较容易踩到的坑主要有下面几个。
当前问题一:没初始化就搜索
这个最常见。页面逻辑如果不拦住,用户一进来直接点搜索,就会报错。
技术判断
把状态拦截放在页面层,而不是让底层能力每次都抛异常给用户看。底层可以抛错,但页面最好先把流程挡住。
当前问题二:图片还没导入,结果当然为空
很多人第一次接这种能力,会以为“只要图片在应用里,搜索就能搜到”。其实不是这么回事。你需要先把这些图片纳入到文搜图的可检索集合里。
技术判断
页面明确分成三个步骤:
- 初始化;
- 导入;
- 搜索。
不要让用户去猜当前到了哪一步。
当前问题三:关键词为空
这类问题虽然小,但最影响体验。空字符串提交上去没有意义,前端就应该先拦掉。
当前问题四:返回结果为空
这个不是错误,而是正常状态。比如你搜“火车站”,测试图片里本来就没有,那就应该给出明确反馈,而不是静默不动。
异常处理
所以状态文案里要区分:
- 搜索失败;
- 搜索成功但无结果。
这两者完全不是一回事。
当前问题五:路径和图片展示问题
如果搜索返回的是图片路径,而页面展示不出来,那你要优先检查的不是“是不是搜索错了”,而是路径本身是不是能被当前页面直接消费。
技术判断
在 Demo 阶段,图片路径最好统一管理、命名清晰,并尽量保持来源一致。这样排查问题时,你不会一会儿怀疑搜索,一会儿怀疑 UI,一会儿怀疑资源本身。
八、这篇文章真正讲清楚的,不是 API,而是一条最小闭环
写到这里,PhotoFinder 第一版其实已经完成了。它做到了几件很具体的事:
- 有一个像样的页面;
- 能完成初始化;
- 能导入一批测试图片;
- 用户可以输入自然语言;
- 页面返回相关图片结果;
- 基本状态可观察,异常可判断。
如果只看功能,这个 Demo 当然还不算复杂。它没有做图库管理,没有做分类,没有做删除同步,没有做历史记录,也没有做多作用域隔离。
但我并不觉得这是问题。因为第一篇的任务,本来就不是“把所有东西一口气做完”,而是建立第一条可运行、可观察、可继续演进的主链路。
这点很重要。
很多技术文章喜欢一步到位,结果看起来什么都讲了,读者真正落地时却不知道从哪里开始。相比之下,我更愿意让第一篇像一块稳固的地基:先把最小闭环立起来,后面的复杂度再一层一层往上加。
从这个角度看,PhotoFinder 第一版最有价值的,并不是搜索出了哪几张图,而是它让我确认了几件事:
- 文搜图能力是可以直接嵌入到实际页面流程里的;
- 这不是一个“只适合演示”的 API,它有明确的产品化空间;
- 结果展示要按“结果集 + 相似度”来思考,而不是按“命中一张图”来想;
- 页面状态管理从一开始就要清晰,否则后面一复杂就容易失控。
这些判断,会直接决定第二篇怎么写。

九、下一篇该解决什么问题
第一篇做到这里,主线已经打通了。但只要你把测试图片数量往上加一点,很快就会看到下一层问题。
比如:
- 所有图片都混在一起,结果会不会变乱?
- 旅行图、工作图、宠物图,能不能分开搜?
- 图片如果删除了,索引怎么同步?
- 结果里的相似度到底怎么理解?
- 页面退出之后,能力对象什么时候释放更合适?
- 如果连续搜索,状态怎么避免互相覆盖?
你会发现,第一篇结束的时候,真正复杂的问题才刚刚开始露头。这就对了。一个连载最怕的不是“讲不完”,而是“第一篇就把该说的都说光了”。
所以第二篇我会接着把 PhotoFinder 往前推一步:
不只是“它能搜”,而是“它开始像一个真正的功能”。
那一篇里,我会重点讲四件事:
- 用 Scope 管理不同图片集合;
- 观察和理解相似度排序;
- 处理新增、删除和重建;
- 收口生命周期和页面状态。
十、本文小记
回头看,这一篇其实做了一件挺朴素的事:从看到 HarmonyOS 7 的一个新能力开始,顺手把一个小想法做成了第一版 Demo。
没有宏大叙事,也没有什么“改变相册交互方式”的口号。就是一个很实际的问题:
如果我不想翻时间轴,能不能直接用一句话把图片找出来?
PhotoFinder 的第一版给出的答案是:可以,而且这条链路并不虚。
它当然还很早期,甚至可以说还有点“毛坯”。但毛坯不一定是坏事。毛坯意味着你能看见结构,也意味着后面的演进空间是真实存在的。
如果让我给这一篇下个定义,我会说:
这不是一篇“文搜图 API 介绍”,而是一篇“第一条语义搜图链路是怎么被搭出来的”。
这条链路跑起来之后,文章后面的内容也就自然了。第二篇,我们就不再满足于“能搜”,而是开始把它整理成一个更像样的应用功能。
更多推荐




所有评论(0)