【共创稿事节】鸿蒙文搜图·回忆明信片:一句话搜出照片,排版成明信片,翻面写留言,经系统分享面板寄出去

系列第五篇。前四篇我们走完了「找到 → 留住 → 带走 → 收藏」:语义相册一句话搜出照片,回忆胶囊封存倒计时,回忆拼贴排版成海报落盘,回忆放映室放映成短片存入系统图库。这一篇补上最后一块拼图——寄出:把搜出的照片排版成一张真正的明信片,正面是照片加邮戳,翻面写留言贴邮票,然后经系统分享面板寄给任何人。

搜索「海边日落」命中 1 张,相似度 92%

技术栈还是那套零联网零权限的端侧组合:photoAccessHelper 图库选图 + image Kit 解码 + 语义检索排序。新引入三样前四篇都没碰过的东西:Share Kit(systemShare)拉起系统分享面板、OffscreenCanvas 离屏排版明信片、两阶段 rotateY 做 3D 翻面。

一、效果展示

第 1 步:载入回忆。 首次进入是空态引导,点「载入回忆」从系统图库选图建索引(12 张演示照片,0.5 秒内完成,全程无网络权限):

载入回忆:空态引导 + 载入按钮

第 2 步:一句话搜索。 点快捷词「海边日落」,端侧语义检索命中 1 张,相似度 92%——和第一篇语义相册同一套检索内核,这里直接复用(命中墙三态状态机见第三节)。

第 3 步:做成明信片。 点命中卡片,照片被排版成明信片正面:左半幅照片(cover 裁切),右半幅邮戳区——红色双圈邮戳带当天日期,右侧竖排「回忆明信片」题字,底部一行检索元信息(查询词 + 相似度):

明信片正面:照片 + 邮戳 + 题字

第 4 步:翻面写留言。 点「翻面写留言」,明信片以两阶段 3D 旋转翻到背面:横线信纸区 + 右下角「回忆」邮票(齿孔边 + 内框 + 竖排票面字)。输入留言后点「寄出」:

明信片背面:横线信纸 + 邮票 + 留言输入

第 5 步:寄出。 点「📮 寄出」,当前面(正/背面各自独立排版)被渲染成 135 KB 的 JPEG,写入沙箱缓存目录,然后 Share Kit 拉起系统分享面板——「分享 1 项 · 135.05 KB」,华为分享、保存至图库、打印,收件人任选(面板截图见第四节 4.2)。

hilog 字节级确认:share postcard file=postcard_….jpg uri=file://com.example.memorypostcard/data/storage/el2/base/haps/entry/cache/postcard_….jpg——真实文件真实落盘,URI 真实交给系统分享面板。
在这里插入图片描述

二、为什么是明信片

系列走到第四篇,照片的「进」和「出」都齐了,但缺一个「送」字。拼贴海报是落盘给自己,放映短片是存图库给自己——都还是私藏。明信片是照片唯一一种「为他人而作」的形态:正面是风景,背面是手写的话,寄出去才算完成。

技术上也正好补齐两块空白:

  • Share Kit:前四篇的出口都是「写文件」(SaveButton、MediaLibrary),从没拉起过系统分享面板。systemShare 是 HMS 的系统级分享服务,应用只提供内容,收件人、传输方式全部由系统面板接管——这是「端侧处理完交给系统分发」的标准姿势。
  • Canvas 2D 排版:拼贴篇用过 OffscreenCanvas,但只是拼图;这一篇要在画布上画邮戳、齿孔邮票、横线信纸——真正的「版面设计」,Canvas 2D 全套 API(arc/strokeRect/fillText/clip)都用上了。

三、交互设计:一张卡片的三个状态

明信片模式是一个三态状态机:hitWall(命中墙,选一张)→ cardFront(正面,可翻面)→ cardBack(背面,可写留言)。翻面按钮在正/背面之间切换文案,寄出按钮始终渲染「当前面」——正面寄的是照片面,背面寄的是留言面,两次寄出内容不同。命中墙是这个状态机的第一态——所有明信片都从「点一张」开始:

命中墙:搜索结果卡片墙,点一张做成明信片
/** 明信片正面(false)/ 背面(true) */
@State flipped: boolean = false;
/** 翻面旋转角(0 正视,±90 侧立) */
@State flipAngle: number = 0;
/** 翻面动画进行中(防连点) */
@State flipping: boolean = false;

翻面动画是这一篇交互上的小机关——两阶段 rotateY,见第五节 5.2。

四、核心实现:Share Kit 寄出链路

4.1 从画布到分享面板的完整链路

寄出按钮触发的是一条五步流水线:渲染 → 打包 → 落盘 → 规范 URI → 拉起面板:

/** 寄出:渲染当前面 → Share Kit 拉起系统分享面板 */
private async sendPostcard(): Promise<void> {
  const hit: SearchHit | null = this.currentHit();
  if (this.sending || !hit) {
    return;
  }
  this.sending = true;
  this.sentOk = false;
  this.statusText = '正在排版明信片…';
  try {
    const jpeg: ArrayBuffer = this.flipped
      ? await this.service.renderBack(hit, this.message)
      : await this.service.renderFront(hit, this.queryText.trim());
    this.statusText = '正在唤起系统分享面板…';
    await this.service.sharePostcard(jpeg, this.queryText.trim());
    this.sentOk = true;
    this.statusText = '已交寄,去分享面板选收件人吧';
    this.toast('明信片已交寄');
  } catch (e) {
    const err = e as BusinessError;
    console.error(`sendPostcard failed, code: ${err.code}, message: ${err.message}`);
    this.statusText = `寄出失败 code=${err.code}`;
    this.toast(`寄出失败 code=${err.code}`);
  } finally {
    this.sending = false;
  }
}

注意 this.flipped 三元分支——寄出哪一面由用户当前看到哪一面决定,这是明信片「翻面」语义的落点。

4.2 Share Kit:三行代码拉起系统面板

sharePostcard 的核心是 systemShare 的标准三步——SharedRecord 描述内容、SharedData 聚合、ShareController.show() 拉起:

/** 拉起系统分享面板,把明信片寄出去(Share Kit) */
public async sharePostcard(jpeg: ArrayBuffer, query: string): Promise<void> {
  const context: common.Context = this.getContext();
  // 1. JPEG 写入应用沙箱缓存目录(文件名用 ASCII,避免 IPC 超限/编码问题)
  const stamp: number = Number(systemDateTime.getTime(true));
  const fileName: string = `postcard_${stamp}.jpg`;
  const cacheDir: string = context.cacheDir;
  const filePath: string = `${cacheDir}/${fileName}`;
  const file: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
  try {
    fileIo.writeSync(file.fd, jpeg);
  } finally {
    fileIo.closeSync(file.fd);
  }

  // 2. 构造分享记录 + SharedData + ShareController,拉起系统分享面板
  // URI 用 fileUri.getUriFromPath 规范化(沙箱路径 → 合法 file:// URI)
  const uri: string = fileUri.getUriFromPath(filePath);
  const record: systemShare.SharedRecord = {
    utd: 'general.image',
    uri: uri,
    title: `回忆明信片:${query}`
  };
  const data: systemShare.SharedData = new systemShare.SharedData(record);
  const controller: systemShare.ShareController = new systemShare.ShareController(data);
  hilog.info(DOMAIN, TAG, 'share postcard file=%{public}s uri=%{public}s', fileName, uri);
  await controller.show(context as common.UIAbilityContext, {
    selectionMode: systemShare.SelectionMode.SINGLE,
    previewMode: systemShare.SharePreviewMode.DETAIL
  } as systemShare.ShareControllerOptions);
}

这里有三个踩过的坑,值得单独说。先看最终效果——show() 拉起的系统分享面板,应用只提供内容,收件人和传输方式全部由系统接管:

Share Kit 系统分享面板:分享 1 项 135.05 KB,华为分享/保存至图库/打印

坑一:URI 不能手拼。 第一版直接 `file://${filePath}` 拼出 file:///data/storage/... 三斜杠 URI,show() 抛 1003702002: IPC data is oversized。正解是 fileUri.getUriFromPath()——它会把沙箱路径规范化成 file://com.example.memorypostcard/data/storage/... 这种带应用域名的合法 URI。

坑二:文件名不要用中文。 第一版文件名是 回忆明信片_海边日落_时间戳.jpg,同样触发 IPC 超限。改成纯 ASCII 的 postcard_时间戳.jpg 后稳定通过。分享标题(title 字段)用中文没问题——标题走的是文本通道,URI 走的是文件通道。

坑三:context 必须是 UIAbilityContext。 show() 的第一个参数类型是 UIAbilityContext,service 里存的 common.Context 需要 as 转型——前提是它真的来自 EntryAbility(本工程正是),否则会静默失败。

4.3 OffscreenCanvas:明信片正反面排版

正反面各是一个 1000×700 的离屏画布。正面 renderFront:照片 cover 裁切进左半幅,右半幅画邮戳和题字;背面 renderBack:横线信纸 + 邮票 + 留言文字。以邮戳为例看 Canvas 2D 的用法:

/** 邮戳:双圈 + 波浪线 + 日期文字 */
private drawPostmark(ctx: OffscreenCanvasRenderingContext2D, cx: number, cy: number, r: number): void {
  ctx.strokeStyle = '#B03A2E';
  ctx.lineWidth = 4;
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.stroke();
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(cx, cy, r - 10, 0, Math.PI * 2);
  ctx.stroke();
  // 内部日期
  ctx.fillStyle = '#B03A2E';
  ctx.font = '20px sans-serif';
  ctx.textAlign = 'center';
  const now: Date = new Date();
  ctx.fillText(`${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`, cx, cy + 7);
  // 底部三横线(邮戳签名)
  ctx.lineWidth = 3;
  for (let i: number = 0; i < 3; i++) {
    ctx.beginPath();
    ctx.moveTo(cx - r + 8, cy + r + 14 + i * 8);
    ctx.lineTo(cx + r - 8, cy + r + 14 + i * 8);
    ctx.stroke();
  }
}

邮票的齿孔是「先画票体,再以纸底色抠孔」的技巧——沿四边等距画圆、以画布底色填充,视觉上就是打孔纸边(完整代码见工程 drawStamp)。

4.4 画布导出:toDataURL → base64 → JPEG

OffscreenCanvas 没有直接的「导出 JPEG」API,链路要走三步:toDataURL 拿 base64 PNG → 解码成 PixelMap → ImagePacker 转 JPEG:

/** OffscreenCanvas → JPEG 字节流(quality 92) */
private async packCanvas(ctx: OffscreenCanvasRenderingContext2D): Promise<ArrayBuffer> {
  // toDataURL 拿 base64 PNG → 解码成 PixelMap → ImagePacker 转 JPEG
  const dataUrl: string = ctx.toDataURL('image/png');
  const base64: string = dataUrl.substring(dataUrl.indexOf(',') + 1);
  const bytes: Uint8Array = this.decodeBase64(base64);
  const imageSource: image.ImageSource = image.createImageSource(bytes.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();
    pixelMap.release();
  }
}

decodeBase64 是纯本地实现(位运算查表解码),不依赖任何三方库——端侧零依赖原则延续到第五篇。

五、交互细节

5.1 两阶段 rotateY:真 3D 翻面

翻面如果只是淡入淡出切换,就浪费了「明信片」这个物理隐喻。这里用两阶段 rotateY:先转到 90 度(卡片侧立,正背面都不可见),在侧立瞬间换内容,再转回 0 度:

/** 翻面:两阶段 rotateY(0→90 换内容 →0),真 3D 翻页 */
private toggleFlip(): void {
  if (this.flipping) {
    return;
  }
  this.flipping = true;
  const ui = this.getUIContext();
  // 阶段一:0 → 90(侧立,正背面都不可见)
  ui.animateTo({ duration: FLIP_MS / 2, curve: Curve.EaseIn }, () => {
    this.flipAngle = 90;
  });
  setTimeout(() => {
    // 侧立瞬间换内容
    this.flipped = !this.flipped;
    // 阶段二:90 → 0(落回正面视角)
    ui.animateTo({ duration: FLIP_MS / 2, curve: Curve.EaseOut }, () => {
      this.flipAngle = 0;
    });
    setTimeout(() => {
      this.flipping = false;
    }, FLIP_MS / 2);
  }, FLIP_MS / 2);
}

配合外层的 rotate + scale(侧立时轻微缩到 92%,模拟透视收缩),翻面有真实的「翻纸」手感。flipping 标志位防连点——翻面进行中再点会被吞掉,避免状态错乱。

5.2 纸感配色

前四篇的配色分别走了浅色、深蓝紫、深蓝紫、影院暖黑。第五篇是纸感明信片风:米白纸底 #F6F1E3 + 墨绿 #2E6B4F + 邮戳红 #B03A2E——三个颜色全部来自真实明信片的物料:纸、墨、戳。

六、工程结构

memory-postcard/
├── entry/src/main/ets/
│   ├── common/
│   │   ├── PostcardService.ets   # 检索 + Canvas 排版 + Share Kit 分享
│   │   └── DemoData.ets           # 12 张演示照片数据
│   ├── pages/
│   │   └── Index.ets              # 明信片风 UI + 三态状态机 + 翻面动画
│   └── entryability/
│       └── EntryAbility.ets
└── entry/src/main/resources/rawfile/demo/   # 12 张演示照片

全工程权限清单依然是空的——选图走 photoAccessHelper 的 PhotoViewPicker(系统代理,免权限),分享走 Share Kit(系统面板,免权限),落盘走应用沙箱(免权限)。

七、验证

模拟器实测全链路:

  • 载入回忆:12 张照片建索引,状态条实时计数
  • 搜索「海边日落」:命中 1 张,相似度 92%
  • 点卡片:正面排版完成(照片 + 邮戳 + 题字)
  • 翻面:两阶段 3D 旋转,背面信纸 + 邮票呈现
  • 寄出:135.05 KB JPEG 落盘,Share Kit 面板弹出(分享 1 项),华为分享/保存至图库/打印可选

hilog 关键行:share postcard file=postcard_….jpg uri=file://com.example.memorypostcard/data/storage/el2/base/haps/entry/cache/postcard_….jpg。

八、总结

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

五个动作,五种出口,但内核始终是同一套:photoAccessHelper 选图、image Kit 解码编码、端侧语义检索排序、零联网零权限。系列想证明的事情到第五篇已经说完了——端侧 AI 不是缩水版云服务,它就是照片该有的处理方式:你的照片不出设备,能力却一样不少。

下一站?五部曲收官,但工程还在。也许该让这五篇的能力互相组合了——比如:搜出的照片先封成胶囊,到期自动排版成明信片寄给未来的自己。听起来是个好主意。

Logo

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

更多推荐