往期推文全新看点(文中附带全新鸿蒙5.0全栈学习笔录)
✏️ 鸿蒙应用开发与鸿蒙系统开发哪个更有前景?
✏️ 嵌入式开发适不适合做鸿蒙南向开发?看完这篇你就了解了~
✏️ 对于大前端开发来说,转鸿蒙开发究竟是福还是祸?
✏️ 鸿蒙岗位需求突增!移动端、PC端、IoT到底该怎么选?
✏️ 记录一场鸿蒙开发岗位面试经历~
✏️ 持续更新中……
概述
在移动应用中,标准的截图方法仅能捕捉当前屏幕显示的内容,对于超出屏幕可视区域的长页面或文档而言,这种方式显得不够便捷。当用户截图分享和保存(如聊天记录、网页文章、活动海报等)的内容较长的时候,需要用户多次截图来保证内容完整性。为了解决这一问题,本文将介绍长截图功能,使用户能够一键截取整个页面的长图,更轻松地分享和保存信息。
长截图功能适用于支持滚动的UI组件,比如List组件、Scroll组件、Web组件等。本文将以List组件和Web组件为例来介绍长截图功能的开发,分别通过控制器Scroller和WebviewController,结合组件截图模块componentSnapshot,实现长截图功能。
实现原理
List组件和Web组件实现长截图功能的原理相同,均可以通过模拟用户滚动行为,然后使用componentSnapshot.get()方法逐步截取不同位置的画面,将这些画面通过拼接得到长截图。Web组件通过WebviewController的相关API控制组件滚动,List组件通过Scroller的相关API控制组件滚动。
长截图拼接原理如下,将每次滚动新进入屏幕的内容裁剪后,拼接到之前的屏幕截图,依次类推。
图1长截图拼接原理图
长截图主要流程如下:
图2 滚动长截图流程
说明
在长截图的拼接过程中,所有截图会被暂时缓存到内存中。对于无限滚动或数据量较大的场景,应当限制单张截图的高度,以防止过高的内存占用影响应用性能。
滚动组件长截图
List、Scroll、Grid、WaterFlow等滚动组件均是通过Scroller来控制组件滚动,本章将以List组件为例来介绍滚动组件长截图的实现。下面介绍了滚动组件两种常见的长截图场景,一键截图和滚动截图。
一键截图
场景描述
一键截图将组件数据从顶部截取到底部,在截图过程中用户看不到界面的滚动,做到无感知滚动截图。这种方案一般用于分享截图、保存数据量较少的场景。
实现效果
点击“一键截图”,会生成整个列表的长截图。
开发流程
-
给List绑定滚动控制器,添加监听事件。
1.1 为List滚动组件绑定Scroller控制器,以控制其滚动行为,并给List组件绑定自定义的id。
1.2 通过onDidScroll()方法实时监听并获取滚动偏移量,确保截图拼接位置的准确性。
1.3 同时,利用onAreaChange()事件获取List组件的尺寸,以便精确计算截图区域的大小。
// src/main/ets/view/ScrollSnapshot.ets
@Component
export struct ScrollSnapshot {
private scroller: Scroller = new Scroller();
private listComponentWidth: number = 0;
private listComponentHeight: number = 0;
// The current offset of the List component
private curYOffset: number = 0;
// Value of each scroll
private scrollHeight: number = 0;
// ...
build() {
// ...
Stack() {
// ...
// 1.1 Bind the Scroller controller to the list scrolling component and define the customized ID.
List({
scroller: this.scroller
})// ...
.id(LIST_ID)
// 1.2 Obtains the scrolling offset.
.onDidScroll(() => {
this.curYOffset = this.scroller.currentOffset().yOffset;
})
.onAreaChange((oldValue, newValue) => {
// 1.3 Obtains the width and height of a component.
this.listComponentWidth = newValue.width as number;
this.listComponentHeight = newValue.height as number;
})
}
}
}
- 给List添加遮罩图,初始化滚动位置。
“一键截图”功能确保在滚动截图过程中用户不会察觉到页面的滚动。通过截取当前屏幕生成遮罩图覆盖列表,并记录此时的滚动偏移量(yOffsetBefore),便于后续完成滚动截图之后,恢复到之前记录的偏移量,使用户无感知页面变化。
为保证截图的完整性,设置完遮罩图后,同样利用scrollTo()方法将列表暂时滚至顶部,确保截图从最顶端开始。
// src/main/ets/view/ScrollSnapshot.ets
@Component
export struct ScrollSnapshot {
// Component coverage during screenshot process
@State componentMaskImage: PixelMap | undefined = undefined;
// The current offset of the List component
private curYOffset: number = 0;
// Backup component location before screenshot
private yOffsetBefore: number = 0;
// ...
// One-click screenshot
async onceSnapshot() {
// Initialize state before screenshot
await this.beforeSnapshot();
// Start scrolling screenshots in a loop
await this.snapAndMerge();
// Restore scroll component state
await this.afterSnapshot();
// ...
}
async beforeSnapshot() {
// Offset before scrolling
this.yOffsetBefore = this.curYOffset;
// Take a screenshot of the loaded List component as a cover image for the List component
this.componentMaskImage = await componentSnapshot.get(LIST_ID);
this.scroller.scrollTo({
xOffset: 0,
yOffset: 0,
animation:
{
duration: 200
}
});
// ...
// Delay ensures that the scroll has reached the top
await CommonUtils.sleep(200);
}
build() {
// ...
Stack() {
// The mask layer during the screenshot process prevents users from noticing the rapid sliding of the screen and improves user experience.
if (this.componentMaskImage) {
Image(this.componentMaskImage)
// ...
}
List({
scroller: this.scroller
})//...
.id(LIST_ID)
.onDidScroll(() => {
this.curYOffset = this.scroller.currentOffset().yOffset;
})
}
}
}
- 循环滚动截图,裁剪和缓存截图数据。
3.1 记录每次滚动的位置到数组scrollYOffsets中,并使用componentSnapshot.get(LIST_ID) 方法获取当前画面的截图。
3.2 如果非首次截图,则使用crop方法截取从底部滚动进来的区域,然后调用pixmap.readPixelsSync(area)方法将截图数据读取到缓冲区域area中,并将area通过集合进行保存,用于后续截图拼接。
3.3 如果页面没有滚动到底部,继续滚动,继续递归调用snapAndMerge()方法进行截图;如果到达底部,则调用mergeImage()方法拼接所有收集到的图像片段,生成完整的长截图;同时还需限制截图的高度,以防过大的截图占用过多内存,影响应用性能,例如这里设置截长截图高度不超过5000。
async snapAndMerge() {
this.scrollYOffsets.push(this.curYOffset);
// Call the component screenshot interface to obtain the current screenshot
const pixelMap = await componentSnapshot.get(LIST_ID);
// Gets the number of bytes per line of image pixels.
let area: image.PositionArea =
await ImageUtils.getSnapshotArea(pixelMap, this.scrollYOffsets, this.listComponentWidth,
this.listComponentHeight);
this.areaArray.push(area);
// Determine whether the bottom has been reached during the loop process
if (!this.scroller.isAtEnd() && this.listComponentWidth + this.curYOffset < SNAPSHOT_MAX_LENGTH) {
CommonUtils.scrollAnimation(this.scroller, 200, this.scrollHeight);
await CommonUtils.sleep(200)
await this.snapAndMerge();
} else {
this.mergedImage =
await ImageUtils.mergeImage(this.areaArray, this.scrollYOffsets[this.scrollYOffsets.length - 1],
this.listComponentWidth, this.listComponentHeight);
}
}
// src/main/ets/common/ImageUtils.ets
static async getSnapshotArea(pixelMap: PixelMap, scrollYOffsets: number[], listWidth: number,
listHeight: number): Promise<image.PositionArea> {
// Gets the number of bytes per line of image pixels.
let stride = pixelMap.getBytesNumberPerRow();
// Get the total number of bytes of image pixels.
let bytesNumber = pixelMap.getPixelBytesNumber();
let buffer: ArrayBuffer = new ArrayBuffer(bytesNumber);
// Region size, read based on region. PositionArea represents the data within the specified area of the image.
let len = scrollYOffsets.length;
// // Except for the first screenshot, you don't need to crop it, and you need to crop the new parts
if (scrollYOffsets.length >= 2) {
// Realistic roll distance
let realScrollHeight = scrollYOffsets[len-1] - scrollYOffsets[len-2];
if (listHeight - realScrollHeight > 0) {
let cropRegion: image.Region = {
x: 0,
y: vp2px(listHeight - realScrollHeight),
size: {
height: vp2px(realScrollHeight),
width: vp2px(listWidth)
}
};
// Crop roll area
await pixelMap.crop(cropRegion);
}
}
let imgInfo = pixelMap.getImageInfoSync();
// Region size, read based on region. PositionArea represents the data within the specified area of the image.
let area: image.PositionArea = {
pixels: buffer,
offset: 0,
stride: stride,
region: {
size: {
width: imgInfo.size.width,
height: imgInfo.size.height
},
x: 0,
y: 0
}
}
// Write data to a specified area
pixelMap.readPixelsSync(area);
return area;
}
- 拼接截图片段。
使用image.createPixelMapSync()方法创建长截图longPixelMap,并遍历之前保存的图像片段数据 (this.areaArray),构建image.PositionArea对象area,然后调用longPixelMap.writePixelsSync(area) 方法将这些片段逐个写入到正确的位置,从而拼接成一个完整的长截图。
// src/main/ets/common/ImageUtils.ets
static async mergeImage(areaArray: image.PositionArea[], lastOffsetY: number, listWidth: number,
listHeight: number): Promise<PixelMap> {
// Create a long screenshot PixelMap
let opts: image.InitializationOptions = {
editable: true,
pixelFormat: 4,
size: {
width: vp2px(listWidth),
height: vp2px(lastOffsetY + listHeight)
}
};
let longPixelMap = image.createPixelMapSync(opts);
let imgPosition: number = 0;
for (let i = 0; i < areaArray.length; i++) {
let readArea = areaArray[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,
y: imgPosition
}
}
imgPosition += readArea.region.size.height;
longPixelMap.writePixelsSync(area);
}
return longPixelMap;
}
- 恢复到截图前的状态,滚动到截图前的位置。
async afterSnapshot() {
this.scroller.scrollTo({
xOffset: 0,
yOffset: this.yOffsetBefore,
animation: {
duration: 200
}
});
await CommonUtils.sleep(200);
}
- 使用安全控件SaveButton保存截图相册。
通过安全控件SaveButton结合photoAccessHelper模块保存截图到相册。
// src/main/ets/view/SnapshotPreview.ets
SaveButton({
icon: SaveIconStyle.FULL_FILLED,
text: SaveDescription.SAVE_IMAGE,
buttonType: ButtonType.Capsule
})
// ...
.onClick((event, result) => {
this.saveSnapshot(result);
})
async saveSnapshot(result: SaveButtonOnClickResult): Promise<void> {
if (result === SaveButtonOnClickResult.SUCCESS) {
const helper = photoAccessHelper.getPhotoAccessHelper(this.context);
const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png');
// Open the file with a URI to write content continuously
const file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
const imagePackerApi: image.ImagePacker = image.createImagePacker();
const packOpts: image.PackingOption = {
format: 'image/png',
quality: 100,
};
imagePackerApi.packing(this.mergedImage, packOpts).then((data) => {
fileIo.writeSync(file.fd, data);
fileIo.closeSync(file.fd);
Logger.info(TAG, `Succeeded in packToFile`);
promptAction.showToast({
message: $r('app.string.save_album_success'),
duration: 1800
})
}).catch((error: BusinessError) => {
Logger.error(TAG, `Failed to packToFile. Error code is ${error.code}, message is ${error.message}`);
});
}
// ...
}
滚动截图
场景描述
此方案允许用户控制长截图的起止位置,增加了使用的灵活性。它适用于大数据量场景,方便用户选择性保存滚动组件中的特定数据。
实现效果
点击“滚动截图”按钮后,列表将自动滚动。点击列表中的任意条目时,滚动会立即停止,并开始截取从滚动开始到停止这段时间内的数据截图。
功能实现
“滚动截图”功能的实现流程与前述的“一键截图”一样,因此这里不再重复详述整个过程,而仅聚焦于其中的几个关键差异点,例如滚动的控制和偏移量的记录,分别如下面1和2所描述。
- 在截图滚动的过程中,为了防止用户手动滚动对截图产生干扰,应禁用列表的手动滚动功能。可以通过设置List组件的enableScrollInteraction属性来控制是否允许手动滚动。
当准备开始截图时,将isEnableScroll设置为false以禁用滚动交互。而当用户点击列表项以确定截图结束位置时,使用scroller.scrollBy(0, 0)方法确保列表立即停止滑动。
// src/main/ets/view/ScrollSnapshot.ets
@Component
export struct ScrollSnapshot {
// The current offset of the List component
private curYOffset: number = 0;
// Backup component location before screenshot
private yOffsetBefore: number = 0;
// is click to stop scroll
private isClickStop: boolean = false;
@State isEnableScroll: boolean = true;
// ...
// Scrolling Screenshot Entry Function
async scrollSnapshot() {
// The settings list cannot be manually scrolled during the screenshot process
// to avoid interference with the screenshot
this.isEnableScroll = false;
// Saves the current location of the component for recovery
this.yOffsetBefore = this.curYOffset;
await this.scrollSnapAndMerge();
// ...
this.isEnableScroll = true;
this.isClickStop = false;
}
build() {
// ...
List({
scroller: this.scroller
})//...
.id(LIST_ID)
.onClick(() => {
// Click on the list to stop scrolling
if (!this.isEnableScroll) {
this.scroller.scrollBy(0, 0);
this.isClickStop = true;
}
})
}
}
- “滚动截图”功能依据当前坐标启动截图过程,因此在记录滚动偏移量时,通过 this.curYOffset - this.yOffsetBefore 来计算相对于初始位置的变化。
async scrollSnapAndMerge() {
// Record an array of scrolls
this.scrollYOffsets.push(this.curYOffset - this.yOffsetBefore);
// Call the API for taking screenshots to obtain the current screenshots
const pixelMap = await componentSnapshot.get(LIST_ID);
// Gets the number of bytes per line of image pixels.
let area: image.PositionArea =
await ImageUtils.getSnapshotArea(pixelMap, this.scrollYOffsets, this.listComponentWidth, this.listComponentHeight)
this.areaArray.push(area);
// During the loop, it is determined whether the bottom is reached, and the user does not stop taking screenshots
if (!this.scroller.isAtEnd() && !this.isClickStop &&
this.listComponentWidth + this.curYOffset < SNAPSHOT_MAX_LENGTH) {
// Scroll to the next page without scrolling to the end
CommonUtils.scrollAnimation(this.scroller, 1000, this.scrollHeight);
await CommonUtils.sleep(1500);
await this.scrollSnapAndMerge();
} else {
// After scrolling to the bottom, the buffer obtained by each round of scrolling is spliced
// to generate a long screenshot
this.mergedImage =
await ImageUtils.mergeImage(this.areaArray, this.scrollYOffsets[this.scrollYOffsets.length - 1],
this.listComponentWidth, this.listComponentHeight);
}
}
- 与“一键截图”不同,“滚动截图”在执行过程中不使用遮罩层,用户能够直接看到列表的滚动效果。为了确保流畅的视觉体验,在调用 scroller.scrollTo 进行滚动时,添加了动画效果,使得滚动更加自然和顺滑。
// src/main/ets/common/CommonUtils.ets
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
}
});
}
Web组件长截图
场景描述
Web组件的长截图功能与之前介绍的滚动组件长截图在使用场景上相似,两者均旨在为用户提供快速便捷的数据信息分享和保存方式。主要区别在于,Web组件专门针对网页内容进行截图,确保用户能够完整地捕获和分享浏览的网页信息。
实现效果
点击“截图”按钮即可完成整个网页的长截图,并可将截图保存至相册。
功能实现
Web组件的长截图可以通过前文介绍的滚动截图方案以及使用WebView提供的webPageSnapshot()方法进行全量截图。本章将重点介绍webPageSnapshot()方法的使用方法,而滚动截图的相关信息已在前文详述。
使用滚动截图的方式进行长截图
Web组件滚动长截图和滚动组件长截图开发流程大体一样,主要是控制组件的滚动的方法不同。List组件使用的是Scroller,而Web组件使用的是webViewController。
在滚动截图过程中,webViewController负责控制Web组件的滚动,通过调用webViewController.scrollBy()方法来实现。为了判断是否已滚动到底部,使用this.webViewController.getPageHeight() 方法获取网页内容的总高度,并将当前偏移量this.curYOffset加上组件自身的高度与网页总高度进行比较。如果两者的和小于网页总高度,则表示尚未触底。
// src/main/ets/view/WebSnapshot.ets
async snapAndMerge() {
this.scrollYOffsets.push(this.curYOffset);
// Call the component screenshot interface to obtain the current screenshot
const pixelMap = await componentSnapshot.get(WEB_ID);
let area: image.PositionArea =
await ImageUtils.getSnapshotArea(pixelMap, this.scrollYOffsets, this.webComponentWidth, this.webComponentHeight);
this.areaArray.push(area);
// Determine whether the bottom has been reached during the loop process
if (Math.ceil(this.curYOffset + this.webComponentHeight) < this.webviewController.getPageHeight()) {
// Not scrolling to the bottom, scrolling to the next page
this.webviewController.scrollByWithResult(0, this.scrollHeight);
await CommonUtils.sleep(1000)
await this.snapAndMerge();
} else {
this.mergedImage =
await ImageUtils.mergeImage(this.areaArray, this.scrollYOffsets[this.scrollYOffsets.length - 1],
this.webComponentWidth, this.webComponentHeight);
}
}
使用webPageSnapshot()方法进行网页全量截图
此外,Web组件还可以使用 webPageSnapshot() 方法进行网页全量截图,比较适合结构简单、静态元素的页面长截图。如果网页中有动态资源,结构相对复杂,比如有固定的标题头等,推荐上面的滚动长截图方案。使用webPageSnapshot()方法实现长截图的步骤如下:
- Web初始化,调用enableWholeWebPageDrawing()方法开启网页全量绘制能力
aboutToAppear(): void {
webview.WebviewController.initializeWebEngine();
webview.WebviewController.enableWholeWebPageDrawing();
webview.WebviewController.prepareForPageLoad(EXAMPLE_URL, true, 2);
}
- 获取网页内容高度和宽度。
async getWebSize() {
const SCRIPT = '[document.documentElement.scrollWidth, document.documentElement.scrollHeight]';
try {
this.webviewController.runJavaScriptExt(SCRIPT).then((result) => {
if (result.getType() === webview.JsMessageType.ARRAY) {
this.h5Width = (result.getArray() as number[])[0];
this.h5Height = (result.getArray() as number[])[1];
Logger.info(TAG, `h5Width is ${this.h5Width}, h5Height is ${this.h5Height}`);
}
});
} catch (error) {
Logger.error(TAG, `Run script to get web page size failed. Error: ${JSON.stringify(error)}`);
}
}
- 调用webPageSnapshot()方法,进行网页截图。
async webSnapshot() {
try {
this.webviewController.webPageSnapshot({ id: WEB_ID, size: { width: this.h5Width, height: this.h5Height } },
async (error, result) => {
if (result) {
this.longPixelMap = result.imagePixelMap;
}
});
} catch (error) {
Logger.error(TAG, `webPageSnapshot err : ${JSON.stringify(error)}`);
}
}
完整示例代码如下:
import { webview } from '@kit.ArkWeb';
const TAG = 'WebComponent';
const url: string = 'https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/application-dev-guide';
@Entry
@Component
struct WebComponent {
@State h5Width: number = 0;
@State h5Height: number = 0;
@State longPixelMap: PixelMap | undefined = undefined;
private webviewController: webview.WebviewController = new webview.WebviewController();
aboutToAppear(): void {
webview.WebviewController.initializeWebEngine();
webview.WebviewController.enableWholeWebPageDrawing();
webview.WebviewController.prepareForPageLoad(url, true, 2);
}
async getWebSize() {
const SCRIPT = '[document.documentElement.scrollWidth, document.documentElement.scrollHeight]';
try {
this.webviewController.runJavaScriptExt(SCRIPT).then((result) => {
if (result.getType() === webview.JsMessageType.ARRAY) {
this.h5Width = (result.getArray() as number[])[0];
this.h5Height = (result.getArray() as number[])[1];
console.info(TAG, `h5Width is ${this.h5Width}, h5Height is ${this.h5Height}`);
}
});
} catch (error) {
console.error(TAG, `Run script to get web page size failed. Error: ${JSON.stringify(error)}`);
}
}
async webSnapshot() {
try {
this.webviewController.webPageSnapshot({ id: 'webTest', size: { width: this.h5Width, height: this.h5Height } },
async (error, result) => {
if (result) {
this.longPixelMap = result.imagePixelMap;
}
});
} catch (error) {
console.error(TAG, `webPageSnapshot err : ${JSON.stringify(error)}`);
}
}
build() {
Stack() {
Web({
src: url,
controller: this.webviewController
})
.id('webTest')
Scroll() {
Column() {
Image(this.longPixelMap)
.width('80%')
}
}
Button('WebPageSnapshot')
.onClick(() => {
this.webSnapshot();
})
}
}
}