HarmonyOS APP开发:绘图工具与图片编辑器实战
HarmonyOS APP开发:绘图工具与图片编辑器实战
📌 核心要点:从画笔渲染到图层系统,手把手打造一个功能完整的图片编辑器
一、背景与动机
你有没有想过,手机上那些功能强大的图片编辑器——比如美图秀秀、Snapseed——它们背后的技术原理是什么?当你在屏幕上随手画下一笔,系统是如何把你的手指轨迹变成流畅的线条的?当你给照片加滤镜时,像素数据又是怎样被逐个变换的?
这些问题,在HarmonyOS上同样存在,而且因为ArkTS的声明式UI范式,实现方式还有自己的特色。
想象一下这个场景:你正在开发一个社交APP,用户需要给照片打马赛克、加标注、画箭头;或者你在做一个教育类应用,学生需要在PDF上做笔记、圈画重点。这些需求都指向同一个技术方向——绘图工具与图片编辑器。
但说实话,绘图工具的开发并不简单。它涉及触摸事件处理、Canvas渲染、图层管理、滤镜算法等多个技术领域,每一个都是"坑点密集区"。很多开发者一上来就写代码,结果画笔卡顿、图层混乱、内存爆炸,最后只能推倒重来。
所以这篇文章,咱们不搞花架子,从架构设计到代码实现,把绘图工具和图片编辑器的核心知识点一次性讲透。
二、核心原理
2.1 图片编辑器整体架构
一个完整的图片编辑器,核心架构可以分成五层:
graph TD
A[交互层<br>触摸事件/手势识别]:::primary --> B[工具层<br>画笔/橡皮/选区/滤镜]:::info
B --> C[图层系统<br>图层栈/混合模式/合成]:::warning
C --> D[渲染引擎<br>Canvas绘制/离屏缓冲]:::info
D --> E[数据持久层<br>图片编解码/文件存储]:::error
classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
classDef error fill:#F44336,stroke:#D32F2F,color:#fff
classDef info fill:#2196F3,stroke:#1976D2,color:#fff
交互层负责捕获用户的触摸事件,把手势转化为绘图指令;工具层是各种绘图工具的具体实现;图层系统管理多个图层的叠加和合成;渲染引擎负责最终的Canvas绘制;数据持久层处理图片的加载、保存和编解码。
这五层各司其职,互不干扰。你换一个画笔工具,只需要改工具层的代码,其他层完全不受影响。这就是架构设计的魅力——解耦让一切变得可控。
2.2 画笔渲染原理
画笔工具的核心问题是:如何把离散的触摸点连成平滑的曲线?
最简单的做法是直接连线——相邻两个触摸点之间画一条直线。但你会发现,手指移动速度快的时候,采样点之间的间距很大,画出来的线条全是折角,像锯齿一样。
解决方案是贝塞尔曲线插值。我们用二次贝塞尔曲线把相邻的采样点平滑连接起来,控制点取相邻两点的中点,这样就能得到丝滑的笔迹。
graph LR
A[触摸点P0]:::primary --> B[中点M1]:::info
B --> C[触摸点P1]:::primary
C --> D[中点M2]:::info
D --> E[触摸点P2]:::primary
F[贝塞尔曲线段1<br>M0→M1, 控制点P0]:::warning
G[贝塞尔曲线段2<br>M1→M2, 控制点P1]:::warning
classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
classDef info fill:#2196F3,stroke:#1976D2,color:#fff
2.3 图层合成原理
图层系统的核心是合成算法。多个图层叠加时,每个像素的最终颜色取决于所有图层在该位置的像素值和混合模式。
最常见的混合模式是Alpha合成(Porter-Duff Over操作):
结果颜色 = 源颜色 × 源Alpha + 目标颜色 × (1 - 源Alpha)
当你在图层A上叠加图层B时,B的像素会根据其透明度和A的像素进行混合,这就是我们常说的"半透明叠加"效果。
三、代码实战
3.1 基础用法:铅笔工具实现
先从最简单的铅笔工具开始,感受一下触摸绘图的基本流程:
// 铅笔工具组件
@Component
struct PencilTool {
// 画笔路径数据
private paths: Array<Array<Point>> = []
private currentPath: Array<Point> = []
// 画笔配置
@State strokeColor: string = '#000000'
@State strokeWidth: number = 3
build() {
Stack() {
// 画布组件
Canvas(this.context)
.width('100%')
.height('100%')
.onReady(() => {
this.onCanvasReady()
})
.onTouch((event: TouchEvent) => {
this.handleTouchEvent(event)
})
}
}
// 画布初始化
private onCanvasReady() {
this.context.lineWidth = this.strokeWidth
this.context.strokeStyle = this.strokeColor
this.context.lineCap = 'round'
this.context.lineJoin = 'round'
}
// 处理触摸事件
private handleTouchEvent(event: TouchEvent) {
const touch = event.touches[0]
const point: Point = { x: touch.x, y: touch.y }
switch (event.type) {
case TouchType.Down:
// 手指按下,开始新路径
this.currentPath = [point]
this.context.beginPath()
this.context.moveTo(point.x, point.y)
break
case TouchType.Move:
// 手指移动,追加路径点并绘制
this.currentPath.push(point)
this.context.lineTo(point.x, point.y)
this.context.stroke()
break
case TouchType.Up:
// 手指抬起,保存路径
this.paths.push([...this.currentPath])
this.currentPath = []
break
}
}
}
// 坐标点接口
interface Point {
x: number
y: number
}
这段代码实现了最基本的铅笔绘图:手指按下开始新路径,移动时逐点绘制,抬起时保存路径。但你会发现,快速滑动时线条会有明显的锯齿——这就是前面说的采样点间距问题。
3.2 进阶用法:毛笔效果与贝塞尔平滑
毛笔和铅笔最大的区别在于:毛笔的笔触宽度会随书写速度变化——慢写时粗,快写时细。这个效果怎么实现呢?
核心思路是根据相邻采样点的距离(即速度的倒数)来动态调整线宽:
// 毛笔工具 - 带贝塞尔平滑和动态笔宽
@Component
struct BrushTool {
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(new Settings())
private paths: Array<BrushStroke> = []
private currentPoints: Array<Point> = []
@State strokeColor: string = '#1a1a1a'
@State baseWidth: number = 8 // 基础笔宽
@State minWidth: number = 2 // 最小笔宽
@State maxWidth: number = 16 // 最大笔宽
@State velocityFilter: number = 0.7 // 速度滤波系数
build() {
Stack() {
Canvas(this.context)
.width('100%')
.height('100%')
.onReady(() => {
this.initCanvas()
})
.onTouch((event: TouchEvent) => {
this.handleBrushTouch(event)
})
}
}
private initCanvas() {
this.context.lineCap = 'round'
this.context.lineJoin = 'round'
}
// 处理毛笔触摸事件
private handleBrushTouch(event: TouchEvent) {
const touch = event.touches[0]
const point: Point = { x: touch.x, y: touch.y }
if (event.type === TouchType.Down) {
this.currentPoints = [point]
// 画一个起始圆点
this.drawDot(point, this.baseWidth)
} else if (event.type === TouchType.Move) {
this.currentPoints.push(point)
if (this.currentPoints.length >= 3) {
// 使用贝塞尔曲线平滑绘制
this.drawSmoothLine()
}
} else if (event.type === TouchType.Up) {
// 保存笔画数据
const stroke: BrushStroke = {
points: [...this.currentPoints],
color: this.strokeColor,
baseWidth: this.baseWidth
}
this.paths.push(stroke)
this.currentPoints = []
}
}
// 绘制平滑曲线
private drawSmoothLine() {
const len = this.currentPoints.length
if (len < 3) return
const p0 = this.currentPoints[len - 3]
const p1 = this.currentPoints[len - 2]
const p2 = this.currentPoints[len - 1]
// 计算中点作为贝塞尔端点
const mid1: Point = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }
const mid2: Point = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
// 根据速度计算动态笔宽
const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
const velocity = Math.min(distance, 50) // 限制最大速度
const targetWidth = this.maxWidth - (velocity / 50) * (this.maxWidth - this.minWidth)
// 平滑过渡笔宽
const smoothWidth = this.baseWidth * (1 - this.velocityFilter) + targetWidth * this.velocityFilter
this.baseWidth = smoothWidth
// 绘制贝塞尔曲线段
this.context.beginPath()
this.context.lineWidth = smoothWidth
this.context.strokeStyle = this.strokeColor
this.context.moveTo(mid1.x, mid1.y)
this.context.quadraticCurveTo(p1.x, p1.y, mid2.x, mid2.y)
this.context.stroke()
}
// 绘制圆点(起始点)
private drawDot(point: Point, width: number) {
this.context.beginPath()
this.context.fillStyle = this.strokeColor
this.context.arc(point.x, point.y, width / 2, 0, Math.PI * 2)
this.context.fill()
}
}
// 毛笔笔画数据
interface BrushStroke {
points: Array<Point>
color: string
baseWidth: number
}
这段代码的精髓在于两个地方:一是用quadraticCurveTo绘制二次贝塞尔曲线实现平滑笔迹;二是根据手指移动速度动态调整线宽,模拟毛笔的书写效果。
3.3 完整示例:图片编辑器
下面是一个包含画笔、橡皮擦、图层、滤镜等完整功能的图片编辑器:
// 图片编辑器主页面
@Entry
@Component
struct ImageEditorPage {
// 画布上下文 - 主画布
private mainContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(new Settings())
// 画布上下文 - 临时画布(用于实时预览)
private tempContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(new Settings())
// 编辑器状态
@State currentTool: EditorTool = EditorTool.PENCIL
@State strokeColor: string = '#FF0000'
@State strokeWidth: number = 5
@State eraserSize: number = 20
@State layers: Array<LayerData> = []
@State activeLayerIndex: number = 0
@State sourceImage: PixelMap | null = null
@State filterType: FilterType = FilterType.NONE
@State brightness: number = 0
@State contrast: number = 1.0
@State saturation: number = 1.0
@State canvasWidth: number = 1080
@State canvasHeight: number = 1920
// 撤销/重做栈
private undoStack: Array<ImageData> = []
private redoStack: Array<ImageData> = []
private maxUndoSteps: number = 20
aboutToAppear() {
this.initEditor()
}
build() {
Column() {
// 顶部工具栏
this.Toolbar()
// 画布区域
Stack() {
// 主画布 - 显示合成结果
Canvas(this.mainContext)
.width('100%')
.height('100%')
.onReady(() => {
this.onMainCanvasReady()
})
// 临时画布 - 绘制中的实时预览
Canvas(this.tempContext)
.width('100%')
.height('100%')
.onReady(() => {
this.onTempCanvasReady()
})
.onTouch((event: TouchEvent) => {
this.handleEditorTouch(event)
})
}
.layoutWeight(1)
.backgroundColor('#F5F5F5')
// 底部属性面板
this.BottomPanel()
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
}
// 顶部工具栏
@Builder Toolbar() {
Row() {
// 撤销
Button('撤销')
.onClick(() => this.undo())
.fontSize(14)
.fontColor('#333333')
.backgroundColor('#EEEEEE')
// 重做
Button('重做')
.onClick(() => this.redo())
.fontSize(14)
.fontColor('#333333')
.backgroundColor('#EEEEEE')
Blank()
// 保存
Button('保存')
.onClick(() => this.saveImage())
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#4CAF50')
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.justifyContent(FlexAlign.SpaceBetween)
}
// 底部属性面板
@Builder BottomPanel() {
Column() {
// 工具选择
Row() {
ForEach([
{ tool: EditorTool.PENCIL, icon: '✏️', label: '铅笔' },
{ tool: EditorTool.BRUSH, icon: '🖌️', label: '毛笔' },
{ tool: EditorTool.MARKER, icon: '🖊️', label: '马克笔' },
{ tool: EditorTool.ERASER, icon: '🧹', label: '橡皮' },
{ tool: EditorTool.SELECTION, icon: '⬜', label: '选区' },
{ tool: EditorTool.FILTER, icon: '🎨', label: '滤镜' }
], (item: ToolItem) => {
Column() {
Text(item.icon).fontSize(24)
Text(item.label).fontSize(10).fontColor('#666666')
}
.width(56)
.height(56)
.borderRadius(8)
.justifyContent(FlexAlign.Center)
.backgroundColor(this.currentTool === item.tool ? '#E3F2FD' : '#FFFFFF')
.border(this.currentTool === item.tool ? 2 : 0, '#2196F3')
.onClick(() => {
this.currentTool = item.tool
})
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.padding({ top: 8, bottom: 8 })
// 画笔属性
if (this.currentTool === EditorTool.PENCIL ||
this.currentTool === EditorTool.BRUSH ||
this.currentTool === EditorTool.MARKER) {
Row() {
Text('粗细:').fontSize(12)
Slider({ value: this.strokeWidth, min: 1, max: 30 })
.width(150)
.onChange((value: number) => {
this.strokeWidth = value
})
Text('颜色:').fontSize(12).margin({ left: 16 })
// 颜色选择器
Row() {
ForEach(['#FF0000', '#00FF00', '#0000FF', '#000000', '#FFFFFF'], (color: string) => {
Circle()
.width(24)
.height(24)
.fill(color)
.border(1, '#CCCCCC')
.onClick(() => { this.strokeColor = color })
})
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
}
// 滤镜属性
if (this.currentTool === EditorTool.FILTER) {
Row() {
ForEach([
{ type: FilterType.NONE, label: '原图' },
{ type: FilterType.GRAYSCALE, label: '灰度' },
{ type: FilterType.SEPIA, label: '复古' },
{ type: FilterType.INVERT, label: '反色' },
{ type: FilterType.BLUR, label: '模糊' }
], (item: FilterItem) => {
Text(item.label)
.fontSize(12)
.padding(8)
.borderRadius(4)
.backgroundColor(this.filterType === item.type ? '#2196F3' : '#EEEEEE')
.fontColor(this.filterType === item.type ? '#FFFFFF' : '#333333')
.onClick(() => {
this.filterType = item.type
this.applyFilter()
})
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.padding({ top: 4, bottom: 4 })
}
}
.width('100%')
.backgroundColor('#FAFAFA')
.border({ width: { top: 1 }, color: '#E0E0E0' })
}
// 初始化编辑器
private async initEditor() {
// 创建默认图层
const bgLayer: LayerData = {
id: 0,
name: '背景',
visible: true,
opacity: 1.0,
blendMode: BlendMode.NORMAL,
pixels: null
}
const drawLayer: LayerData = {
id: 1,
name: '绘画层',
visible: true,
opacity: 1.0,
blendMode: BlendMode.NORMAL,
pixels: null
}
this.layers = [bgLayer, drawLayer]
this.activeLayerIndex = 1
}
// 主画布就绪
private onMainCanvasReady() {
this.mainContext.fillStyle = '#FFFFFF'
this.mainContext.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
this.compositeAndRender()
}
// 临时画布就绪
private onTempCanvasReady() {
this.tempContext.lineCap = 'round'
this.tempContext.lineJoin = 'round'
}
// 处理编辑器触摸事件
private handleEditorTouch(event: TouchEvent) {
const touch = event.touches[0]
const point: Point = { x: touch.x, y: touch.y }
switch (this.currentTool) {
case EditorTool.PENCIL:
this.handlePencilDraw(event, point)
break
case EditorTool.BRUSH:
this.handleBrushDraw(event, point)
break
case EditorTool.MARKER:
this.handleMarkerDraw(event, point)
break
case EditorTool.ERASER:
this.handleEraser(event, point)
break
case EditorTool.SELECTION:
this.handleSelection(event, point)
break
default:
break
}
}
// 铅笔绘制
private handlePencilDraw(event: TouchEvent, point: Point) {
if (event.type === TouchType.Down) {
this.saveUndoState()
this.tempContext.beginPath()
this.tempContext.lineWidth = this.strokeWidth
this.tempContext.strokeStyle = this.strokeColor
this.tempContext.moveTo(point.x, point.y)
} else if (event.type === TouchType.Move) {
this.tempContext.lineTo(point.x, point.y)
this.tempContext.stroke()
} else if (event.type === TouchType.Up) {
// 将临时画布内容合并到当前图层
this.mergeTempToLayer()
this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
}
}
// 马克笔绘制(半透明效果)
private handleMarkerDraw(event: TouchEvent, point: Point) {
if (event.type === TouchType.Down) {
this.saveUndoState()
this.tempContext.beginPath()
this.tempContext.lineWidth = this.strokeWidth * 3 // 马克笔更粗
this.tempContext.strokeStyle = this.strokeColor
this.tempContext.globalAlpha = 0.4 // 半透明效果
this.tempContext.moveTo(point.x, point.y)
} else if (event.type === TouchType.Move) {
this.tempContext.lineTo(point.x, point.y)
this.tempContext.stroke()
} else if (event.type === TouchType.Up) {
this.tempContext.globalAlpha = 1.0
this.mergeTempToLayer()
this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
}
}
// 毛笔绘制(带动态笔宽)
private brushPoints: Array<Point> = []
private handleBrushDraw(event: TouchEvent, point: Point) {
if (event.type === TouchType.Down) {
this.saveUndoState()
this.brushPoints = [point]
this.tempContext.beginPath()
this.tempContext.strokeStyle = this.strokeColor
this.tempContext.moveTo(point.x, point.y)
} else if (event.type === TouchType.Move) {
this.brushPoints.push(point)
if (this.brushPoints.length >= 3) {
const len = this.brushPoints.length
const p0 = this.brushPoints[len - 3]
const p1 = this.brushPoints[len - 2]
const p2 = this.brushPoints[len - 1]
const mid1 = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }
const mid2 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
// 动态笔宽
const dist = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
const dynamicWidth = Math.max(2, Math.min(20, 30 / (dist + 1)))
this.tempContext.lineWidth = dynamicWidth
this.tempContext.beginPath()
this.tempContext.moveTo(mid1.x, mid1.y)
this.tempContext.quadraticCurveTo(p1.x, p1.y, mid2.x, mid2.y)
this.tempContext.stroke()
}
} else if (event.type === TouchType.Up) {
this.mergeTempToLayer()
this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.brushPoints = []
}
}
// 橡皮擦
private handleEraser(event: TouchEvent, point: Point) {
if (event.type === TouchType.Down) {
this.saveUndoState()
}
if (event.type === TouchType.Move || event.type === TouchType.Down) {
this.tempContext.save()
this.tempContext.globalCompositeOperation = 'destination-out'
this.tempContext.beginPath()
this.tempContext.arc(point.x, point.y, this.eraserSize / 2, 0, Math.PI * 2)
this.tempContext.fill()
this.tempContext.restore()
}
if (event.type === TouchType.Up) {
this.mergeTempToLayer()
this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
}
}
// 选区工具(矩形选区)
private selectionStart: Point = { x: 0, y: 0 }
private selectionEnd: Point = { x: 0, y: 0 }
private handleSelection(event: TouchEvent, point: Point) {
if (event.type === TouchType.Down) {
this.selectionStart = point
} else if (event.type === TouchType.Move) {
this.selectionEnd = point
// 绘制选区框
this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.tempContext.strokeStyle = '#2196F3'
this.tempContext.lineWidth = 2
this.tempContext.setLineDash([6, 4]) // 虚线选区
const x = Math.min(this.selectionStart.x, this.selectionEnd.x)
const y = Math.min(this.selectionStart.y, this.selectionEnd.y)
const w = Math.abs(this.selectionEnd.x - this.selectionStart.x)
const h = Math.abs(this.selectionEnd.y - this.selectionStart.y)
this.tempContext.strokeRect(x, y, w, h)
this.tempContext.setLineDash([])
}
}
// 将临时画布内容合并到当前图层
private mergeTempToLayer() {
const activeLayer = this.layers[this.activeLayerIndex]
if (activeLayer) {
// 获取临时画布的像素数据
const tempImageData = this.tempContext.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
// 合并到图层(简化实现,实际需要处理混合模式)
if (!activeLayer.pixels) {
activeLayer.pixels = tempImageData
} else {
// Alpha合成
this.blendPixels(activeLayer.pixels, tempImageData)
}
}
this.compositeAndRender()
}
// 像素混合(Alpha合成)
private blendPixels(dest: ImageData, src: ImageData) {
const d = dest.data
const s = src.data
for (let i = 0; i < d.length; i += 4) {
const srcAlpha = s[i + 3] / 255
const destAlpha = d[i + 3] / 255
const outAlpha = srcAlpha + destAlpha * (1 - srcAlpha)
if (outAlpha > 0) {
d[i] = (s[i] * srcAlpha + d[i] * destAlpha * (1 - srcAlpha)) / outAlpha
d[i + 1] = (s[i + 1] * srcAlpha + d[i + 1] * destAlpha * (1 - srcAlpha)) / outAlpha
d[i + 2] = (s[i + 2] * srcAlpha + d[i + 2] * destAlpha * (1 - srcAlpha)) / outAlpha
d[i + 3] = outAlpha * 255
}
}
}
// 合成所有图层并渲染到主画布
private compositeAndRender() {
this.mainContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 绘制白色背景
this.mainContext.fillStyle = '#FFFFFF'
this.mainContext.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
// 从底层到顶层依次合成
for (const layer of this.layers) {
if (!layer.visible || !layer.pixels) continue
this.mainContext.globalAlpha = layer.opacity
this.mainContext.putImageData(layer.pixels, 0, 0)
}
this.mainContext.globalAlpha = 1.0
}
// 应用滤镜
private applyFilter() {
const activeLayer = this.layers[this.activeLayerIndex]
if (!activeLayer || !activeLayer.pixels) return
const data = activeLayer.pixels.data
switch (this.filterType) {
case FilterType.GRAYSCALE:
// 灰度滤镜
for (let i = 0; i < data.length; i += 4) {
const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114
data[i] = data[i + 1] = data[i + 2] = gray
}
break
case FilterType.SEPIA:
// 复古滤镜
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2]
data[i] = Math.min(255, r * 0.393 + g * 0.769 + b * 0.189)
data[i + 1] = Math.min(255, r * 0.349 + g * 0.686 + b * 0.168)
data[i + 2] = Math.min(255, r * 0.272 + g * 0.534 + b * 0.131)
}
break
case FilterType.INVERT:
// 反色滤镜
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i]
data[i + 1] = 255 - data[i + 1]
data[i + 2] = 255 - data[i + 2]
}
break
case FilterType.BLUR:
// 简单均值模糊(3x3卷积核)
this.applyBoxBlur(activeLayer.pixels, 3)
break
default:
break
}
this.compositeAndRender()
}
// 均值模糊
private applyBoxBlur(imageData: ImageData, radius: number) {
const w = imageData.width
const h = imageData.height
const src = new Uint8ClampedArray(imageData.data)
const dst = imageData.data
const size = radius * 2 + 1
const area = size * size
for (let y = radius; y < h - radius; y++) {
for (let x = radius; x < w - radius; x++) {
let r = 0, g = 0, b = 0
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const idx = ((y + dy) * w + (x + dx)) * 4
r += src[idx]
g += src[idx + 1]
b += src[idx + 2]
}
}
const idx = (y * w + x) * 4
dst[idx] = r / area
dst[idx + 1] = g / area
dst[idx + 2] = b / area
}
}
}
// 保存撤销状态
private saveUndoState() {
const activeLayer = this.layers[this.activeLayerIndex]
if (activeLayer && activeLayer.pixels) {
// 深拷贝当前像素数据
const snapshot = new ImageData(
new Uint8ClampedArray(activeLayer.pixels.data),
activeLayer.pixels.width,
activeLayer.pixels.height
)
this.undoStack.push(snapshot)
if (this.undoStack.length > this.maxUndoSteps) {
this.undoStack.shift()
}
this.redoStack = []
}
}
// 撤销
private undo() {
if (this.undoStack.length === 0) return
const activeLayer = this.layers[this.activeLayerIndex]
if (activeLayer && activeLayer.pixels) {
// 保存当前状态到重做栈
this.redoStack.push(new ImageData(
new Uint8ClampedArray(activeLayer.pixels.data),
activeLayer.pixels.width,
activeLayer.pixels.height
))
// 恢复上一个状态
activeLayer.pixels = this.undoStack.pop()!
this.compositeAndRender()
}
}
// 重做
private redo() {
if (this.redoStack.length === 0) return
const activeLayer = this.layers[this.activeLayerIndex]
if (activeLayer && activeLayer.pixels) {
this.undoStack.push(new ImageData(
new Uint8ClampedArray(activeLayer.pixels.data),
activeLayer.pixels.width,
activeLayer.pixels.height
))
activeLayer.pixels = this.redoStack.pop()!
this.compositeAndRender()
}
}
// 保存图片到相册
private async saveImage() {
try {
// 从主画布获取像素数据
const imageData = this.mainContext.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
// 创建PixelMap并保存(简化示意)
console.info('图片保存成功')
} catch (error) {
console.error(`保存失败: ${error}`)
}
}
}
// 枚举定义
enum EditorTool {
PENCIL = 'pencil',
BRUSH = 'brush',
MARKER = 'marker',
ERASER = 'eraser',
SELECTION = 'selection',
FILTER = 'filter'
}
enum FilterType {
NONE = 'none',
GRAYSCALE = 'grayscale',
SEPIA = 'sepia',
INVERT = 'invert',
BLUR = 'blur'
}
enum BlendMode {
NORMAL = 'normal',
MULTIPLY = 'multiply',
SCREEN = 'screen'
}
// 图层数据接口
interface LayerData {
id: number
name: string
visible: boolean
opacity: number
blendMode: BlendMode
pixels: ImageData | null
}
// 工具项接口
interface ToolItem {
tool: EditorTool
icon: string
label: string
}
// 滤镜项接口
interface FilterItem {
type: FilterType
label: string
}
interface Point {
x: number
y: number
}
这个完整示例涵盖了图片编辑器的核心功能:多种画笔工具、橡皮擦、选区、图层合成、滤镜、撤销重做。虽然某些API在HarmonyOS中可能有细微差异,但整体架构和算法逻辑是通用的。
四、踩坑与注意事项
坑点1:Canvas触摸事件坐标偏移
在HarmonyOS中,Canvas的触摸坐标是相对于组件左上角的,但如果Canvas外层有滚动容器或者Canvas本身有缩放,坐标就会偏移。务必在处理触摸事件时做坐标转换:
// 错误做法:直接使用触摸坐标
const point = { x: touch.x, y: touch.y }
// 正确做法:考虑Canvas的实际显示区域
const canvasRect = this.canvasArea
const scaleX = this.canvasWidth / canvasRect.width
const scaleY = this.canvasHeight / canvasRect.height
const point = {
x: (touch.x - canvasRect.left) * scaleX,
y: (touch.y - canvasRect.top) * scaleY
}
坑点2:频繁重绘导致卡顿
每次手指移动都调用stroke()会导致Canvas反复重绘,性能很差。解决方案是使用离屏Canvas做缓冲,只在手指抬起时才把结果合并到主画布:
// 在临时画布上绘制,避免频繁操作主画布
// 手指抬起时才合并
private mergeTempToLayer() {
// 一次性将临时画布内容绘制到主画布
this.mainContext.drawImage(this.tempCanvas, 0, 0)
}
坑点3:橡皮擦的globalCompositeOperation兼容性
橡皮擦需要用destination-out混合模式来"擦除"像素,但不同版本的HarmonyOS对这个属性的支持可能不一致。建议在初始化时做兼容性检测,如果不支持,可以用白色画笔模拟(但只适用于白色背景)。
坑点4:图层像素数据的内存管理
每个图层都保存一份完整的ImageData,对于1080×1920的画布,一个图层就需要约8MB内存。10个图层就是80MB!务必限制最大图层数量,并在不可见图层上做内存回收:
// 图层不可见时释放像素数据
private releaseHiddenLayers() {
for (const layer of this.layers) {
if (!layer.visible && layer.pixels) {
layer.cachedPixels = layer.pixels // 缓存以便恢复
layer.pixels = null // 释放内存
}
}
}
坑点5:滤镜操作阻塞UI线程
像素级的滤镜操作(遍历每个像素)非常耗时,一张1080×1920的图片有200万个像素,灰度滤镜需要遍历800万个数值。必须使用TaskPool或Worker在子线程执行:
import { taskpool } from '@kit.ArkTS'
// 在子线程执行滤镜
@Concurrent
function applyGrayscaleFilter(data: Uint8ClampedArray): Uint8ClampedArray {
for (let i = 0; i < data.length; i += 4) {
const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114
data[i] = data[i + 1] = data[i + 2] = gray
}
return data
}
// 主线程调用
private async applyFilterAsync() {
const task = new taskpool.Task(applyGrayscaleFilter, this.pixelData)
const result = await taskpool.execute(task)
this.pixelData = result as Uint8ClampedArray
}
坑点6:贝塞尔曲线的起点处理
使用中点法绘制贝塞尔曲线时,第一段曲线(从触摸起点到第一个中点)容易被遗漏。需要在TouchDown时额外绘制一个起始圆点,否则线条开头会出现断裂。
坑点7:马克笔的透明度叠加问题
马克笔的特点是半透明,但如果在同一个笔画内反复经过同一区域,透明度会不断叠加变深。解决方案是在临时画布上以不透明方式绘制,合并时再应用透明度,这样同一笔画内的透明度就是均匀的。
五、HarmonyOS 6适配说明
API差异
| API | HarmonyOS 5.0 | HarmonyOS 6.0 | 迁移建议 |
|---|---|---|---|
| Canvas.onTouch() | TouchEvent接口 | TouchEvent接口增强,新增pressure字段 | 可使用pressure实现压感画笔 |
| CanvasRenderingContext2D | 基础2D绘制API | 新增filter属性(CSS滤镜) | 优先使用原生filter替代手动像素操作 |
| ImageData | 标准像素数据 | 新增colorSpace字段 | 需处理色彩空间转换 |
| PixelMap | 基础图片操作 | 新增effectKit集成 | 滤镜操作优先使用effectKit |
| taskpool.Task | 基础任务池 | 支持优先级和任务组 | 复杂滤镜可拆分为任务组并行执行 |
行为变更
- Canvas硬件加速默认开启:HarmonyOS 6.0中Canvas默认使用GPU渲染,绘制性能大幅提升,但某些混合模式的行为可能与软件渲染不同,需要重新测试
- ImageData内存布局变更:6.0中ImageData的data属性返回的是SharedArrayBuffer,可以直接在Worker间零拷贝传递,但不能再直接修改
- 触摸采样率提升:6.0支持240Hz触摸采样,画笔工具需要做更激进的点采样过滤,否则数据量过大
适配代码
// HarmonyOS 6适配:压感画笔
private handlePressureBrushDraw(event: TouchEvent, point: Point) {
const touch = event.touches[0]
// HarmonyOS 6新增pressure字段
const pressure = (touch as any).pressure ?? 0.5
const dynamicWidth = this.baseWidth * pressure * 2
if (event.type === TouchType.Down) {
this.tempContext.beginPath()
this.tempContext.lineWidth = dynamicWidth
this.tempContext.strokeStyle = this.strokeColor
this.tempContext.moveTo(point.x, point.y)
} else if (event.type === TouchType.Move) {
this.tempContext.lineWidth = dynamicWidth
this.tempContext.lineTo(point.x, point.y)
this.tempContext.stroke()
}
}
// HarmonyOS 6适配:使用原生CSS滤镜
private applyNativeFilter() {
// 6.0支持Canvas原生filter属性
this.mainContext.filter = 'grayscale(1)' // 灰度
this.mainContext.filter = 'sepia(1)' // 复古
this.mainContext.filter = 'blur(5px)' // 模糊
this.mainContext.filter = 'brightness(1.5)' // 亮度
this.mainContext.filter = 'contrast(2)' // 对比度
// 绘制时自动应用滤镜
this.compositeAndRender()
// 清除滤镜
this.mainContext.filter = 'none'
}
六、总结
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐ |
| 使用频率 | ⭐⭐⭐⭐ |
| 重要程度 | ⭐⭐⭐⭐ |
绘图工具和图片编辑器的开发,表面上看是在"画画",实际上是在和触摸事件、像素数据、内存管理、渲染性能这四大对手博弈。画笔的平滑度取决于贝塞尔曲线的实现质量,橡皮擦的正确性取决于混合模式的理解深度,图层系统的稳定性取决于内存管理的严谨程度,而滤镜的流畅度取决于多线程的运用能力。
记住一个核心原则:永远不要在UI线程做像素级操作。无论是滤镜、选区还是图层合成,只要是遍历像素的活儿,统统扔到子线程去。UI线程只负责接收触摸事件和最终渲染,这样你的编辑器才能既流畅又稳定。
另外,架构设计先行。不要一上来就写画笔代码,先把交互层、工具层、图层系统、渲染引擎的接口定义好,再逐层实现。你会发现,好的架构让后续的功能扩展变得水到渠成——加一个新画笔?实现工具接口就行。加一个新滤镜?写一个像素处理函数就行。这就是架构的力量。
- 点赞
- 收藏
- 关注作者
评论(0)