在HarmonyOS 6购物比价或电商类应用中,"商品图文详情快照分享"是高频运营需求——用户点分享按钮,自动将超出一屏的商品图文介绍(标题+参数+WebView图文详情)截取成长图,预览后可保存相册或直接发给好友。直接用componentSnapshot.get()只截到当前可视区、长图拼接重复、WebView未开全页绘制、WRITE_MEDIA权限被拒是常见坑。

本文将参考官方行业实践与51CTO社区快照分享案例,用 componentSnapshot.get()+ Scroller分段滚屏 + PixelMap.crop/writePixelsSync拼接 + photoAccessHelper+SaveButton落盘​ 完整实现商品详情页长截图分享功能。


一、需求拆解与页面结构

1. 典型商品详情截图区

Column
 ├── Header(商品名/价格/标签)— 固定高
 ├── Tabs(图文详情 | 参数 | 评价)
 │    └── Scroll/Web(图文详情区,可变高)
 └── BottomBar(加入购物车/立即购买)← 不截

截图目标 = Header + Tabs下图文详情区整体内容(不含底部操作栏),生成长图。

2. 核心API

API

用途

componentSnapshot.get(componentId)

按组件ID截取当前渲染像素→PixelMap

Scroller.scrollTo({yOffset, animation})

驱动Scroll分段滚屏

PixelMap.crop(region)

裁剪每屏新增部分(避免重复拼接)

image.createPixelMapSync(opts)+ writePixelsSync(area)

创建空白长图并按区写入

photoAccessHelper.createAsset()+ SaveButton

安全写入相册(无需动态申明存储权限)

webview.WebviewController.enableWholeWebPageDrawing()

Web图文详情必须调,否则只截可视区


二、图片处理工具类(裁剪与合并)

// 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 } }
    };

    const len = offsets.length;
    if (len >= 2) {
      // 非首屏:只保留本次新增滚动部分
      const prevY = offsets[len - 2];
      const curY  = offsets[len - 1];
      const addH  = curY - prevY;
      const cropRgn = {
        x: 0,
        y: uiCtx.vp2px(vpH - 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 新增滚入区域可保证长图无缝。


三、商品详情页——滚屏截图+预览+保存

// pages/GoodsDetailSnapshotPage.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';

@Entry
@Component
struct GoodsDetailSnapshotPage {
  private ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;

  // ===== 详情内容Scroller =====
  private detailScroller: Scroller = new Scroller();
  @State curOffset: number = 0;
  private scrollYHistory: number[] = [];
  private cropAreas: image.PositionArea[] = [];

  // ===== 快照结果 =====
  @State mergedPic: image.PixelMap | undefined = undefined;
  @State showPreview: boolean = false;

  // 布局常量(实际可用getInspectorBounds动态取更准)
  private DETAIL_ID = 'goods_detail_scroll';
  private DETAIL_W_VP = 360;     // ≈屏幕宽
  private DETAIL_H_VP = 480;     // 可视区高(含Header估算)

  // ===== 开始截图 =====
  async doSnapshot() {
    // 记住原位
    const bakY = this.detailScroller.currentOffset().yOffset;
    // 滚回顶部 & 等渲染
    this.detailScroller.scrollTo({ yOffset: 0, animation: false });
    await SnapshotUtil.sleep(300);
    this.scrollYHistory = [0];
    this.cropAreas = [];

    await this.captureLoop();

    // 恢复原位置
    this.detailScroller.scrollTo({ yOffset: bakY, animation: { duration: 200 } });
    this.showPreview = true;
  }

  private async captureLoop(): Promise<void> {
    const pm = await componentSnapshot.get(this.DETAIL_ID);
    const area = await SnapshotUtil.getCropArea(
      this.getUIContext(), pm, this.scrollYHistory,
      this.DETAIL_W_VP, this.DETAIL_H_VP
    );
    this.cropAreas.push(area);

    if (!this.detailScroller.isAtEnd()) {
      const nextY = this.scrollYHistory[this.scrollYHistory.length - 1] + this.DETAIL_H_VP;
      this.detailScroller.scrollTo({ yOffset: nextY, animation: { duration: 200 } });
      await SnapshotUtil.sleep(350);
      this.scrollYHistory.push(nextY);
      return this.captureLoop();
    }

    // 到底 → 合并
    const totalH = this.scrollYHistory[this.scrollYHistory.length - 1] + this.DETAIL_H_VP;
    this.mergedPic = await SnapshotUtil.mergeToLong(
      this.getUIContext(), this.cropAreas, totalH, this.DETAIL_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() {
        // ---- 商品详情内容(待截图区域)----
        Scroll(this.detailScroller) {
          Column({ space: 12 }) {
            // Header
            Column() {
              Text('HarmonyOS 6 智慧手表 Ultra')
                .fontSize(20).fontWeight(FontWeight.Bold)
              Text('¥1499  限时满减中')
                .fontSize(16).fontColor('#FF5722').margin({ top: 4 })
            }.padding(16)

            // 图文详情区(示意多屏高度)
            Column() {
              ForEach([1,2,3,4,5], (i: number) => {
                Image($r('app.media.prod_01'))
                  .width('100%').height(220)
                  .objectFit(ImageFit.Cover).margin({ bottom: 8 })
              })
            }.padding(16)
          }
        }
        .id(this.DETAIL_ID)
        .onScroll(() => {
          this.curOffset = this.detailScroller.currentOffset().yOffset;
        })
        .layoutWeight(1)

        // ---- 底部操作栏(不截图)----
        Row() {
          Button('分享商品快照')
            .layoutWeight(1)
            .height(44)
            .backgroundColor('#FF5722')
            .borderRadius(22)
            .onClick(() => this.doSnapshot())
        }
        .padding(12)
        .backgroundColor('#FFF')
      }
      .width('100%').height('100%').backgroundColor(Color.White)

      // ---- 预览弹窗 ----
      if (this.showPreview && this.mergedPic) {
        this.buildPreviewDialog()
      }
    }
  }

  // 预览+保存弹窗
  @Builder
  buildPreviewDialog() {
    Column() {
      // 遮罩
      Column().layoutWeight(1).backgroundColor('rgba(0,0,0,0.45)').onClick(()=>{
        this.showPreview=false; this.mergedPic=undefined;
      })

      Column() {
        Scroll() { Image(this.mergedPic!).width('100%').objectFit(ImageFit.Contain) }
          .height('70%')

        Row({ space: 20 }) {
          Button('取消').onClick(()=>{
            this.showPreview=false; this.mergedPic=undefined;
          })
          // ✅ 安全控件保存——无需 WRITE_MEDIA 权限
          SaveButton({ icon: SaveIconStyle.FULL_FILLED, text:'保存', buttonType:ButtonType.NORMAL })
            .onClick(async (_e, res) => {
              if (res === SaveButtonOnClickResult.SUCCESS && this.mergedPic) {
                await this.saveToAlbum(this.mergedPic);
              }
            })
        }.padding(16)
      }
      .backgroundColor(Color.White)
      .borderRadius({ topLeft:24, topRight:24 })
    }
    .width('100%').height('100%').position({x:0,y:0})
  }
}

WebView图文详情补充:若图文详情用 Web({controller, src}),须先调 webview.WebviewController.enableWholeWebPageDrawing(),在 onPageEnd后开始滚屏截图,判断是否到底用 webController.getPageHeight()而非 isAtEnd()


四、避坑指南

问题

原因

修复

截图只截到首屏/可视区

componentSnapshot.get()只截组件当前渲染像素

Scroller分段滚 + 多次 get()

长图有重复段落

每次截全可视区直接拼接

PixelMap.crop()只保留新增滚动部分(参考 getCropArea

保存时报权限拒绝

普通Button调MediaLibrary写文件

SaveButton安全控件(系统弹授权框)

Web图文详情截不全

未启用全页绘制

webview.WebviewController.enableWholeWebPageDrawing()+ 等 onPageEnd后开始截图

滚屏截到动画残影

scrollTo动画未完成即截图

await sleep(300~500ms)等渲染完再 componentSnapshot.get()


五、总结:商品详情长截图SOP

  1. 给截图区组件设唯一 id,包在 Scroll内(Web需开 enableWholeWebPageDrawing

  2. 滚回顶部​ → componentSnapshot.get(id)截首屏 → crop存首段

  3. 循环scrollTo(y+可视高) → sleep → get → crop新增区 → push,直到 isAtEnd()(Web用 getPageHeight()

  4. 合并createPixelMapSync+ 按序 writePixelsSync(area)

  5. 预览 + SaveButtonphotoAccessHelper.createAsset+ ImagePacker.packToData+ fileIo.write

核心法则:HarmonyOS 6 中商品图文详情长截图分享 = "componentSnapshot分段截 + PixelMap.crop去重拼接 + SaveButton安全落盘",Web详情额外开 enableWholeWebPageDrawing()

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任。

Logo

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

更多推荐