在HarmonyOS 6购物比价或电商类应用中,AI推荐模块可生成富媒体商品卡片(图文混排、价格标签、评分星级),用户希望"一键分享给好友"——自动滚动截取完整推荐卡片生成长图,预览后可保存相册或直接调系统分享。常见坑是:直接用componentSnapshot.get()只截到可视区、长图拼接重复、Web图文详情未开全页绘制、WRITE_MEDIA权限动态申请被拒。
本文将参考官方行业实践与51CTO社区快照分享案例,用componentSnapshot.get()+Scroller分段滚屏 +PixelMap.crop/writePixelsSync拼接 +photoAccessHelper+SaveButton落盘 完整实现AI推荐卡片的长截图分享功能。
一、需求拆解与页面结构
1. 典型AI推荐卡片结构
Column (AI推荐区,包在 Scroll 内) ├── 推荐标题 "为你推荐" ├── 商品图文块1 (Image + 名称 + ¥价格 + ★评分) ├── 商品图文块2 ├── 商品图文块3 └── 底部说明 "由HarmonyOS AI生成"截图目标 = 上述Scroll内全部内容(不含底部"加入购物车"操作栏)。
2. 交互流程
用户点"分享推荐" → 禁交互 → 滚回顶部 → 循环【滚一段+截一屏+crop新增区】→ 合并为长PixelMap → 恢复原位 → 弹预览弹窗 → SaveButton保存 / 系统分享二、图片处理工具类(裁剪与合并)
// utils/SnapshotUtil.ets import { image } from '@kit.ImageKit'; import { UIContext } from '@kit.ArkUI'; export class SnapshotUtil { /** * 裁剪截图中的新增滚动部分(避免拼接重复) * @param uiCtx UI上下文(vp2px转换) * @param pm 当前屏 componentSnapshot 得到的 PixelMap * @param offsets 历次滚屏Y偏移记录 [0, h1, h2...] * @param vpW 截图组件宽(vp) * @param vpH 截图组件可视高(vp) */ static async getCropArea( uiCtx: UIContext, pm: image.PixelMap, offsets: number[], vpW: number, vpH: number ): Promise<image.PositionArea> { const stride = pm.getBytesNumberPerRow(); const buf = new ArrayBuffer(pm.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 prevY = offsets[offsets.length - 2]; const curY = offsets[offsets.length - 1]; const addH = curY - prevY; // 新增像素高 const cropRgn = { x: 0, y: uiCtx.vp2px(vpH - addH), // pm底部addH区域 size: { width: uiCtx.vp2px(vpW), height: uiCtx.vp2px(addH) } }; await pm.crop(cropRgn); area.region = cropRgn; } else { // 首屏保留全部 area.region = { x: 0, y: 0, size: { width: uiCtx.vp2px(vpW), height: uiCtx.vp2px(vpH) } }; } pm.readPixelsSync(area); return area; } /** * 按序合并裁剪区域为一张长图 PixelMap * @param uiCtx UI上下文 * @param areas 裁剪PositionArea数组(顺序=滚屏顺序) * @param totalContentHVP 内容总高vp = 末次Y偏移 + 首屏可视高 * @param vpW 内容宽vp */ static async mergeToLong( uiCtx: UIContext, areas: image.PositionArea[], totalContentHVP: number, vpW: number ): Promise<image.PixelMap> { const totalH = uiCtx.vp2px(totalContentHVP); const w = uiCtx.vp2px(vpW); const opts: image.InitializationOptions = { editable: true, pixelFormat: image.PixelFormat.RGBA_8888, size: { width: w, height: totalH } }; const longPm = image.createPixelMapSync(opts); let offY = 0; for (const a of areas) { a.offset = offY; longPm.writePixelsSync(a); offY += a.region.size.height; } return longPm; } /** 简易延时 */ static sleep(ms: number): Promise<void> { return new Promise(r => setTimeout(r, ms)); } }为什么只保留新增部分?
每次滚屏后
componentSnapshot.get()截的是当前可视区全图,直接拼接上下相邻内容会重叠。只 crop 新增滚入区域可保证长图无缝。
三、AI推荐卡片页——滚屏截图+预览+保存
// pages/AIRecCardPage.ets import { componentSnapshot } from '@kit.ArkUI'; import { image } from '@kit.ImageKit'; import { photoAccessHelper } from '@kit.MediaLibraryKit'; import { fileIo } from '@kit.CoreFileKit'; import { SnapshotUtil } from '../utils/SnapshotUtil'; import { common } from '@kit.AbilityKit'; // 模拟推荐商品 interface RecItem { id: string; name: string; price: string; score: number; img: Resource; } const REC_ITEMS: RecItem[] = [ { id:'R01', name:'HarmonyOS 6 智慧耳机 Pro', price:'¥899', score:4.8, img:$r('app.media.ic_headphone') }, { id:'R02', name:'氮化镓快充套装 120W', price:'¥149', score:4.6, img:$r('app.media.ic_charger') }, { id:'R03', name:'AI翻译词典笔 2代', price:'¥259', score:4.9, img:$r('app.media.ic_dict') }, ]; @Entry @Component struct AIRecCardPage { private ctx = this.getUIContext().getHostContext() as common.UIAbilityContext; private scroller: Scroller = new Scroller(); private REC_AREA_ID = 'ai_rec_area'; @State snapshotPm: image.PixelMap | undefined = undefined; @State showPreview: boolean = false; // 滚屏历史 & 裁剪区 private offsets: number[] = []; private areas: image.PositionArea[] = []; // 布局常量(可用getInspectorBounds动态取更准) private AREA_W_VP = 360; private AREA_H_VP = 420; // ===== 执行快照 ===== async doSnapshot() { // 记住原位 const bakY = this.scroller.currentOffset().yOffset; // 滚回顶部等渲染 this.scroller.scrollTo({ yOffset: 0, animation: false }); await SnapshotUtil.sleep(300); this.offsets = [0]; this.areas = []; await this.captureLoop(); // 恢复原位置 this.scroller.scrollTo({ yOffset: bakY, animation: { duration: 200 } }); this.showPreview = true; } private async captureLoop(): Promise<void> { const pm = await componentSnapshot.get(this.REC_AREA_ID); const area = await SnapshotUtil.getCropArea( this.getUIContext(), pm, this.offsets, this.AREA_W_VP, this.AREA_H_VP ); this.areas.push(area); if (!this.scroller.isAtEnd()) { const nextY = this.offsets[this.offsets.length - 1] + this.AREA_H_VP; this.scroller.scrollTo({ yOffset: nextY, animation: { duration: 200 } }); await SnapshotUtil.sleep(350); this.offsets.push(nextY); return this.captureLoop(); } // 合并 const totalH = this.offsets[this.offsets.length - 1] + this.AREA_H_VP; this.snapshotPm = await SnapshotUtil.mergeToLong( this.getUIContext(), this.areas, totalH, this.AREA_W_VP ); } // ===== 保存相册(配合SaveButton)===== async saveToAlbum(pm: image.PixelMap) { try { const helper = photoAccessHelper.getPhotoAccessHelper(this.ctx); const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png'); const file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); const packer = image.createImagePacker(); const data = await packer.packToData(pm, { format: 'image/png', quality: 100 }); fileIo.writeSync(file.fd, data); fileIo.closeSync(file.fd); this.getUIContext().getPromptAction().showToast({ message: '已保存到相册' }); this.showPreview = false; } catch (e) { this.getUIContext().getPromptAction().showToast({ message: '保存失败' }); console.error(`save err: ${JSON.stringify(e)}`); } } build() { Stack() { Column() { // ---- AI推荐区(待截图)---- Scroll(this.scroller) { Column({ space: 16 }) { Text('为你推荐 · AI精选') .fontSize(18) .fontWeight(FontWeight.Bold) .padding({ top: 12, bottom: 4 }) ForEach(REC_ITEMS, (it: RecItem) => { Row({ space: 12 }) { Image(it.img).width(80).height(80).borderRadius(8).objectFit(ImageFit.Cover) Column() { Text(it.name).fontSize(15).fontColor('#333').maxLines(1) Text(it.price).fontSize(14).fontColor('#FF5722').margin({ top: 4 }) Row({ space: 4 }).margin({ top: 4 }) { ForEach(new Array(Math.round(it.score)).fill(0), (_i,i)=>{ Image($r('sys.media.ohos_ic_public_star_filled')) .width(14).height(14).fillColor('#FF9800') }) } }.layoutWeight(1).alignItems(HorizontalAlign.Start) } .padding(12) .backgroundColor(Color.White) .borderRadius(12) .shadow({ radius: 3, color:'rgba(0,0,0,0.05)', offsetX:0, offsetY:1 }) }) Text('由 HarmonyOS AI 生成 · 仅供参考') .fontSize(11) .fontColor('#BBB') .margin({ top: 8, bottom: 16 }) } .padding(16) } .id(this.REC_AREA_ID) .layoutWeight(1) // ---- 底部操作栏 ---- Row() { Button('分享推荐快照') .layoutWeight(1) .height(44) .backgroundColor('#FF5722') .borderRadius(22) .onClick(() => this.doSnapshot()) } .padding(12) .backgroundColor('#FFF') } .width('100%') .height('100%') .backgroundColor('#F5F6F8') // ---- 预览弹窗 ---- if (this.showPreview && this.snapshotPm) { this.buildPreview() } } } @Builder buildPreview() { Column() { // 遮罩 Column().layoutWeight(1).backgroundColor('rgba(0,0,0,0.45)').onClick(()=>{ this.showPreview=false; this.snapshotPm=undefined; }) Column() { Scroll() { Image(this.snapshotPm!).width('100%').objectFit(ImageFit.Contain) }.height('70%') Row({ space: 20 }) { Button('取消').onClick(()=>{ this.showPreview=false; this.snapshotPm=undefined; }) // ✅ 安全控件保存——无需 WRITE_MEDIA 权限 SaveButton({ icon: SaveIconStyle.FULL_FILLED, text:'保存', buttonType:ButtonType.NORMAL }) .onClick(async (_e, res) => { if (res === SaveButtonOnClickResult.SUCCESS && this.snapshotPm) { await this.saveToAlbum(this.snapshotPm); } }) }.padding(16) } .backgroundColor(Color.White) .borderRadius({ topLeft:24, topRight:24 }) } .width('100%') .height('100%') .position({ x:0,y:0 }) } }四、避坑指南
问题 | 原因 | 修复 |
|---|---|---|
截图只截到首屏 |
| 用 |
长图有重复段落 | 每次截全可视区直接拼接 |
|
保存时报权限拒绝 | 普通 Button 调 MediaLibrary 写文件 | 用 |
Web图文详情截不全 | 未启用全页绘制 | 调 |
滚屏截到动画残影 |
|
|
五、总结:AI卡片快照分享SOP
给截图区组件设唯一
id,包在Scroll内滚回顶部 →
componentSnapshot.get(id)截首屏 →crop存首段循环
scrollTo(y+可视高) → sleep → get → crop新增区 → push,直到isAtEnd()合并:
createPixelMapSync+ 按序writePixelsSync(area)预览 + SaveButton:
photoAccessHelper.createAsset+ImagePacker.packToData+fileIo.write
核心法则:HarmonyOS 6 中富媒体卡片长截图分享 ="componentSnapshot分段截 + PixelMap.crop去重拼接 + SaveButton安全落盘",Web详情额外开enableWholeWebPageDrawing()。
©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任。