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 {// 获取画布数据URLconst 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 {// 获取画布数据URLconst 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%');}
}