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() 之前设置 shadowColor 和 shadowBlur,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
}
六、页面导航实现
6.1 路由配置(Navigation + NavPathStack)
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,原因:
@Provide/@Consume仅支持@Component:这是 V1 装饰器,不能用于@ComponentV2(V2 对应的是@Provider/@Consumer,但当前 API 版本尚不完善)@Observed深度观察 :配合@State可以追踪类对象属性的变化,满足画笔颜色、笔宽等实时更新需求- 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
在 onTouch 的 Move 事件中,每次 stroke() 前设置阴影属性,Canvas 会为每一小段线条绘制光晕。Up 事件时清除 shadowBlur 避免影响后续绘制。
九、扩展优化建议
9.1 功能扩展方向
| 方向 | 实现思路 |
|---|---|
| 撤销/重做 | 使用 getImageData() 保存画布快照栈 |
| 笔画引导动画 | 用 Path2D + 定时器逐帧绘制笔画路径 |
| 多字帖主题 | 抽象 WordItem 列表为配置项,支持切换古诗/成语等 |
| 音效反馈 | 在 TouchType.Down 时播放短促音效 |
| 作品分享 | 使用 @kit.ShareKit 分享保存的图片 |
9.2 性能优化
- 避免在
onTouch的Move事件中调用beginPath()(会导致笔迹断裂) - 对大量重复绘制使用离屏 Canvas(
OffscreenCanvas) - 通过
@Observed+@State实现深度属性观察,避免不必要的全组件重渲染
十、总结
本案例通过以下技术栈实现了一款沉浸光感儿童练字板:
- Canvas 绘图:手写交互 + 米字格辅助线 + 图片导出保存
- 状态管理 :
@Observed深度观察模型 +@Component页面 +@State状态 +@Provide/@Consume跨组件共享 - 沉浸光感 UI:多层渐变 + 霓虹发光 + 呼吸动画 + 磨砂玻璃
- Navigation 路由 :
NavPathStack管理页面跳转与参数传递
核心知识点速记:
onReady是 Canvas 绑制的起点shadowBlur+shadowColor= 发光效果@Observed+@State= 深度属性观察与 UI 自动更新@Provide/@Consume= 跨组件状态共享(仅限@Component)animateTo+AlternateReverse= 呼吸动画toDataURL()+fileIo= 图片保存
参考资源
- Canvas 组件文档
- CanvasRenderingContext2D 文档
- 儿童练字板架构指南
- 状态管理 V2 指南
(仅限@Component)
animateTo+AlternateReverse= 呼吸动画toDataURL()+fileIo= 图片保存
参考资源