在移动应用开发中,将长内容(如一篇完整的旅行攻略、聊天记录或新闻详情)便捷地分享给好友,一直是提升用户体验的关键。传统的“截图拼接”方式既繁琐又容易导致信息割裂。本文将基于HarmonyOS 6的原生能力,深入解析滚动长截图Web内容快照的核心实现方案,并提供一套完整的、可直接复用的实战代码。

功能设计:从“海报生成”到“快照分享”的演进

许多应用最初采用动态生成海报图的方式分享内容,但其弊端显而易见:生成耗时、消耗大量云端计算资源(Token)、响应延迟。尤其对于AI生成的富媒体卡片或长列表,这些成本与体验损失更为突出。

因此,转向本地滚动快照方案成为更优解:

  1. 本地处理,实时响应:所有截图、裁剪、拼接操作均在设备端完成,无需网络请求,速度极快。

  2. 节省资源,降低成本:避免为每段内容生成一次性的、可能不被分享的海报图片,极大节省服务器与流量成本。

  3. 体验无损,所见即所得:最终生成的快照与用户当前屏幕展示的UI布局、样式完全一致。

核心技术架构

快照分享功能的核心在于“滚动-截图-裁剪-合并”​ 的自动化流程,其技术架构与关键API如下:

核心步骤

关键技术/API

作用

1. 获取组件快照

componentSnapshot.get(componentId)

获取指定组件的当前可视区域像素图(PixelMap)。

2. 滚动控制

Scroller.scrollTo()/ WebController.scrollBy()

控制组件(List/Web)滚动,以捕获屏幕外内容。

3. 智能裁剪

PixelMap.crop(region)

裁剪出每次滚动后新增的图像区域,避免重复拼接。

4. 像素合并

PixelMap.writePixelsSync(area)

将多次截图的像素数据按顺序写入一个新的、更大的PixelMap中,形成长图。

5. 编码保存

image.createImagePacker()+ photoAccessHelper

将最终的PixelMap编码为PNG/JPEG文件,并安全保存至系统相册。

核心实现:图片处理工具类

核心是ImageUtils工具类,它封装了裁剪与合并的核心算法。

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

export class ImageUtils {

  /**
   * 获取截图的有效区域(关键:只裁剪新增部分,避免重复)
   * @param uiContext UI上下文
   * @param pixelMap 原始截图
   * @param scrollYOffsets 记录每次滚动偏移量的数组
   * @param listWidth 列表/组件宽度(vp)
   * @param listHeight 列表/组件高度(vp)
   * @returns 处理后的图片区域数据
   */
  static async getSnapshotArea(
    uiContext: UIContext,
    pixelMap: image.PixelMap,
    scrollYOffsets: number[],
    listWidth: number,
    listHeight: number
  ): Promise<image.PositionArea> {

    const offsetCount = scrollYOffsets.length;
    let cropRegion: image.Region;

    if (offsetCount >= 2) {
      // 非首次截图:计算本次滚动实际新增的高度
      const realScrollHeight = scrollYOffsets[offsetCount - 1] - scrollYOffsets[offsetCount - 2];
      // 裁剪区域:从(组件高度 - 新增高度)的位置开始,高度为新增高度
      cropRegion = {
        x: 0,
        y: Math.ceil(uiContext.vp2px(listHeight - realScrollHeight)), // 转为px
        size: {
          height: uiContext.vp2px(realScrollHeight),
          width: uiContext.vp2px(listWidth)
        }
      };
      await pixelMap.crop(cropRegion); // 执行裁剪
    } else {
      // 首次截图:保留整个可视区域
      cropRegion = {
        x: 0,
        y: 0,
        size: {
          width: uiContext.vp2px(listWidth),
          height: uiContext.vp2px(listHeight)
        }
      };
    }

    // 读取裁剪后的像素数据到缓冲区
    const bytesNumber = pixelMap.getPixelBytesNumber();
    const buffer: ArrayBuffer = new ArrayBuffer(bytesNumber);
    const area: image.PositionArea = {
      pixels: buffer,
      offset: 0,
      stride: pixelMap.getBytesNumberPerRow(),
      region: cropRegion
    };
    pixelMap.readPixelsSync(area);

    return area;
  }

  /**
   * 合并所有截图区域,生成一张长图
   * @param areaArray 所有截图区域数据
   * @param totalHeight 最终图片的总高度(px)
   * @returns 合并后的PixelMap长图
   */
  static async mergeImage(areaArray: image.PositionArea[], totalHeight: number): Promise<image.PixelMap> {
    // 1. 创建一张足够大的空白画布
    const maxWidth = this.getMaxAreaWidth(areaArray);
    const createOptions: image.InitializationOptions = {
      editable: true,
      pixelFormat: 4, // PixelMapFormat.RGBA_8888
      size: { width: maxWidth, height: totalHeight }
    };
    const longPixelMap: image.PixelMap = image.createPixelMapSync(createOptions);

    // 2. 按顺序将所有区域的像素数据写入画布
    let currentWritePosition = 0; // 当前写入的垂直起始位置
    for (const area of areaArray) {
      area.offset = currentWritePosition; // 设置本次写入的起始偏移
      longPixelMap.writePixelsSync(area);
      currentWritePosition += area.region.size.height; // 更新下次写入位置
    }

    return longPixelMap;
  }

  private static getMaxAreaWidth(areas: image.PositionArea[]): number {
    return areas.length > 0 ? areas[0].region.size.width : 0;
  }
}

场景一:列表组件(List)长截图

适用于聊天记录、新闻列表等可滚动组件。

// view/ScrollSnapshot.ets
@Component
export struct ScrollSnapshot {
  private scroller: Scroller = new Scroller();
  @State curYOffset: number = 0;
  @State scrollYOffsets: number[] = [];
  @State areaArray: image.PositionArea[] = [];
  @State mergedImage: image.PixelMap | null = null;
  private listId: string = 'snapshot_list';
  private yOffsetBeforeSnapshot: number = 0; // 记录截图前的位置

  async startSnapshot() {
    // 1. 准备:保存当前位置并滚动到顶部
    this.yOffsetBeforeSnapshot = this.curYOffset;
    this.scroller.scrollTo({ yOffset: 0, animation: { duration: 200 } });
    await this.sleep(200);

    // 2. 清空历史数据,开始递归截图流程
    this.scrollYOffsets = [];
    this.areaArray = [];
    await this.captureAndScrollRecursively();

    // 3. 恢复:滚动回原始位置
    this.scroller.scrollTo({ yOffset: this.yOffsetBeforeSnapshot, animation: { duration: 200 } });
  }

  private async captureAndScrollRecursively() {
    // 记录本次滚动开始的位置
    this.scrollYOffsets.push(this.curYOffset);

    // 截图
    const pixelMap = await this.getUIContext().getComponentSnapshot().get(this.listId);
    if (!pixelMap) return;

    // 裁剪出新增部分
    const area = await ImageUtils.getSnapshotArea(
      this.getUIContext(),
      pixelMap,
      this.scrollYOffsets,
      360, // 组件宽度vp
      600  // 组件高度vp
    );
    this.areaArray.push(area);

    // 判断是否已滚动到底部
    if (!this.scroller.isAtEnd()) {
      // 滚动一屏高度,继续下一轮截图
      this.scroller.scrollBy({ yOffset: 550, animation: { duration: 200 } }); // 略小于组件高度,确保有重叠用于裁剪
      await this.sleep(200);
      await this.captureAndScrollRecursively();
    } else {
      // 已到底,合并所有截图
      const totalHeight = this.scrollYOffsets[this.scrollYOffsets.length - 1] + 600;
      this.mergedImage = await ImageUtils.mergeImage(this.areaArray, totalHeight);
      // 触发预览弹窗显示
      this.previewSnapshot();
    }
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  build() {
    Column() {
      List({ scroller: this.scroller }) {
        // ... 你的列表内容
      }
      .id(this.listId)
      .onScroll((xOffset: number, yOffset: number) => {
        this.curYOffset = yOffset;
      })

      Button('生成快照')
        .onClick(() => this.startSnapshot())
    }
  }
}

场景二:Web组件内容快照

适用于渲染富文本、文章详情页等Web内容,其关键在于启用全网页绘制

// view/WebSnapshot.ets
import { webview } from '@kit.ArkWeb';

@Component
export struct WebSnapshot {
  private webController: webview.WebviewController = new webview.WebviewController();
  @State curScrollY: number = 0;
  @State scrollOffsets: number[] = [];
  @State areaArray: image.PositionArea[] = [];
  @State mergedImage: image.PixelMap | null = null;
  private webId: string = 'snapshot_web';

  aboutToAppear() {
    // 关键配置:启用全网页绘制,否则只能截取可视区域
    webview.WebviewController.enableWholeWebPageDrawing();
  }

  async captureWebContent() {
    // 1. 滚动到顶部
    this.webController.scrollTo(0, 0);
    await this.sleep(100);

    this.scrollOffsets = [];
    this.areaArray = [];
    await this.captureWebRecursively();
  }

  private async captureWebRecursively() {
    this.scrollOffsets.push(this.curScrollY);

    const pixelMap = await this.getUIContext().getComponentSnapshot().get(this.webId);
    if (!pixelMap) return;

    const area = await ImageUtils.getSnapshotArea(
      this.getUIContext(),
      pixelMap,
      this.scrollOffsets,
      360,
      600
    );
    this.areaArray.push(area);

    const pageHeight = this.webController.getPageHeight();
    const viewportBottom = this.curScrollY + 600;

    if (viewportBottom < pageHeight) {
      // 滚动一屏高度
      this.webController.scrollBy(0, 550);
      await this.sleep(500); // Web内容渲染需要更长时间
      await this.captureWebRecursively();
    } else {
      const totalHeight = this.scrollOffsets[this.scrollOffsets.length - 1] + 600;
      this.mergedImage = await ImageUtils.mergeImage(this.areaArray, totalHeight);
      this.previewSnapshot();
    }
  }

  build() {
    Column() {
      Web({ src: 'https://developer.harmonyos.com/', controller: this.webController })
        .id(this.webId)
        .onScroll((event: { scrollY: number }) => {
          this.curScrollY = event.scrollY;
        })

      Button('生成网页快照')
        .onClick(() => this.captureWebContent())
    }
  }
}

保存与分享:使用安全控件

生成快照后,需通过系统提供的SaveButton安全控件保存至相册。

// view/SnapshotPreview.ets
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileIo } from '@kit.CoreFileKit';

@Component
export struct SnapshotPreview {
  @Prop mergedImage: image.PixelMap;
  @Link isVisible: boolean;

  async saveToGallery() {
    try {
      const context = this.getUIContext().getHostContext();
      const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);

      // 1. 在相册中创建图片资源
      const uri = await phAccessHelper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png');

      // 2. 打开文件并写入数据
      const file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
      const imagePacker = image.createImagePacker();
      const packOpts: image.PackingOptions = { format: 'image/png', quality: 100 };
      const imageData: ArrayBuffer = await imagePacker.packToData(this.mergedImage, packOpts);

      fileIo.writeSync(file.fd, imageData);
      fileIo.closeSync(file.fd);

      promptAction.showToast({ message: '已保存到相册' });
      this.isVisible = false;
    } catch (err) {
      console.error('保存失败:', err);
      promptAction.showToast({ message: '保存失败,请重试' });
    }
  }

  build() {
    if (!this.isVisible) {
      return;
    }

    Column() {
      // 半透明遮罩层
      Column()
        .width('100%')
        .layoutWeight(1)
        .backgroundColor('rgba(0,0,0,0.5)')
        .onClick(() => { this.isVisible = false; })

      // 预览面板
      Column() {
        // 预览图片
        Scroll() {
          Image(this.mergedImage)
            .width('100%')
            .objectFit(ImageFit.Contain)
        }
        .height('70%')

        // 操作按钮
        Row({ space: 20 }) {
          Button('取消')
            .onClick(() => { this.isVisible = false; })

          // 必须使用SaveButton进行保存操作
          SaveButton({
            icon: SaveIconStyle.FULL_FILLED,
            text: '保存到相册',
            buttonType: ButtonType.NORMAL
          })
          .onClick((event, result) => {
            if (result === SaveButtonOnClickResult.SUCCESS) {
              this.saveToGallery();
            }
          })
        }
        .padding(20)
      }
      .width('100%')
      .backgroundColor(Color.White)
      .borderRadius({ topLeft: 24, topRight: 24 })
    }
  }
}

避坑指南

  1. Web组件必须启用全网页绘制:调用enableWholeWebPageDrawing(),否则getComponentSnapshot()只能获取到当前可视区域。

  2. 处理好异步滚动:在scrollToscrollBy后,必须通过sleep或监听滚动事件确保滚动动画完成、内容渲染到位后再截图,否则会截到空白或中间状态。

  3. 重叠区域计算:滚动距离应略小于组件高度(如组件高度-50vp),以确保相邻两张截图有重叠部分,ImageUtils.getSnapshotArea方法利用此重叠部分精确计算出新增区域,避免拼接后出现重复内容或断层。

  4. 内存管理:处理大尺寸长图时,注意及时释放不再使用的PixelMap对象(release()方法),避免内存溢出。

总结

本文提供了一套在HarmonyOS 6中实现列表与Web组件滚动长截图的完整方案。该方案通过本地化、自动化的“滚动-截图-裁剪-合并”流程,高效生成高质量长图,并利用SaveButton安全保存,完美替代了高成本的云端海报生成方案,显著提升了内容分享功能的用户体验与性能。

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

Logo

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

更多推荐