Flutter for OpenHarmony 三方库实战:使用 lottie 构建运动打卡动效页面
在移动应用开发中,动画效果可以明显提升页面体验。比如启动页动画、加载动画、成功提示动画、按钮反馈动画等,都能让页面看起来更生动。如果完全手写动画,不仅代码量大,而且效果也很难做到细腻。因此本篇文章选择使用 OpenHarmony 三方库来实现动效展示。Lottie 动画通常由 Adobe After Effects 通过 Bodymovin 插件导出为 JSON 文件,开发时只需要加载这个 JSO
欢迎加入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net
项目效果
本文实现的是一个基于 @ohos/lottie 的运动打卡动效页面。应用启动后,会在页面上展示一个 Lottie 动画区域,并提供播放、暂停、停止和完成打卡按钮。用户点击“完成打卡”后,页面会更新今日运动状态,并展示运动时长、消耗热量和连续打卡天数。
最终运行效果如下:
页面主要包含以下内容:
- 顶部标题:运动打卡;
- Lottie 动画展示区域;
- 今日运动状态卡片;
- 播放、暂停、停止动画按钮;
- 完成打卡按钮;
- 运动时长、消耗热量、连续打卡天数展示;
- 页面整体采用清爽的卡片式布局。
这个页面可以作为运动 App、习惯打卡、健身记录、健康提醒等项目的基础版本。
前言
在移动应用开发中,动画效果可以明显提升页面体验。比如启动页动画、加载动画、成功提示动画、按钮反馈动画等,都能让页面看起来更生动。
如果完全手写动画,不仅代码量大,而且效果也很难做到细腻。因此本篇文章选择使用 OpenHarmony 三方库 @ohos/lottie 来实现动效展示。Lottie 动画通常由 Adobe After Effects 通过 Bodymovin 插件导出为 JSON 文件,开发时只需要加载这个 JSON 文件,就可以在应用中渲染复杂动画。
本篇文章以“运动打卡”为场景,使用 @ohos/lottie 实现一个带动画展示和打卡状态更新的页面。项目重点不是复杂业务,而是学习如何在 OpenHarmony 项目中接入 Lottie 动画库,并结合 ArkUI 完成一个完整页面。
相比普通静态页面,加入 Lottie 动画后,用户完成打卡时的反馈会更明显,页面也更有应用感。毕竟一个健身页面如果连动效都没有,看起来就像电子表格换了件运动服。
一、项目目标
本次实践主要实现以下目标:
- 在 OpenHarmony 项目中安装
@ohos/lottie三方库; - 在 ArkTS 页面中引入并使用 lottie;
- 使用 Canvas 作为动画渲染容器;
- 加载本地 Lottie JSON 动画文件;
- 实现动画播放、暂停、停止控制;
- 实现运动打卡状态更新;
- 使用 ArkUI 构建运动数据卡片;
- 在 OpenHarmony 模拟器中完成运行验证。
二、技术栈
| 类型 | 内容 |
|---|---|
| 实战方向 | Flutter for OpenHarmony 三方库实战 |
| 实现平台 | OpenHarmony |
| 开发语言 | ArkTS |
| 三方库 | @ohos/lottie |
| 动画格式 | Lottie JSON |
| UI 框架 | ArkUI |
| 功能场景 | 运动打卡 / 习惯记录 |
| 开发工具 | DevEco Studio |
| 运行环境 | OpenHarmony 模拟器 |
三、为什么选择 lottie
在应用开发中,动画效果经常用于提升页面交互体验。常见场景包括:
- 启动页 Logo 动画;
- 加载中动画;
- 成功提示动画;
- 空状态插画动画;
- 打卡完成反馈;
- 页面氛围动效。
如果使用普通代码手写动画,复杂度会比较高。Lottie 的优势是可以直接使用设计师导出的 JSON 动画文件,让开发者不用从零绘制复杂动画。
在 OpenHarmony 项目中,@ohos/lottie 可以通过 Canvas 渲染 Lottie 动画,并提供播放、暂停、停止等控制方法。这样既能减少手写动画的工作量,也能让页面效果更接近真实应用。
本项目主要使用 lottie 完成以下功能:
- 加载本地 JSON 动画;
- 自动播放动画;
- 控制动画播放状态;
- 在打卡页面中展示动态反馈效果。
四、安装 lottie 三方库
在项目根目录打开终端,执行以下命令:
ohpm install @ohos/lottie
安装完成后,可以检查项目中是否出现以下内容:
oh_modules
oh-package-lock.json5
oh-package.json5
如果依赖安装成功,就可以在 ArkTS 页面中引入:
import lottie, { AnimationItem } from '@ohos/lottie';
五、准备 Lottie 动画文件
本项目需要准备一个 Lottie JSON 动画文件,例如:
fitness.json
可以将动画文件放到下面目录中:
entry/src/main/ets/common/lottie/fitness.json
最终路径示例:
entry
└── src
└── main
└── ets
├── pages
│ └── Index.ets
└── common
└── lottie
└── fitness.json
在代码中加载动画时,路径可以写成:
private animationPath: string = 'common/lottie/fitness.json';
需要注意,Lottie 加载路径不是从 Index.ets 当前文件位置开始写的,不要写成:
../common/lottie/fitness.json
这种写法容易导致动画资源加载失败。路径问题这种东西不难,但它特别擅长浪费人的生命。
六、项目结构
本次主要修改页面文件,并添加一个动画 JSON 文件:
entry
└── src
└── main
└── ets
├── pages
│ └── Index.ets
└── common
└── lottie
└── fitness.json
文件说明:
| 文件 | 作用 |
|---|---|
| Index.ets | 页面展示、动画加载、按钮控制、打卡状态更新 |
| fitness.json | Lottie 动画资源文件 |
本项目不需要请求远程接口,因此不需要额外配置网络权限。动画文件使用本地资源,重点是展示 @ohos/lottie 的接入和控制方式。
七、页面状态设计
页面中主要使用以下状态变量:
@State isChecked: boolean = false;
@State statusText: string = '今日未打卡';
@State durationText: string = '0 分钟';
@State calorieText: string = '0 kcal';
@State streakDays: number = 6;
含义如下:
| 状态变量 | 作用 |
|---|---|
| isChecked | 判断今日是否已经完成打卡 |
| statusText | 展示当前打卡状态 |
| durationText | 展示运动时长 |
| calorieText | 展示消耗热量 |
| streakDays | 展示连续打卡天数 |
当用户点击“完成打卡”按钮后,页面会更新这些状态,并让运动数据发生变化。
八、动画加载逻辑
页面中先创建 Canvas 渲染上下文:
private renderingSettings: RenderingContextSettings = new RenderingContextSettings(true);
private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderingSettings);
然后在 Canvas 的 onReady() 中加载动画:
Canvas(this.canvasContext)
.width(220)
.height(220)
.onReady(() => {
this.loadAnimation();
})
加载动画的方法如下:
loadAnimation(): void {
lottie.destroy(this.animationName);
this.animationItem = lottie.loadAnimation({
container: this.canvasContext,
renderer: 'canvas',
loop: true,
autoplay: true,
name: this.animationName,
contentMode: 'Contain',
path: this.animationPath
});
}
这里使用 lottie.destroy(this.animationName) 是为了避免重复加载动画导致资源没有释放。动画页面这种东西,不销毁就像健身后不拉伸,迟早出事。
九、Index.ets 完整代码
打开文件:
entry/src/main/ets/pages/Index.ets
完整代码如下:
import lottie, { AnimationItem } from '@ohos/lottie';
@Entry
@Component
struct Index {
@State isChecked: boolean = false;
@State statusText: string = '今日未打卡';
@State durationText: string = '0 分钟';
@State calorieText: string = '0 kcal';
@State streakDays: number = 6;
private renderingSettings: RenderingContextSettings = new RenderingContextSettings(true);
private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderingSettings);
private animationItem: AnimationItem | null = null;
private animationName: string = 'fitnessAnimation';
private animationPath: string = 'common/lottie/fitness.json';
aboutToDisappear(): void {
lottie.destroy(this.animationName);
this.animationItem = null;
}
loadAnimation(): void {
lottie.destroy(this.animationName);
this.canvasContext.imageSmoothingEnabled = true;
this.canvasContext.imageSmoothingQuality = 'medium';
this.animationItem = lottie.loadAnimation({
container: this.canvasContext,
renderer: 'canvas',
loop: true,
autoplay: true,
name: this.animationName,
contentMode: 'Contain',
path: this.animationPath
});
}
playAnimation(): void {
if (this.animationItem !== null) {
this.animationItem.play();
}
}
pauseAnimation(): void {
if (this.animationItem !== null) {
this.animationItem.pause();
}
}
stopAnimation(): void {
if (this.animationItem !== null) {
this.animationItem.stop();
}
}
completeCheckIn(): void {
this.isChecked = true;
this.statusText = '今日已完成';
this.durationText = '35 分钟';
this.calorieText = '218 kcal';
this.streakDays = this.streakDays + 1;
if (this.animationItem !== null) {
this.animationItem.setSpeed(1.2);
this.animationItem.play();
}
}
resetCheckIn(): void {
this.isChecked = false;
this.statusText = '今日未打卡';
this.durationText = '0 分钟';
this.calorieText = '0 kcal';
if (this.animationItem !== null) {
this.animationItem.setSpeed(1);
this.animationItem.stop();
}
}
build() {
Column() {
Column() {
Text('运动打卡')
.fontSize(30)
.fontWeight(FontWeight.Bold)
.fontColor('#182431')
Text('基于 lottie 的动效打卡页面')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 8 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({
left: 16,
right: 16,
top: 28,
bottom: 12
})
Column() {
Canvas(this.canvasContext)
.width(220)
.height(220)
.backgroundColor('#FFFFFF')
.onReady(() => {
this.loadAnimation();
})
Text(this.statusText)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(this.isChecked ? '#00A870' : '#182431')
.margin({ top: 12 })
Text(this.isChecked ? '今天的运动目标已经完成' : '完成一次运动后点击打卡')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 6 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding(18)
.margin({
left: 16,
right: 16
})
.backgroundColor('#F7F8FA')
.borderRadius(20)
Row() {
Button('播放')
.fontSize(14)
.height(40)
.layoutWeight(1)
.onClick(() => {
this.playAnimation();
})
Button('暂停')
.fontSize(14)
.height(40)
.layoutWeight(1)
.margin({ left: 10 })
.onClick(() => {
this.pauseAnimation();
})
Button('停止')
.fontSize(14)
.height(40)
.layoutWeight(1)
.margin({ left: 10 })
.onClick(() => {
this.stopAnimation();
})
}
.width('100%')
.padding({
left: 16,
right: 16,
top: 16
})
Row() {
Column() {
Text(this.durationText)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#182431')
Text('运动时长')
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(this.calorieText)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#182431')
Text('消耗热量')
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text(`${this.streakDays} 天`)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#182431')
Text('连续打卡')
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding(16)
.margin({
left: 16,
right: 16,
top: 16
})
.backgroundColor('#F7F8FA')
.borderRadius(18)
Button(this.isChecked ? '重新打卡' : '完成打卡')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.height(48)
.width('90%')
.margin({ top: 20 })
.onClick(() => {
if (this.isChecked) {
this.resetCheckIn();
} else {
this.completeCheckIn();
}
})
Column() {
Text('说明')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#182431')
.margin({ bottom: 8 })
Text('本页面用于演示 @ohos/lottie 三方库的动画加载和播放控制能力。页面通过 Canvas 加载本地 Lottie JSON 动画,并结合按钮实现播放、暂停、停止和打卡状态更新。')
.fontSize(14)
.fontColor('#666666')
.lineHeight(24)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(16)
.margin({
left: 16,
right: 16,
top: 18
})
.backgroundColor('#F7F8FA')
.borderRadius(18)
Blank()
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
}
}
十、代码实现说明
1. 引入 lottie
页面顶部引入三方库:
import lottie, { AnimationItem } from '@ohos/lottie';
其中:
lottie用于加载、播放、暂停和销毁动画;AnimationItem用于定义动画实例类型。
2. 创建 Canvas 渲染上下文
Lottie 动画需要绘制到 Canvas 上,因此页面中创建了渲染上下文:
private renderingSettings: RenderingContextSettings = new RenderingContextSettings(true);
private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderingSettings);
然后在页面中使用:
Canvas(this.canvasContext)
作为动画展示区域。
3. 加载本地动画文件
动画文件路径设置为:
private animationPath: string = 'common/lottie/fitness.json';
加载动画时调用:
this.animationItem = lottie.loadAnimation({
container: this.canvasContext,
renderer: 'canvas',
loop: true,
autoplay: true,
name: this.animationName,
contentMode: 'Contain',
path: this.animationPath
});
这里的几个参数含义如下:
| 参数 | 作用 |
|---|---|
| container | 指定 Canvas 渲染上下文 |
| renderer | 指定渲染模式 |
| loop | 是否循环播放 |
| autoplay | 是否自动播放 |
| name | 动画名称 |
| contentMode | 动画填充模式 |
| path | 动画 JSON 文件路径 |
4. 控制动画播放状态
页面中封装了三个控制方法:
playAnimation(): void {
if (this.animationItem !== null) {
this.animationItem.play();
}
}
pauseAnimation(): void {
if (this.animationItem !== null) {
this.animationItem.pause();
}
}
stopAnimation(): void {
if (this.animationItem !== null) {
this.animationItem.stop();
}
}
点击页面上的“播放”“暂停”“停止”按钮时,就会调用对应方法控制动画状态。
5. 完成运动打卡
点击“完成打卡”后,会更新页面状态:
this.isChecked = true;
this.statusText = '今日已完成';
this.durationText = '35 分钟';
this.calorieText = '218 kcal';
this.streakDays = this.streakDays + 1;
同时让动画继续播放,并稍微提高播放速度:
this.animationItem.setSpeed(1.2);
this.animationItem.play();
这样用户完成打卡后,页面会有更明显的反馈。
6. 销毁动画资源
页面退出时,需要销毁动画资源:
aboutToDisappear(): void {
lottie.destroy(this.animationName);
this.animationItem = null;
}
这样可以避免动画资源没有释放导致性能问题。动画库不是许愿池,加载了就要记得收拾。
十一、运行效果说明
完成代码后,点击 DevEco Studio 中的运行按钮,将应用运行到 OpenHarmony 模拟器中。
运行成功后,页面顶部显示:
运动打卡
中间区域展示 Lottie 动画。点击“播放”“暂停”“停止”按钮,可以控制动画状态。
点击“完成打卡”后,页面会显示:
今日已完成
运动时长:35 分钟
消耗热量:218 kcal
连续打卡:7 天
同时动画会继续播放,形成完成打卡后的动态反馈效果。
十二、开发中遇到的问题
1. 找不到 @ohos/lottie 模块
如果代码中出现找不到模块的问题,可以先检查是否已经执行安装命令:
ohpm install @ohos/lottie
同时检查项目中是否存在:
oh_modules
oh-package-lock.json5
如果依赖没有正确安装,可以重新执行安装命令。
2. 动画不显示
如果页面可以运行,但是动画区域没有内容,可以检查以下内容:
fitness.json是否已经放到entry/src/main/ets/common/lottie/目录;- 代码中的路径是否写成了
common/lottie/fitness.json; - JSON 文件是否是有效的 Lottie 动画文件;
- Canvas 的宽高是否设置合理;
- 是否在
Canvas.onReady()中调用了loadAnimation()。
3. 路径写错导致加载失败
本项目中动画路径写法为:
private animationPath: string = 'common/lottie/fitness.json';
不要写成:
private animationPath: string = '../common/lottie/fitness.json';
Lottie 的资源路径规则和普通文件相对路径不完全一样,写错后页面通常不会直接告诉你“我迷路了”,它只会安静地失败,像一个冷漠的谜语。
4. 重复加载动画
如果页面反复进入后动画异常,可以在加载前先销毁旧动画:
lottie.destroy(this.animationName);
页面退出时也应该调用:
lottie.destroy(this.animationName);
这样可以减少重复加载和资源泄漏的问题。
5. 混淆后编译失败
如果开启混淆后出现与 lottie 相关的编译问题,可以在对应模块的混淆规则文件中添加保留配置:
-keep ./oh_modules/@ohos/lottie
这样可以避免三方库在混淆过程中被错误处理。
十三、总结
本篇完成了一个基于 @ohos/lottie 的运动打卡动效页面。项目通过 Canvas 加载本地 Lottie JSON 动画,并结合 ArkUI 实现播放、暂停、停止和完成打卡状态更新。
通过本次实践,我主要完成了以下内容:
- 安装并使用
@ohos/lottie三方库; - 在 ArkTS 页面中引入 lottie;
- 使用 Canvas 作为动画渲染容器;
- 加载本地 Lottie JSON 动画文件;
- 控制动画播放、暂停和停止;
- 实现运动打卡状态更新;
- 使用 ArkUI 构建卡片式页面;
- 在模拟器中完成运行验证。
虽然这个项目只是一个基础动效页面,但它已经包含了三方库接入、动画资源加载、动画控制和页面状态更新的完整流程。
后续可以继续扩展为一个更完整的运动打卡应用,例如:
- 添加运动类型选择;
- 添加每日目标设置;
- 添加打卡历史记录;
- 添加完成动画切换;
- 添加数据统计图表;
- 添加本地持久化存储;
- 接入真实运动数据接口。
整体来看,@ohos/lottie 可以让 OpenHarmony 页面更容易实现复杂动效。通过这个项目,可以更清楚地理解 OpenHarmony 中三方库接入、Canvas 动画渲染和 ArkUI 页面交互之间的关系。
更多推荐


所有评论(0)