8 min read

云服务、不OOM、跨端一致:我第二个鸿蒙App 的 4 个技术点复盘

这是我开发的**第二个**鸿蒙 App,它已经上架华为应用市场。功能一句话就能说清:在手机上给相册照片批量加水印。但把这件事做到"能上架、不崩、换设备位置也一致",背后全是不太好糊弄的工程问题——这也是这篇想拆给你看的。

云服务、不OOM、跨端一致:我第二个鸿蒙App 的 4 个技术点复盘

这个 App 是干嘛的? 名字叫速印 QuickMark,一句话:在手机上给相册照片批量加水印。目标用户很具体——外拍回来想在手机上立刻给几张图加版权标识的摄影师、出差途中给图片加专属水印的图片工作者、发朋友圈害怕作品被盗用的摄影爱好者。对它们来说,手机拍得越来越好、拍得越来越多,系统相机自带的水印要么不够用、要么不能批量,而这个 App 让你选一批照片、挑好水印预设,一键批量加完存回相册;换设备登录账号,预设和水印跟着走。

功能不算多,但每个功能背后都顶着一个工程难点:

  • 📌 批量加水印:一次最多 20 张,逐张自动合成、存回相册,进度可见、不卡界面
  • 🖼️ 图片水印 + 精细自定义:上传 Logo / 版权标识,位置拖拽、大小、透明度自由调整,存成预设一键复用
  • ☁️ 多设备同步预设:换设备登录同一账号,所有预设与水印立即同步,马上就能批量加印
  • 🌓 多端多语言:手机 / 平板 / 折叠屏自适应,简中 / 繁中 / 英文,深色模式
  • 🔒 隐私保障:照片全程本地合成、不上传服务器,匿名登录免注册即用

下面挑最硬的四个技术点展开——每个功能能"看起来很轻松",都是这几块在托底。

最硬的四个技术点

① 像素级水印合成引擎

水印叠加不是"贴张图"那么简单。原图 6000×4000 是 2400 万像素,你得把水印 PixelMap 逐像素做 alpha 混合,再写回原图。我用的是 PixelMap.writeBufferToPixels 直接写回,绕过 ImagePacker 的大图编码——后者正是 OOM 重灾区。

界面上的"位置拖拽、大小、透明度"最后都会归一成一份预设数据,批量时每张照片读取同一份 preset,算好缩放因子和水印坐标,再进这段像素循环——一次设置、整批复用,就是这个引擎的入口。

// 核心:逐像素 alpha 混合
for (let i = 0; i < wmLen; i++) {
  const dstA = bgBuf[i * 4 + 3] / 255;
  const srcA = wmBuf[i * 4 + 3] / 255 * opacity;
  const outA = srcA + dstA * (1 - srcA);
  if (outA > 0) {
    out[i * 4]     = (wmBuf[i * 4] * srcA + bgBuf[i * 4] * dstA * (1 - srcA)) / outA;
    out[i * 4 + 1] = (wmBuf[i * 4 + 1] * srcA + bgBuf[i * 4 + 1] * dstA * (1 - srcA)) / outA;
    out[i * 4 + 2] = (wmBuf[i * 4 + 2] * srcA + bgBuf[i * 4 + 2] * dstA * (1 - srcA)) / outA;
  }
}

踩过的坑:一开始用 createPixelMap(buffer, opts) 重建图,结果 pixelFormat 声明和数据格式不一致,R/B 通道直接互换——红色水印变蓝色。最后改成 writeBufferToPixels 写回原 PixelMap,格式由系统统一,问题根治。

② 大图内存治理:不让 App 死给你看

2400 万像素的原图 PixelMap 本身就占 ~50MB 内存,再加水印副本、编码缓冲,随手就是 OOM。我的策略是:水印图整批只下载一次缓存复用;每张照片的缩放副本合成完立刻 release();页面销毁时 aboutToDisappear 里统一回收所有 PixelMap;砍掉中间缩略图步骤。实测 6000×4000 单张 1~2s,连批 20 张不崩——批量上限设在 20,正是为了在"一次够用"和"内存可控"之间取平衡;处理进度走独立任务队列,UI 只负责刷新进度条,不参与像素运算。

页面销毁时的统一回收,核心就是"离开即释放"——页面一退出,所有预览图立即 release(),不把几十 MB 的大图内存留给下一个页面:

// 页面销毁时统一回收所有 PixelMap,避免大图内存残留
aboutToDisappear(): void {
  this.releaseAllPixelMaps();
}

/** 释放所有预览 PixelMap */
private releaseAllPixelMaps(): void {
  for (let i = 0; i < this.processedList.length; i++) {
    try {
      this.processedList[i].pixelMap.release();
    } catch (e) {
      // ignore
    }
  }
}

③ AGC 端云一体化:云数据库 + 云存储 + 云函数,一个人也能撑起后端

"换设备同步预设"听起来像是要一个正经后端,其实全靠云服务,没搭任何自建服务器。华为 AGC 的端云一体化把 CloudDB(云数据库)、CloudStorage(云存储)、云函数打包在一起:匿名登录(免注册、用户无感)拿到一个 unionID,跨设备识别就靠它;预设和用户数据存 CloudDB,水印图片存 CloudStorage,需要调用第三方服务时才让云函数出手。

有两个细节值得单独说。一是密钥不落地客户端:需要调用第三方服务(比如内容审核)时,不在客户端放任何 AccessKey,而是把调用包进云函数,由云函数端生成签名、代理请求,客户端只持有 AGC 的匿名凭证——就算客户端被扒,也拿不到任何敏感密钥。二是匿名登录的并发问题:App 启动时多个模块可能同时触发登录,我用一个"进行中的登录 Promise"复用,避免并发登录导致重复建用户、同一 unionID 下出现脏数据。

「并发登录」这段用了个小技巧——进行中的登录 Promise 直接复用,而不是每次重新发起,从源头杜绝重复建用户:

// 匿名登录:并发复用进行中的 Promise,防止重复建用户
private static loginInFlight: Promise<boolean> | null = null;

public static login(): Promise<boolean> {
  if (SilentLoginService.loginInFlight) {
    return SilentLoginService.loginInFlight;  // 已有登录在进行,直接复用
  }
  const p: Promise<boolean> = SilentLoginService.doLogin();
  SilentLoginService.loginInFlight = p;
  p.then(() => { SilentLoginService.loginInFlight = null; });
  p.catch(() => { SilentLoginService.loginInFlight = null; });
  return p;
}

④ 多端尺寸不一,预设位置怎么同步不漂移?

手机、平板、折叠屏的预览区宽度不一样(横屏平板 400vp,其他 340vp)。如果直接把"像素坐标"存进预设,换设备后水印位置必然对不上。我的做法是引入一套统一设计稿坐标空间(340×452):保存时把当前设备预览区的坐标按比例换算到设计稿空间,加载时再按目标设备的预览区尺寸换算回来。横竖屏比例一致的前提下,换算系数只是两个比例值,一套逻辑三种设备通用,水印相对位置完全一致。顺带还修了一个竞态:预览区尺寸如果放在 await 之后才初始化,pad 编辑时可能拿到默认值导致位置偏移,改成同步计算后根治。

换算其实就是两个比例值,双向各乘一次——保存时缩到设计稿空间,加载时再按当前设备尺寸乘回来:

// 保存:当前设备预览区(vp) → 统一设计稿空间(340×452)
const designScaleX = PresetForm.DESIGN_W / this.previewWidth;   // 340 / 预览区宽
const designScaleY = PresetForm.DESIGN_H / this.previewHeight;  // 452 / 预览区高
const watermarkData: WatermarkPresetData = {
  width: this.watermarkBaseWidth * designScaleX,
  height: this.watermarkBaseHeight * designScaleY,
  x: this.watermarkX * designScaleX,
  y: this.watermarkY * designScaleY,
  // 位置百分比 positionXPercent / positionYPercent 原样存储
};

// 加载:设计稿空间 → 当前设备预览区(按当前设备尺寸换算回来)
const kx = this.previewWidth / PresetForm.DESIGN_W;
const ky = this.previewHeight / PresetForm.DESIGN_H;
this.watermarkX = watermarkData.x * kx;
this.watermarkY = watermarkData.y * ky;

做得好的 & 还能优化的

做得好的:像素合成管线稳定、内存可控;端云一体化让"换机同步预设"这种功能云服务即开即用、按量计费;云函数代理第三方服务,密钥全程不落地客户端。

还能优化的:单张大图处理仍在主线程,长图批量时希望拆到子线程 / 分批调度;批量任务队列目前是串行,后续想支持可中断、可续传;内容审核目前是同步等待云函数返回(最长 15s),后续想改成异步回调,让用户保存时不用干等。

给同行的话

从小而美的工具切入,比一上来就想做平台靠谱得多。先跑通一条最核心的管线——哪怕只是一个像素循环——把它做到稳定、能上架,再谈优化和扩展。端云一体化能帮你把后端交给云服务,把精力留给真正值得抠的像素和体验。

如果你也是"写代码、也爱拍照"的人,想给手机里的照片批量加个专属水印,在鸿蒙 AppGallery 搜「速印 QuickMark」就能用。

转载请注明来源:

原文链接:http://hanhan.pro/four-technology-point-in-my-second-harmonyos-app-quickmark/
作者:Reno