【共创稿事节】鸿蒙文搜图 · 回忆放映室:一句话搜出照片,放映成 Ken Burns 回忆短片,安全控件免权限存入系统图库

文搜图系列前三篇走完「找到 → 留住 → 带走」:一句话进、相似度排序的图片网格出(找到);封存成带倒计时的时间胶囊(留住);编排成海报落盘成文件(带走)。但第三篇结尾留了一个尾巴——海报落在应用沙箱里,用户在系统相册里看不到它,「保存了等于没保存」。这一篇补上最后一公里:走出沙箱,写入系统图库。

而且不是简单「导出」。这一篇把「搜出的一组照片」做成回忆放映室:检索命中的一组照片自动放映成回忆短片——Ken Burns 式缓动缩放让每张照片有电影感、胶片打孔条和字幕条构成影院视觉签名、进度点即帧导航、放映中随时一键把当前帧存入系统相册。全程零联网、零权限申请。

技术上的关键差异:前三篇的写入边界都是应用沙箱,这一篇第一次走出沙箱——SaveButton 安全控件提供 10 秒免授权窗口(不申请 ohos.permission.WRITE_IMAGEVIDEO),窗口内 photoAccessHelper.createAsset 把 JPEG 字节流写进公共媒体库,相册立即可见。UI 也彻底换风格:前三篇分别是浅色(一)、深蓝紫(二、三),这一篇是影院胶片风——暖黑底 + 琥珀金主色 + 胶片打孔条 + 字幕条。

放映中:Ken Burns 缩放 + 胶片边框 + 字幕条

工程地址:LI_harmonyOS/memory-cinema
运行环境:HarmonyOS 7.0(API 26)+ ArkTS 严格模式,已在模拟器安装运行、全流程验证(含媒体库写入 URI 字节级确认)。
包名:com.example.memorycinema,module.json5 没有申请任何权限——包括媒体库写入。


在这里插入图片描述

一、效果展示:一条「搜出 → 放映 → 入库」的完整叙事线

完整流程四步:载入 → 搜索 → 放映 → 存入图库。以下截图均为演示模式(内置 12 张真实照片素材)模拟器运行,真机模式走同一套 UI、只把检索内核换成 NPU。

第 1 步:就绪。 应用启动后初始化完成,影院胶片风首屏——暖黑底、琥珀金主色、胶片打孔条标题区 + 搜索框 + 五个快捷回忆词 + 空态引导:

初始就绪态:影院胶片风 + 五个快捷词 + 空态引导

第 2 步:载入 + 搜索。 点「载入回忆」把 12 张照片登记进端侧索引,点快捷词「宠物」,350ms 返回命中 4 张,状态栏提示点「▶ 放映」开始回忆短片:

搜「宠物」:命中 4 张,待放映

第 3 步:放映。 点「▶ 放映」,放映定时器每 4 秒推进一帧,每帧 Ken Burns 缓动缩放(1.0 ↔ 1.12 交替)+ 字幕延迟淡入,放映中画面如下(第 1 帧,构图随缓动缩放在变,具体机关见第三节):

放映中第 1 帧:Ken Burns 缩放 + 字幕条淡入

第 4 步:暂停 + 存入图库。 点「⏸ 暂停」定格当前帧,进度点即帧导航。点底部 SaveButton 安全控件(系统渲染文案「下载」),首次触发系统授权弹窗「安全保存图片和视频」——点「允许」后,当前帧写入公共媒体库(完整过程截图见第五节 5.2)。

hilog 字节级确认:frame saved to gallery uri=file://media/Photo/1/IMG_1790339306_000/回忆放映_狗.jpg bytes=36123 query=宠物——真实文件真实写入公共媒体库,相册立即可见。


二、这个 App 解决什么问题

「搜出一组照片想慢慢看、想把好看的存进相册」这个需求,现有路径都很别扭:

方案死角
网格逐张翻看一组照片被压成平铺缩略图,没有「看一段回忆」的沉浸感
逐张长按保存保存要权限弹窗(或每次确认),存到哪张是哪张,没有字幕没有叙事
第三方相册 slideshow选片靠人肉,没有「按语义成组」的概念,更没有端侧检索

回忆放映室的答案是:语义检索天然给出「一组相关照片」,放映机把这组照片变成有节奏的短片,安全控件让「存入相册」零权限一步到位。三个技术点各管一段:

  • 选片靠语义:一句话召回语义相关的一组照片,天然成组,不用人肉多选;
  • 节奏靠放映机:定时器 + Ken Burns 缓动缩放 + 字幕淡入,把静态照片变成有呼吸感的短片;
  • 入库靠安全控件:SaveButton 点击即授权 10 秒窗口,窗口内 createAsset 写公共媒体库——不申请权限、不弹系统权限对话框。

三、交互设计:放映机是一个小型状态机

页面状态沿用系列纪律:单真相源、每条异步路径专属文案、loading 闸门防重入。这一篇的新增状态围绕「放映」展开:

  @State ready: boolean = false;
  @State indexedCount: number = 0;
  @State loading: boolean = false;
  @State queryText: string = '';
  @State resultList: SearchHit[] = [];
  @State statusText: string = '初始化中…';
  /** 放映中 */
  @State playing: boolean = false;
  /** 当前放映帧下标 */
  @State frameIndex: number = 0;
  /** Ken Burns 缩放相位(每帧翻转,驱动 animateTo) */
  @State zoomPhase: number = 0;
  /** 字幕可见(帧切换后淡入) */
  @State captionVisible: boolean = false;
  /** 存入图库中 */
  @State saving: boolean = false;
  /** 已存入的相册 URI(非空时展示成功条) */
  @State savedUri: string = '';

放映机核心是三个联动状态:playing(定时器开关)、frameIndex(当前帧)、zoomPhase(缩放相位)。帧推进时翻转缩放相位是 Ken Burns 效果的机关——相邻两帧缩放方向交替,视觉上永远有缓动在发生,不会出现「静止的一张图」:

放映中第 2 帧:自动切帧,缩放相位翻转,构图与第 1 帧不同

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.savedUri = '';
    this.stopTimer();
    this.playing = false;
    this.statusText = '正在端侧检索回忆…';
    try {
      const hits: SearchHit[] = await this.service.search(keyword);
      this.resultList = hits;
      if (hits.length > 0) {
        this.frameIndex = 0;
        this.statusText = `找到 ${hits.length} 张,点「▶ 放映」开始回忆短片`;
      } else {
        this.statusText = '没有找到这段回忆,换个说法试试';
      }
    } finally {
      this.loading = false;
    }
  }

注意 this.savedUri = '' + stopTimer() + playing = false 三连——新检索开始时旧放映停机、旧入库记录失效,和第三篇「新检索清空旧海报保存记录」同一条纪律。空结果也是一等公民:状态栏给「没有找到这段回忆,换个说法试试」,放映区回落空态引导。

3.2 放映定时器:帧推进 + 进帧动效

放映机的引擎是 setInterval,每 FRAME_MS(4 秒)推进一帧,循环播放:

  /** 放映定时器:每帧 FRAME_MS 推进 */
  private startTimer(): void {
    this.stopTimer();
    this.timerId = setInterval(() => {
      this.frameIndex = (this.frameIndex + 1) % this.resultList.length;
      this.enterFrame();
    }, FRAME_MS);
  }

  private stopTimer(): void {
    if (this.timerId >= 0) {
      clearInterval(this.timerId);
      this.timerId = -1;
    }
  }

  /** 进帧:翻转缩放相位 + 字幕延迟淡入 */
  private enterFrame(): void {
    this.zoomPhase = this.frameIndex % 2;
    this.captionVisible = false;
    setTimeout(() => {
      this.getUIContext().animateTo({ duration: CAPTION_MS, curve: Curve.EaseOut }, () => {
        this.captionVisible = true;
      });
    }, 200);
  }

三个细节值得抄走:

  • startTimer 先 stopTimer:定时器重启前先清旧句柄,防止双定时器并行导致帧速翻倍——和系列前作「生命周期成对收口」同一条纪律;
  • enterFrame 翻转 zoomPhase:frameIndex % 2 让相邻帧缩放方向交替,配合 .animation() 属性动画,每帧都在 4 秒里从 1.0 缓动到 1.12(或反向)——这就是 Ken Burns 的全部机关,没有用任何转场框架;
  • 字幕延迟 200ms 淡入:帧切换瞬间字幕先隐藏,200ms 后 animateTo 淡入——字幕跟着帧走,不是常驻装饰。animateTo 走 UIContext 版(this.getUIContext().animateTo),和系列前作 Toast 走 getUIContext().getPromptAction() 同一逻辑:全局单例 API 一律让位给 UIContext 版。

3.3 银幕渲染:缩放相位驱动属性动画

银幕本体是「上打孔条 + 帧 + 字幕条 + 下打孔条」的胶片结构,帧的 Ken Burns 缩放靠 .scale + .animation 声明式实现:

          // 放映帧(Ken Burns 缓动缩放)
          Stack() {
            Image($rawfile(this.currentFrame().imagePath))
              .width('100%')
              .height(250)
              .objectFit(ImageFit.Cover)
              .scale(this.zoomPhase === 0
                ? { x: 1.0, y: 1.0 }
                : { x: 1.12, y: 1.12 })
              .animation({
                duration: FRAME_MS,
                curve: Curve.Friction,
                iterations: 1,
                playMode: PlayMode.Normal
              })
          }

zoomPhase 变化 → .scale 目标值变化 → .animation 自动补间——这是声明式动画的标准范式:不写插值代码,只声明「属性变了怎么动」。duration: FRAME_MS 让缩放动画时长恰好等于帧时长,动画结束的下一刻定时器切帧,节奏无缝衔接。Curve.Friction 是摩擦曲线,开头快结尾慢,比 EaseInOut 更有「电影镜头推近」的质感。

字幕条的淡入用 transition:

            if (this.captionVisible) {
              Text(`「${this.currentFrame().caption ?? ''}」`)
                .fontSize(14)
                .fontColor('#E8C77E')
                .width('100%')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .transition(TransitionEffect.OPACITY.animation({ duration: CAPTION_MS, curve: Curve.EaseOut }))
            }

captionVisible 切换触发 if 分支增删节点,transition 给节点出入场挂透明度动画——条件渲染 + transition 是「组件级淡入淡出」的标准解,比常驻节点改 opacity 语义更清晰。


四、工程结构

memory-cinema/
├── entry/src/main/
│   ├── ets/
│   │   ├── pages/Index.ets               # 检索 + 放映机状态机 + 银幕渲染 + 入库流程
│   │   └── common/
│   │       ├── CinemaService.ets          # 单例服务:检索 + 排片 + 媒体库写入
│   │       └── DemoData.ets              # 12 张示例图:标签 + 主题 + 字幕文案
│   └── resources/rawfile/demo/           # 12 张真实照片素材(旅程/拼搏/陪伴)

三层职责与系列前作同构,服务层多了一段「媒体库写入」职责:

层文件职责不知道的事
页面层Index.ets放映机状态机、银幕渲染、SaveButton 触发不知道媒体库写入的 API 细节
服务层CinemaService.ets检索 + saveFrameToGallery不知道放映节奏和 UI 动效
数据层DemoData.ets素材定义(标签 + 主题 + 字幕)不知道自己被谁消费

SearchHit 接口比前作多一个 caption 字段——放映字幕跟着检索结果走,不是写死的装饰:

export interface SearchHit {
  imagePath: string;
  similarity: number;
  rawfile?: boolean;
  title?: string;
  /** 放映字幕文案 */
  caption?: string;
  /** 回忆主题 */
  theme?: string;
}

接口即防火墙依然成立:真机换 NPU 检索只改 search 内部,放映机和入库链路与检索内核零耦合。


五、核心实现

5.1 走出沙箱:SaveButton + createAsset 三步入库

saveFrameToGallery 是本篇的技术核心——系列第一次把文件写出应用沙箱:

  public async saveFrameToGallery(frame: SearchHit, query: string): Promise<string> {
    const context: common.Context = this.getContext();
    // 1. rawfile → PixelMap → JPEG 字节流
    const buf: ArrayBuffer = await this.encodeFrame(frame.imagePath);
    // 2. createAsset 建媒体资产,拿到相册 URI
    const helper: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
    const title: string = `回忆放映_${frame.title ?? '帧'}`;
    const options: photoAccessHelper.CreateOptions = {
      title: title,
      subtype: photoAccessHelper.PhotoSubtype.DEFAULT
    };
    const uri: string = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg', options);
    // 3. URI 写入字节流
    const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.WRITE_ONLY);
    try {
      fileIo.writeSync(file.fd, buf);
    } finally {
      fileIo.closeSync(file.fd);
    }
    hilog.info(DOMAIN, TAG, 'frame saved to gallery uri=%{public}s bytes=%{public}d query=%{public}s',
      uri, buf.byteLength, query);
    return uri;
  }

三步各自有讲究:

  • 先编码再入库:rawfile 素材先解码成 PixelMap 再压成 JPEG 字节流(quality 92),入库的是标准 JPEG——媒体库不认 rawfile 路径,只认字节流;
  • createAsset 返回 URI:先在公共媒体库建一个空资产拿到 file://media/Photo/... URI,再往 URI 写字节流——建资产和写内容是两步,URI 就是应用世界和媒体库世界的桥;
  • CreateOptions.title:写入时带标题 回忆放映_狗,相册里显示的是这个标题而不是乱码文件名——入库的元信息跟着数据走。

5.2 SaveButton:安全控件的授权窗口

页面层的 SaveButton 是整个入库链路的钥匙:

        // 存入系统图库:SaveButton 安全控件(免权限写入公共媒体库)
        // 安全控件样式受限:只设置尺寸,不套用通用布局属性
        if (this.resultList.length > 0) {
          SaveButton()
            .width(220)
            .height(44)
            .onClick((_event: ClickEvent, result: SaveButtonOnClickResult) => {
              if (result === SaveButtonOnClickResult.SUCCESS) {
                this.saveCurrentFrame();
              } else {
                this.toast('保存授权未完成');
              }
            })
        }

安全控件的规则必须吃透:

暂停态:进度点导航 + 底部 SaveButton 安全控件(系统渲染文案「下载」)
  • 免权限:SaveButton 是系统渲染的控件,用户点击它这个动作本身就是授权——应用不需要在 module.json5 申请 ohos.permission.WRITE_IMAGEVIDEO,本工程零权限申请;
  • 10 秒窗口:点击后授权窗口约 10 秒,窗口内 createAsset 可用;窗口外调用直接报 code 201 Permission denied。实测验证过:绕过 SaveButton 直接调 saveFrameToGallery,hilog 报 saveFrameToGallery failed, code: 201, message: Permission denied——安全控件不是装饰,是唯一的门;
  • 样式受限:安全控件由系统渲染,fontSize/backgroundColor 等通用样式属性不生效,只设尺寸(220×44)。系统渲染的文案是「下载」——文案不可自定义,这是安全控件换取免权限的代价;
  • onClick 带授权结果:回调第二参数 SaveButtonOnClickResult.SUCCESS 表示授权成功,先判结果再干活——授权失败时不该执行写入。

首次使用还会触发系统授权弹窗「安全保存图片和视频」(告知用户这个按钮的权限范围),用户点「允许」后 WRITE_IMAGEVIDEO 临时授权生效(hilog 可见 ActiveStatusChange 记录),之后写入一路畅通。

5.3 编码链路:rawfile → PixelMap → JPEG

encodeFrame 是入库前的编码流水线,资源成对收口:

  /** rawfile 解码 → JPEG 编码(quality 92),返回字节流 */
  private async encodeFrame(rawfilePath: string): Promise<ArrayBuffer> {
    const context: common.Context = this.getContext();
    const resourceMgr = context.resourceManager;
    const raw: Uint8Array = await resourceMgr.getRawFileContent(rawfilePath);
    const imageSource: image.ImageSource = image.createImageSource(raw.buffer as ArrayBuffer);
    const pixelMap: image.PixelMap = await imageSource.createPixelMap();
    try {
      const packer: image.ImagePacker = image.createImagePacker();
      try {
        const opts: image.PackingOption = { format: 'image/jpeg', quality: 92 };
        return await packer.packing(pixelMap, opts);
      } finally {
        await packer.release();
      }
    } finally {
      await imageSource.release();
      await pixelMap.release();
    }
  }

三层 try-finally 嵌套:packer、imageSource、pixelMap 各自一层收口,任何一步抛异常都不会泄漏底层句柄。实测入库帧 36KB(quality 92 的 JPEG),相册立即可见。image.createImageSource 传 raw.buffer as ArrayBuffer——rawfile 内容是 Uint8Array,ArkTS 严格模式要求显式转 ArrayBuffer,这个 cast 在系列前作里也踩过。

5.4 放映控制:暂停态手动切帧

放映中「⏸ 暂停」停定时器,暂停态「⏮ 上一帧 / 下一帧 ⏭」手动切帧,进度点直接跳帧:

  /** 上一帧 / 下一帧(暂停态手动切帧) */
  private stepFrame(delta: number): void {
    if (this.resultList.length === 0) {
      return;
    }
    const n: number = this.resultList.length;
    this.frameIndex = (this.frameIndex + delta + n) % n;
    this.enterFrame();
  }

(frameIndex + delta + n) % n 是环形算术——delta 为负时先加 n 保证非负,模运算结果永远落在 [0, n)。手动切帧也走 enterFrame(),自动放映和手动切帧共用同一套进帧动效,视觉行为一致。


六、真机运行指南

  • 演示模式(默认):点「载入回忆」载入 12 张内置素材,标签匹配引擎出结果,任何设备完整跑通载入→搜索→放映→入库全流程;五个快捷词按命中数演示不同长度的放映——「海边日落」1 张、「深夜加班」2 张、「风景」3 张、「宠物」4 张、「和宠物的旅行」8 张;
  • 真机模式:把 CinemaService.search 内部替换为 textSearchImage 调用(与前作 RealTextImageSearchService 同款),放映与入库部分零改动;
  • 入库走 SaveButton 安全控件,不申请任何权限;首次使用有系统授权弹窗,点「允许」一次即可;保存的照片在系统相册立即可见,标题为 回忆放映_帧标题;
  • 全程端侧、不联网、不上传;hilog 里 frame saved to gallery uri=... bytes=... 可字节级确认入库结果。

七、踩坑清单速查表

现象原因修法
createAsset 报 code 201 Permission denied不在 SaveButton 授权窗口内调用只在 onClick(result === SUCCESS) 回调里执行写入
module.json5 申请了 WRITE_IMAGEVIDEO 仍被拒该权限对普通应用不开放(ACL 受限)用 SaveButton 安全控件,彻底不申请
SaveButton 设置 backgroundColor 等样式不生效安全控件系统渲染,样式受限只设 width/height,接受系统默认样式
SaveButton 在 dump/无障碍树里找不到文本系统渲染文案是「下载」且节点特殊按 type":"SaveButton" 定位节点
image.createImageSource 传 Uint8Array 报错严格模式要求 ArrayBufferraw.buffer as ArrayBuffer 显式转换
ImageSource/ImagePacker 用后卡顿底层句柄未释放多层 try-finally 成对 release()
缩放动画每帧都从 1.0 开始显得机械缩放相位不翻转zoomPhase = frameIndex % 2 相邻帧方向交替
双定时器并行帧速翻倍startTimer 前没清旧句柄startTimer 首行先 stopTimer
字幕常驻显得呆板字幕不跟帧切换帧切换隐藏 + 200ms 后 animateTo 淡入
animateTo 报废弃用告警全局版已废弃this.getUIContext().animateTo()

八、总结

至此文搜图四部曲走完:一句话进、相似度排序的图片出(找到)→ 封存成时间胶囊(留住)→ 编排成海报落盘成文件(带走)→ 放映成回忆短片存入系统图库(收藏)。

存入系统图库成功:成功条回显相册 URI
  • 检索内核四部曲同源,接口即防火墙,换 NPU 内核 UI 零改动;
  • 放映机是一个小型状态机,定时器 + 缩放相位翻转 + 字幕延迟淡入,把静态照片变成有呼吸感的 Ken Burns 短片;
  • 声明式动画的标准范式:属性目标值变化 + .animation 自动补间,不写插值代码;
  • 系列第一次走出应用沙箱:SaveButton 安全控件 10 秒授权窗口 + createAsset 建资产写字节流,零权限申请、相册立即可见;
  • 安全控件不是装饰是唯一的门:绕过它直接写媒体库,code 201 立刻打脸——实测验证过。
Logo

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

更多推荐