敲木鱼是一款具有禅意的趣味小游戏,本文将通过鸿蒙 ArkUI 框架的实现代码,逐步解析其核心技术点,包括动画驱动、状态管理、音效震动反馈等。
一、架构设计与工程搭建
1.1 项目结构解析
完整项目包含以下核心模块:
├── entry/src/main/ets/
│ ├── components/ // 自定义组件库
│ ├── model/ // 数据模型(如StateArray)
│ ├── pages/ // 页面组件(WoodenFishGame.ets)
│ └── resources/ // 多媒体资源(木鱼图标、音效)
通过模块化设计分离 UI层(pages)、逻辑层(model)、资源层(resources),符合鸿蒙应用开发规范。
1.2 组件化开发模式
使用 @Component
装饰器创建独立可复用的 UI 单元,@Entry
标记为页面入口。关键状态通过 @State
管理:
@Entry
@Component
struct WoodenFishGame {
@State count: number = 0; // 功德计数器
@State scaleWood: number = 1; // 木鱼缩放系数
@State rotateWood: number = 0; // 木鱼旋转角度
@State animateTexts: Array<StateArray> = []; // 动画队列
private audioPlayer?: media.AVPlayer; // 音频播放器实例
private autoPlay: boolean = false; // 自动敲击标志位
}
@State
实现了 响应式编程:当变量值变化时,ArkUI 自动触发关联 UI 的重新渲染。
二、动画系统深度解析
2.1 木鱼敲击复合动画
动画分为 按压收缩(100ms)和 弹性恢复(200ms)两个阶段,通过 animateTo
实现平滑过渡:
playAnimation() {
// 第一阶段:快速收缩+左旋
animateTo({
duration: 100,
curve: Curve.Friction // 摩擦曲线模拟物理阻力
}, () => {
this.scaleWood = 0.9; // X/Y轴缩放到90%
this.rotateWood = -2; // 逆时针旋转2度
});
// 第二阶段:弹性恢复
setTimeout(() => {
animateTo({
duration: 200,
curve: Curve.Linear // 线性恢复保证流畅性
}, () => {
this.scaleWood = 1;
this.rotateWood = 0;
});
}, 100); // 延迟100ms衔接动画
}
曲线选择:
Curve.Friction
模拟木槌敲击时的瞬间阻力Curve.Linear
确保恢复过程无加速度干扰
2.2 功德文字飘浮动画
采用 动态数组管理 + 唯一ID标识 实现多实例独立控制:
countAnimation() {
const animId = new Date().getTime(); // 时间戳生成唯一ID
// 添加新动画元素
this.animateTexts = [...this.animateTexts, {
id: animId,
opacity: 1,
offsetY: 20
}];
// 启动渐隐上移动画
animateTo({
duration: 800,
curve: Curve.EaseOut // 缓出效果模拟惯性
}, () => {
this.animateTexts = this.animateTexts.map(item =>
item.id === animId ?
{ ...item, opacity: 0, offsetY: -100 } : item
);
});
// 动画完成后清理内存
setTimeout(() => {
this.animateTexts = this.animateTexts.filter(t => t.id !== animId);
}, 800); // 与动画时长严格同步
}
关键技术点:
- 数据驱动:通过修改
animateTexts
数组触发 ForEach 重新渲染 - 分层动画:
opacity
控制透明度,offsetY
控制垂直位移 - 内存优化:定时清理已完成动画元素,防止数组膨胀
三、多模态交互实现
3.1 触觉震动反馈
调用 @kit.SensorServiceKit
的振动模块实现触觉反馈:
vibrator.startVibration({
type: "time", // 按时间模式振动
duration: 50 // 50ms短震动
}, {
id: 0, // 振动器ID
usage: 'alarm' // 资源使用场景标识
});
参数调优建议:
- 时长:50ms 短震动模拟木鱼敲击的“清脆感”
- 强度:鸿蒙系统自动根据
usage
分配最佳强度等级
3.2 音频播放与资源管理
通过 media.AVPlayer
实现音效播放:
aboutToAppear(): void {
media.createAVPlayer().then(player => {
this.audioPlayer = player;
this.audioPlayer.url = "";
this.audioPlayer.loop = false; // 禁用循环播放
});
}
// 敲击时重置播放进度
if (this.audioPlayer) {
this.audioPlayer.seek(0); // 定位到0毫秒
this.audioPlayer.play(); // 播放音效
}
最佳实践:
- 预加载资源:在
aboutToAppear
阶段提前初始化播放器 - 避免延迟:调用
seek(0)
确保每次点击即时发声 - 资源释放:需在
onPageHide
中调用release()
防止内存泄漏
四、自动敲击功能实现
4.1 定时器与状态联动
通过 Toggle
组件切换自动敲击模式:
// 状态切换回调
Toggle({ type: ToggleType.Checkbox, isOn: false })
.onChange((isOn: boolean) => {
this.autoPlay = isOn;
if (isOn) {
this.startAutoPlay();
} else {
clearInterval(this.intervalId); // 清除指定定时器
}
});
// 启动定时器
private intervalId: number = 0;
startAutoPlay() {
this.intervalId = setInterval(() => {
if (this.autoPlay) this.handleTap();
}, 400); // 400ms间隔模拟人类点击频率
}
关键改进点:
- 使用
intervalId
保存定时器引用,避免clearInterval()
失效 - 间隔时间 400ms 平衡流畅度与性能消耗
4.2 线程安全与性能保障
风险点:频繁的定时器触发可能导致 UI 线程阻塞
解决方案:
// 在 aboutToDisappear 中清除定时器
aboutToDisappear() {
clearInterval(this.intervalId);
}
确保页面隐藏时释放资源,避免后台线程持续运行。
五、UI 布局与渲染优化
5.1 层叠布局与动画合成
使用 Stack
实现多层 UI 元素的叠加渲染:
Stack() {
// 木鱼主体(底层)
Image($r("app.media.icon_wooden_fish"))
.width(280)
.height(280)
.margin({ top: -10 })
.scale({ x: this.scaleWood, y: this.scaleWood })
.rotate({ angle: this.rotateWood });
// 功德文字(上层)
ForEach(this.animateTexts, (item, index) => {
Text(`+1`)
.translate({ y: -item.offsetY * index }) // 按索引错位显示
});
}
渲染优化技巧:
- 为静态图片资源添加
fixedSize(true)
避免重复计算 - 使用
translate
代替margin
实现位移,减少布局重排
5.2 状态到 UI 的高效绑定
通过 链式调用 实现样式动态绑定:
Text(`功德 +${this.count}`)
.fontSize(20)
.fontColor('#4A4A4A')
.margin({
top: 20 + AppUtil.getStatusBarHeight() // 动态适配刘海屏
})
适配方案:
AppUtil.getStatusBarHeight()
获取状态栏高度,避免顶部遮挡- 使用鸿蒙的 弹性布局(Flex)自动适应不同屏幕尺寸
六、完整代码
import { media } from '@kit.MediaKit';
import { vibrator } from '@kit.SensorServiceKit';
import { AppUtil, ToastUtil } from '@pura/harmony-utils';
import { StateArray } from '../model/HomeModel';
@Entry
@Component
struct WoodenFishGame {
@State count: number = 0;
@State scaleWood: number = 1;
@State rotateWood: number = 0;
audioPlayer?: media.AVPlayer;
// 添加自动敲击功能
autoPlay: boolean = false;
// 新增状态变量
@State animateTexts: Array<StateArray> = []
aboutToAppear(): void {
media.createAVPlayer().then(player => {
this.audioPlayer = player
this.audioPlayer.url = ""
})
}
startAutoPlay() {
setInterval(() => {
if (this.autoPlay) {
this.handleTap();
}
}, 400);
}
// 敲击动画
playAnimation() {
animateTo({
duration: 100,
curve: Curve.Friction
}, () => {
this.scaleWood = 0.9;
this.rotateWood = -2;
});
setTimeout(() => {
animateTo({
duration: 200,
curve: Curve.Linear
}, () => {
this.scaleWood = 1;
this.rotateWood = 0;
});
}, 100);
}
// 敲击处理
handleTap() {
this.count++;
this.playAnimation();
this.countAnimation();
// 在handleTap中添加:
vibrator.startVibration({
type: "time",
duration: 50
}, {
id: 0,
usage: 'alarm'
});
// 播放音效
if (this.audioPlayer) {
this.audioPlayer.seek(0);
this.audioPlayer.play();
}
}
countAnimation(){
// 生成唯一ID防止动画冲突
const animId = new Date().getTime()
// 初始化动画状态
this.animateTexts = [...this.animateTexts, {id: animId, opacity: 1, offsetY: 20}]
// 执行动画
animateTo({
duration: 800,
curve: Curve.EaseOut
}, () => {
this.animateTexts = this.animateTexts.map(item => {
if (item.id === animId) {
return { id:item.id, opacity: 0, offsetY: -100 }
}
return item
})
})
// 动画完成后清理
setTimeout(() => {
this.animateTexts = this.animateTexts.filter(t => t.id !== animId)
}, 800)
}
build() {
Column() {
// 计数显示
Text(`功德 +${this.count}`)
.fontSize(20)
.margin({ top: 20+AppUtil.getStatusBarHeight() })
// 木鱼主体
Stack() {
// 可敲击部位
Image($r("app.media.icon_wooden_fish"))
.width(280)
.height(280)
.margin({ top: -10 })
.scale({ x: this.scaleWood, y: this.scaleWood })
.rotate({ angle: this.rotateWood })
.onClick(() => this.handleTap())
// 功德文字动画容器
ForEach(this.animateTexts, (item:StateArray,index) => {
Text(`+1`)
.fontSize(24)
.fontColor('#FFD700')
.opacity(item.opacity)
.margin({ top: -100}) // 初始位置调整
.translate({ y: -item.offsetY*index }) // 使用translateY实现位移
.animation({ duration: 800, curve: Curve.EaseOut })
})
}
.margin({ top: 50 })
Row(){
// 自动敲击开关(扩展功能)
Toggle({ type: ToggleType.Checkbox, isOn: false })
.onChange((isOn: boolean) => {
// 可扩展自动敲击功能
this.autoPlay = isOn;
if (isOn) {
this.startAutoPlay();
} else {
clearInterval();
}
})
Text("自动敲击")
}.alignItems(VerticalAlign.Center)
.justifyContent(FlexAlign.Center)
.width("100%")
.position({bottom:100})
}
.width('100%')
.height('100%')
.backgroundColor('#f0f0f0')
}
}