【共创稿事节】鸿蒙文搜图 · 回忆拼贴:一句话搜出多张照片,自动编排成回忆海报,组件截图落盘保存

文搜图系列第一篇解决了「找到」——一句话进、相似度排序的图片网格出;第二篇解决了「留住」——封存成带倒计时的时间胶囊。这一篇解决「带走」:搜出来的那组照片,自动编排成一张排版讲究的回忆海报,一键保存成真实 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 张命中自动选「单图大画幅」布局,海报本体直接渲染:检索词标题 + 日期 + 大图 + 主题标签 + 布局说明:

搜「海边日落」: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 张的结果——检索返回的同一刻,海报已按主图马赛克自动编排好,中间没有任何「生成」按钮:

搜「和宠物的旅行」: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,海报立刻切成左右双联——电脑桌面与城市夜景并排:

搜「深夜加班」:2 张命中,pickLayout 返回 DUO,左右双联

五档布局的共同纪律:resultList 已按相似度降序,[0] 永远是主图——单图布局它是唯一,trio 布局它占 200px 大画幅、两张副图 120px 跟随,mosaic 布局它 220px 领衔、四张副图 100px 环绕。语义最相关的照片获得最大画幅,这是检索排序在构图层的直接延续。

搜「风景」命中 3 张正是 TRIO:相似度最高的海边占 200px 大画幅,雪山、森林两张副图 120px 跟随,主次一眼可辨:

搜「风景」: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:

搜「宠物」:4 张命中,quadBody 渲染的 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
截图目标含搜索框等无关 UIid 挂在整页容器上id 挂海报 Column,截图边界即产品边界
保存的图片半张是空白Image 组件解码未完成截图前确保图片已渲染,waitUntilRenderFinished 兜底
重复保存文件互相覆盖文件名固定时间戳文件名 poster_yyyyMMdd_HHmmss.jpg
ImagePacker 用后卡顿编码器未释放finally 里 packer.release() 成对收口
保存按钮点击无反应按钮在屏幕外(dump 只抓可视区)滚动定位后再点,或布局压缩高度
hdc shell 看不到沙箱文件应用沙箱对 shell 用户隔离走 hilog 字节级确认,或应用内读回大小
枚举成员大小写笔误编译失败MOSAIC vs Mosaic枚举成员命名统一 PascalCase

八、总结

至此文搜图三部曲走完:一句话进、相似度排序的图片出(找到)→ 封存成时间胶囊(留住)→ 编排成海报落盘成文件(带走)。

  • 检索内核三部曲同源,接口即防火墙,换 NPU 内核 UI 零改动;
  • 布局是数据的函数,五档编排按命中数量自动分派,相似度最高的永远占最大画幅;
  • 组件截图保证所见即所得,屏幕上渲染成什么样、落盘文件就是什么样;
  • 截图→编码→写文件三步流水线,每步资源成对收口,时间戳文件名天然唯一;
  • 全程零权限、零联网,海报落在应用沙箱,导出公共媒体库走安全写入是下一篇的方向。
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐