OS
应用整体架构与技术栈
该绘图应用采用了鸿蒙系统推荐的ArkUI框架进行开发,基于TypeScript语言编写,充分利用了鸿蒙系统的图形渲染和文件操作能力。应用整体架构遵循MVVM(Model-View-ViewModel)模式,通过@State装饰器实现状态与视图的双向绑定,确保数据变化时UI能够自动更新。
技术栈主要包括:
- ArkUI框架:提供声明式UI开发能力,支持响应式布局和组件化开发
- Canvas绘图API:通过CanvasRenderingContext2D实现底层绘图逻辑
- 文件操作API:使用fileIo和fs模块进行文件读写和管理
- 系统交互API:通过window、promptAction等模块实现系统交互功能
核心功能模块解析
状态管理与数据模型
应用使用@State装饰器管理核心状态,这些状态直接影响UI展示和用户交互:
@State brushSize: number = 10; // 画笔大小
@State brushColor: string = '#000000'; // 画笔颜色
@State backgroundColor1: string = '#FFFFFF'; // 背景颜色
@State isEraser: boolean = false; // 是否使用橡皮擦
@State drawingPoints: Array<Array<number>> = []; // 绘制的点数据
@State isDrawing: boolean = false; // 是否正在绘制
其中,drawingPoints
是一个二维数组,用于存储绘制轨迹的坐标点,每个元素形如[x, y]
,记录了用户绘制时的每一个关键点。这种数据结构使得应用能够高效地重绘整个画布,即使在界面旋转或尺寸变化时也能保持绘制内容的完整性。
绘图核心逻辑实现
绘图功能的核心在于drawLine
方法,它负责在画布上绘制线条,并根据是否为橡皮擦模式应用不同的绘制样式:
drawLine(x1: number, y1: number, x2: number, y2: number) {
this.context.beginPath();
this.context.moveTo(x1, y1);
this.context.lineTo(x2, y2);
// 设置画笔样式
if (this.isEraser) {
// 橡皮擦效果
this.context.strokeStyle = this.backgroundColor1;
this.context.lineWidth = this.brushSize * 1.5;
} else {
// 画笔效果
this.context.strokeStyle = this.brushColor;
this.context.lineWidth = this.brushSize;
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
}
this.context.stroke();
}
橡皮擦功能的实现采用了巧妙的设计:通过将笔触颜色设置为背景色,并适当增加线条宽度,实现了擦除已有绘制内容的效果。lineCap
和lineJoin
属性设置为round
,使得线条端点和连接处呈现圆角效果,提升了绘制线条的美观度。
画布管理与交互处理
Canvas组件的交互处理是绘图应用的关键,代码中通过onTouch
事件监听实现了绘制轨迹的记录:
onTouch((event) => {
const touch: TouchObject = event.touches[0];
const touchX = touch.x;
const touchY = touch.y;
switch (event.type) {
case TouchType.Down:
this.isDrawing = true;
this.drawingPoints.push([touchX, touchY]);
break;
case TouchType.Move:
if (this.isDrawing) {
this.drawingPoints.push([touchX, touchY]);
this.drawLine(touchX, touchY, touchX, touchY);
}
break;
case TouchType.Up:
this.isDrawing = false;
break;
}
});
这段代码实现了典型的触摸事件三阶段处理:
- 按下(Down):开始绘制,记录起始点
- 移动(Move):持续记录移动轨迹,绘制线条
- 抬起(Up):结束绘制
通过这种方式,应用能够准确捕捉用户的绘制意图,并将其转化为画布上的线条。
界面设计与用户体验优化
响应式布局设计
应用采用了ArkUI的响应式布局特性,确保在不同尺寸的屏幕上都能良好显示:
build() {
Column() {
// 顶部工具栏
Row({ space: 15 }) { /* 工具栏组件 */ }
// 颜色选择区
Row({ space: 5 }) { /* 颜色选择组件 */ }
// 绘画区域
Stack() { /* Canvas组件 */ }
// 底部操作区
Column() { /* 说明文本和保存按钮 */ }
}
.width('100%')
.height('100%');
}
根布局使用Column垂直排列各功能区块,顶部工具栏、颜色选择区、绘画区域和底部操作区依次排列。各组件使用百分比宽度(如width('90%')
)和相对单位,确保界面元素能够根据屏幕尺寸自动调整。
交互组件设计
应用提供了直观的用户交互组件,包括:
- 工具栏:
-
- 清除按钮:一键清空画布
- 橡皮擦/画笔切换按钮:通过颜色变化直观显示当前模式
- 画笔大小滑块:实时调整画笔粗细
- 颜色选择区:
-
- 预设七种常用颜色,选中时显示黑色边框
- 点击颜色块即可切换当前画笔颜色
- 画布区域:
-
- 初始状态显示提示文本"点击开始绘画"
- 支持手势绘制,实时显示绘制内容
- 保存功能:
-
- 底部醒目的保存按钮,点击后将画布内容保存为PNG图片
图片保存与文件操作
图片导出功能实现
图片保存功能是该应用的重要组成部分,通过exportCanvas
方法实现:
exportCanvas() {
try {
// 获取画布数据URL
const dataUrl = this.context.toDataURL('image/png');
if (!dataUrl) {
promptAction.showToast({
message: '获取画布数据失败',
duration: 2000
});
return;
}
// 解析Base64数据
const base64Data = dataUrl.split(';base64,').pop() || '';
const bufferData = new Uint8Array(base64Data.length);
for (let i = 0; i < base64Data.length; i++) {
bufferData[i] = base64Data.charCodeAt(i);
}
// 生成保存路径
const timestamp = Date.now();
const fileName = `drawing_${timestamp}.png`;
const fileDir = getContext().filesDir;
const filePath = `${fileDir}/${fileName}`;
// 写入文件
fileIo.open(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY).then((file) => {
// 写入文件内容并处理后续逻辑
}).catch((err:Error) => {
// 错误处理
});
} catch (error) {
console.error('导出画布时发生错误:', error);
promptAction.showToast({
message: '保存图片失败',
duration: 2000
});
}
}
该方法首先通过toDataURL
获取画布的PNG格式数据URL,然后将Base64编码的数据转换为Uint8Array,最后使用fileIo模块将数据写入文件系统。这种实现方式确保了画布内容能够准确地保存为图片文件。
文件操作与错误处理
代码中采用了Promise链式调用处理文件操作的异步逻辑,并包含了完整的错误处理机制:
fileIo.open(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY).then((file) => {
fileIo.write(file.fd, bufferData.buffer).then(() => {
fileIo.close(file.fd).then(() => {
promptAction.showToast({
message: '保存图片成功',
duration: 2000
});
}).catch((err: Error) => {
console.error('关闭文件失败:', err);
promptAction.showToast({
message: '保存图片失败',
duration: 2000
});
});
}).catch((err:Error) => {
console.error('写入文件失败:', err);
fileIo.close(file.fd).then(() => {
promptAction.showToast({
message: '保存图片失败',
duration: 2000
});
});
});
}).catch((err:Error) => {
console.error('打开文件失败:', err);
promptAction.showToast({
message: '保存图片失败',
duration: 2000
});
});
这种分层的错误处理方式确保了无论在文件打开、写入还是关闭阶段发生错误,都能给出适当的错误提示,并确保资源被正确释放。
技术要点
关键技术要点
- 状态管理:使用@State实现数据与UI的双向绑定,简化了状态更新逻辑
- Canvas绘图:掌握CanvasRenderingContext2D的基本操作,包括路径绘制、样式设置等
- 异步操作:通过Promise和async/await处理文件操作等异步任务
- 响应式布局:利用ArkUI的布局组件和百分比单位实现适配不同屏幕的界面
总结
本文介绍的鸿蒙绘图应用实现了基础的绘图功能,包括画笔绘制、橡皮擦、颜色选择和图片保存等核心功能。通过ArkUI框架和Canvas绘图API的结合,展示了鸿蒙系统在图形应用开发方面的强大能力。
对于开发者而言,该应用可以作为进一步开发复杂绘图应用的基础。通过添加更多绘图工具(如矩形、圆形、文本工具)、图像处理功能(如滤镜、调整亮度对比度)以及云同步功能,能够将其拓展为功能完善的绘图应用。
在鸿蒙生态不断发展的背景下,掌握这类图形应用的开发技术,将有助于开发者创造出更多优秀的用户体验,满足不同用户的需求。
附:代码
import { mediaquery, promptAction, window } from '@kit.ArkUI';
import { fileIo } from '@kit.CoreFileKit';
import preferences from '@ohos.data.preferences';
@Entry
@Component
struct Index {
@State brushSize: number = 10; // 画笔大小
@State brushColor: string = '#000000'; // 画笔颜色
@State backgroundColor1: string = '#FFFFFF'; // 背景颜色
@State isEraser: boolean = false; // 是否使用橡皮擦
@State drawingPoints: Array<Array<number>> = []; // 绘制的点数据
@State isDrawing: boolean = false; // 是否正在绘制
// 预设颜色
private presetColors: Array<string> = ['#000000', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
// 画布参数
private canvasWidth: number = 0;
private canvasHeight: number = 0;
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D({ antialias: true});
// 页面初始化
aboutToAppear(): void {
// 设置页面背景色
window.getLastWindow(getContext()).then((windowClass) => {
windowClass.setWindowBackgroundColor('#F5F5F5');
});
}
// 清除画布
clearCanvas() {
this.drawingPoints = [];
this.redrawCanvas();
}
// 重绘画布
redrawCanvas() {
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.context.fillStyle = this.backgroundColor1;
this.context.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
// 重绘所有绘制点
for (let i = 0; i < this.drawingPoints.length; i++) {
const point = this.drawingPoints[i];
if (i > 0) {
const prevPoint = this.drawingPoints[i - 1];
this.drawLine(prevPoint[0], prevPoint[1], point[0], point[1]);
}
}
}
// 绘制线条
drawLine(x1: number, y1: number, x2: number, y2: number) {
this.context.beginPath();
this.context.moveTo(x1, y1);
this.context.lineTo(x2, y2);
// 设置画笔样式
if (this.isEraser) {
// 橡皮擦效果
this.context.strokeStyle = this.backgroundColor1;
this.context.lineWidth = this.brushSize * 1.5;
} else {
// 画笔效果
this.context.strokeStyle = this.brushColor;
this.context.lineWidth = this.brushSize;
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
}
this.context.stroke();
}
// 导出画布为图片
exportCanvas() {
try {
// 获取画布数据URL
const dataUrl = this.context.toDataURL('image/png');
if (!dataUrl) {
promptAction.showToast({
message: '获取画布数据失败',
duration: 2000
});
return;
}
// 解析Base64数据
const base64Data = dataUrl.split(';base64,').pop() || '';
const bufferData = new Uint8Array(base64Data.length);
for (let i = 0; i < base64Data.length; i++) {
bufferData[i] = base64Data.charCodeAt(i);
}
// 生成保存路径
const timestamp = Date.now();
const fileName = `drawing_${timestamp}.png`;
const fileDir = getContext().filesDir;
const filePath = `${fileDir}/${fileName}`;
// 写入文件
fileIo.open(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY).then((file) => {
fileIo.write(file.fd, bufferData.buffer).then(() => {
fileIo.close(file.fd).then(() => {
promptAction.showToast({
message: '保存图片成功',
duration: 2000
});
console.info(`图片已保存至: ${filePath}`);
}).catch((err: Error) => {
console.error('关闭文件失败:', err);
promptAction.showToast({
message: '保存图片失败',
duration: 2000
});
});
}).catch((err:Error) => {
console.error('写入文件失败:', err);
fileIo.close(file.fd).then(() => {
promptAction.showToast({
message: '保存图片失败',
duration: 2000
});
});
});
}).catch((err:Error) => {
console.error('打开文件失败:', err);
promptAction.showToast({
message: '保存图片失败',
duration: 2000
});
});
} catch (error) {
console.error('导出画布时发生错误:', error);
promptAction.showToast({
message: '保存图片失败',
duration: 2000
});
}
}
build() {
Column() {
// 顶部工具栏
Row({ space: 15 }) {
// 清除按钮
Button('清除')
.width('20%')
.height('8%')
.fontSize(14)
.backgroundColor('#FFCCCC')
.onClick(() => {
this.clearCanvas();
});
// 橡皮擦按钮
Button(this.isEraser ? '橡皮擦':'画笔')
.width('18%')
.height('8%')
.fontSize(14)
.backgroundColor(this.isEraser ? '#FFCCCC' : '#CCFFCC')
.onClick(() => {
this.isEraser = !this.isEraser;
});
// 画笔大小控制
Column() {
Text('画笔')
.fontSize(12)
.margin({ bottom: 2 });
Slider({
min: 1,
max: 30,
value: this.brushSize,
// showTips: true
})
.width('60%')
.onChange((value: number) => {
this.brushSize = value;
});
}
.width('30%');
}
.width('100%')
.padding(10)
.backgroundColor('#E6E6E6');
// 颜色选择区
Row({ space: 5 }) {
ForEach(this.presetColors, (color: string) => {
Stack() {
// 显示颜色块
Column()
.width(30)
.height(30)
.borderRadius(5)
.backgroundColor(color)
.borderWidth(this.brushColor === color ? 2 : 0)
.borderColor('#000000') // 统一使用黑色边框表示选中状态,避免颜色冲突
.onClick(() => {
this.brushColor = color;
this.isEraser = false; // 切换颜色时取消橡皮擦模式
console.log(`Selected color: ${color}`)
});
}
.width(30)
.height(30)
.onClick(() => {
this.brushColor = color;
this.isEraser = false; // 切换颜色时取消橡皮擦模式
});
});
}
.width('100%')
.padding(10)
.backgroundColor('#FFFFFF');
// 绘画区域
Stack() {
Canvas(this.context)
.aspectRatio(3/4)
.width('90%')
.height('60%')
.backgroundColor(this.backgroundColor1)
.borderRadius(10)
.onReady(() => {
this.context.fillStyle = this.backgroundColor1;
this.context.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
})
.onAreaChange((oldVal, newVal) => {
this.canvasWidth = newVal.width as number;
this.canvasHeight = newVal.height as number;
this.context.fillStyle = this.backgroundColor1;
this.context.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
})
.onTouch((event) => {
const touch: TouchObject = event.touches[0];
const touchX = touch.x;
const touchY = touch.y;
switch (event.type) {
case TouchType.Down:
this.isDrawing = true;
this.drawingPoints.push([touchX, touchY]);
break;
case TouchType.Move:
if (this.isDrawing) {
this.drawingPoints.push([touchX, touchY]);
// 使用更平滑的绘制方式
this.drawLine(touchX, touchY, touchX, touchY);
}
break;
case TouchType.Up:
this.isDrawing = false;
break;
}
});
// 提示文本
if (this.drawingPoints.length === 0) {
Text('点击开始绘画')
.fontSize(18)
.fontColor('#999')
.fontStyle(FontStyle.Italic);
}
}
.width('100%')
.margin({ top: 20, bottom: 30 });
// 底部说明
Text('简单绘画板 - 拖动手指即可绘制')
.fontSize(14)
.fontColor('#666')
.margin({ bottom: 20 });
Button('保存图片', { type: ButtonType.Normal, stateEffect: true })
.width('90%')
.height(40)
.fontSize(16)
.fontColor('#333333')
.backgroundColor('#E0E0E0')
.borderRadius(8)
.onClick(() => {
this.exportCanvas();
});
}
.width('100%')
.height('100%');
}
}