纯血HarmonyOS5 打造小游戏实践:绘画板(附源文件)

发布于:2025-06-25 ⋅ 阅读:(18) ⋅ 点赞:(0)

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();
}

橡皮擦功能的实现采用了巧妙的设计:通过将笔触颜色设置为背景色,并适当增加线条宽度,实现了擦除已有绘制内容的效果。lineCaplineJoin属性设置为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%'))和相对单位,确保界面元素能够根据屏幕尺寸自动调整。

交互组件设计

应用提供了直观的用户交互组件,包括:

  1. 工具栏
    • 清除按钮:一键清空画布
    • 橡皮擦/画笔切换按钮:通过颜色变化直观显示当前模式
    • 画笔大小滑块:实时调整画笔粗细
  1. 颜色选择区
    • 预设七种常用颜色,选中时显示黑色边框
    • 点击颜色块即可切换当前画笔颜色
  1. 画布区域
    • 初始状态显示提示文本"点击开始绘画"
    • 支持手势绘制,实时显示绘制内容
  1. 保存功能
    • 底部醒目的保存按钮,点击后将画布内容保存为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
  });
});

这种分层的错误处理方式确保了无论在文件打开、写入还是关闭阶段发生错误,都能给出适当的错误提示,并确保资源被正确释放。

技术要点

关键技术要点

  1. 状态管理:使用@State实现数据与UI的双向绑定,简化了状态更新逻辑
  2. Canvas绘图:掌握CanvasRenderingContext2D的基本操作,包括路径绘制、样式设置等
  3. 异步操作:通过Promise和async/await处理文件操作等异步任务
  4. 响应式布局:利用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%');
  }
}

网站公告

今日签到

点亮在社区的每一天
去签到