【共创稿事节】鸿蒙文搜图·回忆明信片:一句话搜出照片,排版成明信片,翻面写留言,经系统分享面板寄出去
【共创稿事节】鸿蒙文搜图·回忆明信片:一句话搜出照片,排版成明信片,翻面写留言,经系统分享面板寄出去
系列第五篇。前四篇我们走完了「找到 → 留住 → 带走 → 收藏」:语义相册一句话搜出照片,回忆胶囊封存倒计时,回忆拼贴排版成海报落盘,回忆放映室放映成短片存入系统图库。这一篇补上最后一块拼图——寄出:把搜出的照片排版成一张真正的明信片,正面是照片加邮戳,翻面写留言贴邮票,然后经系统分享面板寄给任何人。
技术栈还是那套零联网零权限的端侧组合: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() 拉起的系统分享面板,应用只提供内容,收件人和传输方式全部由系统接管:
坑一: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 不是缩水版云服务,它就是照片该有的处理方式:你的照片不出设备,能力却一样不少。
下一站?五部曲收官,但工程还在。也许该让这五篇的能力互相组合了——比如:搜出的照片先封成胶囊,到期自动排版成明信片寄给未来的自己。听起来是个好主意。
更多推荐




所有评论(0)