欢迎加入开源鸿蒙跨平台社区:
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 页面交互之间的关系。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐