【共创稿事节】鸿蒙文搜图 · 回忆拼贴:一句话搜出多张照片,自动编排成回忆海报,组件截图落盘保存
【共创稿事节】鸿蒙文搜图 · 回忆拼贴:一句话搜出多张照片,自动编排成回忆海报,组件截图落盘保存
文搜图系列第一篇解决了「找到」——一句话进、相似度排序的图片网格出;第二篇解决了「留住」——封存成带倒计时的时间胶囊。这一篇解决「带走」:搜出来的那组照片,自动编排成一张排版讲究的回忆海报,一键保存成真实 JPEG 文件落进沙箱——发朋友圈、存档、打印,随你处置。
技术上的关键差异在前两篇都是「屏幕内」的能力,这一篇打通「屏幕外」的最后一公里:组件截图(ComponentSnapshot.get 把屏幕上任意组件树截成 PixelMap)→ ImagePacker 编码(PixelMap 压成 JPEG 字节流)→ 沙箱落盘(字节流写进应用 files 目录)。检索内核照旧端侧 NPU、零联网、零权限。
工程地址:
LI_harmonyOS/memory-collage
运行环境:HarmonyOS 7.0(API 26)+ ArkTS 严格模式,已在模拟器安装运行、全流程验证(含落盘文件字节级确认)。
一、效果展示:一条「搜出 → 编排 → 落盘」的完整叙事线
完整流程四步:载入 → 搜索 → 看编排 → 保存。快捷词按命中数覆盖五档布局。以下截图均为演示模式(内置 12 张真实照片素材)模拟器运行,真机模式走同一套 UI、只把检索内核换成 NPU。
第 1 步:就绪。 应用启动后初始化完成,深色主题首屏——搜索框 + 五个快捷回忆词 + 空态引导:
第 2 步:载入 + 搜索。 点「载入回忆」把 12 张照片登记进端侧索引,点快捷词「海边日落」,350ms 返回命中——1 张命中自动选「单图大画幅」布局,海报本体直接渲染:检索词标题 + 日期 + 大图 + 主题标签 + 布局说明:
第 3 步:五档布局自动分派。 换不同关键词,命中数不同、布局自动切换——用户不选模板,布局是数据的函数:搜「深夜加班」命中 2 张走左右双联,搜「风景」命中 3 张走一主两副,搜「宠物」命中 4 张走 2×2 宫格,搜「和宠物的旅行」命中 8 张走主图马赛克。这四档实际编排效果分别配在后文讲解对应逻辑的 3.1、3.2、5.3 小节里。
第 4 步:落盘。 点「💾 保存海报」,组件截图 → JPEG 编码 → 沙箱写入,保存成功条展示完整路径。hilog 字节级确认:单图海报 134KB、四图宫格海报 274KB,真实文件真实落盘:
二、这个 App 解决什么问题
「搜出一组照片然后想发出去/存下来」这个需求,现有路径都很别扭:
| 方案 | 死角 |
|---|---|
| 截屏整屏 | 带着状态栏、导航条、输入法,裁剪成本高,构图全无 |
| 逐张保存再拼图 | 要装第三方拼图 App,排版模板和照片内容零关联 |
| 相册自带拼图 | 模板固定,选图靠人肉,没有「按语义成组」的概念 |
回忆拼贴的答案是:语义检索天然给出「一组相关照片」,编排规则把这组照片变成一张有构图的海报,组件截图保证所见即所得。三个技术点各管一段:
- 选图靠语义:一句话召回语义相关的一组照片,天然成组,不用人肉多选;
- 构图靠规则:五档布局按命中数量自动分派(1 张单图/2 张双联/3 张一主两副/4 张宫格/5+ 张主图马赛克),相似度最高的永远占最大画幅;
- 出图靠组件截图:屏幕上渲染成什么样,落盘文件就是什么样——所见即所得,不用自己画 Canvas。
三、交互设计:布局是数据的函数
页面状态沿用系列纪律:单真相源、每条异步路径专属文案、loading 闸门防重入。这一篇的新增状态只有三个:
@State ready: boolean = false;
@State indexedCount: number = 0;
@State loading: boolean = false;
@State queryText: string = '';
@State resultList: SearchHit[] = [];
@State statusText: string = '初始化中…';
/** 当前拼贴布局(由命中数量决定) */
@State layout: CollageLayout = CollageLayout.SINGLE;
/** 保存中 */
@State saving: boolean = false;
/** 已保存的海报路径(非空时展示保存成功条) */
@State savedPath: string = '';
layout 不是用户选的,是 pickLayout(resultList.length) 算出来的——布局是数据的函数,用户不决策。搜出 1 张就是单图、4 张就是宫格,没有「选模板」这一步。这个设计砍掉了一个交互环节,也保证任何检索结果都有合理的构图兜底。
3.1 检索即编排:一次异步流程两件事
doSearch 里检索完成后立刻算布局、更新状态栏文案——检索和编排是同一次用户操作的两个阶段,不需要用户再点一次「生成海报」:
/** 检索(防重入:loading 闸门) */
private async doSearch(): Promise<void> {
if (this.loading) {
return;
}
const keyword: string = this.queryText.trim();
if (!keyword) {
this.toast('先写一句回忆,比如「海边日落」');
return;
}
this.loading = true;
this.savedPath = '';
this.statusText = '正在端侧检索回忆…';
try {
const hits: SearchHit[] = await this.service.search(keyword);
this.resultList = hits;
if (hits.length > 0) {
this.layout = this.service.pickLayout(hits.length);
this.statusText = `找到 ${hits.length} 张,已按「${LAYOUT_LABELS[this.layout]}」编排成海报`;
} else {
this.statusText = '没有找到这段回忆,换个说法试试';
}
} finally {
this.loading = false;
}
}
注意 this.savedPath = '' 在检索入口清空——新检索开始时旧海报的保存记录必须失效,否则用户会误以为新海报已经保存过。空结果也是一等公民:状态栏给「没有找到这段回忆,换个说法试试」,海报区回落空态引导。
下图是搜「和宠物的旅行」、一次检索命中 8 张的结果——检索返回的同一刻,海报已按主图马赛克自动编排好,中间没有任何「生成」按钮:
3.2 五档布局:数量驱动,相似度定主图
布局选择收在服务层 pickLayout,页面只管渲染分派:
/** 按命中数量选择拼贴布局 */
public pickLayout(count: number): CollageLayout {
if (count <= 0) {
throw new Error('无命中图');
}
if (count === 1) {
return CollageLayout.SINGLE;
}
if (count === 2) {
return CollageLayout.DUO;
}
if (count === 3) {
return CollageLayout.TRIO;
}
if (count === 4) {
return CollageLayout.QUAD;
}
return CollageLayout.Mosaic;
}
比如搜「深夜加班」命中 2 张,pickLayout(2) 返回 DUO,海报立刻切成左右双联——电脑桌面与城市夜景并排:
五档布局的共同纪律:resultList 已按相似度降序,[0] 永远是主图——单图布局它是唯一,trio 布局它占 200px 大画幅、两张副图 120px 跟随,mosaic 布局它 220px 领衔、四张副图 100px 环绕。语义最相关的照片获得最大画幅,这是检索排序在构图层的直接延续。
搜「风景」命中 3 张正是 TRIO:相似度最高的海边占 200px 大画幅,雪山、森林两张副图 120px 跟随,主次一眼可辨:
3.3 保存:三步流水线一次收口
savePoster 是三步异步流水线:组件截图 → 服务层编码落盘 → 状态回显。saving 闸门防重入,finally 复位:
/** 保存海报:组件截图 → 编码 → 落盘 */
private async savePoster(): Promise<void> {
if (this.saving || this.resultList.length === 0) {
return;
}
this.saving = true;
this.savedPath = '';
this.statusText = '正在生成海报文件…';
try {
// 组件截图:走 UIContext 的 ComponentSnapshot.get(componentSnapshot.get 已废弃)
const pixelMap: image.PixelMap =
await this.getUIContext().getComponentSnapshot().get('posterNode', { waitUntilRenderFinished: true });
const path: string = await this.service.savePoster(pixelMap, this.queryText);
this.savedPath = path;
this.statusText = '海报已生成,保存到沙箱';
this.toast('海报已保存');
} catch (e) {
const err = e as BusinessError;
console.error(`savePoster failed, code: ${err.code}, message: ${err.message}`);
this.statusText = '保存失败,请重试';
this.toast('保存失败,请重试');
} finally {
this.saving = false;
}
}
两个 API 细节值得抄走:
getComponentSnapshot().get(id)而不是全局componentSnapshot.get——后者 API 18 起已废弃,UIContext 版本是标准写法,和系列前作 Toast 走getUIContext().getPromptAction()同一逻辑:全局单例 API 一律让位给 UIContext 版;waitUntilRenderFinished: true——强制等所有渲染指令完成再截图。海报里是 4 张刚解码完的图片,不等渲染收尾可能截到半成品。官方文档也明说这个选项「应尽可能开启」。
四、工程结构
memory-collage/
├── entry/src/main/
│ ├── ets/
│ │ ├── pages/Index.ets # 检索 + 五档布局渲染 + 保存流程
│ │ └── common/
│ │ ├── CollageService.ets # 单例服务:检索 + 布局分派 + 编码落盘
│ │ └── DemoData.ets # 12 张示例图:标签 + 主题 + rawfile 路径
│ └── resources/rawfile/demo/ # 12 张真实照片素材(旅程/拼搏/陪伴)
三层职责与系列前作同构,服务层多了一段「落盘」职责:
| 层 | 文件 | 职责 | 不知道的事 |
|---|---|---|---|
| 页面层 | Index.ets | 状态机、五档布局渲染、截图触发 | 不知道编码格式和落盘路径规则 |
| 服务层 | CollageService.ets | 检索 + pickLayout + savePoster | 不知道 UI 有哪些布局渲染细节 |
| 数据层 | DemoData.ets | 素材定义(标签 + 主题) | 不知道自己被谁消费 |
接口即防火墙依然成立:真机换 NPU 检索只改 search 内部,pickLayout 和 savePoster 与检索内核零耦合。
五、核心实现
5.1 落盘三步:截图 → 编码 → 写文件
savePoster 服务层实现是本篇的技术核心,三步各自有讲究:
/**
* 海报落盘:组件截图(PixelMap)→ ImagePacker JPEG 编码 → 沙箱 files 目录保存。
* 返回保存的绝对路径,UI 层展示。
*/
public async savePoster(pixelMap: image.PixelMap, query: string): Promise<string> {
const context: common.Context = this.getContext();
const dir: string = `${context.filesDir}/posters`;
if (!fileIo.accessSync(dir)) {
fileIo.mkdirSync(dir, true);
}
const stamp: string = this.formatStamp(new Date());
const path: string = `${dir}/poster_${stamp}.jpg`;
const packer: image.ImagePacker = image.createImagePacker();
const opts: image.PackingOption = { format: 'image/jpeg', quality: 92 };
const buf: ArrayBuffer = await packer.packing(pixelMap, opts);
const file = fileIo.openSync(path, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
try {
fileIo.writeSync(file.fd, buf);
} finally {
fileIo.closeSync(file);
await packer.release();
}
hilog.info(DOMAIN, TAG, 'poster saved path=%{public}s bytes=%{public}d', path, buf.byteLength);
return path;
}
四个细节:
filesDir/posters/子目录:海报不散落在 files 根目录,独立子目录方便后续做「海报列表」功能时一次listFileSync全取;- 时间戳文件名
poster_20260925_180441.jpg:精确到秒,天然唯一、天然可排序,重名覆盖的风险归零; - quality 92:JPEG 质量档位在 90~95 之间是肉眼无损和体积的甜点区,实测单图海报 134KB、四图宫格 274KB——发社交平台绰绰有余;
finally里双收口:文件句柄closeSync+ 编码器release成对释放。ImagePacker 不释放会泄漏底层编码资源,和系列前作「生命周期成对收口」同一条纪律。
5.2 组件截图的 id 约定
海报本体组件挂 .id('posterNode')——ComponentSnapshot.get 按组件 id 定位截图目标。id 挂在海报 Column 上而不是整页,截出来的就是纯海报:标题、拼贴、主题标签一应俱全,但不含搜索框和保存按钮。截图边界就是产品边界——用户要保存的是海报,不是屏幕。
5.3 布局渲染:@Builder 分派五档
collageBody 按 layout 分派到五个 @Builder,每档布局是纯声明式渲染,无任何命令式操作。以 QUAD 为例:
/** 2×2 宫格 */
@Builder
quadBody() {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.resultList.slice(0, 4), (h: SearchHit) => {
Image($rawfile(h.imagePath))
.width('48.5%')
.height(150)
.objectFit(ImageFit.Cover)
.borderRadius(12)
.margin(3)
}, (h: SearchHit) => h.imagePath)
}
.width('100%')
}
搜「宠物」命中 4 张时走的就是这个 QUAD,slice(0, 4) 取猫/狗/兔子/鱼,FlexWrap.Wrap 自动换行成 2×2:
48.5% + margin 3 是算过的:两列各 48.5% 加左右 margin 恰好填满行宽,FlexWrap.Wrap 自动换行成 2×2。slice(0, 4) 是防御——QUAD 只取前 4 张,万一未来命中数和布局档位解耦(比如用户手动选布局),也不会渲染溢出。
5.4 海报头尾:检索词与主题标签
海报头是检索词 + 日期,尾是主题标签 + 布局说明。主题标签来自结果的 theme 字段去重:
/** 结果的主题去重列表 */
private themesOf(hits: SearchHit[]): string[] {
const set: string[] = [];
for (const h of hits) {
const t: string = h.theme ?? '';
if (t && !set.includes(t)) {
set.push(t);
}
}
return set;
}
搜「宠物」4 张全是 companion 主题,海报尾一个「陪伴」标签;如果未来搜出跨主题的结果(比如「风景」命中旅行+自然两类),标签会并列展示——海报的元信息跟着数据走,不是写死的装饰。
六、真机运行指南
- 演示模式(默认):点「载入回忆」载入 12 张内置素材,标签匹配引擎出结果,任何设备完整跑通载入→搜索→编排→落盘全流程;五个快捷词按命中数覆盖五档布局——「海边日落」1 张单图、「深夜加班」2 张双联、「风景」3 张一主两副、「宠物」4 张宫格、「和宠物的旅行」8 张主图马赛克;
- 真机模式:把
CollageService.search内部替换为textSearchImage调用(与前作RealTextImageSearchService同款),布局与落盘部分零改动; - 海报保存在应用沙箱
files/posters/目录,不申请任何存储权限——应用沙箱内读写不需要权限,这是 HarmonyOS 沙箱模型的红利;要导出到公共媒体库需走photoAccessHelper的安全写入(MediaAssetChangeRequest),那是下一篇的主题; - 全程端侧、不联网、不上传;hilog 里
poster saved path=... bytes=...可字节级确认落盘结果。
七、踩坑清单速查表
| 现象 | 原因 | 修法 |
|---|---|---|
componentSnapshot.get 报废弃用告警 | 全局版 API 18 起废弃 | getUIContext().getComponentSnapshot().get(id) |
| 截图内容缺最新一帧 | 渲染指令未收尾就截图 | waitUntilRenderFinished: true |
| 截图目标含搜索框等无关 UI | id 挂在整页容器上 | id 挂海报 Column,截图边界即产品边界 |
| 保存的图片半张是空白 | Image 组件解码未完成 | 截图前确保图片已渲染,waitUntilRenderFinished 兜底 |
| 重复保存文件互相覆盖 | 文件名固定 | 时间戳文件名 poster_yyyyMMdd_HHmmss.jpg |
| ImagePacker 用后卡顿 | 编码器未释放 | finally 里 packer.release() 成对收口 |
| 保存按钮点击无反应 | 按钮在屏幕外(dump 只抓可视区) | 滚动定位后再点,或布局压缩高度 |
| hdc shell 看不到沙箱文件 | 应用沙箱对 shell 用户隔离 | 走 hilog 字节级确认,或应用内读回大小 |
| 枚举成员大小写笔误编译失败 | MOSAIC vs Mosaic | 枚举成员命名统一 PascalCase |
八、总结
至此文搜图三部曲走完:一句话进、相似度排序的图片出(找到)→ 封存成时间胶囊(留住)→ 编排成海报落盘成文件(带走)。
- 检索内核三部曲同源,接口即防火墙,换 NPU 内核 UI 零改动;
- 布局是数据的函数,五档编排按命中数量自动分派,相似度最高的永远占最大画幅;
- 组件截图保证所见即所得,屏幕上渲染成什么样、落盘文件就是什么样;
- 截图→编码→写文件三步流水线,每步资源成对收口,时间戳文件名天然唯一;
- 全程零权限、零联网,海报落在应用沙箱,导出公共媒体库走安全写入是下一篇的方向。
更多推荐






所有评论(0)