做购物比价/商城应用时,"分享"是个容易低估的需求:商品详情页很长(图文+规格+评价),用户想发给朋友看;AI 生成的购物攻略/比价报告,也想一键转成图片发出去。第一反应往往是"调 AIGC 生成一张海报"——但真试过就知道:动态海报费 token、响应慢、效果还未必好。资源有限的场景下,更务实的路线是把"当前页面"变成一张长截图:滚动 → 截 → 裁新增部分 → 拼 → 保存相册 → 走系统分享。

华为在购物比价实践里把这套能力归到"快照分享"场景,核心 API 是 componentSnapshot.get()+ PixelMap.crop()+ PixelMap.writePixelsSync()+ SaveButton。下面把这条链路从"为什么这么做"讲到"工程上怎么不翻车"。


一、先对齐:为什么不是"生成海报"而是"截页面快照"

方案

体验

成本

AIGC 动态海报(图文合成)

视觉精致,但慢、token 贵、失败时用户无兜底

组件长截图(滚动+裁+拼)

所见即所得,速度跟滚动动画走,失败时至少有局部图

几乎为零(纯端侧)

商城/比价场景里,用户分享的多半是"我刚看的这个商品/这份攻略"——页面本身已经渲染好了,没必要再让模型画一遍。所以决策很朴素:

把"页面快照"当分享载体,海报留给运营侧静态素材,动态分享走截图。


二、长截图的核心原理:不是"截全屏 N 次",而是"滚动+裁增量+拼"

很多人直觉写法是:

滚到顶 → 截一张 → 滚一屏 → 截一张 → …… → 全截完拼起来

但这里有个隐藏问题:相邻两张会重叠(上一张的底部 = 下一张的顶部)。如果直接拼,长图里会出现"重复的一截"。

所以正确的裁剪策略是:

第1张:截完整 List 可视区(作为起始)
第2张:滚 scrollHeight → 截 → 只裁"新增的那 scrollHeight 区域" → push
第3张:再滚 scrollHeight → 截 → 只裁新增区 → push
……
拼图时按 crop 后的 region.size.height 逐段 writePixelsSync 追写

关键点componentSnapshot.get(id)每次截到的都是"当前可视区",所以你要自己记 scrollYOffsets[],用 当前offset - 上一个offset算出"新增高度",再用 PixelMap.crop(region)把那一段切出来。


三、工具层:ImageUtils 只做三件事

不贴大段,只留骨架——这一层的目标是"可被 List 和 Web 两套场景复用"。

// utils/ImageUtils.ets
import { image } from '@kit.ImageKit'

export class ImageUtils {
  /**
   * 裁"新增滚动部分"
   * scrollYOffsets: 每次截图前记录的 yOffset 数组
   * 第1张 → 裁全部;第2张起 → 裁 (offset[n-1]~offset[n]) 这一段
   */
  static async cropIncrement(
    uiCtx: UIContext,
    pixelMap: PixelMap,
    offsets: number[],
    vpWidth: number,
    vpHeight: number          // 可视区高度(vp)
  ): Promise<image.PositionArea> {
    const stride = pixelMap.getBytesNumberPerRow()
    const buf = new ArrayBuffer(pixelMap.getPixelBytesNumber())

    const area: image.PositionArea = {
      pixels: buf, offset: 0, stride,
      region: { x: 0, y: 0, size: { width: 0, height: 0 } }
    }

    if (offsets.length >= 2) {
      const inc = offsets[offsets.length - 1] - offsets[offsets.length - 2]
      const crop = {
        x: 0,
        y: Math.ceil(uiCtx.vp2px(vpHeight - inc)),   // 从底部往上数 inc 高
        size: { width: uiCtx.vp2px(vpWidth), height: uiCtx.vp2px(inc) }
      }
      await pixelMap.crop(crop)
      area.region = crop
    } else {
      // 第1张:全可视区
      area.region = { x: 0, y: 0,
        size: { width: uiCtx.vp2px(vpWidth), height: uiCtx.vp2px(vpHeight) } }
    }

    pixelMap.readPixelsSync(area)
    return area
  }

  /** 把 N 个 crop 区域追写成一张长 PixelMap */
  static createLongPixelMap(
    uiCtx: UIContext,
    areas: image.PositionArea[],
    totalH_px: number    // 最终长图总高(px) = 最后一个offset + vpHeight 换算
  ): PixelMap {
    const w = Math.max(...areas.map(a => a.region.size.width))
    const opts = {
      editable: true,
      pixelFormat: 4,                     // RGBA_8888
      size: { width: w, height: totalH_px }
    }
    const long = image.createPixelMapSync(opts)

    let off = 0
    for (const a of areas) {
      a.offset = off
      long.writePixelsSync(a)
      off += a.region.size.height
    }
    return long
  }
}

两个地方容易算错:

  • crop.region.y要用 vpHeight - inc转 px——因为 PixelMap 坐标是从上往下的,可视区"新增的那一段"在底部

  • totalH_px建议用"最后一个 scrollOffset + vpHeight"换算,不要赌 areas.sum(h)完全等于理论值(浮点/像素取整会差一两行)


四、List 场景(商品评价/攻略列表)的滚动调度

商品详情页的"评价区"或"AI 比价攻略"通常是 List + scroller,调度逻辑长这样:

  1. 记当前 yOffset(用来恢复原位置)

  2. scroller.scrollTo({ y:0 })→ sleep 等动画

  3. 循环:

    • push 当前 offset → componentSnapshot.get(listId)→ cropIncrement → push area

    • 没到底就 scrollBy(0, scrollStep)→ sleep → 递归

    • 到底 → createLongPixelMap→ 得到 merged

  4. 滚回原 yOffset,清临时数组

  5. 弹预览 → 保存/分享

两个 sleep 不能省

  • scrollTo/ scrollBy带 animation 时是异步的,不 sleep 就截到"滚动中间态"

  • 建议 sleep(200)animation: { duration: 200 },成对

恢复原位置这步也很关键——用户点"分享"前可能正看着评价中部,截图完回来如果还在顶部,体验是断的。


五、Web 场景(商品详情富文本 / AI 攻略页)的三个额外点

商城里不少"商品详情"和"AI 购物攻略"是 Web 渲染的(富文本 + 图片懒加载),链路类似但有三个 Web 专属点:

1)必须 enableWholeWebPageDrawing()

默认 Web 组件只绘可视区,componentSnapshot.get(webId)截到的就是屏幕那一块。要截全页滚动长图,得在 aboutToAppear里:

webview.WebviewController.initializeWebEngine()
webview.WebviewController.enableWholeWebPageDrawing()

否则你滚到底再截,上面部分全是空白。

2)判断是否到底用 getPageHeight(),不是 isAtEnd()

const pageH = webController.getPageHeight()
const viewportH = webHeight   // 你给 Web 组件设的 height(vp) 转 px
// 当前已滚 + viewport < pageH → 还没到底

List 用 scroller.isAtEnd(),Web 用 getPageHeight(),别混。

3)等 onPageEnd再开始截图

Web 内容没加载完就截,截出来是白屏。正确顺序是:

Web({ controller })
  .onPageEnd(() => { this.pageLoaded = true })
  .onScroll(() => { this.curYOffset = event.scrollY })

截图按钮的 onClick里先 guard if (!this.pageLoaded) return,避免用户手快。


六、保存相册:为什么必须用 SaveButton

长图画好后要落盘,鸿蒙的权限模型是:

写到相册必须走 photoAccessHelper+ SaveButton安全控件,普通 Button 没这权限,点了也白点。

最小链路:

// 1. 建 asset
const helper = photoAccessHelper.getPhotoAccessHelper(context)
const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png')

// 2. 写文件
const fd = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE)
const packer = image.createImagePacker()
const data = await packer.packToData(longPixelMap, { format: 'image/png', quality: 100 })
fileIo.writeSync(fd, data)
fileIo.closeSync(fd)

// 3. UI 上用 SaveButton(安全控件)
SaveButton({ icon: SaveIconStyle.FULL_FILLED, text: '保存' })
  .onClick((_, result) => {
    if (result === SaveButtonOnClickResult.SUCCESS) {
      // 上面那段 saveSnapshot 逻辑
    }
  })

SaveButton点击后会弹系统授权框,用户确认后 result === SUCCESS你再执行写盘——这是鸿蒙相册写入的"标准姿势",绕不开。


七、坑点速查表(照着对一遍,上线不返工)

现象

根因

修法

截出来只有可视区一屏

Web 没 enableWholeWebPageDrawing()

aboutToAppear 里开

Web 首张白屏

onPageEnd前就截了

pageLoaded守卫

List 截图重叠一截

每次裁全图没做增量 crop

scrollYOffsets[n-1]~[n]算 inc

滚动后截图"半截状态"

动画异步,没 sleep

scroll + 200ms sleep 成对

保存到相册报权限

用普通 Button + 直接 write

SaveButton+ photoAccessHelper

长图底部差一两行

totalH_px算错(浮点/取整)

最后一个offset + vpHeight换算


八、总结

商城/比价场景下的"快照分享",本质是一条端侧滚动+裁剪+拼接的链路:

  • 截图源componentSnapshot.get(componentId)—— 按组件截,不是全屏

  • 增量裁剪PixelMap.crop(region)—— 只留滚动新增段,避免重叠

  • 拼长图createPixelMapSync + writePixelsSync—— 逐段追写

  • Web 专属enableWholeWebPageDrawing()+ getPageHeight()+ onPageEnd守卫

  • 落盘photoAccessHelper+ SaveButton安全控件(权限必经之路)

把这条链路封成"快照工具类 + 滚动调度 + 保存"三层,商品详情、AI 购物攻略、评价长图文都能复用——用户点一下"分享",滚、截、拼、存、发,一路顺下来,比 AIGC 海报便宜也快得多。

下一篇可延伸的方向:Share Kit 把长图发出去时如何带 App Linking 回链(让朋友点开图片/分享卡片能直接跳回你的商品详情页),以及 componentSnapshotwindow.snapshot的选取边界(组件级 vs 窗口级)。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐