本文同步发表于 微信公众号,微信搜索 程语新视界 即可关注,每个工作日都有文章更新

组件截图 指将应用内某个组件节点树的渲染结果生成为 PixelMap(位图) 的能力。
支持两种方式:

  1. 对已挂树显示的组件截图:组件已在界面上显示。

  2. 对离线组件截图:通过 Builder 或 ComponentContent 实现的、尚未挂载到树上的组件。

注意

  • 截图依赖UI上下文,建议优先使用 UIContext.getComponentSnapshot() 返回的 ComponentSnapshot 对象接口,而非直接导入的 componentSnapshot 接口。

  • 截图仅能获取最近一帧的绘制内容,若在组件更新时调用,返回的是前一帧内容。

二、对挂树组件截图

2.1 使用组件ID截图

通过 .id() 为组件设置唯一标识,使用以下接口截图:

  • get():异步截图

  • getSync():同步截图

注意

  • 系统仅遍历已挂树的组件,不查找缓存或离屏组件。

  • 建议确保组件ID唯一性,系统以首个查找到的结果为准。

2.2 使用组件唯一ID截图(API 15+)

若已知组件的 getUniqueId(),可使用以下接口,省去查找过程:

  • getWithUniqueId():异步

  • getSyncWithUniqueId():同步

三、对离线组件截图

离线组件指尚未挂载到树上的组件,使用以下接口截图:

  • createFromBuilder():通过Builder创建

  • createFromComponent()(API 18+):通过ComponentContent创建

注意事项

  • 离线组件需进行构建、布局、资源加载等操作,耗时较长。

  • 建议设置 delay 参数确保操作完成。

  • 对于图片资源,建议设置 Image.syncLoad(true) 强制同步加载,确保截图时图片已准备就绪。

四、示例:截取长内容(滚动截图)

对于滚动容器(如List),一次截图只能捕获可见区域。需通过模拟滚动、分页截图、拼接位图实现长截图。

步骤1:添加滚动控制器及事件监听

@Component
export struct ScrollSnapshot {
  // 滚动控制器:用于控制List的滚动行为
  private scroller: Scroller = new Scroller();
  private listComponentWidth: number = 0; // 组件宽度,默认值为0
  private listComponentHeight: number = 0; // 组件高度,默认值为0
  // list组件的当前垂直偏移量
  private curYOffset: number = 0;
  // 每次滚动的距离
  private scrollHeight: number = 0;
  // ... 其他变量

  build() {
    Stack() {
      // ... 其他组件
      
      // 1.1 绑定滚动控制器,并通过.id()配置组件唯一标识
      List({ space: 12, scroller: this.scroller }) {
        // 使用LazyForEach优化长列表性能
        LazyForEach(this.dataSource, (item: number) => {
          ListItem() {
            NewsItem({ index: item }) // 自定义列表项组件
          }
        }, (item: number) => item.toString()) // 键值生成函数
      }
      // ... 其他属性
      .id('LIST_ID') // 设置唯一ID,用于后续截图
      // 1.2 通过回调获取滚动偏移量
      .onDidScroll(() => {
        // 更新当前垂直偏移量
        this.curYOffset = this.scroller.currentOffset().yOffset;
      })
      // 1.3 监听组件区域变化,获取实际宽高
      .onAreaChange((oldValue, newValue) => {
        // 获取组件的实际宽度和高度
        this.listComponentWidth = newValue.width as number;
        this.listComponentHeight = newValue.height as number;
        // 设置每次滚动距离为组件高度(一屏)
        this.scrollHeight = this.listComponentHeight;
      })
      // ... 其他设置
    }
  }
}

步骤2:循环滚动截图并缓存

/**
 * 递归滚动截图,直到滚动到底,最后合并所有截图
 */
async scrollSnapAndMerge() {
  try {
    // 记录本次滚动相对于上次的偏移量
    this.scrollYOffsets.push(this.curYOffset - this.yOffsetBefore);
    
    // 调用组件截图接口,获取list组件的当前屏幕截图
    // get()是异步方法,返回Promise<PixelMap>
    const pixelMap = await this.getUIContext().getComponentSnapshot().get('LIST_ID');
    
    // 获取位图像素字节,并保存在数组中
    // ImageUtils.getSnapshotArea是自定义工具函数,用于处理截图区域
    let area: image.PositionArea = await ImageUtils.getSnapshotArea(
      pixelMap, 
      this.scrollYOffsets, 
      this.listComponentWidth,
      this.listComponentHeight
    );
    this.areaArray.push(area); // 保存截图区域数据
    
    // 判断是否滚动到底以及用户是否已经强制停止
    if (!this.scroller.isAtEnd() && !this.isClickStop) {
      // 如果没有到底或被停止,则播放一个滚动动效
      // 滚动到下一屏位置
      CommonUtils.scrollAnimation(this.scroller, 1000, this.scrollHeight);
      
      // 等待1.5秒,确保滚动完成且界面稳定
      await CommonUtils.sleep(1500);
      
      // 递归调用,继续截取下一屏
      await this.scrollSnapAndMerge();
    } else {
      // 当滚动到底时,调用mergeImage将所有保存的位图数据进行拼接
      this.mergedImage = await ImageUtils.mergeImage(
        this.areaArray,
        this.scrollYOffsets[this.scrollYOffsets.length - 1], // 最后偏移量
        this.listComponentWidth, 
        this.listComponentHeight
      );
    }
  } catch (err) {
    let error = err as BusinessError;
    console.error(`scrollSnapAndMerge err, errCode: ${error.code}, error message: ${error.message}`);
  }
}

// 滚动动画工具函数
static scrollAnimation(scroller: Scroller, duration: number, scrollHeight: number): void {
  scroller.scrollTo({
    xOffset: 0, // 水平方向不滚动
    yOffset: (scroller.currentOffset().yOffset + scrollHeight), // 垂直滚动一屏距离
    animation: {
      duration: duration, // 动画持续时间(毫秒)
      curve: Curve.Smooth, // 平滑动画曲线
      canOverScroll: false // 禁止过度滚动
    }
  });
}

步骤3:拼接长截图

static async mergeImage(
  areaArray: image.PositionArea[], // 所有截图区域数据
  lastOffsetY: number,             // 最后偏移量
  listWidth: number,               // 组件宽度
  listHeight: number               // 组件高度
): Promise<PixelMap> {
  
  // 创建长截图位图对象的配置选项
  let opts: image.InitializationOptions = {
    editable: true,      // 可编辑,允许写入像素
    pixelFormat: 4,      // 像素格式(通常4表示RGBA_8888)
    size: {
      // 将虚拟像素转换为物理像素
      width: uiContext?.vp2px(listWidth) || 0,
      // 总高度 = 最后偏移量 + 组件高度
      height: uiContext?.vp2px(lastOffsetY + listHeight) || 0
    }
  };
  
  // 同步创建PixelMap对象(长图空白画布)
  let longPixelMap = image.createPixelMapSync(opts);
  let imgPosition: number = 0; // 当前写入位置(垂直方向)
  
  // 遍历所有截图区域
  for (let i = 0; i < areaArray.length; i++) {
    let readArea = areaArray[i]; // 获取第i屏的截图数据
    
    // 构建要写入的区域对象
    let area: image.PositionArea = {
      pixels: readArea.pixels,    // 像素数据
      offset: 0,                  // 像素数据偏移量
      stride: readArea.stride,    // 行跨度
      region: {
        size: {
          width: readArea.region.size.width,   // 区域宽度
          height: readArea.region.size.height  // 区域高度
        },
        x: 0,           // 水平位置从0开始
        y: imgPosition  // 垂直位置逐屏累加
      }
    };
    
    // 更新下一屏的写入位置
    imgPosition += readArea.region.size.height;
    
    try {
      // 将当前屏幕截图写入长图对应位置
      longPixelMap.writePixelsSync(area);
    } catch (err) {
      let error = err as BusinessError;
      console.error(`writePixelsSync err, code: ${error.code}, message: ${error.message}`);
    }
  }
  
  return longPixelMap; // 返回拼接完成的长图
}

步骤4:保存截图

使用 SaveButton 保存到相册:

// 使用安全控件SaveButton实现截图保存到相册
SaveButton({
  icon: SaveIconStyle.FULL_FILLED,     // 图标样式
  text: SaveDescription.SAVE_IMAGE,    // 按钮文本
  buttonType: ButtonType.Capsule       // 胶囊按钮样式
})
.onClick(async (event, result) => {
  // 点击事件回调
  await this.saveSnapshot(result);
})

async saveSnapshot(result: SaveButtonOnClickResult): Promise<void> {
  try {
    // 检查保存是否成功
    if (result === SaveButtonOnClickResult.SUCCESS) {
      // 获取相册访问助手
      const helper = photoAccessHelper.getPhotoAccessHelper(this.context);
      
      // 在相册中创建新的图片文件(PNG格式)
      const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png');
      
      // 打开文件准备写入
      const file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
      
      // 创建图片打包器
      const imagePackerApi: image.ImagePacker = image.createImagePacker();
      
      // 打包选项:PNG格式,100%质量
      const packOpts: image.PackingOption = {
        format: 'image/png',
        quality: 100,
      };
      
      // 将PixelMap打包为二进制数据
      imagePackerApi.packToData(this.mergedImage, packOpts)
        .then((data: ArrayBuffer) => {
          // 写入文件
          fileIo.writeSync(file.fd, data);
          // 关闭文件
          fileIo.closeSync(file.fd);
          console.log('Succeeded in packToFile');
          
          // 显示保存成功的提示
          this.getUIContext().getPromptAction().showToast({
            message: $r('app.string.save_album_success'), // 资源文件中定义的文本
            duration: 1800 // 显示时长(毫秒)
          });
        })
        .catch((error: BusinessError) => {
          console.error(`Failed to packToFile. Error code is ${error.code}, message is ${error.message}`);
        });
    }
  } catch (err) {
    let error = err as BusinessError;
    console.error(`saveSnapshot err, errCode: ${error.code}, error message: ${error.message}`);
  }
}

步骤5:释放位图资源

closeSnapPopup(): void {
  // 关闭预览弹窗
  this.isShowPreview = false;
  
  // 释放位图对象,避免内存泄漏
  this.mergedImage = undefined;
  
  // 重置相关UI参数
  this.snapPopupWidth = 100;
  this.snapPopupHeight = 200;
  this.snapPopupPosition = PopupUtils.calcPopupCenter(
    this.screenWidth, 
    this.screenHeight,
    this.snapPopupWidth, 
    this.snapPopupHeight
  );
  this.isLargePreview = false;
}

五、封装全局截图接口(API 18+)

对于固定结构的组件,可封装全局截图方法供不同模块调用。

import { image } from '@kit.ImageKit';
import { ComponentContent } from '@kit.ArkUI';

// 参数类:用于向Builder传递数据
export class Params {
  public text: string | undefined | null = '';
  constructor(text: string | undefined | null) {
    this.text = text;
  }
}

// Builder函数:构建固定结构的组件
@Builder
function awardBuilder(params: Params) {
  Column() {
    Text(params.text)
      .fontSize(90)
      .fontWeight(FontWeight.Bold)
      .margin({ bottom: 36 })
      .width('100%')
      .height('100%')
  }
  .backgroundColor('#FFF0F0F0') // 浅灰色背景
}

// 全局截图工具类
export class GlobalStaticSnapshot {
  /**
   * 静态方法:获取固定结构的组件截图
   * @param uiContext UI上下文
   * @param textParam 文本参数
   * @returns Promise<PixelMap | undefined> 截图位图对象
   */
  static async getAwardSnapshot(
    uiContext: UIContext, 
    textParam: Params
  ): Promise<image.PixelMap | undefined> {
    
    let resultPixmap: image.PixelMap | undefined = undefined;
    
    // 创建ComponentContent对象(离线组件)
    // wrapBuilder是系统提供的包装函数,将Builder转换为可执行内容
    let contentNode = new ComponentContent(uiContext, wrapBuilder(awardBuilder), textParam);
    
    // 调用截图接口
    await uiContext.getComponentSnapshot()
      .createFromComponent(
        contentNode,      // 组件内容
        320,              // 延迟时间(毫秒)
        true,             // checkImageStatus:检查图片状态
        { 
          scale: 1,                       // 缩放比例(1表示原图)
          waitUntilRenderFinished: true   // 等待渲染完成
        }
      )
      .then((pixmap: image.PixelMap) => {
        // 截图成功,返回PixelMap
        resultPixmap = pixmap;
      })
      .catch((err: Error) => {
        // 截图失败,打印错误
        console.error('error: ' + err);
      });
    
    return resultPixmap;
  }
}

六、建议

6.1 截图时机

  • 组件渲染需经过测量、布局、提交指令等多个步骤。

  • 建议在组件完全渲染后再截图,避免立即截图导致内容不完整或失败。

6.2 了解组件绘制状态

  • 修改界面状态后,应预留系统处理时间,可通过增加延时确保截图正确。

6.3 等待绘制完成

  • 使用 SnapshotOptions 中的 waitUntilRenderFinished: true,确保截图前所有绘制指令已执行。

6.4 资源加载对截图的影响

图片资源异步加载可能导致截图不完整,优化方案:

  1. 提前解析图片为PixelMap,将PixelMap配置给图片组件(推荐)。

  2. 设置 Image.syncLoad(true) 强制同步加载。

  3. 指定延迟时间并设置 checkImageStatus: true,若返回错误码160001,可重新尝试。

6.5 及时释放位图对象

  • 使用完 PixelMap 后,应将其赋值为 undefined,及时释放内存。

6.6 合理控制采样精度

  • 截图尺寸不宜过大,建议不超过屏幕尺寸。

  • 通过 SnapshotOptions.scale 减小采样精度,节省内存并提升效率。

6.7 自渲染场景截图

  • 若组件包含 VideoXComponentWeb 等自渲染组件,不建议使用组件截图。

  • 推荐使用 image.createPixelMapFromSurface() 接口实现截图。

Logo

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

更多推荐