华为跑跑码特——迷你工具
最近,我积极参与了华为精心组织的跑跑码特比赛活动,在这个充满挑战与创新的舞台上,我对自己的元服务迷你工具进行了一系列重要的上架更新。目前,工具已具备十分实用的生成二维码功能;识别二维码功能同样强大,只需对准二维码轻轻一扫,便能快速获取其中信息;还有趣味十足的台词拼接功能。
不仅如此,我还在积极拓展工具的功能边界,图片拼接、长截图和图片马赛克功能正处于紧张的迭代进程中。然而,颇为遗憾的是,受限于元服务的 api,暂时无法将这些功能更新上线。但我仍迫不及待地想和大家详细介绍一番,让大家提前感受它们的魅力。
沉浸式屏幕
沉浸式屏幕是通过隐藏或淡化状态栏、导航栏等不必要界面元素,最大化利用屏幕空间,让用户专注于主要内容的显示模式。其原理是在软件层面处理系统界面元素,调整内容显示区域占满屏幕。广泛应用于视频播放、游戏、阅读等场景,可通过系统自带功能或应用自身设置开启。
代码
FullScreen.ets
import { window } from '@kit.ArkUI';
import { AREA_BOTTOM, AREA_TOP, ENTRY_ABILITY_CONTEXT } from '../constants/Index';
class FullScreen {
/**
* 启动沉浸式
*/
async enable() {
// 读取存储起来 数据 上下文
const ctx = AppStorage.get<Context>(ENTRY_ABILITY_CONTEXT)
console.log("ctx",JSON.stringify(ctx))
// 1 获取最新的窗口对象
let windowClass = await window.getLastWindow(ctx!);
// 2 设置沉浸式 全屏
windowClass.setWindowLayoutFullScreen(true)
//保证安全区域
//顶部状态栏
const area = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM)
AppStorage.setOrCreate(AREA_TOP, px2vp(area.topRect.height))
//底部导航栏
const area2 = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR)
AppStorage.setOrCreate(AREA_BOTTOM, px2vp(area2.bottomRect.height))
}
// 禁用沉浸式
async disable() {
const ctx = AppStorage.get<Context>(ENTRY_ABILITY_CONTEXT)
// 1 获取最新的窗口对象
let windowClass = await window.getLastWindow(ctx!);
// 2 设置沉浸式 全屏
windowClass.setWindowLayoutFullScreen(false)
}
}
export const fullScreen = new FullScreen()
使用
EntryAbility.ets
效果
保存图片(不同于saveButton安全控件)
保存到沙箱
saveImage = async () => {
const imagePacker = image.createImagePacker()
const arrayBuffer = await imagePacker.packToData(this.saveImagePixel, { format: 'image/jpeg', quality: 98 })
const ctx = getContext(this)
const imagePath = ctx.cacheDir + '/' + Date.now() + '.jpeg'
// openSync 获取路径文件的 权限
const file = fileIo.openSync(imagePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE)
// 开始写入!!
fileIo.writeSync(file.fd, arrayBuffer)
await this.save(imagePath)
// 关闭权限
fileIo.closeSync(file.fd)
}
保存到相册
async save(srcFileUri: string) {
let context = getContext(this);
let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
try {
let srcFileUris: Array<string> = [
srcFileUri
];
// 指定待保存照片的创建选项,包括文件后缀和照片类型,标题和照片子类型可选
let photoCreationConfigs: Array<photoAccessHelper.PhotoCreationConfig> = [
{
title: `${Date.now()}`, // 可选
fileNameExtension: 'jpeg',
photoType: photoAccessHelper.PhotoType.IMAGE,
subtype: photoAccessHelper.PhotoSubtype.DEFAULT, // 可选
}
];
// 基于弹窗授权的方式获取媒体库的目标uri
let desFileUris: Array<string> = await phAccessHelper.showAssetsCreationDialog(srcFileUris, photoCreationConfigs);
// 将来源于应用沙箱的内容写入媒体库的目标uri
let desFile: fileIo.File = await fileIo.open(desFileUris[0], fileIo.OpenMode.WRITE_ONLY);
let srcFile: fileIo.File = await fileIo.open(srcFileUri, fileIo.OpenMode.READ_ONLY);
await fileIo.copyFile(srcFile.fd, desFile.fd);
fileIo.closeSync(srcFile);
fileIo.closeSync(desFile);
promptAction.showToast({ message: '下载成功' })
} catch (err) {
console.error(`failed to create asset by dialog successfully errCode is: ${err.code}, ${err.message}`);
}
}
效果
二维码生成
我利用华为原生的QRCode组件来展示二维码,同时为了增加趣味性,我还写了一个函数来随机生成颜色来填充二维码的背景颜色。
generateSimilarColors()
function generateSimilarColors() {
// 随机生成一个基础颜色
const baseColor = Math.floor(Math.random() * 16777215).toString(16);
const paddedBaseColor = baseColor.padStart(6, '0');
// 为每个通道生成不同的偏移量,范围在 30 - 100 之间,保证有一定区分度
const rOffset = Math.floor(Math.random() * 71) + 30;
const gOffset = Math.floor(Math.random() * 71) + 30;
const bOffset = Math.floor(Math.random() * 71) + 30;
// 将基础颜色转换为 RGB 值
const r = parseInt(paddedBaseColor.slice(0, 2), 16);
const g = parseInt(paddedBaseColor.slice(2, 4), 16);
const b = parseInt(paddedBaseColor.slice(4, 6), 16);
// 应用偏移量生成相似颜色,确保值在 0 - 255 范围内
const newR = Math.min(255, Math.max(0, r + (Math.random() > 0.5 ? rOffset : -rOffset)));
const newG = Math.min(255, Math.max(0, g + (Math.random() > 0.5 ? gOffset : -gOffset)));
const newB = Math.min(255, Math.max(0, b + (Math.random() > 0.5 ? bOffset : -bOffset)));
// 将新的 RGB 值转换为十六进制字符串
const similarColor = [newR, newG, newB].map(x => x.toString(16).padStart(2, '0')).join('');
return [`#${paddedBaseColor}`, `#${similarColor}`];
}
二维码展示
@Builder
QrcodeSheet() {
Column() {
Row() {
Text('点我来抽一下二维码的颜色吧')
.fontSize(10)
.fontWeight(FontWeight.Medium)
.onClick(() => {
this.colorBan = generateSimilarColors()
})
Text('保存')
.fontColor('#00FFFF')
.fontWeight(FontWeight.Bolder)
.onClick(() => {
this.save()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ right: 20, left: 20 })
.margin({ bottom: 20 })
QRCode(this.text)
.width('70%')
.aspectRatio(1)
.color(this.colorBan[1])
.backgroundColor(this.colorBan[0])
.id('Qrcode')
.margin({ top: 10 })
}
.padding({ top: 20 })
.width('100%')
.justifyContent(FlexAlign.Start)
.alignItems(HorizontalAlign.Center)
.height('100%')
}
效果
扫描二维码
在使用此功能时,要先检查设备是否设备'SystemCapability.Multimedia.Scan.ScanBarcode'
能力
scanCode = async () => {
if (canIUse('SystemCapability.Multimedia.Scan.ScanBarcode')) {
const result = await scanBarcode.startScanForResult(getContext(this))
if (result.originalValue) {
this.str = result.originalValue
}
} else {
promptAction.showToast({ message: '暂时不具备此能力' })
}
}
台词拼接
之前网上比较流行的台词拼接在一起,如下图;我在鸿蒙里实现了这项技术;
拉起相册,获得像素
/**
* 拉起相册 获取像素
*/
async getPhoto() {
let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
PhotoSelectOptions.maxSelectNumber = 9;
let photoPicker = new photoAccessHelper.PhotoViewPicker();
const PhotoSelectResult = await photoPicker.select(PhotoSelectOptions);
// `file://media/Photo/1/IMG_1725939294_000/screenshot_20240910_113314.jpg`
// 假设这里只关心用户选择的第一张图片
for (let i = 0; i < PhotoSelectResult.photoUris.length; i++) {
const uri = PhotoSelectResult.photoUris[i];
// 2 拷贝到沙箱目录 开始 😄
const originFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY)
// 获取原图imageSource
const imageSource = image.createImageSource(originFile.fd);
// TODO 知识点: 将图片设置为可编辑
const decodingOptions: image.DecodingOptions = {
editable: true,
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
}
// 创建PixelMap
const pixelMapSrc = await imageSource.createPixelMap(decodingOptions);
this.imageArr.push({ imagePixel: pixelMapSrc, id: Date.now(), imageSource })
}
}
调整图片间的距离
/**
* 图片列表
*/
Column() {
List({ scroller: this.listScroller, }) {
ForEach(this.imageArr, (item: ImageType, index: number,) => {
ListItem() {
if (index !== 0) {
Image(item.imagePixel)
.width('100%')
.margin({ top: `-${this.jl * 2}` })
.objectFit(ImageFit.Contain)
.border({
width: this.chooseIndex === this.imageArr.findIndex((item1: ImageType) => {
return item1.id === item.id
}) ? 1 : 0, color: this.chooseIndex === this.imageArr.findIndex((item1: ImageType) => {
return item1.id === item.id
}) ? Color.Blue : Color.White
})
.onClick(() => {
const i = this.imageArr.findIndex((item1: ImageType) => {
return item1.id === item.id
})
this.chooseIndex = i
this.tuChuLiShow = true
this.yAddress =
this.listScroller.getItemRect(this.chooseIndex).y
if (Math.abs(this.yAddress) > px2vp(this.heightPhone / 2)) {
this.yAddress = px2vp(this.heightPhone) / 2
}
})
} else {
Image(item.imagePixel)
.width('100%')
.objectFit(ImageFit.Contain)
.border({
width: this.chooseIndex === this.imageArr.findIndex((item1: ImageType) => {
return item1.id === item.id
}) ? 1 : 0, color: this.chooseIndex === this.imageArr.findIndex((item1: ImageType) => {
return item1.id === item.id
}) ? Color.Blue : Color.White
})
.onComplete((event) => {
this.maxHeight = event!.componentHeight / 4
})
.onClick(() => {
const i = this.imageArr.findIndex((item1: ImageType) => {
return item1.id === item.id
})
this.chooseIndex = i
this.tuChuLiShow = true
this.yAddress =
this.listScroller.getItemRect(this.chooseIndex).y
if (Math.abs(this.yAddress) > px2vp(this.heightPhone / 2)) {
this.yAddress = px2vp(this.heightPhone) / 2
}
})
}
}
.zIndex(this.imageArr.length - index)
}, (item: ImageType, index: number) => item.id.toString())
}
.width('78.67%')
.scrollBar(BarState.Off)
.onDidScroll(() => {
this.tuChuLiShow = false
})
.edgeEffect(EdgeEffect.None)
.id('ImageTai')
}
.width('100%')
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.margin({ bottom: 100 })
.justifyContent(FlexAlign.Start)
.zIndex(1)
Row() {
Text('调节位置:')
.fontColor('#00FFFF')
.fontWeight(FontWeight.Medium)
.fontSize(13)
Image($r('app.media.jian'))
.width(22.2)
.height(22.2)
.margin({ left: 16, right: 19.5 })
.onClick(() => {
if (this.jl - 10 < 0) {
this.jl = 0
return
}
this.jl = this.jl - 10
})
Slider({ value: this.jl, max: this.maxHeight, min: 0 })
.onChange((val) => {
console.log(`当前 ${val}`)
this.jl = val
})
.width(161)
.height(6)
Image($r('app.media.jia'))
.width(22.2)
.height(22.2)
.margin({ left: 16, right: 19.5 })
.onClick(() => {
console.log(`最大${this.maxHeight} 当前${this.jl}`)
if (this.jl + 10 > this.maxHeight) {
this.jl = this.maxHeight
return
}
this.jl = this.jl + 10
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 23, right: 23, top: 10 })
处理结果
这里利用组件截图来获取完成图片调整之后的结果,获取像素之后再利用上方介绍的方法保存到相册
const pixmap = await componentSnapshot.get('ImageTai')
日历
在其中我利用鸿蒙的第三方库来展示三种日历组件,一个支持三种模式的轻量级日历组件:
- 支持常规日历,全月日期、切换月份、点击反馈、标注显示、controller控制、选中样式、国际化、深色模式
- 支持超级迷你日历,全月日期、当前时间
- 支持单周日历,当周日期、当前时间
安装
ohpm install @ohmos/calendar
常规日历
HmCalendar({
color: '#00aa00',
selectedDays: [
{ date: new Date(Date.now()).toLocaleDateString('en-US').replace(///g, '-'), text: '今日' },
]
})
.borderRadius(8)
.border({ width: 0.5, color: '#ededed' })
.shadow({ color: '#ededed', radius: 16 })
迷你日历
HmCalendar({
type: HmCalendarType.MONTH_DOT,
gutter: 6,
color: '#00aa00',
selectedDays: [
{ date: dayjs().format('YYYY-MM-DD') },
]
})
单周日历
HmCalendar({
type: HmCalendarType.WEEK,
color: '#00aa00',
selectedDays: [
{ date: dayjs().format('YYYY-MM-DD'), text: ' ' },
]
})
.borderRadius(8)
.border({ width: 0.5, color: '#ededed' })
.shadow({ color: '#ededed', radius: 16 })
由于元服务的API限制,以上是我目前可以上架的功能,但是我研究了图片处理的其他功能,也分享给大家
图片马赛克功能
手势交互(打码)
在之前的文章中我也具体介绍过手势交互的用法:
Image(this.pixelMapSrc!.img).objectFit(ImageFit.Fill)
.id('img_mosaic')
.width('100%')
.gesture(
GestureGroup(GestureMode.Exclusive,
PanGesture({ distance: 10 })
.onActionStart((event: GestureEvent) => {
this.saveHistoryPixel[this.historyIndex++] = this.pixelMapSrc!.img
const finger: FingerInfo = event.fingerList[0];
if (finger == undefined) {
return;
}
this.startX = finger.localX;
this.startY = finger.localY;
})
.onActionUpdate(async (event: GestureEvent) => {
const finger: FingerInfo = event.fingerList[0];
if (finger == undefined) {
return;
}
this.endX = finger.localX;
this.endY = finger.localY;
// 执行马赛克任务
await this.doMosaicTask(this.startX, this.startY, this.endX, this.endY);
this.startX = this.endX;
this.startY = this.endY;
})
)
)
马赛克任务
/**
* 图片马赛克处理函数
*/
@Concurrent
async function applyMosaic(dataArray: Uint8Array, imageWidth: number, imageHeight: number, blockSize: number,
offMinX: number, offMinY: number, offMaxX: number, offMaxY: number): Promise<Uint8Array | undefined> {
try {
// 计算横排和纵排的块数
let xBlocks = Math.floor((Math.abs(offMaxX - offMinX)) / blockSize);
let yBlocks = Math.floor((Math.abs(offMaxY - offMinY)) / blockSize);
// 不足一块的,按一块计算
if (xBlocks < 1) {
xBlocks = 1;
offMaxX = offMinX + blockSize;
}
if (yBlocks < 1) {
yBlocks = 1;
offMaxY = offMinY + blockSize;
}
// 遍历每个块
for (let y = 0; y < yBlocks; y++) {
for (let x = 0; x < xBlocks; x++) {
const startX = x * blockSize + offMinX;
const startY = y * blockSize + offMinY;
// 计算块内的平均颜色
let totalR = 0;
let totalG = 0;
let totalB = 0;
let pixelCount = 0;
for (let iy = startY; iy < startY + blockSize && iy < imageHeight && iy < offMaxY; iy++) {
for (let ix = startX; ix < startX + blockSize && ix < imageWidth && ix < offMaxX; ix++) {
// TODO 知识点:像素点数据包括RGB通道的分量值及图片透明度
const index = (iy * imageWidth + ix) * 4; // 4 像素点数据包括RGB通道的分量值及图片透明度
totalR += dataArray[index];
totalG += dataArray[index + 1];
totalB += dataArray[index + 2];
pixelCount++;
}
}
const averageR = Math.floor(totalR / pixelCount);
const averageG = Math.floor(totalG / pixelCount);
const averageB = Math.floor(totalB / pixelCount);
// TODO 知识点: 将块内平均颜色应用到块内的每个像素
for (let iy = startY; iy < startY + blockSize && iy < imageHeight && iy < offMaxY; iy++) {
for (let ix = startX; ix < startX + blockSize && ix < imageWidth && ix < offMaxX; ix++) {
const index = (iy * imageWidth + ix) * 4; // 4 像素点数据包括RGB通道的分量值及图片透明度
dataArray[index] = averageR;
dataArray[index + 1] = averageG;
dataArray[index + 2] = averageB;
}
}
}
}
return dataArray;
} catch (error) {
promptAction.showToast({ message: '处理失败' })
return undefined;
}
}
/**
* 执行马赛克任务
*/
async doMosaicTask(offMinX: number, offMinY: number, offMaxX: number, offMaxY: number): Promise<void> {
// TODO 知识点:将手势移动的起始坐标转换为原始图片中的坐标
offMinX = Math.round(offMinX * this.imageWidth / this.displayWidth);
offMinY = Math.round(offMinY * this.imageHeight / this.displayHeight);
offMaxX = Math.round(offMaxX * this.imageWidth / this.displayWidth);
offMaxY = Math.round(offMaxY * this.imageHeight / this.displayHeight);
// 处理起始坐标大于终点坐标的情况
if (offMinX > offMaxX) {
const temp = offMinX;
offMinX = offMaxX;
offMaxX = temp;
}
if (offMinY > offMaxY) {
const temp = offMinY;
offMinY = offMaxY;
offMaxY = temp;
}
// 获取像素数据的字节数
const bufferData = new ArrayBuffer(this.pixelMapSrc!.img!.getPixelBytesNumber());
await this.pixelMapSrc!.img!.readPixelsToBuffer(bufferData);
// 将像素数据转换为 Uint8Array 便于像素处理
let dataArray = new Uint8Array(bufferData);
// TODO: 性能知识点:使用new taskpool.Task()创建任务项,传入任务执行函数和所需参数
const task: taskpool.Task =
new taskpool.Task(applyMosaic, dataArray, this.imageWidth, this.imageHeight, this.MosaicSize,
offMinX, offMinY, offMaxX, offMaxY);
try {
taskpool.execute(task, taskpool.Priority.HIGH).then(async (res: Object) => {
this.pixelMapSrc!.img = image.createPixelMapSync((res as Uint8Array).buffer, this.opts);
// this.pixelMapSrc!.img = await this.createPixelMapFromUint8Array(res as Uint8Array)
})
} catch (err) {
console.error("doMosaicTask: execute fail, " + (err as BusinessError).toString());
}
}
async createPixelMapFromUint8Array(uint8Array: Uint8Array) {
const arrayBuffer = uint8Array.buffer;
const imageSource = image.createImageSource(arrayBuffer);
const pixelMap = await imageSource.createPixelMap(this.opts);
return pixelMap;
}
完整代码
其中可调整马赛克的大小(有一些小细节可能需要小伙伴自己调整哦)
import { image } from '@kit.ImageKit';
import { componentSnapshot, display, promptAction } from '@kit.ArkUI';
import { taskpool } from '@kit.ArkTS';
import { BusinessError, emitter } from '@kit.BasicServicesKit';
import { resourceManager } from '@kit.LocalizationKit';
import { PixelType } from '../../models';
import { AREA_TOP } from '../../constants/Index';
@Builder
function image_mosaicBuilder() {
image_mosaic()
}
/**
* 获取图片内容
*/
@Concurrent
async function getImageContent(imgPath: string, context: Context): Promise<Uint8Array | undefined> {
// 获取resourceManager资源管理
const resourceMgr: resourceManager.ResourceManager = context.resourceManager;
// 获取rawfile中的图片资源
const fileData: Uint8Array = await resourceMgr.getRawFileContent(imgPath);
return fileData;
}
/**
* 图片马赛克处理函数
*/
@Concurrent
async function applyMosaic(dataArray: Uint8Array, imageWidth: number, imageHeight: number, blockSize: number,
offMinX: number, offMinY: number, offMaxX: number, offMaxY: number): Promise<Uint8Array | undefined> {
try {
// 计算横排和纵排的块数
let xBlocks = Math.floor((Math.abs(offMaxX - offMinX)) / blockSize);
let yBlocks = Math.floor((Math.abs(offMaxY - offMinY)) / blockSize);
// 不足一块的,按一块计算
if (xBlocks < 1) {
xBlocks = 1;
offMaxX = offMinX + blockSize;
}
if (yBlocks < 1) {
yBlocks = 1;
offMaxY = offMinY + blockSize;
}
// 遍历每个块
for (let y = 0; y < yBlocks; y++) {
for (let x = 0; x < xBlocks; x++) {
const startX = x * blockSize + offMinX;
const startY = y * blockSize + offMinY;
// 计算块内的平均颜色
let totalR = 0;
let totalG = 0;
let totalB = 0;
let pixelCount = 0;
for (let iy = startY; iy < startY + blockSize && iy < imageHeight && iy < offMaxY; iy++) {
for (let ix = startX; ix < startX + blockSize && ix < imageWidth && ix < offMaxX; ix++) {
// TODO 知识点:像素点数据包括RGB通道的分量值及图片透明度
const index = (iy * imageWidth + ix) * 4; // 4 像素点数据包括RGB通道的分量值及图片透明度
totalR += dataArray[index];
totalG += dataArray[index + 1];
totalB += dataArray[index + 2];
pixelCount++;
}
}
const averageR = Math.floor(totalR / pixelCount);
const averageG = Math.floor(totalG / pixelCount);
const averageB = Math.floor(totalB / pixelCount);
// TODO 知识点: 将块内平均颜色应用到块内的每个像素
for (let iy = startY; iy < startY + blockSize && iy < imageHeight && iy < offMaxY; iy++) {
for (let ix = startX; ix < startX + blockSize && ix < imageWidth && ix < offMaxX; ix++) {
const index = (iy * imageWidth + ix) * 4; // 4 像素点数据包括RGB通道的分量值及图片透明度
dataArray[index] = averageR;
dataArray[index + 1] = averageG;
dataArray[index + 2] = averageB;
}
}
}
}
return dataArray;
} catch (error) {
promptAction.showToast({ message: '处理失败' })
return undefined;
}
}
@Component
export struct image_mosaic {
@StorageProp(AREA_TOP)
top: number = 0
@State pixelMapSrc: PixelType | undefined = undefined
private imageSource: image.ImageSource | undefined = undefined;
private opts: image.InitializationOptions | undefined = undefined;
private displayWidth: number = 0; // 图片显示的宽度
@State displayHeight: number = 1; // 图片显示的高度,根据图片分辨率计算
private imageWidth: number = 0; // 图片原始宽度
private imageHeight: number = 0; // 图片原始高度
// 手势移动位置
private startX: number = 0;
private startY: number = 0;
private endX: number = 0;
private endY: number = 0;
@State MosaicSize: number = 50; // 马赛克大小
@State maImg: PixelMap | undefined = undefined;
@Consume pageStack: NavPathStack
@State saveHistoryPixel: PixelMap[] = []
@State historyIndex: number = 0
/**
* 执行马赛克任务
*/
async doMosaicTask(offMinX: number, offMinY: number, offMaxX: number, offMaxY: number): Promise<void> {
// TODO 知识点:将手势移动的起始坐标转换为原始图片中的坐标
offMinX = Math.round(offMinX * this.imageWidth / this.displayWidth);
offMinY = Math.round(offMinY * this.imageHeight / this.displayHeight);
offMaxX = Math.round(offMaxX * this.imageWidth / this.displayWidth);
offMaxY = Math.round(offMaxY * this.imageHeight / this.displayHeight);
// 处理起始坐标大于终点坐标的情况
if (offMinX > offMaxX) {
const temp = offMinX;
offMinX = offMaxX;
offMaxX = temp;
}
if (offMinY > offMaxY) {
const temp = offMinY;
offMinY = offMaxY;
offMaxY = temp;
}
// 获取像素数据的字节数
const bufferData = new ArrayBuffer(this.pixelMapSrc!.img!.getPixelBytesNumber());
await this.pixelMapSrc!.img!.readPixelsToBuffer(bufferData);
// 将像素数据转换为 Uint8Array 便于像素处理
let dataArray = new Uint8Array(bufferData);
// TODO: 性能知识点:使用new taskpool.Task()创建任务项,传入任务执行函数和所需参数
const task: taskpool.Task =
new taskpool.Task(applyMosaic, dataArray, this.imageWidth, this.imageHeight, this.MosaicSize,
offMinX, offMinY, offMaxX, offMaxY);
try {
taskpool.execute(task, taskpool.Priority.HIGH).then(async (res: Object) => {
this.pixelMapSrc!.img = image.createPixelMapSync((res as Uint8Array).buffer, this.opts);
// this.pixelMapSrc!.img = await this.createPixelMapFromUint8Array(res as Uint8Array)
})
} catch (err) {
console.error("doMosaicTask: execute fail, " + (err as BusinessError).toString());
}
}
async createPixelMapFromUint8Array(uint8Array: Uint8Array) {
const arrayBuffer = uint8Array.buffer;
const imageSource = image.createImageSource(arrayBuffer);
const pixelMap = await imageSource.createPixelMap(this.opts);
return pixelMap;
}
/**
*保存像素
*/
saveImage = async () => {
// 1 组件截图
const pixmap = await componentSnapshot.get('img_mosaic')
this.maImg = pixmap
}
async aboutToAppear(): Promise<void> {
this.pixelMapSrc = this.pageStack.getParamByName('image_mosaic').pop() as PixelType
this.saveHistoryPixel[0] = this.pixelMapSrc.img
// 获取图片参数
this.opts = {
editable: true,
pixelFormat: this.pixelMapSrc!.img!.getImageInfoSync().pixelFormat,
size: {
height: this.pixelMapSrc!.img!.getImageInfoSync().size.height,
width: this.pixelMapSrc!.img!.getImageInfoSync().size.width
},
srcPixelFormat: this.pixelMapSrc!.img!.getImageInfoSync().pixelFormat,
alphaType: this.pixelMapSrc!.img!.getImageInfoSync().alphaType
};
// 读取图片信息
const imageInfo: image.ImageInfo = await this.pixelMapSrc!.img!.getImageInfo();
// 获取图片的宽度和高度
this.imageWidth = imageInfo.size.width;
this.imageHeight = imageInfo.size.height;
// 获取屏幕尺寸
const displayData: display.Display = display.getDefaultDisplaySync();
// 计算图片的显示尺寸
this.displayWidth = px2vp(displayData.width);
this.displayHeight = this.displayWidth * this.imageHeight / this.imageWidth;
}
build() {
NavDestination() {
Column() {
/**
* 标题行
*/
Row() {
Image($r('app.media.back'))
.width(16)
.height(16)
.position({ x: 15 })
.onClick(() => {
this.pixelMapSrc!.img = this.saveHistoryPixel[0]
this.pageStack.pop()
})
Text('打码')
.fontSize(17)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 16.5 })
.margin({ bottom: 30 })
List() {
ListItem() {
Image(this.pixelMapSrc!.img).objectFit(ImageFit.Fill)
.id('img_mosaic')
.width('100%')
.gesture(
GestureGroup(GestureMode.Exclusive,
PanGesture({ distance: 10 })
.onActionStart((event: GestureEvent) => {
this.saveHistoryPixel[this.historyIndex++] = this.pixelMapSrc!.img
const finger: FingerInfo = event.fingerList[0];
if (finger == undefined) {
return;
}
this.startX = finger.localX;
this.startY = finger.localY;
})
.onActionUpdate(async (event: GestureEvent) => {
const finger: FingerInfo = event.fingerList[0];
if (finger == undefined) {
return;
}
this.endX = finger.localX;
this.endY = finger.localY;
// 执行马赛克任务
await this.doMosaicTask(this.startX, this.startY, this.endX, this.endY);
this.startX = this.endX;
this.startY = this.endY;
})
)
)
}
}
.width('100%')
.layoutWeight(1)
.alignListItem(ListItemAlign.Center)
Column() {
/**
* 控制马赛克
*/
Row() {
Text('马赛克大小:')
.fontColor('#7B8695')
.fontWeight(FontWeight.Medium)
.fontSize(13)
Image($r('app.media.jian'))
.width(22.2)
.height(22.2)
.margin({ left: 16, right: 19.5 })
.onClick(() => {
if (this.MosaicSize <= 0) {
return
}
this.MosaicSize = this.MosaicSize - 10
})
Slider({ value: this.MosaicSize, max: 100, min: 0 })
.onChange((val) => {
this.MosaicSize = val
})
.width(161)
.height(6)
Image($r('app.media.jia'))
.width(22.2)
.height(22.2)
.margin({ left: 16, right: 19.5 })
.onClick(() => {
if (this.MosaicSize >= 100) {
return
}
this.MosaicSize = this.MosaicSize + 10
})
}
.width('100%')
.padding({ left: 15, top: 10 })
/**
*打码时的操作
*/
Row() {
this.DiItemBuilder($r('app.media.fanhui'), '返回', () => {
this.pixelMapSrc!.img = this.saveHistoryPixel[0]
emitter.emit(`gengxinPixel${this.pixelMapSrc!.id}`, { data: { 'newPixel': this.pixelMapSrc!.img } })
this.pageStack.pop()
})
this.DiItemBuilder($r('app.media.shuaxin'), '撤销', () => {
this.historyIndex--
if (this.historyIndex <= 0) {
this.historyIndex = 0
}
this.pixelMapSrc!.img = this.saveHistoryPixel[this.historyIndex]
})
this.DiItemBuilder($r('app.media.wancheng'), '完成', () => {
emitter.emit(`gengxinPixelMo${this.pixelMapSrc!.id}`, { data: { 'newPixel': this.pixelMapSrc!.img } })
this.pageStack.pop()
})
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.SpaceAround)
}
.width('100%')
.height(115)
}
.width('100%')
.height('100%')
.padding({ top: this.top })
}
.hideTitleBar(true)
.onBackPressed(() => {
this.pixelMapSrc!.img = this.saveHistoryPixel[0]
this.pageStack.pop()
return true
})
}
@Builder
DiItemBuilder(icon: Resource, title: string, doSome?: () => void) {
Column({ space: 9.5 }) {
Image(icon)
.width(23)
.height(23)
.objectFit(ImageFit.Contain)
Text(title)
.fontColor('#7B8695')
.fontSize(13)
.fontWeight(FontWeight.Medium)
}
.onClick(doSome)
}
}
图片裁剪
安装
ohpm install @candies/image_cropper
参数
参数 | 类型 | 描述 |
---|---|---|
image | image.ImageSource | 需要裁剪图片的数据源 |
config | image_cropper.ImageCropperConfig | 裁剪的一些参数设置 |
import * as image_cropper from "@candies/image_cropper";
import { ImageCropper } from "@candies/image_cropper";
import { image } from '@kit.ImageKit';
@Entry
@Component
struct Index {
@State image: image.ImageSource | undefined = undefined;
private controller: image_cropper.ImageCropperController = new image_cropper.ImageCropperController();
@State config: image_cropper.ImageCropperConfig = new image_cropper.ImageCropperConfig(
{
maxScale: 8,
cropRectPadding: image_cropper.geometry.EdgeInsets.all(20),
controller: this.controller,
initCropRectType: image_cropper.InitCropRectType.imageRect,
cropAspectRatio: image_cropper.CropAspectRatios.custom,
}
);
build() {
Column() {
if (this.image != undefined) {
ImageCropper(
{
image: this.image,
config: this.config,
}
)
}
}
}
}
裁剪参数
参数 | 描述 | 默认 |
---|---|---|
maxScale | 最大的缩放倍数 | 5.0 |
cropRectPadding | 裁剪框跟图片 layout 区域之间的距离。最好是保持一定距离,不然裁剪框边界很难进行拖拽 | EdgeInsets.all(20.0) |
cornerSize | 裁剪框四角图形的大小 | Size(30.0, 5.0) |
cornerColor | 裁剪框四角图形的颜色 | 'sys.color.brand' |
lineColor | 裁剪框线的颜色 | 'sys.color.background_primary' 透明度 0.7 |
lineHeight | 裁剪框线的高度 | 0.6 |
editorMaskColorHandler | 蒙层的颜色回调,你可以根据是否手指按下来设置不同的蒙层颜色 | 'sys.color.background_primary' 如果按下透明度 0.4 否则透明度 0.8 |
hitTestSize | 裁剪框四角以及边线能够拖拽的区域的大小 | 20.0 |
cropRectAutoCenterAnimationDuration | 当裁剪框拖拽变化结束之后,自动适应到中间的动画的时长 | 200 milliseconds |
cropAspectRatio | 裁剪框的宽高比 | null(无宽高比) |
initialCropAspectRatio | 初始化的裁剪框的宽高比 | null(custom: 填充满图片原始宽高比) |
initCropRectType | 剪切框的初始化类型(根据图片初始化区域或者图片的 layout 区域) | imageRect |
controller | 提供旋转,翻转,撤销,重做,重置,重新设置裁剪比例,获取裁剪之后图片数据等操作 | null |
actionDetailsIsChanged | 裁剪操作变化的时候回调 | null |
speed | 缩放平移图片的速度 | 1 |
cropLayerPainter | 用于绘制裁剪框图层 | ImageCropperLayerPainter |
裁剪框的宽高比
这是一个 number | null
类型,你可以自定义裁剪框的宽高比。 如果为 null
,那就没有宽高比限制。 如果小于等于 0
,宽高比等于图片的宽高比。 下面是一些定义好了的宽高比
export class CropAspectRatios {
/// No aspect ratio for crop; free-form cropping is allowed.
static custom: number | null = null;
/// The same as the original aspect ratio of the image.
/// if it's equal or less than 0, it will be treated as original.
static original: number = 0.0;
/// Aspect ratio of 1:1 (square).
static ratio1_1: number = 1.0;
/// Aspect ratio of 3:4 (portrait).
static ratio3_4: number = 3.0 / 4.0;
/// Aspect ratio of 4:3 (landscape).
static ratio4_3: number = 4.0 / 3.0;
/// Aspect ratio of 9:16 (portrait).
static ratio9_16: number = 9.0 / 16.0;
/// Aspect ratio of 16:9 (landscape).
static ratio16_9: number = 16.0 / 9.0;
}
裁剪图层 Painter
你现在可以通过覆写 [ImageCropperConfig.cropLayerPainter] 里面的方法来自定裁剪图层.
export class ImageCropperLayerPainter {
/// Paint the entire crop layer, including mask, lines, and corners
/// The rect may be bigger than size when we rotate crop rect.
/// Adjust the rect to ensure the mask covers the whole area after rotation
public paint(
config: ImageCropperLayerPainterConfig
): void {
// Draw the mask layer
this.paintMask(config);
// Draw the grid lines
this.paintLines(config);
// Draw the corners of the crop area
this.paintCorners(config);
}
/// Draw corners of the crop area
protected paintCorners(config: ImageCropperLayerPainterConfig): void {
}
/// Draw the mask over the crop area
protected paintMask(config: ImageCropperLayerPainterConfig): void {
}
/// Draw grid lines inside the crop area
protected paintLines(config: ImageCropperLayerPainterConfig): void {
}
}
裁剪控制器
ImageCropperController
提供旋转,翻转,撤销,重做,重置,重新设置裁剪比例,获取裁剪之后图片数据等操作。
翻转
参数 | 描述 | 默认 |
---|---|---|
animation | 是否开启动画 | false |
duration | 动画时长 | 200 milliseconds |
export interface FlipOptions {
animation?: boolean,
duration?: number,
}
flip(options?: FlipOptions)
controller.flip();
旋转
参数 | 描述 | 默认 |
---|---|---|
animation | 是否开启动画 | false |
duration | 动画时长 | 200 milliseconds |
degree | 旋转角度 | 90 |
rotateCropRect | 是否旋转裁剪框 | true |
当 rotateCropRect
为 true
并且 degree
为 90
度时,裁剪框也会跟着旋转。
export interface RotateOptions {
degree?: number,
animation?: boolean,
duration?: number,
rotateCropRect?: boolean,
}
rotate(options?: RotateOptions)
controller.rotate();
重新设置裁剪比例
重新设置裁剪框的宽高比
controller.updateCropAspectRatio(CropAspectRatios.ratio4_3);
撤消
撤销上一步操作
// 判断是否能撤销
bool canUndo = controller.canUndo;
// 撤销
controller.undo();
重做
重做下一步操作
// 判断是否能重做
bool canRedo = controller.canRedo;
// 重做
controller.redo();
重置
重置所有操作
controller.reset();
历史
// 获取当前是第几个操作
controller.getCurrentIndex();
// 获取操作历史
controller.getHistory();
// 保存当前状态
controller.saveCurrentState();
// 获取当前操作对应的配置
controller.getCurrentConfig();
裁剪数据
获取裁剪之后的图片数据, 返回一个 PixelMap
对象
controller.getCroppedImage();
获取状态信息
- 获取当前裁剪信息
controller.getActionDetails();
- 获取当前旋转角度
controller.rotateDegrees;
- 获取当前裁剪框的宽高比
controller.cropAspectRatio;
- 获取初始化设置的裁剪框的宽高比
controller.originalCropAspectRatio;
- 获取初始化设置的裁剪框的宽高比
controller.originalCropAspectRatio;
图片长截图
在之前我们的截图中,我们都是利用componentSnapshot.get
对组件进行截图,但是如果组件的长度超过了屏幕的长度,那么这种方式就无法获得完整的结果。研究另一种截图的方式——长截图
代码
参数部分
//屏幕设备高度
heightPhone: number = 0
@State yAddress: number = 0
// list长截图尺寸
@State snapWidth: number = 0;
@State snapHeight: number = 0;
// List组件尺寸 单位: vp
private listComponentWidth: number = 0;
private listComponentHeight: number = 0;
// List组件尺寸 单位: px
private listWidth: number = 0;
private listHeight: number = 0;
// 当前List组件位置
private curXOffset: number = 0;
private curYOffset: number = 0;
// 屏幕尺寸
densityPixels: number = 0; //屏幕像素密度 px = density * vp;
private displayWidth: number = 0;
private displayHeight: number = 0;
// 备份截图前组件位置
private xOffsetBefore: number = 0;
private yOffsetBefore: number = 0;
// 拼接后图片
@State mergedImage: PixelMap | undefined = undefined;
// 待拼接图片
tempPixelMap: PixelMap | undefined = undefined;
// 截图过程中组件覆盖
@State componentMaskImage: PixelMap | undefined = undefined;
private componentMaskImageZIndex: number = -1;
@State snapPopupPosition: Position = { x: 0, y: 0 };
// 是否显示预览窗口
@State isShowSnapPopup: boolean = false;
@State showPreview: boolean = false;
// 指定区域内图片的数据跨距
private stride: number = 0
// 滚动组件
scroller: Scroller = new Scroller();
// 一键截图滚动过程缓存
private areaArray: image.PositionArea[] = [];
// 一键截图滚动过程中每页List组件y方向偏移量
private scrollOffsets: number[] = [];
aboutToAppear() {
const displayClass = display.getDefaultDisplaySync();
this.heightPhone = displayClass.height
// 获取屏幕尺寸
const displayData = display.getDefaultDisplaySync();
this.densityPixels = displayData.densityPixels;
this.displayWidth = px2vp(displayData.width);
this.displayHeight = px2vp(displayData.height);
}
图片展示部分
List({ scroller: this.scroller, })
{
//这里放需要截图的图片
}
.width('100%')
.scrollBar(BarState.Off)
.onDidScroll(() => {
this.curYOffset = this.scroller.currentOffset().yOffset;
})
.edgeEffect(EdgeEffect.None)
.id('List_Page')
.onAreaChange((oldValue, newValue) => {
// TODO: 高性能知识点: onAreaChange为高频回调,组件变动时每帧都会调用,避免冗余和耗时操作。
this.listComponentWidth = newValue.width as number; // 单位: vp
this.listComponentHeight = newValue.height as number; // 单位: vp
// 初始化长截图宽高
this.snapWidth = this.listComponentWidth;
this.snapHeight = this.listComponentHeight
})
截图函数
/**
* 一键截图。
*/
async snapShot() {
// 截图前状态初始化
await this.beforeSnapshot();
// 执行循环滚动截图
await this.getPixelMapData();
// 拼接之后修改可动画变量
await this.afterGeneratorImage();
}
/** ts
* 截图开始前的操作。
* - 保存滚动组件当前位置,用于恢复状态
* - 截图当前页面作为遮罩层,避免用户察觉组件的滚动,提高用户体验
* - 滚动组件页面滚动到顶部,准备开始截图
* - 设置截图后小弹窗的位置,提示用户暂时不要操作,等待截图
* - 开启提示小弹窗
*/
async beforeSnapshot() {
// 保存组件当前位置,用于恢复
this.xOffsetBefore = this.curXOffset;
this.yOffsetBefore = this.curYOffset;
this.snapHeight = this.curYOffset + Math.ceil(this.listComponentHeight);
// TODO: 知识点: 使用componentSnapshot.get接口直接获取组件的渲染结果,而不需要将屏幕截图
// this.componentMaskImage = await componentSnapshot.get(Constants.COMPONENT_ID);
// this.componentMaskImageZIndex = Constants.MASK_TOP_LAYER;
this.scroller.scrollTo({ xOffset: 0, yOffset: 0 });
// 开启提示弹窗
this.isShowSnapPopup = true;
// 延时确保已经滚动到了顶部
await sleep(200);
}
/**
* 一键截图循环滚动截图。
*/
async getPixelMapData() {
// 记录滚动量数组
this.scrollOffsets.push(this.scroller.currentOffset().yOffset);
// 调用组件截图接口获取当前截图
componentSnapshot.get('List_Page', async (error: Error, pixmap: PixelMap) => {
if (this.listWidth === 0) {
let imageInfo = pixmap.getImageInfoSync();
this.listWidth = imageInfo.size.width;
this.listHeight = imageInfo.size.height;
this.stride = pixmap.getBytesNumberPerRow();
}
let bytesNumber = pixmap.getPixelBytesNumber();
let buffer: ArrayBuffer = new ArrayBuffer(bytesNumber);
let area: image.PositionArea = {
pixels: buffer,
offset: 0,
stride: this.stride,
region: { size: { width: this.listWidth, height: this.listHeight }, x: 0, y: 0 }
}
// TODO: 知识点: readPixels、readPixelsSync均使用BGRA_8888像素格式,需搭配writePixels、writePixelsSync使用。
pixmap.readPixelsSync(area);
this.areaArray.push(area);
// 循环过程中判断是否到达底部
if (!this.scroller.isAtEnd()) {
this.scroller.scrollPage({ next: true });
await sleep(200);
await this.getPixelMapData();
} else { // 滚动到底部后,通过每轮滚动获取的buffer,拼接生成长截图
this.gitPixelMap();
await this.afterSnapshot();
}
})
}
/**
* 一键截图拼接函数。
*/
gitPixelMap() {
let opts: image.InitializationOptions = {
editable: true,
pixelFormat: 4,
size: {
width: this.listWidth,
height: this.scrollOffsets[this.scrollOffsets.length - 1] * this.densityPixels + this.listHeight
}
};
this.mergedImage = image.createPixelMapSync(opts);
this.tempPixelMap = image.createPixelMapSync(opts);
for (let i = 0; i < this.areaArray.length; i++) {
let area: image.PositionArea = {
pixels: this.areaArray[i].pixels,
offset: 0,
stride: this.stride,
region: {
size: {
width: this.listWidth,
height: this.listHeight
},
x: 0,
y: this.scrollOffsets[i] * this.densityPixels
}
}
this.tempPixelMap?.writePixels(area, (error: BusinessError) => {
if (error) {
} else {
}
})
}
this.mergedImage = this.tempPixelMap;
}
总结
以上就是本次关于我参加华为跑跑码特(不知道有什么结果哈哈哈)项目和对图片处理技术的分享,希望这些内容能为你的技术探索之路提供些许帮助。若你在实践中遇到任何问题,或有独特见解,欢迎在评论区留言交流。让我们携手,在技术的海洋中不断探索、共同进步。