HarmonyOS5 儿童画板app:手绘写字(附代码)

在移动应用开发中,针对特定使用场景进行界面适配与交互优化是提升用户体验的关键。本文将深入解析一款基于鸿蒙OS的汉字书写练习应用,该应用通过横屏布局设计、Canvas绘图技术与文件操作功能,为用户打造了沉浸式的汉字书写学习环境。以下从技术实现、核心功能与优化策略三个维度展开详细解析。

一、横屏初始化与界面架构设计

1.1 横屏显示的强制实现

应用在页面加载阶段通过window模块强制设置为横屏模式,这一设计充分考虑了汉字书写对宽屏空间的需求:

复制代码
// 页面初始化横屏设置
aboutToAppear(): void {
  window.getLastWindow(getContext()).then((windowClass) => {
    windowClass.setPreferredOrientation(window.Orientation.LANDSCAPE)
  })
}

这段代码通过window.Orientation.LANDSCAPE参数将屏幕方向固定为横屏,确保应用启动时即呈现宽屏界面。在鸿蒙OS中,这种实现方式比传统的配置文件设置更具灵活性,可根据页面需求动态切换屏幕方向。

1.2 组件化架构设计

应用采用三层组件架构,各组件职责明确:

  • CalligraphyPractice:主入口组件,负责整体布局、状态管理与导航控制
  • BottomText:底部汉字选择组件,实现汉字项的视觉呈现与交互
  • FinishPage:完成页组件,用于展示用户书写成果并提供重新练习入口

这种架构遵循了ArkTS的声明式UI设计理念,通过@Component装饰器定义组件,以build()方法描述UI结构,实现了代码的高内聚低耦合。

二、Canvas绘图核心功能实现

2.1 毛笔书写效果的算法实现

应用通过Canvas的onTouch事件监听实现手写轨迹捕捉,并通过数学算法模拟毛笔书写的粗细变化:

复制代码
// 触摸事件处理 - 实现毛笔书写效果
onTouch((event) => {
  const touch = event.touches[0];
  switch (event.type) {
    case TouchType.Down:
      this.context.beginPath();
      this.context.moveTo(touch.x, touch.y);
      break;
    case TouchType.Move:
      // 计算触摸点与画布中心的距离
      const distance = Math.sqrt(
        (touch.x - this.canvasWidth/2) ** 2 +
        (touch.y - this.canvasHeight/2) ** 2
      );
      // 根据距离动态调整笔刷粗细(距离越远笔触越细)
      const brushWidth = this.selectedWidth * (1 - distance / Math.max(this.canvasWidth, this.canvasHeight) * 0.3);
      this.context.lineWidth = brushWidth > 5 ? brushWidth : 5;
      this.context.strokeStyle = this.modeIndex === 0 ? this.selectedColor : 'white';
      this.context.lineTo(touch.x, touch.y);
      this.context.stroke();
      break;
  }
});

该算法的核心逻辑是通过Math.sqrt计算触摸点与画布中心的欧氏距离,再通过线性变换将距离映射为笔刷粗细值。当笔触靠近画布中心时,线条更粗,模拟毛笔按压的效果;当笔触远离中心时,线条变细,还原毛笔提笔的质感。

2.2 米字格辅助线的几何绘制

为帮助用户掌握汉字结构,应用通过几何计算绘制米字格辅助线:

复制代码
// 米字格顶点坐标计算
getPoints = (r: number, l: number) => {
  const points: number[][] = [[], [], [], []];
  points[0] = [r - Math.sqrt(r * r / 2), r - Math.sqrt(r * r / 2)];
  points[1] = [l - points[0][0], points[0][1]];
  points[2] = [points[1][1], points[1][0]];
  points[3] = [l - points[0][0], l - points[0][1]];
  return points;
}

// 辅助线绘制
drawGuideLines = (ctx: CanvasRenderingContext2D, r: number) => {
  const width = ctx.width;
  const height = ctx.height;
  const points = this.getPoints(r, width);
  
  // 绘制两条对角线、竖中线与横中线
  ctx.beginPath();
  ctx.moveTo(points[0][0], points[0][1]);
  ctx.lineTo(width, height);
  ctx.strokeStyle = '#D2B48C';
  ctx.lineWidth = 1;
  ctx.stroke();
  // 省略其他三条线的绘制代码...
}

这里通过Math.sqrt(r * r / 2)计算等腰直角三角形的边长,确定米字格对角线的顶点坐标,再通过Canvas的beginPathstroke方法绘制辅助线。浅棕色的线条(#D2B48C)既提供了视觉参考,又不会过度干扰用户书写。

三、文件操作与状态管理实现

3.1 书写内容的沙箱存储

应用通过fileIo模块将书写内容保存至设备沙箱目录:

复制代码
// 图片保存至沙箱
savePicture(img: string, n: number) {
  // 生成唯一文件名(时间戳+后缀)
  const imgPath = getContext().tempDir + '/' + Date.now() + '.jpeg';
  const file = fileIo.openSync(imgPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
  // 解析Base64图像数据
  const base64Image = img.split(';base64,').pop();
  const imgBuffer = buffer.from(base64Image, 'base64');
  // 写入文件并关闭句柄
  fileIo.writeSync(file.fd, imgBuffer.buffer);
  fileIo.closeSync(file);
  this.imgUrl[n] = 'file://' + imgPath;
}

该功能实现了完整的文件操作流程:通过getContext().tempDir获取应用临时目录,使用fileIo.openSync创建新文件,通过buffer.from将Base64数据转换为二进制缓冲区,最后通过fileIo.writeSync写入文件。这种实现方式符合鸿蒙OS的文件安全规范,确保数据仅在应用沙箱内可访问。

3.2 响应式状态管理

应用通过ArkTS的响应式状态装饰器实现数据与UI的自动同步:

复制代码
// 响应式状态定义
@State message: string = '汉字书写练习';
@State modeIndex: number = 0; // 0:毛笔 1:橡皮擦
@State selectedWidth: number = 25; // 笔刷粗细
@State wordOpacity: Array<number> = [1, 0, 0, 0, 0]; // 汉字选择透明度

wordOpacity数组中某个元素的值从0变为1时,对应的汉字选择项会通过opacity样式属性自动高亮,无需手动操作DOM。这种声明式的状态管理方式大幅简化了代码逻辑,提升了开发效率。

四、横屏布局优化与交互设计

4.1 界面元素的横屏适配

为适应横屏显示,应用对各界面元素的尺寸与比例进行了针对性调整:

复制代码
// 画布横屏适配
Canvas(this.context)
  .aspectRatio(1) // 正方形画布,充分利用横屏宽度
  .height('90%') // 占据90%的高度空间
  .borderRadius(15)
  
// 底部导航区布局
Row() {
  Image($r('app.media.last'))
    .height('100%')
    .width('8%') // 按钮宽度占比8%
    .borderRadius(5);
  // 汉字选择区省略...
}

通过aspectRatio(1)将画布设置为正方形,使其在横屏中呈现为宽屏正方形,既保证了书写空间,又符合汉字方块字的视觉特性。底部导航区的按钮宽度设置为8%,确保五个汉字选择项在横屏中均匀分布。

4.2 交互流程优化

应用实现了流畅的汉字练习交互流程:

  1. 通过底部汉字选择区切换练习内容

  2. 使用"清除"按钮重置当前汉字书写

  3. 通过"上一个/下一个"按钮切换练习顺序

  4. 完成所有汉字后跳转至作品预览页

    // 下一个汉字切换逻辑
    onClick(() => {
    // 保存当前书写内容
    this.imgUrl[this.n] = this.context.toDataURL();
    this.savePicture(this.imgUrl[this.n], this.n);

    // 清空画布
    this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
    this.drawGuideLines(this.context, 20);

    // 切换至下一个汉字或跳转至完成页
    if (this.n === 4) {
    this.pageInfos.pushPathByName('Finish', this.imgUrl);
    } else {
    this.nextImg = this.n + 1 === 4 ? 'app.media.finish' : 'app.media.next';
    this.n++;
    this.wordOpacity[this.n] = 1;
    this.wordOpacity[this.n-1] = 0;
    }
    });

该逻辑在切换汉字时会先保存当前书写内容,再清空画布并加载新汉字,确保用户练习成果不丢失。完成所有汉字练习后,通过pageInfos.pushPathByName跳转到完成页,展示所有保存的作品。

五、技术亮点

5.1 核心技术亮点

  1. 物理引擎模拟毛笔效果:通过距离算法动态调整笔刷粗细,还原毛笔书写的压力感
  2. 数学几何绘制米字格:基于勾股定理计算顶点坐标,实现精准的辅助线布局
  3. 响应式状态管理:利用ArkTS的@State装饰器,实现数据与UI的双向绑定
  4. 沙箱文件操作:遵循鸿蒙OS安全规范,确保用户数据仅在应用内可访问

六、附:代码

复制代码
import { fileIo } from '@kit.CoreFileKit';
import { buffer } from '@kit.ArkTS';
import { window } from '@kit.ArkUI';

@Entry
@Component
struct CalligraphyPractice {
  @Provide('pageInfos') pageInfos: NavPathStack = new NavPathStack();
  @State message: string = '汉字书写练习';
  @State modeValue: string = '毛笔'; // 当前工具:毛笔 or 橡皮擦
  @State modeIndex: number = 0; // 工具下标
  @State selectedWidth: number = 25; // 笔刷粗细
  @State selectedColor: string = '#8B0000'; // 毛笔颜色(暗红)
  @State imgUrl: Array<string> = []; // 保存图像URL
  @State wordOpacity: Array<number> = [1, 0, 0, 0, 0]; // 汉字选择透明度
  @State clearOpacity: number = 0.5;
  @State n: number = 0; // 当前汉字索引
  @State nextImg: string = 'app.media.next';
  @State imgHeight: Array<string> = ['70%', '100%'];
  @State showGuide: boolean = true; // 是否显示笔画引导
  @State imgOpacity: number = 0.5;

  // 画布参数
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D();

  // 练习的汉字列表
  private words: Array<string> = ['永', '天', '地', '人', '和'];

// 页面初始化(横屏显示)
  aboutToAppear(): void {
    // 设置当前app以横屏方式显示
    window.getLastWindow(getContext()).then((windowClass) => {
      windowClass.setPreferredOrientation(window.Orientation.LANDSCAPE) // 设置为横屏
    })
  }

  // 获取米字格顶点坐标
  getPoints = (r: number, l: number) => {
    let points: number[][] = [[], [], [], []];
    points[0] = [r - Math.sqrt(r * r / 2), r - Math.sqrt(r * r / 2)];
    points[1] = [l - points[0][0], points[0][1]];
    points[2] = [points[1][1], points[1][0]];
    points[3] = [l - points[0][0], l - points[0][1]];
    return points;
  }

  // 构建路由表
  @Builder
  PagesMap(name: string) {
    if (name === 'Finish') {
      FinishPage()
    }
  }

  // 绘制米字格辅助线
  drawGuideLines = (ctx: CanvasRenderingContext2D, r: number) => {
    const width = ctx.width;
    const height = ctx.height;
    let points = this.getPoints(r, width);

    // 对角线1
    let n = 100;
    let step = width / n;
    let start = points[0];
    ctx.beginPath();
    ctx.moveTo(start[0], start[1]);
    ctx.lineTo(width, height);
    ctx.strokeStyle = '#D2B48C';
    ctx.lineWidth = 1;
    ctx.stroke();

    // 对角线2
    start = points[1];
    ctx.beginPath();
    ctx.moveTo(start[0], start[1]);
    ctx.lineTo(0, height);
    ctx.stroke();

    // 竖中线
    ctx.beginPath();
    ctx.moveTo(width / 2, 0);
    ctx.lineTo(width / 2, height);
    ctx.stroke();

    // 横中线
    ctx.beginPath();
    ctx.moveTo(0, height / 2);
    ctx.lineTo(width, height / 2);
    ctx.stroke();
  }

  // 保存图片到沙箱
  savePicture(img: string, n: number) {
    const imgPath = getContext().tempDir + '/' + Date.now() + '.jpeg';
    const file = fileIo.openSync(imgPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
    const base64Image = img.split(';base64,').pop();
    const imgBuffer = buffer.from(base64Image, 'base64');
    fileIo.writeSync(file.fd, imgBuffer.buffer);
    fileIo.closeSync(file);
    this.imgUrl[n] = 'file://' + imgPath;
  }

  build() {
    Navigation(this.pageInfos) {
      Column() {
        // 顶部标题和熊猫图标
        Row() {
          Image($r('app.media.panda'))
            .margin({ top: 20 })
            .aspectRatio(1)
            .height('80%')
            .margin({ top: 10, left: 30 });

          // 书写区域
          Stack() {
            // 汉字提示
            Text(this.words[this.n])
              .fontSize(200)
              .fontFamily('STKaiti') // 楷体字体
              .opacity(this.showGuide ? 0.1 : 0);

            // 画布
            Canvas(this.context)
              .aspectRatio(1)
              .height('90%')
              .backgroundColor('#FFF8DC') // 米黄色背景
              .borderRadius(15)
              .opacity(0.9)
              .onReady(() => {
                this.drawGuideLines(this.context, 20);
              })
              .onAreaChange((oldVal, newVal) => {
                this.canvasWidth = newVal.width as number;
                this.canvasHeight = newVal.height as number;
              })
              .onTouch((event) => {
                const touch: TouchObject = event.touches[0];
                switch (event.type) {
                  case TouchType.Down:
                    this.context.beginPath();
                    this.context.moveTo(touch.x, touch.y);
                    this.clearOpacity = 1;
                    break;
                  case TouchType.Move:
                    // 毛笔效果:移动时线条粗细变化
                    const distance = Math.sqrt(
                      (touch.x - this.canvasWidth/2) * (touch.x - this.canvasWidth/2) +
                        (touch.y - this.canvasHeight/2) * (touch.y - this.canvasHeight/2)
                    );
                    const brushWidth = this.selectedWidth * (1 - distance / Math.max(this.canvasWidth, this.canvasHeight) * 0.3);
                    this.context.lineWidth = brushWidth > 5 ? brushWidth : 5;
                    this.context.strokeStyle = this.modeIndex === 0 ? this.selectedColor : 'white';
                    this.context.lineTo(touch.x, touch.y);
                    this.context.stroke();
                    break;
                  case TouchType.Up:
                    this.context.closePath();
                    break;
                }
              });
          }
          .margin({ left: 20, top: 5 });

          // 清除按钮
          Column() {
            Button('清除')
              .opacity(this.clearOpacity)
              .type(ButtonType.ROUNDED_RECTANGLE)
              .fontColor('#8B0000')
              .fontSize(28)
              .backgroundColor('#FFEBCD')
              .width('15%')
              .height('18%')
              .margin({ top: 20, left: 10 })
              .onClick(() => {
                this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
                this.drawGuideLines(this.context, 20);
                this.clearOpacity = 0.5;
              });
          }
          .height('100%');
        }
        .width('100%')
        .height('80%');

        // 底部导航区
        Row() {
          // 上一个按钮
          Image($r('app.media.last'))
            .onClick(() => {
              if (this.n > 0) {
                this.n--;
                this.wordOpacity[this.n] = 1;
                this.wordOpacity[this.n+1] = 0;
                this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
                this.drawGuideLines(this.context, 20);
              }
              if (this.n < 4) {
                this.nextImg = 'app.media.next';
              }
              if (this.n === 0) {
                // 当切换到第一个汉字时,设置底部导航栏图片透明度为0.5
                this.imgOpacity = 0.5;
              }
            })
            .opacity(this.imgOpacity)
            .backgroundColor('#FFEBCD')
            .margin({ left: 20 })
            .height('100%')
            .borderRadius(5)
            .borderWidth(8)
            .borderColor('#FFEBCD');

          // 汉字选择区
          Row({ space: 10 }) {
            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[0] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[0], wdSize: 45 })
                .opacity(this.wordOpacity[0])
            }

            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[1] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[1], wdSize: 45 })
                .opacity(this.wordOpacity[1])
            }

            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[2] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[2], wdSize: 45 })
                .opacity(this.wordOpacity[2])
            }

            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[3] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[3], wdSize: 45 })
                .opacity(this.wordOpacity[3])
            }

            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[4] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[4], wdSize: 45 })
                .opacity(this.wordOpacity[4])
            }
          }
          .margin({ left: 10 });

          // 下一个/完成按钮
          Image($r(this.nextImg))
            .borderWidth(8)
            .borderColor('#FFEBCD')
            .backgroundColor('#FFEBCD')
            .height('100%')
            .borderRadius(5)
            .onClick(() => {
              // 保存当前书写内容
              this.imgUrl[this.n] = this.context.toDataURL();
              this.savePicture(this.imgUrl[this.n], this.n);

              // 清空画布并准备下一个汉字
              this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
              this.drawGuideLines(this.context, 20);

              if (this.n === 4) {
                // 所有汉字完成,跳转到结果页
                this.pageInfos.pushPathByName('Finish', this.imgUrl);
              } else {
                // 切换到下一个汉字
                this.nextImg = this.n + 1 === 4 ? 'app.media.finish' : 'app.media.next';
                this.n++;
                this.wordOpacity[this.n] = 1;
                this.wordOpacity[this.n-1] = 0;
                if (this.n > 0) {
                  this.imgOpacity = 1;
                }
              }
              console.log('n: ' + this.n);
            })
            .margin({ left: 250 });
        }
        .width('100%')
        .height('18%')
        .backgroundColor('#FFEBCD');
      }
    }
    .width('100%')
    .mode(NavigationMode.Stack)
    .navDestination(this.PagesMap)
    .hideTitleBar(true)
    .hideToolBar(true)
    .backgroundColor('#8B4513'); // 棕色背景
  }
}

// 底部汉字选择组件
@Component
export struct BottomText {
  public imgH: string = '';
  public wds: string = '';
  public wdSize: number = 30;

  build() {
    Stack() {
      Text(this.wds)
        .height(this.imgH)
        .fontSize(this.wdSize)
        .fontFamily('STKaiti');
      Image($r('app.media.mi'))
        .height(this.imgH)
        .opacity(0.3);
    }
    .aspectRatio(1)
    .borderRadius(5)
    .backgroundColor('#FFEBCD');
  }
}

// 完成页组件
@Component
export struct FinishPage {
  @Consume('pageInfos') pageInfos: NavPathStack;
  private imgUrl: Array<string> = [];

  @Builder
  ImageItem(url: string) {
    Image(url)
      .backgroundColor('#FFF8DC')
      .borderRadius(10)
      .height('30%')
      .width('18%');
  }

  build() {
    NavDestination() {
      Column() {
        Image($r("app.media.panda"))
          .height('25%')
          .aspectRatio(1)
          .margin({ top: 10 });

        Text('你的书法作品:')
          .fontSize(30)
          .fontWeight(FontWeight.Bold)
          .fontFamily('STKaiti')
          .margin({ top: 10, bottom: 15 });

        Row({ space: 10 }) {
          this.ImageItem(this.imgUrl[0])
          this.ImageItem(this.imgUrl[1])
          this.ImageItem(this.imgUrl[2])
          this.ImageItem(this.imgUrl[3])
          this.ImageItem(this.imgUrl[4])
        }
        .margin({ top: 5 });

        Button('重新练习')
          .type(ButtonType.ROUNDED_RECTANGLE)
          .fontColor('#8B0000')
          .fontSize(30)
          .backgroundColor('#FFF8DC')
          .height('15%')
          .width('30%')
          .margin({ top: 30 })
          .onClick(() => {
            // 清空数据并返回首页
            this.imgUrl = [];
            this.pageInfos.pop()
          });
      }
      .height('100%')
      .width('100%')
      .backgroundColor('#8B4513');
    }
    .onReady((context: NavDestinationContext) => {
      this.pageInfos = context.pathStack;
      this.imgUrl = context.pathInfo.param as Array<string>;
    })
    .hideTitleBar(true)
    .hideToolBar(true);
  }
}