HarmonyOS组件截图:滚动长图、离线截图
文章摘要:本文详细介绍了组件截图功能,包括对已挂树组件和离线组件的两种截图方式,重点阐述了实现滚动长截图的完整流程。主要内容涵盖:1.通过组件ID或唯一ID获取截图;2.离线组件截图的创建方法;3.滚动截图的五个关键步骤(绑定控制器、循环截图、拼接位图、保存图片、释放资源);4.全局截图接口的封装方法;5.七大优化建议,包括截图时机选择、资源加载处理、内存管理等注意事项。文章提供了完整的代码示例,
本文同步发表于 微信公众号,微信搜索 程语新视界 即可关注,每个工作日都有文章更新
组件截图 指将应用内某个组件节点树的渲染结果生成为 PixelMap(位图) 的能力。
支持两种方式:
-
对已挂树显示的组件截图:组件已在界面上显示。
-
对离线组件截图:通过
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 资源加载对截图的影响
图片资源异步加载可能导致截图不完整,优化方案:
-
提前解析图片为PixelMap,将PixelMap配置给图片组件(推荐)。
-
设置
Image.syncLoad(true)强制同步加载。 -
指定延迟时间并设置
checkImageStatus: true,若返回错误码160001,可重新尝试。
6.5 及时释放位图对象
-
使用完
PixelMap后,应将其赋值为undefined,及时释放内存。
6.6 合理控制采样精度
-
截图尺寸不宜过大,建议不超过屏幕尺寸。
-
通过
SnapshotOptions.scale减小采样精度,节省内存并提升效率。
6.7 自渲染场景截图
-
若组件包含
Video、XComponent、Web等自渲染组件,不建议使用组件截图。 -
推荐使用
image.createPixelMapFromSurface()接口实现截图。
更多推荐
所有评论(0)