《星际练字板》三、沉浸光感儿童练字板案例指南

HarmonyOS 实战:打造「星际练字板」------ 沉浸光感儿童练字应用全流程开发指南

适用版本 :HarmonyOS 6.1+(API 23+)

开发语言 :ArkTS + ArkUI

核心技术:Canvas 绘图、状态管理(@Component + @Observed + @Provide/@Consume)、沉浸式光感 UI、Navigation 路由


效果

一、项目概述

1.1 效果图预览

本案例将开发一款名为 「星际练字板」 的儿童汉字练习应用。应用以深邃宇宙为主题背景,通过渐变光晕、霓虹发光、呼吸动画等视觉手法营造沉浸感。孩子们可以在发光画布上练习"风、花、雪、月、星"五个优美汉字,完成全部练习后进入庆祝展示页。

核心界面:

界面 描述
练字主页 深色宇宙背景 + 发光 Canvas 画布 + 霓虹色工具栏 + 范字展示
成果展示页 金色庆祝标题 + 五幅作品展示 + 脉冲光晕动画

1.2 功能清单

  • ✅ Canvas 画布手写练习,带发光笔迹效果
  • ✅ 米字格辅助线(发光虚线风格)
  • ✅ 范字大字展示(带紫色光晕)
  • ✅ 6 种霓虹色画笔选择
  • ✅ 3 档笔宽切换
  • ✅ 光效开关控制
  • ✅ 5 字逐字练习与进度指示
  • ✅ 作品保存到沙箱 + 成果展示页
  • ✅ 呼吸光感动画(环境光晕脉冲)
  • ✅ 横屏全屏沉浸式体验

1.3 技术架构

复制代码
┌─────────────────────────────────────────────┐
│               EntryAbility                  │
│    (全屏设置 + 横屏 + 加载 CalliBoard)        │
└─────────────────┬───────────────────────────┘
                  │
    ┌─────────────▼─────────────────┐
    │         Navigation            │
    │     (NavPathStack 路由)        │
    └──────┬───────────────┬────────┘
           │               │
    ┌──────▼──────┐  ┌─────▼──────┐
    │  CalliBoard │  │ ResultPage │
    │  (练字主页)  │  │ (成果展示)  │
    └──────┬──────┘  └────────────┘
           │
    ┌──────▼──────────────┐
    │    DrawingConfig     │
    │  (@Observed 模型)    │
    └─────────────────────┘

二、项目搭建

2.1 创建项目

使用 DevEco Studio 创建一个 Empty Ability 项目:

  • Project name:CalliBoardApp
  • Bundle name:com.example.calliboard
  • Compatible SDK:5.0.0(12)
  • Model:Stage 模型

2.2 配置横屏全屏

打开 entry/src/main/module.json5,在 abilities 中添加 orientation 属性:

json5 复制代码
{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "orientation": "landscape",    // 横屏
        "deviceTypes": ["phone", "tablet", "2in1"],
        // ...其他配置
      }
    ]
  }
}

2.3 配置 EntryAbility

修改 EntryAbility.ets,加载练字主页并设置全屏沉浸:

typescript 复制代码
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.context.getApplicationContext()
      .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT);
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/CalliBoard', (err) => {
      if (err.code) return;

      let windowClass = windowStage.getMainWindowSync();

      // 全屏布局
      windowClass.setWindowLayoutFullScreen(true);

      // 隐藏导航条
      windowClass.setSpecificSystemBarEnabled('navigationIndicator', false);

      // 存储避让区域供页面使用
      let area = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
      AppStorage.setOrCreate('topRectHeight', area.topRect.height);
    });
  }
}

关键说明

  • setWindowLayoutFullScreen(true):让应用铺满整个屏幕
  • setSpecificSystemBarEnabled('navigationIndicator', false):隐藏底部导航条
  • setColorMode(COLOR_MODE_LIGHT):固定浅色模式,确保深色 UI 效果一致

2.4 配置路由表

entry/src/main/resources/base/profile/main_pages.json 中注册主入口页面

json 复制代码
{
  "src": [
    "pages/CalliBoard"
  ]
}

重要提示ResultPage 作为 NavDestination 子页面,通过 Navigation 路由动态加载,不需要main_pages.json 中注册。如果将 NavDestination 页面注册到路由表,编译器会强制要求该页面有 @Entry 装饰器,这与 NavDestination 的使用方式冲突。

复制代码
---

## 三、数据模型设计(@Observed 深度观察)

### 3.1 @Observed 模型类

使用 `@Observed` 装饰器实现类属性的深度观察,配合 `@State` 在组件中使用:

```typescript
// model/DrawingModel.ets

@Observed
export class DrawingConfig {
  penColor: string = '#00E5FF'      // 画笔颜色
  penWidth: number = 10             // 画笔粗细
  shadowEnabled: boolean = true     // 发光效果开关
}

@Observed
export class WordItem {
  character: string                 // 汉字
  completed: boolean = false        // 是否已完成
  imageData: string = ''            // 保存的图片路径

  constructor(character: string) {
    this.character = character
  }
}

@Observed 使用要点

  • @Observed 装饰的类,其属性变化可被 @State@ObjectLink@Prop 追踪
  • 类对象必须通过 @State 声明,才能触发 UI 自动更新
  • build() 中读取的属性才会触发对应组件的重渲染

3.2 页面级状态与跨组件通信

使用 @State 管理页面本地状态,@Provide/@Consume 实现跨组件状态共享:

typescript 复制代码
@Entry
@Component
struct CalliBoard {
  // @Provide 向子组件(NavDestination)共享路由栈
  @Provide('navStack') navStack: NavPathStack = new NavPathStack()
  @State currentWordIndex: number = 0                  // 当前字索引
  @State hasContent: boolean = false                   // 画布是否有内容
  @State glowOpacity: number = 0.3                     // 光晕动画值

  @State config: DrawingConfig = new DrawingConfig()   // @Observed 模型
  @State wordList: WordItem[] = [                      // 汉字列表
    new WordItem('风'), new WordItem('花'), new WordItem('雪'),
    new WordItem('月'), new WordItem('星')
  ]
}

子组件通过 @Consume 获取共享状态

typescript 复制代码
@Component
export struct ResultPage {
  @Consume('navStack') navStack: NavPathStack  // 消费父组件提供的路由栈
  @State imageUrls: string[] = []
  // ...
}

四、沉浸光感 UI 实现

4.1 多层背景架构

沉浸感的核心在于多层叠加

复制代码
第 1 层:深色宇宙渐变背景(linearGradient)
第 2 层:环境光晕(radialGradient + 呼吸动画)
第 3 层:主要内容(画布 + 工具栏 + 范字)
第一层:宇宙渐变
typescript 复制代码
Column()
  .linearGradient({
    direction: GradientDirection.RightBottom,
    colors: [
      ['#0B0B3B', 0.0],   // 深夜蓝
      ['#1A0A3E', 0.4],   // 深紫
      ['#0D1B4A', 0.8],   // 藏蓝
      ['#05051A', 1.0]    // 近黑
    ]
  })
第二层:环境光晕

使用 radialGradient 创建柔和的光斑,配合 animateTo 实现呼吸效果:

typescript 复制代码
// 呼吸动画
aboutToAppear(): void {
  animateTo(
    { duration: 2500, iterations: -1, curve: Curve.EaseInOut,
      playMode: PlayMode.AlternateReverse },
    () => { this.glowOpacity = 0.7 }
  )
}

// 紫色光晕
Column()
  .width(350).height(350).borderRadius(175)
  .radialGradient({
    center: ['50%', '50%'], radius: 175,
    colors: [['rgba(100, 0, 255, 0.18)', 0.0], ['rgba(100, 0, 255, 0)', 1.0]]
  })
  .opacity(this.glowOpacity)   // 随动画值变化

4.2 发光画布边框

typescript 复制代码
Canvas(this.canvasContext)
  .backgroundColor('rgba(255, 255, 255, 0.06)')    // 半透明底色
  .borderRadius(18)
  .border({ width: 1, color: 'rgba(0, 229, 255, 0.2)' })  // 微光边框
  .shadow({ radius: 25, color: 'rgba(0, 229, 255, 0.2)',   // 外层光晕
            offsetX: 0, offsetY: 0 })

4.3 范字光效

typescript 复制代码
Text(this.wordList[this.currentWordIndex].character)
  .fontSize(180)
  .fontColor('#E8E0FF')
  .shadow({ radius: 35, color: 'rgba(179, 136, 255, 0.7)',  // 紫色光晕
            offsetX: 0, offsetY: 0 })

4.4 霓虹工具栏

底部工具栏使用 backdropBlur 实现磨砂玻璃效果:

typescript 复制代码
Column()
  .borderRadius({ topLeft: 20, topRight: 20 })
  .backgroundColor('rgba(255, 255, 255, 0.04)')
  .backdropBlur(30)     // 背景模糊

颜色选择器的选中状态使用 shadow 实现发光效果:

typescript 复制代码
Column()
  .width(36).height(36).borderRadius(18)
  .backgroundColor(color)
  .border({ width: this.config.penColor === color ? 3 : 0, color: '#FFFFFF' })
  .shadow(this.config.penColor === color
    ? { radius: 14, color: color, offsetX: 0, offsetY: 0 }  // 选中发光
    : undefined)

五、Canvas 绘图核心实现

5.1 绘制发光米字格

typescript 复制代码
private drawGlowGrid(): void {
  const ctx = this.canvasContext
  const w = ctx.width
  const h = ctx.height

  // 外框发光线
  ctx.strokeStyle = 'rgba(0, 229, 255, 0.25)'
  ctx.lineWidth = 1.5
  ctx.strokeRect(8, 8, w - 16, h - 16)

  // 虚线辅助线(青色调,低透明度 = 光感效果)
  ctx.setLineDash([6, 6])
  ctx.strokeStyle = 'rgba(0, 229, 255, 0.12)'
  ctx.lineWidth = 1

  // 水平中线、垂直中线、两条对角线...
  ctx.beginPath()
  ctx.moveTo(12, h / 2)
  ctx.lineTo(w - 12, h / 2)
  ctx.stroke()
  // ... 其余线条类似

  ctx.setLineDash([])
}

5.2 手写交互(onTouch 事件处理)

typescript 复制代码
Canvas(this.canvasContext)
  .onTouch((event: TouchEvent) => {
    const t: TouchObject = event.touches[0]
    switch (event.type) {
      case TouchType.Down:
        // 手指按下:开始新路径
        this.canvasContext.beginPath()
        this.canvasContext.moveTo(t.x, t.y)
        this.hasContent = true
        break

      case TouchType.Move:
        // 手指移动:绘制线段
        this.canvasContext.lineTo(t.x, t.y)
        this.canvasContext.lineWidth = this.config.penWidth
        this.canvasContext.strokeStyle = this.config.penColor
        this.canvasContext.lineCap = 'round'
        this.canvasContext.lineJoin = 'round'

        // 关键:发光笔迹效果
        if (this.config.shadowEnabled) {
          this.canvasContext.shadowColor = this.config.penColor
          this.canvasContext.shadowBlur = 10
        }
        this.canvasContext.stroke()
        break

      case TouchType.Up:
        // 手指抬起:关闭路径,清除阴影
        this.canvasContext.closePath()
        this.canvasContext.shadowBlur = 0
        break
    }
  })

发光笔迹原理 :在每次 stroke() 之前设置 shadowColorshadowBlur,Canvas 会在绘制线条的同时产生柔和的光晕效果。shadowBlur 值越大,光晕越扩散。

5.3 清除与重绘

typescript 复制代码
private clearAndRedrawGrid(): void {
  this.canvasContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
  this.drawGlowGrid()  // 重绘辅助线
  this.hasContent = false
}

5.4 导出与保存图片

typescript 复制代码
private saveImage(): void {
  const dataUrl = this.canvasContext.toDataURL()
  const imgPath = this.context.tempDir + '/calli_' + Date.now() + '.png'
  const file = fileIo.openSync(imgPath,
    fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE)
  const base64 = dataUrl.split(';base64,').pop()
  if (base64) {
    const imgBuf = buffer.from(base64, 'base64')
    fileIo.writeSync(file.fd, imgBuf.buffer)
  }
  fileIo.closeSync(file)
  this.wordList[this.currentWordIndex].imageData = 'file://' + imgPath
}

六、页面导航实现

typescript 复制代码
@Entry
@Component
struct CalliBoard {
  @Provide('navStack') navStack: NavPathStack = new NavPathStack()

  @Builder
  PagesMap(name: string) {
    if (name === 'Result') {
      ResultPage()
    }
  }

  build() {
    Navigation(this.navStack) {
      // 主内容...
    }
    .mode(NavigationMode.Stack)
    .navDestination(this.PagesMap)
    .hideTitleBar(true)
    .hideToolBar(true)
  }
}

6.2 页面跳转与参数传递

typescript 复制代码
// 练完最后一个字时跳转
private goNext(): void {
  this.saveImage()
  this.wordList[this.currentWordIndex].completed = true

  if (this.currentWordIndex >= this.wordList.length - 1) {
    const imgUrls: string[] = []
    for (const item of this.wordList) {
      imgUrls.push(item.imageData)
    }
    this.navStack.pushPath({ name: 'Result', param: imgUrls })
  } else {
    this.currentWordIndex++
    this.clearAndRedrawGrid()
  }
}

6.3 成果展示页(NavDestination)

typescript 复制代码
@Component
export struct ResultPage {
  @Consume('navStack') navStack: NavPathStack  // 通过 @Consume 获取父组件的路由栈
  @State imageUrls: string[] = []

  build() {
    NavDestination() {
      // 展示所有练字作品...
    }
    .onReady((context: NavDestinationContext) => {
      const param = context.pathInfo.param as Object
      if (param) {
        this.imageUrls = param as string[]
      }
    })
    .hideTitleBar(true)
  }
}

注意pathInfo.param 的类型为 unknown,需要通过 as Object 进行显式类型断言后才能使用。


七、完整项目文件结构

复制代码
entry/src/main/
├── ets/
│   ├── entryability/
│   │   └── EntryAbility.ets          # Ability 入口(全屏+横屏配置)
│   ├── model/
│   │   └── DrawingModel.ets          # @Observed 数据模型
│   └── pages/
│       ├── CalliBoard.ets             # 练字主页面
│       └── ResultPage.ets             # 成果展示页
├── resources/
│   ├── base/
│   │   ├── element/
│   │   │   ├── string.json            # 字符串资源
│   │   │   └── color.json             # 颜色资源
│   │   └── profile/
│   │       └── main_pages.json        # 路由表
│   └── dark/
│       └── element/
│           └── color.json
└── module.json5                       # 模块配置(横屏+设备类型)

八、关键设计决策说明

8.1 状态管理方案选择

本案例使用 @Component + @Observed + @Provide/@Consume,原因:

  1. @Provide/@Consume 仅支持 @Component :这是 V1 装饰器,不能用于 @ComponentV2(V2 对应的是 @Provider/@Consumer,但当前 API 版本尚不完善)
  2. @Observed 深度观察 :配合 @State 可以追踪类对象属性的变化,满足画笔颜色、笔宽等实时更新需求
  3. NavDestination 子页面不需要 @Entry :通过 @Provide/@Consume 共享 NavPathStack,子页面可以访问路由栈进行返回操作

8.2 沉浸光感设计要点

技术 应用场景 效果
linearGradient 页面背景 深邃宇宙感
radialGradient 环境光晕 柔和光斑漂浮感
shadow (offsetX/Y=0) 元素外发光 霓虹灯管效果
backdropBlur 工具栏 磨砂玻璃质感
animateTo (AlternateReverse) 呼吸动画 光晕缓慢脉冲
globalAlpha + 半透明色 画布底色 透出背景层次感

8.3 Canvas 笔迹发光原理

复制代码
普通笔迹:strokeStyle = color
发光笔迹:strokeStyle = color + shadowColor = color + shadowBlur = 10

onTouchMove 事件中,每次 stroke() 前设置阴影属性,Canvas 会为每一小段线条绘制光晕。Up 事件时清除 shadowBlur 避免影响后续绘制。


九、扩展优化建议

9.1 功能扩展方向

方向 实现思路
撤销/重做 使用 getImageData() 保存画布快照栈
笔画引导动画 Path2D + 定时器逐帧绘制笔画路径
多字帖主题 抽象 WordItem 列表为配置项,支持切换古诗/成语等
音效反馈 在 TouchType.Down 时播放短促音效
作品分享 使用 @kit.ShareKit 分享保存的图片

9.2 性能优化

  • 避免在 onTouchMove 事件中调用 beginPath()(会导致笔迹断裂)
  • 对大量重复绘制使用离屏 Canvas(OffscreenCanvas
  • 通过 @Observed + @State 实现深度属性观察,避免不必要的全组件重渲染

十、总结

本案例通过以下技术栈实现了一款沉浸光感儿童练字板:

  1. Canvas 绘图:手写交互 + 米字格辅助线 + 图片导出保存
  2. 状态管理@Observed 深度观察模型 + @Component 页面 + @State 状态 + @Provide/@Consume 跨组件共享
  3. 沉浸光感 UI:多层渐变 + 霓虹发光 + 呼吸动画 + 磨砂玻璃
  4. Navigation 路由NavPathStack 管理页面跳转与参数传递

核心知识点速记

  • onReady 是 Canvas 绑制的起点
  • shadowBlur + shadowColor = 发光效果
  • @Observed + @State = 深度属性观察与 UI 自动更新
  • @Provide/@Consume = 跨组件状态共享(仅限 @Component
  • animateTo + AlternateReverse = 呼吸动画
  • toDataURL() + fileIo = 图片保存

参考资源

  • animateTo + AlternateReverse = 呼吸动画
  • toDataURL() + fileIo = 图片保存

参考资源