【共创稿事节】鸿蒙文搜图 · 回忆胶囊:用一句话搜出照片,封存成带倒计时的时间胶囊,到期才能开启
【共创稿事节】鸿蒙文搜图 · 回忆胶囊:用一句话搜出照片,封存成带倒计时的时间胶囊,到期才能开启
文搜图第一篇把「找到」做完了:一句话进、相似度排序的图片网格出。但「找到」之后呢?翻出来就看完关掉,和没翻过差别不大。这一篇在检索的终点接一个新动作——封存:把搜出来的那张照片连同当时的检索词、给未来的一句留言,一起锁进一颗「回忆胶囊」,设一个倒计时(30 秒到 30 天),时间不到看不了,到期开启时模糊变清晰的揭示。检索解决「找回来」,胶囊解决「再遇见」。
技术底色不变:端侧 NPU 语义检索、零联网、零权限、数据不出设备。新增的是一层纯本地的时间状态机——封存、倒计时、到期、开启、重温,全部收在一个服务类里,和检索内核完全解耦。
工程地址:
LI_harmonyOS/memory-capsule
运行环境:HarmonyOS 7.0(API 26)+ ArkTS 严格模式,已在模拟器安装运行、全流程验证。
包名:com.example.memorycapsule,module.json5没有申请任何权限。
一、效果展示:一条「搜到 → 封存 → 等待 → 开启」的完整叙事线
完整流程五步:载入 → 搜索 → 封存 → 等倒计时 → 开启。以下截图均为演示模式(内置 12 张真实照片素材)模拟器运行,真机模式走同一套 UI、只把检索内核换成 NPU。
第 1 步:就绪。 应用启动后初始化完成,深色主题首屏——顶部标题区、搜索框 + 快捷回忆词、空态引导。和第一篇的浅色形成刻意区分:回忆是夜晚的事,深色才对味:
第 2 步:载入 + 搜索。 点「载入回忆」把 12 张照片登记进端侧索引,点快捷词「海边日落」直接检索,350ms 返回命中卡片——相似度角标 + 主题标签,和第一篇同款双口径:
第 3 步:封存。 点任意一张命中卡片,底部弹出封存表单:照片预览 + 检索词回显 + 给未来的留言框 + 五档封存时长(30 秒/1 分钟/1 天/7 天/30 天)。选时长、写留言、点「🔒 封存」:
第 4 步:等待。 封存成功后胶囊墙出现这颗胶囊:图片模糊预览 + 锁图标,右侧倒计时「还有 29 秒 开启」,下方进度环每秒推进。看不到内容本身就是产品语义——悬念是胶囊的核心体验:
第 5 步:开启。 倒计时归零,胶囊自动切换为「时间到了,可以开启了」,按钮从灰变绿。点开启,弹层里照片从模糊到清晰渐显,留言在动画结束后浮现——这一秒就是整颗胶囊等待的意义:
二、这个 App 解决什么问题
「时间胶囊」类产品(慢递信、给未来的自己写信)有个共同痛点:写的时候不知道配什么图,配图的时候又要翻半天相册。回忆胶囊把这两步合成一步:
| 传统做法 | 死角 |
|---|---|
| 挑一张照片存相册收藏 | 收藏夹几百张,存了等于没存,没有「开启时刻」 |
| 写慢递信 | 纯文字,没有画面;寄丢、忘看 |
| 发「朋友圈仅自己可见」 | 社交产品没有时间锁,随时能翻出来,悬念为零 |
回忆胶囊的答案是:检索即选图、封存即寄出、时间即悬念。一句话搜出那张照片(语义检索保证「就是那张」),封存时带上留言和时长,到期前谁也看不了——包括封存的自己。三件事分别落在三个技术点上:
- 选图靠语义:延续第一篇的文搜图内核,标签匹配引擎演示、NPU 端侧推理真机,接口不变;
- 悬念靠状态机:
SEALED → OPENABLE → OPENED三态流转,到期前 UI 层拿不到清晰图; - 仪式感靠动画:开启时模糊→清晰渐显 + 留言延迟浮现,把「等了 30 秒/30 天」兑现成可感知的一瞬。
三、交互设计:一颗胶囊的三态流转
页面没有路由跳转,状态收在 @State 集合里。除了第一篇同款的检索状态(ready/indexedCount/loading/queryText/resultList/statusText),新增胶囊专属的六个:
@State ready: boolean = false;
@State indexedCount: number = 0;
@State loading: boolean = false;
@State queryText: string = '';
@State resultList: SearchHit[] = [];
@State capsules: Capsule[] = [];
@State statusText: string = '初始化中…';
/** 当前选中要封存的图片(进入封存流程) */
@State selectedHit: SearchHit | null = null;
/** 封存表单可见(bindSheet $$ 绑定,需独立布尔状态) */
@State sealSheetVisible: boolean = false;
/** 开启弹层可见(bindSheet $$ 绑定,需独立布尔状态) */
@State openSheetVisible: boolean = false;
/** 封存表单:留言 */
@State messageText: string = '';
/** 封存表单:时长索引 */
@State durationIndex: number = 0;
/** 开启动画中的胶囊(弹层展示) */
@State openedCapsule: Capsule | null = null;
/** 开启动画触发(false→true 时播放) */
@State revealAnim: boolean = false;
检索流程防重入、状态文案四结局(找到 N 张/没找到/检索中/初始化中)全部沿用第一篇的纪律,不赘述。这一篇的核心是胶囊状态机:
3.1 三态流转:SEALED → OPENABLE → OPENED
一颗胶囊的生命周期由 CapsuleState 枚举驱动,状态是数据不是 UI——UI 只按状态渲染,永远不反向改状态:
export enum CapsuleState {
/** 封存中:不可开启,模糊预览 */
SEALED = 'sealed',
/** 可开启:倒计时结束 */
OPENABLE = 'openable',
/** 已开启:进回忆时间线 */
OPENED = 'opened'
}
三个状态对应三张脸:
- SEALED:模糊预览 + 锁图标 + 倒计时 + 紫色进度环,「查看」按钮点了只 toast「还没到开启时间」;
- OPENABLE:倒计时文案变「时间到了,可以开启了」、按钮变绿「开启」,自动切换不需要任何用户操作;
- OPENED:图片清晰、按钮变「重温」,点开重放揭示动画。
3.2 每秒 tick:状态机的心跳
倒计时靠 setInterval 每秒驱动,这里有个容易踩的坑值得展开。最初版本只在 tick() 返回「有状态变化」时才刷新数组:
// 倒计时刷新:每秒 tick(到期自动变可开启)+ 无条件重赋数组驱动进度环/剩余时间重绘
this.timerId = setInterval(() => {
this.service.tick();
if (this.service.getCapsules().length > 0) {
this.capsules = this.service.getCapsules().slice();
}
}, 1000);
tick() 只在 SEALED→OPENABLE 跃迁时返回 true——但倒计时文本和进度环每秒都在变,如果只在跃迁时刷新,进度环会卡死在封存那一刻,剩余时间永远不走。所以改成有胶囊时每秒无条件重赋数组:slice() 生成新引用触发 @State 重新渲染,tick() 的返回值反而不再重要。时间类 UI 的刷新频率由 UI 决定,不由状态机决定——状态机管跃迁,UI 管心跳。
3.3 封存表单:三字段一次收口
点命中卡片进封存流程,表单三个字段(照片、留言、时长)一次收齐,确认时一次性落库:
/** 确认封存 */
private confirmSeal(): void {
if (!this.selectedHit) {
return;
}
const duration: number = DURATIONS[this.durationIndex].ms;
this.service.seal(this.queryText, this.messageText, this.selectedHit, duration);
this.capsules = this.service.getCapsules().slice();
this.selectedHit = null;
this.sealSheetVisible = false;
this.toast('胶囊已封存,倒计时开始');
}
注意封存时把 queryText(当时的检索词)一起存进胶囊——检索词是回忆的一部分:三个月后开启时看到「海边日落」四个字,比看到一张照片本身多一层「我当时为什么搜它」的语境。留言允许为空(message.trim() 为空时开启弹层显示「封存时没有留下留言」),空值是一等公民,和第一篇空结果同款处理。
3.4 开启:模糊到清晰的仪式
开启胶囊的动画分两拍:先弹层展示模糊态(revealAnim = false),100ms 后置 true 触发渐显——用状态切换驱动动画,而不是动画库,ArkUI 的 .animation() 属性会自动补间 blur 和 opacity 的变化:
/** 开启胶囊(带揭示动画) */
private tryOpen(id: string): void {
const opened: Capsule | null = this.service.openCapsule(id);
if (!opened) {
this.toast('还没到开启时间,再等等');
return;
}
this.capsules = this.service.getCapsules().slice();
this.openedCapsule = opened;
this.openSheetVisible = true;
this.revealAnim = false;
setTimeout(() => {
this.revealAnim = true; // 触发模糊→清晰揭示动画
}, 100);
}
openCapsule 在服务层校验状态——不是 OPENABLE 一律返回 null,UI 层 toast「还没到开启时间」。时间锁是产品语义,必须在数据层强制,UI 只负责把拒绝翻译成人话。
四、工程结构
memory-capsule/
├── entry/src/main/
│ ├── ets/
│ │ ├── pages/Index.ets # 检索 + 胶囊墙 + 两个自实现弹层
│ │ └── common/
│ │ ├── MemoryCapsuleService.ets # 单例服务:检索 + 胶囊管理二合一
│ │ └── DemoData.ets # 12 张示例图:标签 + 主题 + rawfile 路径
│ └── resources/rawfile/demo/ # 12 张真实照片素材(旅程/拼搏/陪伴)
相比第一篇少了一个 scopeConfig.ets(作用域让位给主题标签),多的是 MemoryCapsuleService 里的胶囊管理段。三层职责不变:
| 层 | 文件 | 职责 | 不知道的事 |
|---|---|---|---|
| 页面层 | Index.ets | 状态机、布局、倒计时心跳、弹层 | 不知道检索是 NPU 还是标签匹配 |
| 服务层 | MemoryCapsuleService.ets | 检索 + 封存/倒计时/开启状态机 | 不知道 UI 有哪些状态 |
| 数据层 | DemoData.ets | 素材定义(标签 + 主题) | 不知道自己被谁消费 |
接口即防火墙在胶囊管理上体现得更彻底:seal / tick / openCapsule 三个方法和检索内核(init / importFromAlbum / search)住在同一个类里但互不感知——真机启用 NPU 检索时只换 search 的内部实现,胶囊管理一行不改。
五、核心实现
5.1 数据层:标签矩阵 + 回忆主题
12 张演示图沿用第一篇的同义词矩阵(海边/海/日落/夕阳/沙滩… 保证「大海」「夕阳」都能命中同一张图),每张多了一个 theme 字段——回忆主题(旅程/拼搏/陪伴),封存表单和胶囊墙用它做轻量分类标签:
export const DEMO_IMAGES: DemoImage[] = [
// —— 旅程 journey ——
{ id: 'sea', rawfile: 'demo/sea_sunset.png', scope: 'travel', title: '海边日落', theme: 'journey', tags: ['海边', '海', '日落', '夕阳', '沙滩', '风景', '旅行', '大海'] },
{ id: 'snow', rawfile: 'demo/snow_mountain.png', scope: 'travel', title: '雪山', theme: 'journey', tags: ['雪山', '雪', '山', '冬天', '风景', '旅行', '滑雪'] },
…
主题不是装饰:快捷词六个(海边日落/雪山/深夜加班/撸猫/头脑风暴/城市夜景)两两覆盖三个主题,演示时任意封存两颗胶囊,胶囊墙就能展示不同主题标签并存的样态。
5.2 服务层:seal / tick / open 三件套
封存是纯同步操作——生成 id、算 openAt、unshift 排最前(新胶囊永远在胶囊墙第一位):
/** 封存一颗胶囊 */
public seal(query: string, message: string, hit: SearchHit, durationMs: number): Capsule {
const now: number = Date.now();
const capsule: Capsule = {
id: `cap_${now}_${this.seq}`,
query: query,
message: message,
imagePath: hit.imagePath,
title: hit.title ?? '一张照片',
theme: hit.theme ?? 'journey',
sealedAt: now,
openAt: now + durationMs,
state: CapsuleState.SEALED
};
this.seq++;
this.capsules.unshift(capsule); // 新胶囊排最前
hilog.info(DOMAIN, TAG, 'capsule sealed id=%{public}s openAt=%{public}d', capsule.id, capsule.openAt);
return capsule;
}
tick() 扫描所有 SEALED 胶囊、把到期的置为 OPENABLE;openCapsule() 校验状态后置 OPENED。两个方法都只改数据不改 UI——UI 的刷新由页面的每秒心跳统一驱动。
5.3 胶囊墙:模糊预览的三档透明度
胶囊卡片的预览图按状态分三档渲染——SEALED 模糊 18 + 透明度 0.7 + 锁图标,OPENABLE/OPENED 清晰,.animation() 自动补间状态切换时的过渡:
Stack() {
Image($rawfile(c.imagePath))
.width(72)
.height(72)
.objectFit(ImageFit.Cover)
.borderRadius(14)
.blur(c.state === CapsuleState.SEALED ? 18 : 0)
.opacity(c.state === CapsuleState.SEALED ? 0.7 : 1)
.animation({ duration: 600, curve: Curve.EaseOut })
到期那一秒,模糊值 18→0 被 .animation() 补间成 600ms 的渐显——状态跃迁的瞬间被动画拉长成可感知的过程,用户不用盯进度环也能注意到「这颗胶囊到时间了」。

5.4 倒计时文本:四级单位自适应
剩余时间展示做了单位降级:天/时 → 时/分 → 分/秒 → 秒,30 天和 30 秒共用一个函数,胶囊墙永远显示当前量级最有信息量的两级单位:
/** 剩余时间展示(天/时/分/秒) */
private remainText(c: Capsule): string {
const remain: number = Math.max(0, c.openAt - Date.now());
if (remain <= 0) {
return '可开启';
}
const sec: number = Math.floor(remain / 1000);
const days: number = Math.floor(sec / 86400);
if (days >= 1) {
return `${days} 天 ${Math.floor((sec % 86400) / 3600)} 时`;
}
const hours: number = Math.floor(sec / 3600);
if (hours >= 1) {
return `${hours} 时 ${Math.floor((sec % 3600) / 60)} 分`;
}
const minutes: number = Math.floor(sec / 60);
if (minutes >= 1) {
return `${minutes} 分 ${sec % 60} 秒`;
}
return `${sec} 秒`;
}
进度环同理:sealProgress() 算 (now - sealedAt) / (openAt - sealedAt),30 秒和 30 天的进度环走的是同一条归一化曲线——封存第一秒和最后一秒环的推进速度感知一致。
5.5 弹层:自实现替代 bindSheet
两个弹层(封存表单、开启揭示)没有用系统 bindSheet,而是 Stack + 条件渲染自实现:主界面 build() 根容器是 Stack,弹层作为兄弟节点按 sealSheetVisible / openSheetVisible 条件挂载,遮罩层 Blank() 把内容压到底部、半透明黑遮罩 + 圆角面板,点遮罩关闭、点面板内部被空 onClick 拦截:
// —— 封存表单弹层(自实现,条件渲染) ——
if (this.sealSheetVisible) {
this.sealOverlay()
}
自实现换来三个好处:条件渲染的节点在 dumpLayout 里可见(bindSheet 的内容在无障碍树里时有时无,自动化验证和可达性测试都更稳);出场动画完全可控(translate + animation 自定义,不受系统 Sheet 手势冲突影响);不依赖 $$ 双向绑定——实测当前版本 bindSheet 的 $$ 绑定存在状态设 true 但 Sheet 不渲染的静默失败,自实现彻底绕开。代价是要自己处理遮罩点击、内层拦截这些细节,但对「弹层即产品体验」的胶囊开启场景,控制权在自己手里更值。
六、真机运行指南
- 演示模式(默认):点「载入回忆」载入 12 张内置素材,标签匹配引擎出结果,任何设备完整跑通载入→搜索→封存→倒计时→开启全流程;封存时长选「30 秒 · 演示」档,30 秒后即可体验开启动画;
- 真机模式:把
MemoryCapsuleService.search内部替换为textSearchImage调用(与第一篇RealTextImageSearchService同款),init调visionBase建分析器、导入走PhotoViewPicker+insertImage——胶囊管理部分与检索内核无关,零改动; - 全程端侧、不联网、不上传、不申请权限;胶囊数据在内存态,退出页面
release收口,重进重新初始化——回忆胶囊是体验形态,不是持久化相册,下一步做落盘。
七、踩坑清单速查表
| 现象 | 原因 | 修法 |
|---|---|---|
| bindSheet 状态设 true 但 Sheet 不弹 | $$ 双向绑定静默失败 | Stack + 条件渲染自实现弹层 |
| 进度环/倒计时文本不走 | 只在状态跃迁时刷新数组 | 有胶囊时每秒无条件 slice() 重赋 |
| 点弹层内部误关弹层 | 遮罩 onClick 穿透 | 内层面板加空 onClick 拦截 |
| 到期后胶囊还显示锁 | tick() 没被定时驱动 | setInterval 每秒 tick + aboutToDisappear 清理 |
| 30 秒和 30 天进度感知不一致 | 进度环用绝对时间 | (now-sealedAt)/(openAt-sealedAt) 归一化 |
| ForEach 回调无类型标注报错 | ArkTS 严格模式 | (d: DurationOption, idx: number) 显式标注 |
| 退出页面定时器残留 | setInterval 未清理 | aboutToDisappear 里 clearInterval |
| 开启动画不播 | 弹层挂载即清晰 | 先 false 挂模糊态,100ms 后置 true 触发补间 |

八、总结
至此文搜图方向走出了「找到之后」这一步:一句话进、相似度排序的图片出、选中封存成时间胶囊、到期开启。
- 检索内核与第一篇完全同源,接口即防火墙,换 NPU 内核 UI 零改动;
- 胶囊状态机三态流转,时间锁在数据层强制,UI 只做翻译;
- 模糊预览 + 倒计时 + 进度环,看不到本身就是产品语义;
- 开启时模糊→清晰渐显 + 留言浮现,把等待兑现成可感知的一瞬;
- 弹层自实现替代 bindSheet,控制权换稳定性,自动化验证友好。
下一步两个方向:一是胶囊落盘——用关系型数据库或偏好设置把胶囊持久化,重进应用倒计时继续走,让「封存 30 天」真的跨会话生效;二是和图像超分系列打通——开启老照片胶囊时先走端侧 4× 超分再揭示,「等了 30 天,还你一张更清晰的回忆」。两个内核都已经在各自系列里备齐了。
更多推荐





所有评论(0)