第一篇里我把 PhotoFinder 的第一条“文字 → 图片”链路跑通了。图片能入库文本能搜索结果也能按相似度展示。但当测试图片从十几张增加到几十张以后问题开始从“接口怎么调用”变成“图库怎么组织”。这一篇不再重复基础接入而是继续把 Demo 往前推用 Scope 划分图片范围重新理解 similarity补齐图片增删、异常恢复和 release让 PhotoFinder 从一个能演示的 Demo变成一个开始值得维护的功能。第一篇做完以后我原本以为文搜图最难的部分已经过去了。毕竟核心链路已经跑起来了初始化、导入图片、输入一句话、拿到结果。再往后无非是多加一点 UI再多放一些图片。但当我真的把测试图库扩起来以后很快就发现事情没那么简单。最明显的问题是“混”。我把旅行照片、工作截图、宠物照片、生活随手拍都丢进同一批测试数据里。搜索“海边”还好结果比较集中搜索“电脑”时工作台、会议室屏幕、桌上的笔记本都会进来再搜索“猫”有时背景里很小的一只猫也会排进前几名。这时候再看第一版的结构所有图片 ↓ default ↓ 输入一句话 ↓ 搜索全部图片 ↓ 返回结果它当然能工作但业务边界已经开始模糊。我真正想做的不应该是“把所有图片都扔给搜索”而应该是用户当前在什么图库里就让搜索发生在什么范围里。这也是我继续研究textSearchImage时开始认真看scope的原因。HarmonyOS 7 / API 26 的文搜图接口里insertImage(imagePath, scope)在图片插入时就要求给出作用域搜索接口search(query, scope, topKey)同样要求传入作用域。搜索结果中的ImageObject也会带回imagePath、scope和similarity。也就是说scope并不是一个可有可无的附加字段。它从数据进入检索能力的那一刻就参与了图库的组织。这一篇我就从这里开始改。一、第一版的问题不是“搜不到”而是“搜得太宽”先看一个很具体的场景。我给 PhotoFinder 准备了 60 张测试图片20 张旅行照片15 张工作相关图片15 张宠物照片10 张生活随拍。第一版全部使用同一个 scope比如default然后我搜索桌子上的电脑返回结果里确实有电脑。但同时也可能出现旅行途中咖啡馆里的笔记本、酒店房间里的电视、甚至某张背景里带显示器的照片。单看语义这些结果并不一定“错”。问题在于用户当时可能是在“工作图库”里搜索。这就是我认为文搜图接入后第一个很值得讨论的工程问题语义相似不等于业务上应该出现。AI 能力负责判断“像不像”应用还要负责判断“该不该在这里出现”。这两件事不能混在一起。于是我把 PhotoFinder 的图库重新划成三个最小作用域travel work pet注意这里我没有直接把中文“旅行”“工作”“宠物”作为 scope。官方接口对 scope 有明确约束长度 132支持字母或数字。所以界面上仍然可以显示中文分类名但能力层使用稳定的英文标识。我最终做成UI 分类scope旅行travel工作work宠物pet看起来只是多了三个字符串但从这里开始PhotoFinder 的数据结构就和第一篇不一样了。二、Scope 最好在图片入库时就决定而不是搜索时临时补刚开始我有一个很自然的想法搜索的时候再选分类不就行了吗后来发现这个理解不完整。因为scope不只是搜索参数它在insertImage()时就已经存在。也就是说同一张图片以什么 scope 插入决定了后面应该在哪个范围里搜索它。所以我把第一篇比较松散的 Service 再整理了一次。这次不再让页面自己拼 scope而是在能力层统一定义。文件位置entry/src/main/ets/common/PhotoSearchRepository.ets用途统一管理初始化、图片插入和按 Scope 搜索import { textSearchImage } from kit.CoreVisionKit; import { BusinessError } from kit.BasicServicesKit; import { hilog } from kit.PerformanceAnalysisKit; export type PhotoScope travel | work | pet; export interface PhotoSearchResult { imagePath: string; scope: string; similarity: number; } export class PhotoSearchRepository { private initialized: boolean false; async init(): Promiseboolean { if (this.initialized) { return true; } try { const result await textSearchImage.init(); this.initialized result; hilog.info(0x0000, PhotoFinder, init result${result}); return result; } catch (error) { const err error as BusinessError; hilog.error( 0x0000, PhotoFinder, init failed, code${err.code}, message${err.message} ); return false; } } async insert(imagePath: string, scope: PhotoScope): Promiseboolean { if (!this.initialized) { throw new Error(PhotoSearchRepository has not been initialized); } return await textSearchImage.insertImage(imagePath, scope); } async search( query: string, scope: PhotoScope, topKey: number 12 ): PromisePhotoSearchResult[] { if (!this.initialized) { throw new Error(PhotoSearchRepository has not been initialized); } const keyword query.trim(); if (!keyword) { return []; } const result await textSearchImage.search(keyword, scope, topKey); return result.map(item ({ imagePath: item.imagePath, scope: item.scope, similarity: item.similarity })); } }这段代码没有做什么复杂设计但它解决了一个我比较在意的问题页面不再决定文搜图能力应该怎么组织数据。页面只负责告诉 Repository我现在在 work 我要搜索“会议室里的电脑”至于最后怎么调用textSearchImage.search()交给能力层处理。这是我比较喜欢的一种拆法。因为 UI 分类可能会变。今天是旅行 / 工作 / 宠物以后可能变成项目资料 / 产品图片 / 活动照片如果每个页面都自己拼scope时间一长很容易出现某个地方叫work另一个地方写成office的情况。Scope 本身很小但它其实已经是数据的一部分。三、切换分类以后搜索逻辑反而变简单了能力层收口以后页面上的搜索逻辑明显轻了一点。我给 PhotoFinder 顶部增加三个 Tab旅行 工作 宠物用户切换 Tab本质上只改变一个状态State currentScope: PhotoScope travel;然后搜索的时候把它传下去const result await this.repository.search( this.keyword, this.currentScope, 12 );这时候整个调用链就变成用户选择“工作” ↓ currentScope work ↓ 输入“桌子上的电脑” ↓ search(query, work, 12) ↓ 只返回 work 范围内的匹配图片我很喜欢这种变化。因为第一篇里页面虽然能搜但“图库”只是一个抽象概念。到了第二篇图库开始真正有边界了。而且这个边界不是靠搜索结果出来以后再做一次数组过滤而是在调用文搜图能力时就明确告诉它这次搜索只发生在这个 Scope 里。从工程角度看这要比“先全搜再按业务字段过滤”干净得多。这里还有一个容易忽略的点topKeysearch()的第三个参数topKey控制最多返回多少张图片。官方文档给出的范围是 0100默认值是 100。Demo 阶段我没必要一次拿 100 张。手机页面一屏只能看几张图所以我先取12这样既足够观察排序也不会让 UI 一下子铺满大量结果。以后如果真的做成大图库分页、懒加载和结果二次筛选可以另做但第一步先让返回规模和页面容量匹配。四、Similarity 不是一个“及格线”别急着写死 0.8Scope 解决了“搜哪里”的问题。下一个问题是搜回来的这些图片应该信到什么程度ImageObject里有一个非常关键的字段similarity官方定义的范围是[-1, 1]数值越大表示图片与查询文本的相似程度越高。看到这里开发者很容易马上写if (item.similarity 0.8) { // 展示 }我一开始也想这么干。后来我把这个判断删掉了。原因很简单官方给出了数值范围和大小关系但没有给一个适用于所有业务的统一“合格阈值”。0.8 在我的这批测试图片里看起来不错不代表换一批图片、换一种描述方式之后仍然合适。所以第二版 PhotoFinder 对 similarity 的处理我先做两件事保留原始分数优先用于排序和调试观察。而不是一上来把它变成一个武断的 Boolean。比如搜索海边的照片得到0.921 0.882 0.856 0.801 0.776 0.743此时我真正想知道的是第一名和第二名差多少换一个更具体的 Query排序会不会变化不同 Scope 的分布是不是类似低分图片看起来到底有多“不相关”这种观察比先拍脑袋定一个阈值有价值。五、我用三组 Query 做了一次很小的对照实验为了看 similarity 到底怎么变化我专门拿宠物图库做了一次测试。同一批图片不动数据只改搜索文本猫然后沙发上的猫再然后窗边晒太阳的猫我关心的不是“哪一句一定更好”而是描述越来越具体以后结果排序会发生什么。为此我在 Repository 外面又加了一层简单的调试日志文件位置entry/src/main/ets/pages/Index.ets用途记录 Query、Scope、结果数量和 similarityasync runSearchTest(query: string, scope: PhotoScope) { const result await this.repository.search(query, scope, 8); hilog.info( 0x0000, PhotoFinderTest, query${query}, scope${scope}, count${result.length} ); result.forEach((item, index) { hilog.info( 0x0000, PhotoFinderTest, rank${index 1}, similarity${item.similarity}, path${item.imagePath} ); }); this.resultList result; }然后我就不盯着 UI 猜了直接同时看模拟器和日志。我更推荐这种方式。因为图片结果是“视觉判断”日志是“数值证据”。两个放在一起才比较容易判断这个排序到底是不是我以为的那个排序。比如某张“猫趴在沙发上”的图片在搜“猫”时排第二搜“沙发上的猫”时变成第一这种变化就很值得记录。相反如果某张并没有窗户的图片在“窗边晒太阳的猫”里仍然排得很高也应该把它记下来而不是为了文章好看就忽略。技术文章真正有意思的地方经常就在这些“不完全符合预期”的结果里。六、UI 也要跟着 Scope 改不然用户不知道自己在搜什么数据有 Scope 以后UI 也必须把它表达出来。如果后台已经分成 travel / work / pet但页面上仍然只有一个孤零零的搜索框用户其实不知道当前搜索范围。所以我把页面改成PhotoFinder [旅行] [工作] [宠物] 搜索________________ 当前范围旅行 找到 6 张结果 [图片] [图片] [图片] [图片]这里我没有做特别花的设计。因为我想让“当前 Scope”尽量明显。切换分类时我还会把上一轮结果清空private changeScope(scope: PhotoScope) { if (this.currentScope scope) { return; } this.currentScope scope; this.resultList []; this.statusText 已切换到 ${scope} 图库; }这个动作看起来很小但挺重要。否则用户在“旅行”里搜完海边照片再切到“工作”页面上可能还残留上一轮海边结果。数据本身没错界面状态却会制造错觉。这就是我在第二篇越来越明显的一个感觉接 AI 能力不代表页面状态管理变得不重要反而因为结果是异步返回状态边界更应该清楚。七、图片增加容易真正麻烦的是删除以后怎么办到目前为止我们一直在往图库里加图片。但真实应用一定会遇到删除。用户删除一张照片后如果文搜图的特征数据仍然保留就可能出现一个很尴尬的情况搜索结果里返回了路径但业务侧已经不希望这张图继续参与检索。所以图片维护不能只做insert还需要delete官方接口提供了textSearchImage.deleteImage(imagePath, scope)这也是为什么前面我强调imagePath scope最好由业务层稳定保存。删除的时候不能只知道“我要删一张图片”还要知道哪张图 属于哪个 Scope我把删除逻辑也放回 Repositoryasync delete( imagePath: string, scope: PhotoScope ): Promiseboolean { if (!this.initialized) { throw new Error(PhotoSearchRepository has not been initialized); } try { const result await textSearchImage.deleteImage(imagePath, scope); hilog.info( 0x0000, PhotoFinder, delete result${result}, scope${scope}, path${imagePath} ); return result; } catch (error) { const err error as BusinessError; hilog.error( 0x0000, PhotoFinder, delete failed, code${err.code}, message${err.message} ); return false; } }我的处理习惯是业务层发起删除 ↓ 拿到 imagePath scope ↓ 调用 deleteImage() ↓ 同步更新自己的图片记录 ↓ 刷新当前搜索结果这样图片文件、业务数据和文搜图能力之间至少有一条清楚的维护路径。它不是严格意义上的数据库事务但我们至少知道每一步在做什么也知道失败以后应该查哪一层。八、clearData 不是“删除一个分类”它的级别比想象中大第二篇里我特别想单独说一下clearData()。因为看到这个名字很容易把它理解成清空当前图库。实际上官方定义是textSearchImage.clearData()它清理的是数据库中的全部数据。而且官方文档特别提到当模型能力更新后可以使用这个操作search()也定义了1013100003这一类能力更新相关错误并提示调用clearData()后再重新使用搜索能力。所以我不会在 PhotoFinder 里把它做成清空旅行图库这种普通按钮。因为 travel 只是我们自己定义的 scope。如果只想删除某个分类里的图片更合理的做法仍然是根据业务记录逐个调用deleteImage(imagePath, scope)clearData()更像一个“重置文搜图检索数据”的维护动作。这两个语义必须分开。我给它做了一个独立方法async clearAllSearchData(): Promiseboolean { try { const result await textSearchImage.clearData(); hilog.info( 0x0000, PhotoFinder, clearData result${result} ); return result; } catch (error) { const err error as BusinessError; hilog.error( 0x0000, PhotoFinder, clearData failed, code${err.code}, message${err.message} ); return false; } }而且在真实应用里我会把这个动作放在维护逻辑里而不是普通用户高频能碰到的位置。九、还有最后一个容易被 Demo 忽略的问题release第一篇为了尽快把链路跑起来我对生命周期处理得比较轻。第二篇既然已经开始谈“可维护”那release()就不能再跳过去了。官方提供textSearchImage.release()用于释放文本搜索图片分析器服务。我不太喜欢在每一次搜索后都 release。因为用户可能连续搜索海边 ↓ 海边日落 ↓ 有灯塔的海边如果每次搜索都init → search → release业务逻辑会很碎。我的策略更简单进入 PhotoFinder 能力 ↓ init ↓ 期间多次 insert / search / delete ↓ 确认这个功能阶段不再使用 ↓ release具体在哪个生命周期节点释放要结合应用自己的页面结构来定。我这里不把“某一个页面回调”写成唯一答案因为单页 Demo、多页面应用和长期驻留的功能入口并不一样。真正要守住的是初始化和释放必须成对思考。我给 Repository 加上async release(): Promiseboolean { if (!this.initialized) { return true; } try { const result await textSearchImage.release(); if (result) { this.initialized false; } hilog.info( 0x0000, PhotoFinder, release result${result} ); return result; } catch (error) { const err error as BusinessError; hilog.error( 0x0000, PhotoFinder, release failed, code${err.code}, message${err.message} ); return false; } }到这里PhotoFinder 的能力层就开始有一个比较完整的生命周期init ↓ insertImage ↓ search ↓ deleteImage ↓ 必要时 clearData ↓ release这已经比第一篇那个“先让搜索结果出来”的 Demo 完整很多了。十、异常恢复不能只弹一个 Toast图库清单要握在自己手里做到这里还有一个问题我觉得比普通的try/catch更值得写。官方文档在search()的错误码里给出了1013100002 Service abnormal 1013100003 The capability has been updated第二个尤其值得注意。它给出的处理方向是能力发生更新时先调用clearData()再重新使用相关能力。如果只看接口很容易把恢复代码写成search 失败 ↓ clearData ↓ 再次 search但放到 PhotoFinder 里这条链路其实还少了一步。clearData()清的是文搜图数据库里的全部数据。前面已经插入进去的图片特征也属于这批数据。清完以后我自己的 App 当然还知道“这些照片存在”但文搜图能力里原来的可检索数据已经被重置了。所以真正完整的恢复过程应该是search ↓ 发现能力更新错误 ↓ clearData ↓ 根据 App 自己保存的图片清单重新 insertImage ↓ 恢复各 Scope 的图片数据 ↓ 重新执行 search这让我意识到一个很重要的边界PhotoFinder 不能把 Core Vision Kit 当成自己的图库数据库。文搜图能力负责的是“图片特征的插入和检索”而我的应用仍然应该知道当前有哪些图片每张图片的沙箱路径是什么它属于哪个业务分类对应哪个 scope是否已经完成文搜图入库。所以我给业务侧保留了一份最小图片记录export interface PhotoRecord { id: string; imagePath: string; scope: PhotoScope; imported: boolean; }这份记录看起来很普通却是异常恢复能不能做完整的关键。假设某次能力更新以后PhotoFinder 需要重新建立检索数据我可以遍历这份清单async rebuildSearchData(records: PhotoRecord[]): Promisevoid { const cleared await textSearchImage.clearData(); if (!cleared) { throw new Error(clearData failed); } for (const item of records) { const inserted await textSearchImage.insertImage( item.imagePath, item.scope ); item.imported inserted; } }这里我特意没有做“失败就无限重试”。因为1013100002表示服务异常时连续立即重试并不一定有价值。更稳妥的做法是记录错误码、结束当前 loading 状态让页面恢复可操作然后根据实际产品策略决定是否提供“重新尝试”。同样重建过程中某一张图片失败也不能让页面假装全部恢复成功。我会至少记录总图片数 成功数量 失败数量 失败 path 对应 scope例如底部日志能看到[Rebuild] total60 [Rebuild] success58 [Rebuild] failed2 [Rebuild] failedPath... scopework这样下一次排查时问题就不再是模糊的“怎么有两张照片搜不到”而是有明确证据。这一段做完以后我对 PhotoFinder 的数据关系也更清楚了App 图片记录 │ ├── imagePath ├── scope └── imported │ ↓ textSearchImage 检索数据前者是我自己的业务事实后者是系统能力为了搜索建立的数据。两边有关联但不能当成同一份东西。这也是第二篇相比第一篇我觉得最大的工程变化之一第一篇关心的是“结果能不能出来”第二篇开始关心“当系统状态发生变化以后我有没有办法把它重新恢复出来”。十一、我最后把页面和能力层重新分了一次责任第二篇做完以后我又回头看了一遍工程结构。最后我保留了三层Index.ets ↓ 页面状态、Tab、搜索输入、结果展示 PhotoSearchRepository.ets ↓ init / insert / search / delete / clear / release textSearchImage ↓ HarmonyOS 7 Core Vision Kit 文搜图能力页面里不再出现一堆底层 API。Repository 里也不关心当前 Tab 长什么样搜索按钮是什么颜色图片是一列还是两列空状态用什么组件。两边的连接点其实就几个query scope topKey ImageObject[]这让我觉得第二篇真正完成的并不是“又学了几个 API”。而是把第一篇那条比较直的链路整理成了一个开始有边界的功能。十二、第二版 PhotoFinder我会这样验收做到这里我不会只测“海边能不能搜出来”。我会按几个维度跑一遍。1. Scope 是否真的隔离在travel搜海边的照片应该看到旅行图库里的相关图片。切到work后用同样的 Query结果应来自work范围而不是继续混入 travel。2. 切换 Scope 后旧状态是否清理旅行搜索完成后切到宠物页面不能继续显示旧旅行结果。3. similarity 是否按预期展示我不会先写死 0.8 阈值而是先观察不同 Query 下排序是否变化。4. 删除图片以后能否退出检索数据调用deleteImage(imagePath, scope)后再搜索同一 Query检查该图是否仍然参与结果。5. clearData 是否被误用它只进入维护/恢复路径不拿来做普通分类删除。6. release 后状态是否收口释放后 Repository 的initialized状态要同步更新避免页面还以为能力可直接调用。这些测试都不复杂但比“点一下按钮看到图片”更接近真实开发。十三、两篇连起来以后文搜图才算真正学了一遍回头看第一篇我做的事情非常克制图片进来 ↓ 文字进去 ↓ 图片出来那一篇最重要的是把能力跑起来。第二篇则开始问图片属于哪里 搜索应该发生在哪里 结果为什么这样排 图片删掉以后怎么办 能力什么时候释放这几个问题一出现Demo 的性质就变了。它不再只是“我调用了一个 HarmonyOS 7 新接口”。而开始变成我怎么把一个系统能力放进自己的应用结构里。这也是我做新能力 Demo 时越来越看重的一点。API 调通只是开始。真正值得记录的往往是 API 工作以后暴露出来的那些边界。比如 Scope 不是一个 UI 分类标签它参与图片插入和搜索similarity 不是一个天然的业务及格线它更适合先作为排序和观察依据deleteImage()和clearData()看起来都和“删除”有关但作用范围完全不一样release()也不是为了代码完整好看而是提醒我们这个分析器服务有生命周期。把这些问题想清楚以后再回头看 PhotoFinder结构已经比第一篇稳定很多。最后它大概是这样PhotoFinder │ ├── travel │ └── 文本搜索 → 旅行图片 │ ├── work │ └── 文本搜索 → 工作图片 │ └── pet └── 文本搜索 → 宠物图片 能力层 init insertImage search deleteImage clearData release这两篇写到这里我觉得这个小 Demo 已经完成了它最初的任务。最开始只是看到 HarmonyOS 7 的“文搜图”冒出一个念头能不能不翻相册直接说一句话把照片找出来第一篇给出了“能”。第二篇继续回答能跑之后怎么让这件事变得更可控、更容易继续维护。这比单纯罗列一遍textSearchImageAPI 更有意思。因为真正做开发时我们最终维护的从来不是某一个 API。我们维护的是围绕它长出来的那套应用逻辑。参考资料HarmonyOS 7 / API 26 Core Vision KittextSearchImage通过文本搜索图片API 模块kit.CoreVisionKit本文涉及接口init()、insertImage()、search()、deleteImage()、clearData()、release()
