HarmonyOS 6商城开发学习:商品页长截图与快照分享——componentSnapshot + PixelMap裁剪合并的滚动链路实战
做购物比价/商城应用时,"分享"是个容易低估的需求:商品详情页很长(图文+规格+评价),用户想发给朋友看;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,调度逻辑长这样:
-
记当前 yOffset(用来恢复原位置)
-
scroller.scrollTo({ y:0 })→ sleep 等动画 -
循环:
-
push 当前 offset →
componentSnapshot.get(listId)→ cropIncrement → push area -
没到底就
scrollBy(0, scrollStep)→ sleep → 递归 -
到底 →
createLongPixelMap→ 得到 merged
-
-
滚回原 yOffset,清临时数组
-
弹预览 → 保存/分享
两个 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 没 |
aboutToAppear 里开 |
|
Web 首张白屏 |
|
加 |
|
List 截图重叠一截 |
每次裁全图没做增量 crop |
用 |
|
滚动后截图"半截状态" |
动画异步,没 sleep |
scroll + 200ms sleep 成对 |
|
保存到相册报权限 |
用普通 Button + 直接 write |
换 |
|
长图底部差一两行 |
|
用 |
八、总结
商城/比价场景下的"快照分享",本质是一条端侧滚动+裁剪+拼接的链路:
-
截图源:
componentSnapshot.get(componentId)—— 按组件截,不是全屏 -
增量裁剪:
PixelMap.crop(region)—— 只留滚动新增段,避免重叠 -
拼长图:
createPixelMapSync + writePixelsSync—— 逐段追写 -
Web 专属:
enableWholeWebPageDrawing()+getPageHeight()+onPageEnd守卫 -
落盘:
photoAccessHelper+SaveButton安全控件(权限必经之路)
把这条链路封成"快照工具类 + 滚动调度 + 保存"三层,商品详情、AI 购物攻略、评价长图文都能复用——用户点一下"分享",滚、截、拼、存、发,一路顺下来,比 AIGC 海报便宜也快得多。
下一篇可延伸的方向:Share Kit 把长图发出去时如何带 App Linking 回链(让朋友点开图片/分享卡片能直接跳回你的商品详情页),以及
componentSnapshot与window.snapshot的选取边界(组件级 vs 窗口级)。
更多推荐


所有评论(0)